Merge branch 'main' into feat/allow-everything

This commit is contained in:
Marian Alexandru Alecu
2026-04-08 15:58:19 +03:00
committed by GitHub
184 changed files with 9018 additions and 3191 deletions
+4
View File
@@ -18,3 +18,7 @@ packages/kilo-vscode/tests/**/*.png filter=lfs diff=lfs merge=lfs -text
**/i18n/parity.test.ts linguist-generated=false
packages/kilo-i18n/src/*.ts linguist-generated=true
packages/kilo-i18n/src/en.ts linguist-generated=false
# Auto-generated CLI reference docs
packages/kilo-docs/markdoc/partials/cli-commands-table.md linguist-generated=true
packages/kilo-docs/pages/code-with-ai/platforms/cli-reference.md linguist-generated=true
@@ -0,0 +1,32 @@
name: Check opencode annotations
on:
pull_request:
paths:
- "packages/opencode/**"
- "script/check-opencode-annotations.ts"
- ".github/workflows/check-opencode-annotations.yml"
workflow_dispatch:
jobs:
check-annotations:
name: Check kilocode_change annotations
if: github.repository == 'Kilo-Org/kilocode'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0
- uses: oven-sh/setup-bun@v2
- name: Check kilocode_change annotations in shared opencode files
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
run: |
if [ -n "$BASE_SHA" ]; then
bun run script/check-opencode-annotations.ts --base "$BASE_SHA"
else
echo "No PR base SHA available (workflow_dispatch without PR context) — skipping."
fi
+8 -1
View File
@@ -1,5 +1,5 @@
name: publish
run-name: "${{ format('release {0}', inputs.bump) }}"
run-name: "${{ format('{0} {1}', inputs.pre_release && 'pre-release' || 'release', inputs.bump) }}"
on:
# push:
@@ -22,6 +22,11 @@ on:
description: "Override version (optional)"
required: false
type: string
pre_release:
description: "Publish as pre-release (VS Code marketplace)"
required: false
type: boolean
default: false
concurrency: ${{ github.workflow }}-${{ github.ref }}-${{ inputs.version || inputs.bump }}
@@ -117,6 +122,7 @@ jobs:
env:
CLI_DIST_DIR: ../../packages/opencode/dist
KILO_VERSION: ${{ needs.build-cli.outputs.version }}
KILO_PRE_RELEASE: ${{ inputs.pre_release }}
GH_REPO: ${{ github.repository }}
- uses: actions/upload-artifact@v4
@@ -394,6 +400,7 @@ jobs:
env:
KILO_VERSION: ${{ needs.version.outputs.version }}
KILO_RELEASE: ${{ needs.version.outputs.release }}
KILO_PRE_RELEASE: ${{ inputs.pre_release }}
GH_REPO: ${{ github.repository }}
AUR_KEY: ${{ secrets.AUR_KEY }}
GITHUB_TOKEN: ${{ steps.committer.outputs.token }}
+4
View File
@@ -10,6 +10,7 @@ Kilo CLI is an open source AI coding agent that generates code from natural lang
## Build and Dev
- **Dev**: `bun run dev` (runs from root) or `bun run --cwd packages/opencode --conditions=browser src/index.ts`
- **Dev with params**: `bun dev -- help`
- **Extension**: `bun run extension` (build + launch VS Code with the extension in dev mode). Pass `--no-build` to skip the build.
- **Typecheck**: `bun turbo typecheck` (uses `tsgo`, not `tsc`)
- **Test**: `bun test` from `packages/opencode/` (NOT from root -- root blocks tests)
@@ -18,6 +19,7 @@ Kilo CLI is an open source AI coding agent that generates code from natural lang
- **Knip** (unused exports): `bun run knip` from `packages/kilo-vscode/`. CI runs this — all exported types/functions must be imported somewhere. Remove or unexport unused exports before pushing.
- **Source links**: After adding or changing URLs in `packages/kilo-vscode/`, `packages/kilo-vscode/webview-ui/`, or `packages/opencode/src/`, run `bun run script/extract-source-links.ts` from the repo root and commit the updated `packages/kilo-docs/source-links.md`. CI runs this check — the build fails if the file is stale.
- **kilocode_change check**: `bun run check-kilocode-change` from `packages/kilo-vscode/`. CI runs this — `kilocode_change` is a marker for upstream merge conflicts and must not appear in `packages/kilo-vscode/` or `packages/kilo-ui/` (these are entirely Kilo Code additions). Remove the markers before pushing.
- **opencode annotation check**: `bun run script/check-opencode-annotations.ts` from repo root. CI runs this on PRs touching `packages/opencode/` — every Kilo-specific change in shared opencode files must be annotated with `kilocode_change` markers. Exempt paths (no markers needed): `packages/opencode/src/kilocode/`, `packages/opencode/test/kilocode/`, and any path containing `kilocode` in the name.
## Products
@@ -173,6 +175,8 @@ Tests MUST test actual implementation, do not duplicate logic into a test.
Kilo CLI is a fork of [opencode](https://github.com/anomalyco/opencode).
**Very important**: when planning or coding, update shared files with OpenCode as last resort! Everything is shared code from OpenCode, except folders that contain `kilo` in the name or have a parent directory that contains `kilo` in the name. Example of kilo specific folders: `packages/opencode/src/kilocode/` and `packages/kilo-docs/`. Always look for ways to implement your feature or fix in a way that minimizes changes to shared code.
### Minimizing Merge Conflicts
We regularly merge upstream changes from opencode. To minimize merge conflicts and keep the sync process smooth:
+2 -2
View File
@@ -18,7 +18,7 @@
- ⚡ Inline autocomplete suggestions
- 🤖 Latest AI models
- 🎁 API keys optional
- 💡 **Get $20 in bonus credits when you top-up for the first time** Credits can be used with 500+ models like Gemini 3.1 Pro, Claude 4.6 Sonnet & Opus, and GPT-5.2
- 💡 **Get $20 in bonus credits when you top-up for the first time** Credits can be used with 500+ models like Gemini 3.1 Pro, Claude 4.6 Sonnet & Opus, and GPT-5.4
## Quick Links
@@ -38,7 +38,7 @@
## Get Started in Visual Studio Code
1. Install the Kilo Code extension from the [VS Code Marketplace](https://marketplace.visualstudio.com/items?itemName=kilocode.Kilo-Code).
2. Create your account to access 500+ cutting-edge AI models including Gemini 3 Pro, Claude 4.5 Sonnet & Opus, and GPT-5 with transparent pricing that matches provider rates exactly.
2. Create your account to access 500+ cutting-edge AI models including Gemini 3.1 Pro, Claude 4.6 Sonnet & Opus, and GPT-5.4 with transparent pricing that matches provider rates exactly.
3. Start coding with AI that adapts to your workflow. Watch our quick-start guide to see Kilo in action:
<a href="https://youtu.be/pqGfYXgrhig"><img src="https://img.youtube.com/vi/pqGfYXgrhig/maxresdefault.jpg" alt="Watch the video" width="640" height="360"></a>
+327 -629
View File
File diff suppressed because it is too large Load Diff
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-4yd8H4UN7YIhdIQ607UoxSqwsQEKqUWkOifjQ2PEvn0=",
"aarch64-linux": "sha256-EuzzDc/j63eRV8QEFgDmQRIsiSq4ZPr9RJSdB9dH+kI=",
"aarch64-darwin": "sha256-BLCZubMC/n06HsUeO3FsvREwfsmjtiTBseSMDPPZNYM=",
"x86_64-darwin": "sha256-gU1uhExpYJhhVHbspCwCry/DwUiuddRvYl72RZqWzNo="
"x86_64-linux": "sha256-PvqhO2tOClenS37A80VsNuOglBs5fk8aaAtsktwG74s=",
"aarch64-linux": "sha256-+UN4dTY2s7GWjYumPoKTSv2pX7zhJB51hxJzCwqejaE=",
"aarch64-darwin": "sha256-N5JmmR6XrzGT86AL6TxpcQoZl7PTmgfd6ll5CVamQT0=",
"x86_64-darwin": "sha256-eyLUps3Kzg+6/VPt/XW78+vaL+9RY1aTtnnPxUPSkWQ="
}
}
+18 -8
View File
@@ -38,12 +38,12 @@
"@pierre/diffs": "1.1.0-beta.18",
"@solid-primitives/storage": "4.3.3",
"@tailwindcss/vite": "4.1.11",
"diff": "8.0.2",
"dompurify": "3.3.1",
"diff": "8.0.4",
"dompurify": "3.3.3",
"drizzle-kit": "1.0.0-beta.16-ea816b6",
"drizzle-orm": "1.0.0-beta.16-ea816b6",
"ai": "5.0.124",
"hono": "4.10.7",
"hono": "4.12.12",
"hono-openapi": "1.1.2",
"fuzzysort": "3.1.0",
"luxon": "3.6.1",
@@ -58,11 +58,11 @@
"solid-list": "0.3.0",
"tailwindcss": "4.1.11",
"virtua": "0.42.3",
"vite": "7.1.4",
"vite": "7.3.2",
"@solidjs/meta": "0.29.4",
"@solidjs/router": "0.15.4",
"@solidjs/start": "https://pkg.pr.new/@solidjs/start@dfb2020",
"solid-js": "1.9.10",
"solid-js": "1.9.12",
"vite-plugin-solid": "2.11.10"
}
},
@@ -79,7 +79,7 @@
"turbo": "2.8.13"
},
"dependencies": {
"@aws-sdk/client-s3": "3.933.0",
"@aws-sdk/client-s3": "3.1025.0",
"@kilocode/plugin": "workspace:*",
"@opencode-ai/script": "workspace:*",
"@kilocode/sdk": "workspace:*",
@@ -105,13 +105,23 @@
],
"overrides": {
"@types/bun": "catalog:",
"@types/node": "catalog:"
"@types/node": "catalog:",
"path-to-regexp": ">=8.4.0",
"picomatch": ">=2.3.2",
"defu": "6.1.6",
"lodash": "4.18.1",
"@xmldom/xmldom": ">=0.8.12",
"smol-toml": ">=1.6.1",
"fastify": ">=5.8.3",
"diff": "8.0.4",
"dompurify": "3.3.3",
"happy-dom": ">=20.8.9"
},
"patchedDependencies": {
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
"@openrouter/ai-sdk-provider@1.5.4": "patches/@openrouter%2Fai-sdk-provider@1.5.4.patch",
"ghostty-web@0.3.0": "patches/ghostty-web@0.3.0.patch"
},
"version": "7.1.22",
"version": "7.2.0",
"peerDependencies": {}
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/app",
"version": "7.1.22",
"version": "7.2.0",
"description": "",
"type": "module",
"exports": {
+2 -2
View File
@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/desktop-electron",
"private": true,
"version": "7.1.22",
"version": "7.2.0",
"type": "module",
"license": "MIT",
"homepage": "https://opencode.ai",
@@ -43,7 +43,7 @@
"@types/bun": "catalog:",
"@types/node": "catalog:",
"@typescript/native-preview": "catalog:",
"electron": "40.4.1",
"electron": "40.8.5",
"electron-builder": "^26",
"electron-vite": "^5",
"typescript": "~5.6.2",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/desktop",
"private": true,
"version": "7.1.22",
"version": "7.2.0",
"type": "module",
"license": "MIT",
"scripts": {
+6 -6
View File
@@ -1,7 +1,7 @@
id = "kilo"
name = "Kilo"
description = "The open source coding agent."
version = "7.1.22"
version = "7.2.0"
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.1.22/opencode-darwin-arm64.zip"
archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.0/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.1.22/opencode-darwin-x64.zip"
archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.0/opencode-darwin-x64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-aarch64]
archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.1.22/opencode-linux-arm64.tar.gz"
archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.0/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.1.22/opencode-linux-x64.tar.gz"
archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.0/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.1.22/opencode-windows-x64.zip"
archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.0/opencode-windows-x64.zip"
cmd = "./opencode.exe"
args = ["acp"]
+5 -1
View File
@@ -14,7 +14,11 @@ export const CodeWithAiNav: NavSection[] = [
href: "/code-with-ai/platforms/jetbrains",
children: "JetBrains Extension",
},
{ href: "/code-with-ai/platforms/cli", children: "CLI" },
{
href: "/code-with-ai/platforms/cli",
children: "CLI",
subLinks: [{ href: "/code-with-ai/platforms/cli-reference", children: "Command Reference" }],
},
{ href: "/code-with-ai/platforms/cloud-agent", children: "Cloud Agent" },
{ href: "/code-with-ai/platforms/mobile", children: "Mobile Apps" },
{ href: "/code-with-ai/platforms/slack", children: "Slack" },
@@ -0,0 +1,25 @@
<!-- Auto-generated by script/generate-cli-docs.ts — do not edit manually -->
| Command | Description |
| --- | --- |
| `kilo acp` | start ACP (Agent Client Protocol) server |
| `kilo mcp` | manage MCP (Model Context Protocol) servers |
| `kilo [project]` | start kilo tui |
| `kilo attach <url>` | attach to a running kilo server |
| `kilo run [message..]` | run kilo with a message |
| `kilo debug` | debugging and troubleshooting tools |
| `kilo auth` | manage credentials |
| `kilo agent` | manage agents |
| `kilo upgrade [target]` | upgrade kilo to the latest or a specific version |
| `kilo uninstall` | uninstall kilo and remove all related files |
| `kilo serve` | starts a headless kilo server |
| `kilo models [provider]` | list all available models |
| `kilo stats` | show token usage and cost statistics |
| `kilo export [sessionID]` | export session data as JSON |
| `kilo import <file>` | import session data from JSON file or URL |
| `kilo pr <number>` | fetch and checkout a GitHub PR branch, then run kilo |
| `kilo session` | manage sessions |
| `kilo remote` | enable remote connection for real-time session relay |
| `kilo db` | database tools |
| `kilo help [command]` | show full CLI reference |
| `kilo completion` | generate shell completion script |
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@kilocode/kilo-docs",
"version": "7.1.22",
"version": "7.2.0",
"private": true,
"scripts": {
"dev": "next dev --webpack --port 3002",
@@ -18,27 +18,6 @@ Kilo Code ships with a curated list of models for each provider, but you can use
Add custom models under the `provider.<provider_id>.models` key in your config file. The model key becomes the model ID you reference elsewhere.
{% tabs %}
{% tab label="CLI" %}
**Config file** (`~/.config/kilo/kilo.jsonc` or `./kilo.jsonc`):
```jsonc
{
"$schema": "https://app.kilo.ai/config.json",
"model": "lmstudio/my-custom-model",
"provider": {
"lmstudio": {
"models": {
"my-custom-model": {
"name": "My Custom Model",
},
},
},
},
}
```
{% /tab %}
{% tab label="VSCode" %}
1. Open **Settings** (gear icon) and go to the **Providers** tab.
@@ -64,6 +43,27 @@ To edit an existing custom provider, click the **Edit provider** button next to
For additional model configuration (token limits, tool calling, reasoning, variants), edit the `kilo.jsonc` config file directly — see the **CLI** tab for the format.
{% /tab %}
{% tab label="CLI" %}
**Config file** (`~/.config/kilo/kilo.jsonc` or `./kilo.jsonc`):
```jsonc
{
"$schema": "https://app.kilo.ai/config.json",
"model": "lmstudio/my-custom-model",
"provider": {
"lmstudio": {
"models": {
"my-custom-model": {
"name": "My Custom Model",
},
},
},
},
}
```
{% /tab %}
{% /tabs %}
@@ -91,14 +91,14 @@ All fields are optional. When a model ID matches one already in the built-in cat
| `provider` | `object` | Override `{ npm?, api? }` — the AI SDK package or base API URL for this model |
| `variants` | `object` | Named variant configurations (e.g., different reasoning efforts) |
### Token Limits (`limit`)
### Token Limits (limit)
The `limit` object controls how Kilo manages the model's context window and output length. These values are specified in **tokens**.
| Sub-field | Type | Required | Description |
| --------- | -------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `context` | `number` | No | The model's total context window size (e.g., `131072` for a 128K model). Used to determine when conversation history should be compacted to stay within the window. |
| `output` | `number` | No | The maximum number of tokens the model can generate in a single response. Sent to the provider as `max_tokens` or equivalent. Capped at 32,000 by default. |
| `context` | `number` | No | The model's total context window size (e.g., `131072` for a 128K model). Used to determine when conversation history should be compacted to stay within the window. |
| `output` | `number` | No | The maximum number of tokens the model can generate in a single response. Sent to the provider as `max_tokens` or equivalent. Capped at 32,000 by default. |
| `input` | `number` | No | An optional stricter input limit. Some providers enforce an input token ceiling that is lower than the full context window. When set, compaction triggers against this value instead of `context`. |
```jsonc
@@ -263,7 +263,7 @@ Override options or define reasoning variants for a built-in model:
}
```
### Using the `id` field to map model names
### Using the id field to map model names
If the model key in your config differs from what the provider expects, use the `id` field:
@@ -2,6 +2,80 @@
This guide walks you through setting up Mistral's Codestral model for free autocomplete in Kilo Code. Mistral offers a free tier that's perfect for getting started with AI-powered code completions.
{% tabs %}
{% tab label="VS Code" %}
## Prerequisites
- A [Kilo Code account](https://app.kilo.ai) (free to create)
- A Mistral AI account with a Codestral API key
## Step 1: Navigate to Codestral in Mistral AI Studio
Go to the [Mistral AI console](https://console.mistral.ai/) and sign up or sign in to your account. In the sidebar, click **Codestral** under the Code section.
![Select Codestral](/docs/img/mistral-setup/06-navigate-to-codestral.png)
## Step 2: Generate API Key
Click the **Generate API Key** button to create your new Codestral API key.
![Confirm Generate](/docs/img/mistral-setup/07-confirm-key-generation.png)
## Step 3: Copy Your API Key
Once generated, click the **copy** button next to your API key to copy it to your clipboard.
![Copy API Key](/docs/img/mistral-setup/08-copy-api-key.png)
{% callout type="note" %}
The Codestral API key is separate from the standard Mistral La Plateforme API key. Make sure you generate a key specifically from the **Codestral** section of the Mistral console.
{% /callout %}
## Step 4: Add Your Key via BYOK in Kilo
1. Log into the [Kilo platform](https://app.kilo.ai).
2. Navigate to the [Bring Your Own Key (BYOK) page](https://app.kilo.ai/byok), available in the sidebar under **Account**.
3. Click **Add Your First Key** (or **Add Key** if you already have keys configured).
4. Select **Codestral** as the provider.
5. Paste your Codestral API key.
6. Click **Save**.
{% callout type="tip" %}
For more details on BYOK, see the [Bring Your Own Key documentation](/docs/getting-started/byok).
{% /callout %}
## Step 5: Verify Autocomplete is Working
Once your BYOK key is saved, Kilo Code's autocomplete will automatically use your Codestral key through the Kilo Gateway. No additional configuration is needed in the extension.
1. Open VS Code with the Kilo Code extension installed.
2. Start typing in any code file — you should see inline ghost-text suggestions powered by Codestral.
3. Press `Tab` to accept a suggestion.
The autocomplete status bar in VS Code shows the current provider ("Kilo Gateway") and tracks cumulative cost. With BYOK, requests are billed directly by Mistral at their rates (Codestral has a free tier) and show as $0.00 on your Kilo balance.
## How It Works
When you add a Codestral BYOK key, the request flow is:
```
Your Editor → Kilo Gateway (with your key) → Mistral
```
- The Kilo Gateway detects your BYOK key and routes autocomplete requests using it.
- You are billed directly by Mistral — Kilo does not add any markup.
- If your BYOK key is invalid, the request will fail (it does not fall back to Kilo's keys).
## Troubleshooting
- **Autocomplete not appearing?** Check that autocomplete is enabled in Kilo Code settings (it is on by default). Also verify you are signed into Kilo Code in the extension.
- **Key not working?** Ensure you copied the **Codestral** API key (not the standard La Plateforme key). You can verify your key at [console.mistral.ai/codestral](https://console.mistral.ai/codestral).
- **Seeing charges on your Kilo balance?** If you haven't configured BYOK, autocomplete defaults to using your Kilo credits. Add your Codestral key via BYOK to route requests through your own Mistral account.
{% /tab %}
{% tab label="VS Code Legacy" %}
## Video Walkthrough
{% youtube url="https://www.youtube.com/embed/0aqBbB8fPho" caption="Setting up Mistral for free autocomplete in Kilo Code" /%}
@@ -74,6 +148,9 @@ Click **Save** to apply your Mistral configuration. You're now ready to use free
![Save Settings](/docs/img/mistral-setup/10-save-settings.png)
{% /tab %}
{% /tabs %}
## Next Steps
- Learn more about [Autocomplete features](/docs/code-with-ai/features/autocomplete)
@@ -0,0 +1,816 @@
---
title: "CLI Command Reference"
description: "Complete reference for all Kilo CLI commands and subcommands"
---
# CLI Command Reference
<!-- Auto-generated by script/generate-cli-docs.ts — do not edit manually -->
## kilo acp
```
start ACP (Agent Client Protocol) server
Options:
--help Show help [boolean]
--version Show version number [boolean]
--port port to listen on [number] [default: 0]
--hostname hostname to listen on [string] [default: "127.0.0.1"]
--mdns enable mDNS service discovery (defaults hostname to 0.0.0.0) [boolean] [default: false]
--mdns-domain custom domain name for mDNS service (default: kilo.local) [string] [default: "kilo.local"]
--cors additional domains to allow for CORS [array] [default: []]
--cwd working directory [string] [default: "."]
```
## kilo mcp
```
manage MCP (Model Context Protocol) servers
Commands:
kilo mcp add add an MCP server
kilo mcp list list MCP servers and their status [aliases: ls]
kilo mcp auth [name] authenticate with an OAuth-enabled MCP server
kilo mcp logout [name] remove OAuth credentials for an MCP server
kilo mcp debug <name> debug OAuth connection for an MCP server
Options:
--help Show help [boolean]
--version Show version number [boolean]
```
### kilo mcp add
```
add an MCP server
Options:
--help Show help [boolean]
--version Show version number [boolean]
```
### kilo mcp list
```
list MCP servers and their status
Options:
--help Show help [boolean]
--version Show version number [boolean]
```
### kilo mcp auth
```
authenticate with an OAuth-enabled MCP server
Commands:
kilo mcp auth list list OAuth-capable MCP servers and their auth status [aliases: ls]
Positionals:
name name of the MCP server [string]
Options:
--help Show help [boolean]
--version Show version number [boolean]
```
### kilo mcp auth list
```
list OAuth-capable MCP servers and their auth status
Options:
--help Show help [boolean]
--version Show version number [boolean]
```
### kilo mcp logout
```
remove OAuth credentials for an MCP server
Positionals:
name name of the MCP server [string]
Options:
--help Show help [boolean]
--version Show version number [boolean]
```
### kilo mcp debug
```
debug OAuth connection for an MCP server
Positionals:
name name of the MCP server [string]
Options:
--help Show help [boolean]
--version Show version number [boolean]
```
## kilo [project]
```
start kilo tui
Positionals:
project path to start kilo in [string]
Options:
--help Show help [boolean]
--version Show version number [boolean]
--port port to listen on [number] [default: 0]
--hostname hostname to listen on [string] [default: "127.0.0.1"]
--mdns enable mDNS service discovery (defaults hostname to 0.0.0.0) [boolean] [default: false]
--mdns-domain custom domain name for mDNS service (default: kilo.local) [string] [default: "kilo.local"]
--cors additional domains to allow for CORS [array] [default: []]
-m, --model model to use in the format of provider/model [string]
-c, --continue continue the last session [boolean]
-s, --session session id to continue [string]
--fork fork the session when continuing (use with --continue or --session) [boolean]
--cloud-fork fetch session from cloud and continue locally (use with --session) [boolean]
--prompt prompt to use [string]
--agent agent to use [string]
```
## kilo attach
```
attach to a running kilo server
Positionals:
url http://localhost:4096 [string]
Options:
--help Show help [boolean]
--version Show version number [boolean]
--dir directory to run in [string]
-c, --continue continue the last session [boolean]
-s, --session session id to continue [string]
--fork fork the session when continuing (use with --continue or --session) [boolean]
--cloud-fork fetch session from cloud and continue locally (use with --session) [boolean]
-p, --password basic auth password (defaults to KILO_SERVER_PASSWORD) [string]
```
## kilo run
```
run kilo with a message
Positionals:
message message to send [string] [default: []]
Options:
--help Show help [boolean]
--version Show version number [boolean]
--command the command to run, use message for args [string]
-c, --continue continue the last session [boolean]
-s, --session session id to continue [string]
--fork fork the session before continuing (requires --continue or --session) [boolean]
--cloud-fork fetch session from cloud and continue locally (requires --session) [boolean]
--share share the session [boolean]
-m, --model model to use in the format of provider/model [string]
--agent agent to use [string]
--format format: default (formatted) or json (raw JSON events) [string] [choices: "default", "json"] [default: "default"]
-f, --file file(s) to attach to message [array]
--title title for the session (uses truncated prompt if no value provided) [string]
--attach attach to a running opencode server (e.g., http://localhost:4096) [string]
-p, --password basic auth password (defaults to KILO_SERVER_PASSWORD) [string]
--dir directory to run in, path on remote server if attaching [string]
--port port for the local server (defaults to random port if no value provided) [number]
--variant model variant (provider-specific reasoning effort, e.g., high, max, minimal) [string]
--thinking show thinking blocks [boolean] [default: false]
--auto auto-approve all permissions (for autonomous/pipeline usage) [boolean] [default: false]
```
## kilo debug
```
debugging and troubleshooting tools
Commands:
kilo debug config show resolved configuration
kilo debug lsp LSP debugging utilities
kilo debug rg ripgrep debugging utilities
kilo debug file file system debugging utilities
kilo debug scrap list all known projects
kilo debug skill list all available skills
kilo debug snapshot snapshot debugging utilities
kilo debug agent <name> show agent configuration details
kilo debug paths show global paths (data, config, cache, state)
kilo debug wait wait indefinitely (for debugging)
Options:
--help Show help [boolean]
--version Show version number [boolean]
```
### kilo debug config
```
show resolved configuration
Options:
--help Show help [boolean]
--version Show version number [boolean]
```
### kilo debug lsp
```
LSP debugging utilities
Commands:
kilo debug lsp diagnostics <file> get diagnostics for a file
kilo debug lsp symbols <query> search workspace symbols
kilo debug lsp document-symbols <uri> get symbols from a document
Options:
--help Show help [boolean]
--version Show version number [boolean]
```
### kilo debug lsp diagnostics
```
get diagnostics for a file
Positionals:
file [string]
Options:
--help Show help [boolean]
--version Show version number [boolean]
```
### kilo debug lsp symbols
```
search workspace symbols
Positionals:
query [string]
Options:
--help Show help [boolean]
--version Show version number [boolean]
```
### kilo debug lsp document-symbols
```
get symbols from a document
Positionals:
uri [string]
Options:
--help Show help [boolean]
--version Show version number [boolean]
```
### kilo debug rg
```
ripgrep debugging utilities
Commands:
kilo debug rg tree show file tree using ripgrep
kilo debug rg files list files using ripgrep
kilo debug rg search <pattern> search file contents using ripgrep
Options:
--help Show help [boolean]
--version Show version number [boolean]
```
### kilo debug rg tree
```
show file tree using ripgrep
Options:
--help Show help [boolean]
--version Show version number [boolean]
--limit [number]
```
### kilo debug rg files
```
list files using ripgrep
Options:
--help Show help [boolean]
--version Show version number [boolean]
--query Filter files by query [string]
--glob Glob pattern to match files [string]
--limit Limit number of results [number]
```
### kilo debug rg search
```
search file contents using ripgrep
Positionals:
pattern Search pattern [string]
Options:
--help Show help [boolean]
--version Show version number [boolean]
--glob File glob patterns [array]
--limit Limit number of results [number]
```
### kilo debug file
```
file system debugging utilities
Commands:
kilo debug file read <path> read file contents as JSON
kilo debug file status show file status information
kilo debug file list <path> list files in a directory
kilo debug file search <query> search files by query
kilo debug file tree [dir] show directory tree
Options:
--help Show help [boolean]
--version Show version number [boolean]
```
### kilo debug file read
```
read file contents as JSON
Positionals:
path File path to read [string]
Options:
--help Show help [boolean]
--version Show version number [boolean]
```
### kilo debug file status
```
show file status information
Options:
--help Show help [boolean]
--version Show version number [boolean]
```
### kilo debug file list
```
list files in a directory
Positionals:
path File path to list [string]
Options:
--help Show help [boolean]
--version Show version number [boolean]
```
### kilo debug file search
```
search files by query
Positionals:
query Search query [string]
Options:
--help Show help [boolean]
--version Show version number [boolean]
```
### kilo debug file tree
```
show directory tree
Positionals:
dir Directory to tree [string] [default: "."]
Options:
--help Show help [boolean]
--version Show version number [boolean]
```
### kilo debug scrap
```
list all known projects
Options:
--help Show help [boolean]
--version Show version number [boolean]
```
### kilo debug skill
```
list all available skills
Options:
--help Show help [boolean]
--version Show version number [boolean]
```
### kilo debug snapshot
```
snapshot debugging utilities
Commands:
kilo debug snapshot track track current snapshot state
kilo debug snapshot patch <hash> show patch for a snapshot hash
kilo debug snapshot diff <hash> show diff for a snapshot hash
Options:
--help Show help [boolean]
--version Show version number [boolean]
```
### kilo debug snapshot track
```
track current snapshot state
Options:
--help Show help [boolean]
--version Show version number [boolean]
```
### kilo debug snapshot patch
```
show patch for a snapshot hash
Positionals:
hash hash [string]
Options:
--help Show help [boolean]
--version Show version number [boolean]
```
### kilo debug snapshot diff
```
show diff for a snapshot hash
Positionals:
hash hash [string]
Options:
--help Show help [boolean]
--version Show version number [boolean]
```
### kilo debug agent
```
show agent configuration details
Positionals:
name Agent name [string]
Options:
--help Show help [boolean]
--version Show version number [boolean]
--tool Tool id to execute [string]
--params Tool params as JSON or a JS object literal [string]
```
### kilo debug paths
```
show global paths (data, config, cache, state)
Options:
--help Show help [boolean]
--version Show version number [boolean]
```
### kilo debug wait
```
wait indefinitely (for debugging)
Options:
--help Show help [boolean]
--version Show version number [boolean]
```
## kilo auth
```
manage credentials
Commands:
kilo auth login [url] log in to a provider
kilo auth logout log out from a configured provider
kilo auth list list providers [aliases: ls]
Options:
--help Show help [boolean]
--version Show version number [boolean]
```
### kilo auth login
```
log in to a provider
Positionals:
url kilo auth provider [string]
Options:
--help Show help [boolean]
--version Show version number [boolean]
-p, --provider provider id or name to log in to (skips provider selection) [string]
-m, --method login method label (skips method selection) [string]
```
### kilo auth logout
```
log out from a configured provider
Options:
--help Show help [boolean]
--version Show version number [boolean]
```
### kilo auth list
```
list providers
Options:
--help Show help [boolean]
--version Show version number [boolean]
```
## kilo agent
```
manage agents
Commands:
kilo agent create create a new agent
kilo agent list list all available agents
Options:
--help Show help [boolean]
--version Show version number [boolean]
```
### kilo agent create
```
create a new agent
Options:
--help Show help [boolean]
--version Show version number [boolean]
--path directory path to generate the agent file [string]
--description what the agent should do [string]
--mode agent mode [string] [choices: "all", "primary", "subagent"]
--tools comma-separated list of tools to enable (default: all). Available: "bash, read, write, edit, list, glob, grep, webfetch, task, todowrite, todoread" [string]
-m, --model model to use in the format of provider/model [string]
```
### kilo agent list
```
list all available agents
Options:
--help Show help [boolean]
--version Show version number [boolean]
```
## kilo upgrade
```
upgrade kilo to the latest or a specific version
Positionals:
target version to upgrade to, for ex '0.1.48' or 'v0.1.48' [string]
Options:
--help Show help [boolean]
--version Show version number [boolean]
-m, --method installation method to use [string] [choices: "curl", "npm", "pnpm", "bun", "brew", "choco", "scoop"]
```
## kilo uninstall
```
uninstall kilo and remove all related files
Options:
--help Show help [boolean]
--version Show version number [boolean]
-c, --keep-config keep configuration files [boolean] [default: false]
-d, --keep-data keep session data and snapshots [boolean] [default: false]
--dry-run show what would be removed without removing [boolean] [default: false]
-f, --force skip confirmation prompts [boolean] [default: false]
```
## kilo serve
```
starts a headless kilo server
Options:
--help Show help [boolean]
--version Show version number [boolean]
--port port to listen on [number] [default: 0]
--hostname hostname to listen on [string] [default: "127.0.0.1"]
--mdns enable mDNS service discovery (defaults hostname to 0.0.0.0) [boolean] [default: false]
--mdns-domain custom domain name for mDNS service (default: kilo.local) [string] [default: "kilo.local"]
--cors additional domains to allow for CORS [array] [default: []]
```
## kilo models
```
list all available models
Positionals:
provider provider ID to filter models by [string]
Options:
--help Show help [boolean]
--version Show version number [boolean]
--verbose use more verbose model output (includes metadata like costs) [boolean]
--refresh refresh the models cache from models.dev [boolean]
```
## kilo stats
```
show token usage and cost statistics
Options:
--help Show help [boolean]
--version Show version number [boolean]
--days show stats for the last N days (default: all time) [number]
--tools number of tools to show (default: all) [number]
--models show model statistics (default: hidden). Pass a number to show top N, otherwise shows all
--project filter by project (default: all projects, empty string: current project) [string]
```
## kilo export
```
export session data as JSON
Positionals:
sessionID session id to export [string]
Options:
--help Show help [boolean]
--version Show version number [boolean]
```
## kilo import
```
import session data from JSON file or URL
Positionals:
file path to JSON file or share URL [string]
Options:
--help Show help [boolean]
--version Show version number [boolean]
```
## kilo pr
```
fetch and checkout a GitHub PR branch, then run kilo
Positionals:
number PR number to checkout [number]
Options:
--help Show help [boolean]
--version Show version number [boolean]
```
## kilo session
```
manage sessions
Commands:
kilo session list list sessions
kilo session delete <sessionID> delete a session
Options:
--help Show help [boolean]
--version Show version number [boolean]
```
### kilo session list
```
list sessions
Options:
--help Show help [boolean]
--version Show version number [boolean]
-n, --max-count limit to N most recent sessions [number]
--format output format [string] [choices: "table", "json"] [default: "table"]
```
### kilo session delete
```
delete a session
Positionals:
sessionID session ID to delete [string]
Options:
--help Show help [boolean]
--version Show version number [boolean]
```
## kilo remote
```
enable remote connection for real-time session relay
Options:
--help Show help [boolean]
--version Show version number [boolean]
```
## kilo db
```
database tools
Commands:
kilo db [query] open an interactive sqlite3 shell or run a query [default]
kilo db path print the database path
kilo db migrate migrate JSON data to SQLite (merges with existing data)
Positionals:
query SQL query to execute [string]
Options:
--help Show help [boolean]
--version Show version number [boolean]
--format Output format [string] [choices: "json", "tsv"] [default: "tsv"]
```
### kilo db path
```
print the database path
Options:
--help Show help [boolean]
--version Show version number [boolean]
```
### kilo db migrate
```
migrate JSON data to SQLite (merges with existing data)
Options:
--help Show help [boolean]
--version Show version number [boolean]
```
## kilo help
```
show full CLI reference
Positionals:
command command to show help for [string]
Options:
--help Show help [boolean]
--version Show version number [boolean]
--all show help for all commands [boolean] [default: false]
--format output format [string] [choices: "md", "text"] [default: "md"]
```
@@ -61,27 +61,9 @@ Or use npm:
### Top-Level CLI Commands
| Command | Description |
| ------------------------- | ------------------------------------------ |
| `kilo [project]` | Start the TUI (Terminal User Interface) |
| `kilo run [message..]` | Run with a message (non-interactive mode) |
| `kilo attach <url>` | Attach to a running kilo server |
| `kilo serve` | Start a headless server |
| `kilo web` | Start server and open web interface |
| `kilo auth` | Manage credentials (login, logout, list) |
| `kilo agent` | Manage agents (create, list) |
| `kilo mcp` | Manage MCP servers (list, add, auth) |
| `kilo models [provider]` | List available models |
| `kilo stats` | Show token usage and cost statistics |
| `kilo session` | Manage sessions (list) |
| `kilo export [sessionID]` | Export session data as JSON |
| `kilo import <file>` | Import session data from JSON file or URL |
| `kilo upgrade [target]` | Upgrade kilo to latest or specific version |
| `kilo uninstall` | Uninstall kilo and remove related files |
| `kilo pr <number>` | Fetch and checkout a GitHub PR branch |
| `kilo github` | Manage GitHub agent (install, run) |
| `kilo debug` | Debugging and troubleshooting tools |
| `kilo completion` | Generate shell completion script |
{% partial file="cli-commands-table.md" /%}
For detailed help on every command and subcommand, see the [CLI Command Reference](/docs/code-with-ai/platforms/cli-reference).
### Global Options
@@ -62,6 +62,14 @@ The Agent Manager also includes a built-in diff reviewer that shows every change
You can now trigger local AI-powered code reviews directly by using two commands: **`/local-review`** to review all changes on your current branch vs the base branch, and **`/local-review-uncommitted`** to review staged and unstaged changes.
See the [Code Reviews](/docs/automate/code-reviews/overview) documentation for the full setup and options.
### How can I see the cost of each model?
In the model picker dropdown, click the expand button in the upper-right corner to switch to the full model picker view. From there, click on any model to see its details — including input and output pricing per million tokens, the context window size, and which capabilities the model supports (reasoning, text, images, etc.). This makes it easy to compare costs before selecting a model.
### How do I set context limits or other parameters for custom models?
If you're using a custom model (e.g. via your own API key or a self-hosted provider), you can configure the context window size, max output tokens, and other parameters in your model settings. See the [Custom Models](/docs/code-with-ai/agents/custom-models) documentation for the full guide on adding and configuring custom models.
### Where did my custom profiles go?
In the new extension we simplified the model selection by removing the profile layer. To keep models easily reachable you don't need a profile — you can just star them in the model selector to mark them as favorites.
@@ -20,9 +20,9 @@ Because Autocomplete needs to be ready the moment you start typing, the model st
#### How much does it cost?
You can use Codestral 2508 for Autocomplete completely for free by configuring it through our Mistral integration.
You can use Codestral for Autocomplete without consuming Kilo credits by adding your own Mistral Codestral API key via BYOK (Bring Your Own Key). Mistral offers a free tier for Codestral.
**Setup Guide:** [Setting up Mistral for Free Autocomplete](/docs/code-with-ai/features/autocomplete/mistral-setup#setting-up-mistral-for-free-autocomplete)
**Setup Guide:** [Setting Up Mistral for Free Autocomplete](/docs/code-with-ai/features/autocomplete/mistral-setup)
#### How to Disable These Requests
@@ -64,9 +64,9 @@ If you run out of credits and haven't configured a free alternative, autocomplet
### How to Get It Free
Configure Mistral directly as your autocomplete provider. Mistral offers free access to their Codestral model, which is optimized for code completions. When you configure Mistral directly, it takes precedence over the default Kilo Code routing.
Add your own Mistral Codestral API key via **BYOK (Bring Your Own Key)** on the Kilo Gateway. Mistral offers a free tier for Codestral, and when you configure a BYOK key, autocomplete requests are routed using your key — billed directly by Mistral at $0 on your Kilo balance.
For step-by-step instructions with screenshots, see our [Mistral Setup Guide](/docs/code-with-ai/features/autocomplete/mistral-setup).
For step-by-step instructions, see our [Mistral Setup Guide](/docs/code-with-ai/features/autocomplete/mistral-setup).
## Free CLI Background Tasks
@@ -99,6 +99,6 @@ Replace `your-preferred-free-model` with any free model available in the model p
## Related Resources
- [Free and Budget Models](/docs/code-with-ai/agents/free-and-budget-models) - Complete guide to free and budget-friendly model options
- [Mistral Setup Guide](/docs/code-with-ai/features/autocomplete/mistral-setup) - Step-by-step free autocomplete setup
- [Mistral Setup Guide](/docs/code-with-ai/features/autocomplete/mistral-setup) - Step-by-step autocomplete setup via BYOK
- [Autocomplete](/docs/code-with-ai/features/autocomplete) - Full autocomplete documentation
- [CLI Documentation](/docs/code-with-ai/platforms/cli) - Complete CLI reference
+3 -1
View File
@@ -1,7 +1,7 @@
# Source Code Links
<!-- Auto-generated by script/extract-source-links.ts — DO NOT EDIT -->
<!-- 79 unique URLs extracted from extension and CLI source -->
<!-- 80 unique URLs extracted from extension and CLI source -->
- <https://api.apertis.ai/v1>
<!-- packages/opencode/src/provider/model-cache.ts -->
@@ -83,6 +83,8 @@
- <https://kilo.ai/docs>
<!-- packages/kilo-vscode/webview-ui/src/hooks/useSlashCommand.ts -->
<!-- packages/opencode/src/cli/cmd/tui/app.tsx -->
- <https://kilo.ai/docs/code-with-ai/platforms/vscode/whats-new>
<!-- packages/kilo-vscode/webview-ui/src/components/migration/MigrationWizard.tsx -->
- <https://kilo.ai/docs/providers/#custom-provider>
<!-- packages/kilo-vscode/webview-ui/src/components/settings/CustomProviderDialog.tsx -->
- <https://kilo.ai/gateway>
+2 -2
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@kilocode/kilo-gateway",
"version": "7.1.22",
"version": "7.2.0",
"type": "module",
"license": "MIT",
"description": "Unified Kilo Gateway package for OpenCode - authentication, provider, and API integration",
@@ -43,7 +43,7 @@
"@types/node": "catalog:",
"typescript": "catalog:",
"@typescript/native-preview": "catalog:",
"solid-js": "1.9.10",
"solid-js": "catalog:",
"@opentui/core": "0.1.75",
"@opentui/solid": "0.1.75"
},
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@kilocode/kilo-i18n",
"version": "7.1.22",
"version": "7.2.0",
"type": "module",
"license": "MIT",
"description": "Kilo-specific i18n translations and overrides",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@kilocode/kilo-telemetry",
"version": "7.1.22",
"version": "7.2.0",
"type": "module",
"license": "MIT",
"description": "Telemetry for Kilo CLI - PostHog analytics integration",
+3
View File
@@ -25,6 +25,9 @@ export enum TelemetryEvent {
MCP_SERVER_CONNECTED = "MCP Server Connected",
MCP_SERVER_ERROR = "MCP Server Error",
// Remote Events
REMOTE_CONNECTION_OPENED = "Remote Connection Opened",
// Auth Events
AUTH_SUCCESS = "Auth Success",
AUTH_LOGOUT = "Auth Logout",
+5
View File
@@ -184,6 +184,11 @@ export namespace Telemetry {
track(TelemetryEvent.MCP_SERVER_ERROR, { server, error })
}
// Remote
export function trackRemoteConnectionOpened() {
track(TelemetryEvent.REMOTE_CONNECTION_OPENED)
}
// Auth
export function trackAuthSuccess(provider: string) {
track(TelemetryEvent.AUTH_SUCCESS, { provider })
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@kilocode/kilo-ui",
"version": "7.1.22",
"version": "7.2.0",
"type": "module",
"license": "MIT",
"exports": {
@@ -102,7 +102,7 @@
"storybook": "10.2.10",
"storybook-solidjs-vite": "10.0.9",
"typescript": "catalog:",
"vite": "7.3.1",
"vite": "7.3.2",
"vite-plugin-solid": "2.11.10"
},
"scripts": {
@@ -19,6 +19,18 @@
height: 20px;
}
}
[data-slot="assistant-copy-wrapper"] {
display: flex;
align-items: center;
justify-content: flex-start;
margin-top: 2px;
[data-component="icon-button"] {
width: 20px;
height: 20px;
}
}
}
/* Prevent long title/path from hiding the collapsible expand arrow */
@@ -1155,6 +1155,7 @@ PART_MAPPING["compaction"] = function CompactionPartDisplay() {
PART_MAPPING["text"] = function TextPartDisplay(props) {
const data = useData()
const i18n = useI18n()
const part = () => props.part as TextPart
const displayText = () => (part().text ?? "").trim()
@@ -1166,6 +1167,21 @@ PART_MAPPING["text"] = function TextPartDisplay(props) {
return props.turnDiffSummary
})
const showCopy = createMemo(() => {
if (props.message.role !== "assistant") return false
if (props.showAssistantCopyPartID === null) return false
return props.showAssistantCopyPartID === part().id
})
const [copied, setCopied] = createSignal(false)
const handleCopy = async () => {
const content = displayText()
if (!content) return
await navigator.clipboard.writeText(content)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
const handleMarkdownClick = (e: MouseEvent) => {
if (!data.openFile) return
const target = e.target
@@ -1200,6 +1216,24 @@ PART_MAPPING["text"] = function TextPartDisplay(props) {
<div data-slot="text-part-body">
<Markdown text={throttledText()} cacheKey={part().id} onClick={handleMarkdownClick} />
</div>
<Show when={showCopy()}>
<div data-slot="assistant-copy-wrapper">
<Tooltip
value={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copyResponse")}
placement="right"
gutter={4}
>
<IconButton
icon={copied() ? "check" : "copy"}
size="normal"
variant="ghost"
onMouseDown={(e) => e.preventDefault()}
onClick={handleCopy}
aria-label={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copyResponse")}
/>
</Tooltip>
</div>
</Show>
<Show when={summary()}>
{(render) => (
<GrowBox animate={!!props.animate} fade gap={4} class="w-full min-w-0">
+14 -7
View File
@@ -2,7 +2,7 @@
"name": "kilo-code",
"displayName": "Kilo Code: AI Coding Agent, Copilot, and Autocomplete",
"description": "Open Source AI coding agent that generates code from natural language, automates tasks, and runs terminal commands. Features inline autocomplete, browser automation, automated refactoring, and custom modes for planning, coding, and debugging. Supports 500+ AI models including Claude (Anthropic), Gemini, Grok, GPT, Codex and GLM.",
"version": "7.1.22",
"version": "7.2.0",
"icon": "assets/icons/logo-outline-black.png",
"galleryBanner": {
"color": "#FFFFFF",
@@ -602,12 +602,14 @@
{
"command": "kilo-code.new.cycleAgentMode",
"key": "ctrl+.",
"mac": "cmd+."
"mac": "cmd+.",
"when": "sideBarFocus && kilo-code.new.sidebarVisible || activeWebviewPanelId == 'kilo-code.new.AgentManagerPanel' || activeWebviewPanelId == 'kilo-code.new.TabPanel'"
},
{
"command": "kilo-code.new.cyclePreviousAgentMode",
"key": "ctrl+shift+.",
"mac": "cmd+shift+."
"mac": "cmd+shift+.",
"when": "sideBarFocus && kilo-code.new.sidebarVisible || activeWebviewPanelId == 'kilo-code.new.AgentManagerPanel' || activeWebviewPanelId == 'kilo-code.new.TabPanel'"
},
{
"command": "kilo-code.new.autocomplete.cancelSuggestions",
@@ -765,6 +767,11 @@
"none"
],
"description": "Sound to play on errors"
},
"kilo-code.new.showTaskTimeline": {
"type": "boolean",
"default": true,
"description": "Show the task timeline graph in the chat header"
}
}
}
@@ -823,7 +830,7 @@
"ts-morph": "27.0.2",
"typescript": "^5.9.3",
"typescript-eslint": "^8.54.0",
"vite": "7.3.1",
"vite": "7.3.2",
"vite-plugin-solid": "2.11.10"
},
"dependencies": {
@@ -833,7 +840,7 @@
"@kilocode/sdk": "workspace:*",
"@opencode-ai/ui": "workspace:*",
"@thisbeyond/solid-dnd": "0.7.5",
"diff": "^7.0.0",
"diff": "8.0.4",
"dotenv": "^16.4.7",
"fastest-levenshtein": "^1.0.16",
"friendly-words": "1.3.1",
@@ -842,11 +849,11 @@
"lru-cache": "^11.0.2",
"openai": "^4.85.4",
"quick-lru": "^7.0.0",
"simple-git": "3.31.1",
"simple-git": "3.35.2",
"solid-js": "^1.9.11",
"uri-js": "^4.4.1",
"web-tree-sitter": "^0.24.7",
"yaml": "2.8.2",
"yaml": "2.8.3",
"zod": "^3.24.2"
}
}
+6 -3
View File
@@ -6,8 +6,9 @@ import { existsSync, mkdirSync, rmSync, chmodSync } from "node:fs"
const packageJsonPath = join(import.meta.dir, "..", "package.json")
const packageJson = await Bun.file(packageJsonPath).json()
const version = process.env.KILO_VERSION ? process.env.KILO_VERSION : packageJson.version
const prerelease = process.env.KILO_PRE_RELEASE === "true"
console.log(`Building VSCode extension version: ${version}`)
console.log(`Building VSCode extension version: ${version}${prerelease ? " (pre-release)" : ""}`)
if (packageJson.version !== version) {
console.log(`Updating package.json version from ${packageJson.version} to ${version}`)
@@ -80,9 +81,11 @@ for (const config of targets) {
console.log(` ✅ Binary ready at ${targetBinary}`)
console.log(` 📦 Packaging .vsix for ${config.target}...`)
console.log(` 📦 Packaging .vsix for ${config.target}${prerelease ? " (pre-release)" : ""}...`)
const vsixPath = join(outDir, `kilo-vscode-${config.target}.vsix`)
await $`vsce package --no-dependencies --skip-license --target ${config.target} -o ${vsixPath}`.env({
const args = ["--no-dependencies", "--skip-license", "--target", config.target, "-o", vsixPath]
if (prerelease) args.push("--pre-release")
await $`vsce package ${args}`.env({
...process.env,
npm_config_ignore_scripts: "true",
})
+9 -5
View File
@@ -4,7 +4,9 @@ import { join } from "node:path"
import { existsSync } from "node:fs"
import { Script } from "@opencode-ai/script"
console.log(`Publishing VSCode extension for release: v${Script.version}`)
const prerelease = process.env.KILO_PRE_RELEASE === "true"
console.log(`Publishing VSCode extension for ${prerelease ? "pre-release" : "release"}: v${Script.version}`)
const outDir = process.env.VSIX_DIR || join(import.meta.dir, "..", "out")
@@ -36,14 +38,16 @@ for (const target of targets) {
console.log(`\nFound ${vsixFiles.length} VSIX files`)
const flag = prerelease ? ["--pre-release"] : []
for (const target of targets) {
const vsixPath = join(outDir, `kilo-vscode-${target}.vsix`)
console.log(`\n🚀 Publishing ${target} to VS Code Marketplace...`)
await $`vsce publish --packagePath ${vsixPath}`
console.log(`\n🚀 Publishing ${target} to VS Code Marketplace${prerelease ? " (pre-release)" : ""}...`)
await $`vsce publish ${flag} --packagePath ${vsixPath}`
console.log(` ✅ Published ${target} to VS Code Marketplace`)
console.log(`\n📤 Publishing ${target} to Open VSX...`)
await $`npx ovsx publish --pat ${process.env.OPENVSX_TOKEN} --packagePath ${vsixPath}`
console.log(`\n📤 Publishing ${target} to Open VSX${prerelease ? " (pre-release)" : ""}...`)
await $`npx ovsx publish ${flag} --pat ${process.env.OPENVSX_TOKEN} --packagePath ${vsixPath}`
console.log(` ✅ Published ${target} to Open VSX`)
}
@@ -212,6 +212,7 @@ export class DiffViewerProvider implements vscode.Disposable {
public dispose(): void {
this.stopDiffPolling()
this.gitOps.dispose()
this.panel?.dispose()
this.outputChannel.dispose()
}
+302 -95
View File
@@ -35,11 +35,13 @@ import {
import { GitOps } from "./agent-manager/GitOps"
import { GitStatsPoller, type LocalStats } from "./agent-manager/GitStatsPoller"
import { getWorkspaceRoot } from "./review-utils"
import { MarketplaceService } from "./services/marketplace"
import { MarketplaceService, type MarketplaceItem, type RemoveResult } from "./services/marketplace"
import { resolveProjectDirectory } from "./project-directory"
import { getBusySessionCount, seedSessionStatuses } from "./session-status"
import { retry } from "./services/cli-backend/retry"
import { slimPart, slimParts } from "./kilo-provider/slim-metadata"
import { matchFollowup, recordFollowup, type Followup } from "./kilo-provider/followup-session"
import { retryable, backoff, MAX_RETRIES } from "./util/retry"
// legacy-migration start
import {
checkAndShowMigrationWizard,
@@ -124,6 +126,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
private cachedMcpStatusMessage: unknown = null
/** Ref-count of in-flight handleUpdateConfig calls; prevents fetchAndSendConfig from sending stale data */
private pending = 0
private configWarningsShown = false
/** Cached notificationsLoaded payload */
private cachedNotificationsMessage: unknown = null
private pendingReviewComments: { comments: unknown[]; autoSend: boolean }[] = []
@@ -168,6 +171,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
private pendingFollowup: Followup | null = null
/** Worktree diff stats poller for the sidebar badge — reuses GitStatsPoller (local stats only) */
private statsPoller: GitStatsPoller | null = null
private statsGitOps: GitOps | null = null
private cachedStats: unknown = null
/** Optional interceptor called before the standard message handler.
@@ -281,7 +285,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
// Use fire-and-forget (no throwOnError) to match old getProfile() which returned null on error.
if (this.connectionState === "connected" && this.client) {
console.log("[Kilo New] KiloProvider: 👤 syncWebviewState fetching profile...")
const profileResult = await this.client.kilo.profile()
const profileResult = await retry(() => this.client!.kilo.profile())
const profileData = profileResult.data ?? null
console.log("[Kilo New] KiloProvider: 👤 syncWebviewState profile:", profileData ? "received" : "null")
this.postMessage({
@@ -336,8 +340,10 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
// Handle messages from webview (shared handler)
this.setupWebviewMessageHandler(webviewView.webview)
// Pause stats polling when sidebar is hidden, resume when visible
// Track sidebar visibility for keybinding when-clauses and stats polling
vscode.commands.executeCommand("setContext", "kilo-code.new.sidebarVisible", webviewView.visible)
webviewView.onDidChangeVisibility(() => {
vscode.commands.executeCommand("setContext", "kilo-code.new.sidebarVisible", webviewView.visible)
this.statsPoller?.setEnabled(webviewView.visible)
})
@@ -527,6 +533,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
break
}
case "abort":
this.cancelRetry(message.sessionID ?? "")
await this.handleAbort(message.sessionID)
break
case "revertSession":
@@ -797,6 +804,9 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
case "requestNotificationSettings":
this.sendNotificationSettings()
break
case "requestTimelineSetting":
this.sendTimelineSetting()
break
case "requestNotifications":
this.fetchAndSendNotifications().catch((e) =>
console.error("[Kilo New] fetchAndSendNotifications failed:", e),
@@ -960,12 +970,8 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
break
}
case "removeInstalledMarketplaceItem": {
const workspace = this.getProjectDirectory(this.currentSession?.id)
const scope = message.mpInstallOptions?.target ?? "project"
const result = await this.getMarketplace().remove(message.mpItem, scope, workspace)
if (result.success) {
await this.invalidateAfterMarketplaceChange(scope)
}
const result = await this.removeMarketplaceItem(message.mpItem, scope)
this.postMessage({
type: "marketplaceRemoveResult",
success: result.success,
@@ -1047,6 +1053,9 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
this.postMessage({ type: "connectionState", state })
if (state === "connected") {
// Fire config warnings independently so a failure in the
// sequential await chain doesn't prevent warnings from being shown
void this.checkConfigWarnings("state")
try {
// Profile fetch is best-effort — returns 401 when user isn't logged into gateway.
const sdkClient = this.client
@@ -1122,6 +1131,14 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
}
this.postMessage({ type: "connectionState", state: this.connectionState })
// connect() can resolve after SSE reaches "connected" but before this
// provider subscribes to onStateChange(). In that case the initial
// connected callback is missed, so run the warning check here too.
if (this.connectionState === "connected") {
void this.checkConfigWarnings("init")
}
await this.syncWebviewState("initializeConnection")
await this.flushPendingSessionRefresh("initializeConnection")
@@ -1136,6 +1153,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
this.seedSessionStatusMap(),
])
this.sendNotificationSettings()
this.sendTimelineSetting()
// Start polling worktree diff stats for the sidebar badge
this.startStatsPolling()
@@ -1219,9 +1237,11 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
try {
const workspaceDir = this.getWorkspaceDirectory(sessionID)
const { data: messagesData } = await this.client.session.messages(
{ sessionID, directory: workspaceDir },
{ throwOnError: true, signal: abort.signal },
const { data: messagesData } = await retry(() =>
this.client!.session.messages(
{ sessionID, directory: workspaceDir },
{ throwOnError: true, signal: abort.signal },
),
)
// If this request was aborted while awaiting, skip posting stale results
@@ -1322,9 +1342,8 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
try {
const workspaceDir = this.getWorkspaceDirectory(sessionID)
const { data: messagesData } = await this.client.session.messages(
{ sessionID, directory: workspaceDir },
{ throwOnError: true },
const { data: messagesData } = await retry(() =>
this.client!.session.messages({ sessionID, directory: workspaceDir }, { throwOnError: true }),
)
const messages = messagesData.map((m) => ({
@@ -1594,7 +1613,9 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
try {
const workspaceDir = this.getWorkspaceDirectory()
const { data: agents } = await this.client.app.agents({ directory: workspaceDir }, { throwOnError: true })
const { data: agents } = await retry(() =>
this.client!.app.agents({ directory: workspaceDir }, { throwOnError: true }),
)
const { visible, defaultAgent } = filterVisibleAgents(agents)
@@ -1628,7 +1649,9 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
try {
const workspaceDir = this.getWorkspaceDirectory()
const { data: skills } = await this.client.app.skills({ directory: workspaceDir }, { throwOnError: true })
const { data: skills } = await retry(() =>
this.client!.app.skills({ directory: workspaceDir }, { throwOnError: true }),
)
const message = {
type: "skillsLoaded",
@@ -1651,7 +1674,9 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
try {
const dir = this.getWorkspaceDirectory()
const { data: commands } = await this.client.command.list({ directory: dir }, { throwOnError: true })
const { data: commands } = await retry(() =>
this.client!.command.list({ directory: dir }, { throwOnError: true }),
)
const message = {
type: "commandsLoaded",
@@ -1673,7 +1698,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
if (!this.client) return undefined
try {
const dir = this.getWorkspaceDirectory()
const { data } = await this.client.app.skills({ directory: dir }, { throwOnError: true })
const { data } = await retry(() => this.client!.app.skills({ directory: dir }, { throwOnError: true }))
return data
} catch (error) {
console.error("[Kilo New] KiloProvider: Failed to fetch CLI skills for marketplace:", error)
@@ -1718,59 +1743,87 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
*/
private async handleRemoveMode(name: string): Promise<void> {
if (!this.client) return
let removed = false
// 1. Try CLI removal (handles .md files and legacy .kilocodemodes)
try {
const dir = this.getWorkspaceDirectory()
const result = await this.client.kilocode.removeAgent({ name, directory: dir })
if (!result.error) removed = true
if (!result.error) {
this.cachedAgentsMessage = null
await this.fetchAndSendAgents()
return
}
} catch {
// CLI removal failed — agent may be in kilo.json instead
}
// 2. Try removing from kilo.json (handles marketplace-installed modes)
if (!removed) {
const workspace = this.getProjectDirectory(this.currentSession?.id)
const mp = this.getMarketplace()
const stub = { id: name, type: "mode" as const, name, description: "", content: "" }
const project = await mp.remove(stub, "project", workspace)
const global = await mp.remove(stub, "global", workspace)
if (project.success || global.success) {
await this.disposeCliInstance("global")
removed = true
}
}
const stub = { id: name, type: "mode" as const, name, description: "", content: "" }
const removed = await this.removeMarketplaceItemFromAllScopes(stub)
if (!removed) {
console.error("[Kilo New] KiloProvider: Failed to remove mode:", name)
}
this.cachedAgentsMessage = null
await this.fetchAndSendAgents()
}
private async handleRemoveMcp(name: string): Promise<void> {
const workspace = this.getProjectDirectory(this.currentSession?.id)
const mp = this.getMarketplace()
// Remove from legacy files first so that the subsequent invalidation
// causes the CLI to re-read config without the legacy entry.
await this.removeLegacyMcp(name)
const stub = { id: name, type: "mcp" as const, name, description: "", url: "", content: "" }
// Remove from both scopes — an MCP could exist in project, global, or both
const project = await mp.remove(stub, "project", workspace)
const global = await mp.remove(stub, "global", workspace)
if (project.success || global.success) {
// Use global scope when removed from global (or both) so the global
// config cache is also invalidated; project scope is a subset.
const scope = global.success ? "global" : "project"
await this.disposeCliInstance(scope)
this.cachedConfigMessage = null
await this.fetchAndSendConfig()
} else {
const removed = await this.removeMarketplaceItemFromAllScopes(stub)
if (!removed) {
console.error("[Kilo New] KiloProvider: Failed to remove MCP server:", name)
}
}
/**
* Remove an MCP server from legacy config files (.kilo/mcp.json, .kilocode/mcp.json,
* and the VS Code global storage mcp_settings.json). These files are read by the
* CLI-side McpMigrator and merged into config at the lowest precedence level.
* Returns true if the entry was found and removed from at least one file.
*/
private async removeLegacyMcp(name: string): Promise<boolean> {
const workspace = this.getProjectDirectory(this.currentSession?.id)
const files: vscode.Uri[] = []
// Project-level legacy files
if (workspace) {
files.push(vscode.Uri.file(path.join(workspace, ".kilo", "mcp.json")))
files.push(vscode.Uri.file(path.join(workspace, ".kilocode", "mcp.json")))
}
// Global legacy file (VS Code extension global storage)
const storage = this.extensionContext?.globalStorageUri
if (storage) {
files.push(vscode.Uri.joinPath(storage, "settings", "mcp_settings.json"))
}
let removed = false
for (const uri of files) {
const bytes = await vscode.workspace.fs.readFile(uri).then(
(b) => b,
() => null,
)
if (!bytes) continue
try {
const parsed = JSON.parse(Buffer.from(bytes).toString("utf8")) as Record<string, unknown>
const servers = parsed.mcpServers as Record<string, unknown> | undefined
if (!servers?.[name]) continue
delete servers[name]
const content = Buffer.from(JSON.stringify(parsed, null, 2), "utf8")
await vscode.workspace.fs.writeFile(uri, content)
removed = true
} catch (err) {
console.warn("[Kilo New] KiloProvider: Failed to remove legacy MCP from", uri.fsPath, err)
}
}
return removed
}
private async fetchAndSendMcpStatus(): Promise<void> {
if (!this.client) {
if (this.cachedMcpStatusMessage) {
@@ -1781,7 +1834,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
try {
const directory = this.getWorkspaceDirectory()
const { data } = await this.client.mcp.status({ directory })
const { data } = await retry(() => this.client!.mcp.status({ directory }))
if (data) {
const message = { type: "mcpStatusLoaded", status: data }
this.cachedMcpStatusMessage = message
@@ -1817,23 +1870,35 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
}
/**
* Dispose the CLI backend instance so it re-reads config from disk.
* Call after any marketplace install/remove that writes config files directly.
* Global-scope changes need global.dispose() to also reset the global config cache.
* Remove a marketplace item from a single scope and invalidate CLI caches.
*/
private async disposeCliInstance(scope: "project" | "global"): Promise<void> {
if (!this.client) return
if (scope === "global") {
await this.client.global.dispose().catch((e: unknown) => {
console.warn("[Kilo New] global.dispose() after marketplace change failed:", e)
})
private async removeMarketplaceItem(item: MarketplaceItem, scope: "project" | "global"): Promise<RemoveResult> {
const workspace = this.getProjectDirectory(this.currentSession?.id)
const result = await this.getMarketplace().remove(item, scope, workspace)
if (result.success) {
await this.invalidateAfterMarketplaceChange(scope)
}
// Always dispose the per-project instance so it rebuilds state from
// the (possibly updated) global + project config on the next request.
const dir = this.getWorkspaceDirectory()
await this.client.instance.dispose({ directory: dir }).catch((e: unknown) => {
console.warn("[Kilo New] instance.dispose() after marketplace change failed:", e)
})
return result
}
/**
* Remove a marketplace item from both project and global scopes.
* mp.remove returns success even when the entry doesn't exist (no-op),
* so we must attempt both scopes to cover dual-scope installations.
* Returns true if at least one scope removal succeeded.
*/
private async removeMarketplaceItemFromAllScopes(item: MarketplaceItem): Promise<boolean> {
const workspace = this.getProjectDirectory(this.currentSession?.id)
const mp = this.getMarketplace()
const project = await mp.remove(item, "project", workspace)
const global = await mp.remove(item, "global", workspace)
if (project.success || global.success) {
const scope = global.success ? "global" : "project"
await this.invalidateAfterMarketplaceChange(scope)
return true
}
return false
}
/**
@@ -1892,7 +1957,9 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
try {
const workspaceDir = this.getWorkspaceDirectory()
const { data: config } = await this.client.config.get({ directory: workspaceDir }, { throwOnError: true })
const { data: config } = await retry(() =>
this.client!.config.get({ directory: workspaceDir }, { throwOnError: true }),
)
const message = {
type: "configLoaded",
@@ -1939,7 +2006,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
if (!this.client || this.connectionState !== "connected") return
try {
const dir = this.getWorkspaceDirectory()
const { data: config } = await this.client.config.get({ directory: dir }, { throwOnError: true })
const { data: config } = await retry(() => this.client!.config.get({ directory: dir }, { throwOnError: true }))
this.cachedConfigMessage = { type: "configLoaded", config }
this.postMessage({ type: "configUpdated", config })
} catch (error) {
@@ -1947,6 +2014,49 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
}
}
/**
* Fetch config warnings from the server and display a single consolidated
* VS Code warning with a "Show Details" action button.
* Only shown once per provider lifecycle (flag resets on dispose/re-create, not on SSE reconnect).
*/
private async checkConfigWarnings(from: string): Promise<void> {
if (this.configWarningsShown) {
console.log("[Kilo New] KiloProvider: config warnings already shown", { from })
return
}
if (!this.client) {
console.log("[Kilo New] KiloProvider: config warnings skipped (no client)", { from })
return
}
try {
const dir = this.getWorkspaceDirectory()
console.log("[Kilo New] KiloProvider: checking config warnings", { from, dir })
const result = await this.client.config.warnings({ directory: dir })
const list = result?.data ?? []
console.log("[Kilo New] KiloProvider: config warnings fetched", { from, count: list.length })
if (list.length === 0) return
this.configWarningsShown = true
const first = list[0]!
const summary = list.length === 1 ? first.message : `${first.message} (and ${list.length - 1} more)`
console.warn("[Kilo New] KiloProvider: showing config warnings", { from, count: list.length, path: first.path })
const action = await vscode.window.showWarningMessage(`Config: ${summary}`, "Show Details")
if (action === "Show Details") {
const lines = list.map((w) => {
const base = `${w.path}\n ${w.message}`
return w.detail ? `${base}\n ${w.detail}` : base
})
const channel = vscode.window.createOutputChannel("Kilo Config Warnings")
channel.clear()
channel.appendLine(lines.join("\n\n"))
channel.show()
}
} catch (err) {
console.warn("[Kilo New] KiloProvider: checkConfigWarnings failed:", { from, err })
}
}
/**
* Fetch Kilo news/notifications and send to webview.
* Uses the cached message pattern so the webview gets data immediately on refresh.
@@ -1972,7 +2082,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
}
try {
const { data: all } = await this.client.kilo.notifications(undefined, { throwOnError: true })
const { data: all } = await retry(() => this.client!.kilo.notifications(undefined, { throwOnError: true }))
const notifications = all.filter((n) => !n.showIn || n.showIn.includes("extension"))
const existing = this.extensionContext?.globalState.get<string[]>("kilo.dismissedNotificationIds", []) ?? []
const active = new Set(notifications.map((n) => n.id))
@@ -2041,6 +2151,14 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
})
}
private sendTimelineSetting(): void {
const config = vscode.workspace.getConfiguration("kilo-code.new")
this.postMessage({
type: "timelineSettingLoaded",
visible: config.get<boolean>("showTaskTimeline", true),
})
}
/** Returns the number of sessions currently in "busy" state. */
private getBusySessionCount(): number {
return getBusySessionCount(this.sessionStatusMap)
@@ -2079,7 +2197,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
// Config.state is reset by updateGlobal (via Instance.resetStateEntry) so
// config.get() returns fresh data without a full dispose cycle.
const dir = this.getWorkspaceDirectory()
const { data: merged } = await this.client.config.get({ directory: dir }, { throwOnError: true })
const { data: merged } = await retry(() => this.client!.config.get({ directory: dir }, { throwOnError: true }))
this.cachedConfigMessage = { type: "configLoaded", config: merged }
this.postMessage({ type: "configUpdated", config: merged })
@@ -2136,6 +2254,85 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
return { sid, dir }
}
/** Abort controllers for active retry loops, keyed by session ID */
private retryAbortControllers = new Map<string, AbortController>()
/**
* Execute an SDK call with exponential backoff on HTTP errors.
* Retries on 429, 5xx, and other retryable status codes.
* When the response includes `Retry-After` / `Retry-After-MS` headers,
* the delay honours that value (capped at 5 min). Otherwise uses the
* predefined backoff schedule: 5s -> 10s -> 30s -> 60s -> 300s.
*
* After MAX_RETRIES (5) attempts, automatically throws the error.
* Users can cancel via the cancel button in the UI which sends an abort
* message — this interrupts the backoff delay and stops the retry loop.
*
* The webview receives `sessionStatus` messages with a countdown so the
* user can see that a retry is in progress.
*/
private async withRetry(fn: () => Promise<{ error?: unknown; response: Response }>, sid: string): Promise<void> {
const abortController = new AbortController()
this.retryAbortControllers.set(sid, abortController)
try {
for (let attempt = 1; ; attempt++) {
if (abortController.signal.aborted) {
// User cancelled — return normally without triggering sendMessageFailed
return
}
const result = await fn()
if (!result.error) return
const status = result.response?.status ?? 0
// Non-retryable status codes fail immediately without retry
if (!retryable(status)) {
this.postMessage({ type: "sessionStatus", sessionID: sid, status: "idle" })
throw result.error
}
// Stop retrying after MAX_RETRIES attempts
if (attempt >= MAX_RETRIES) {
this.postMessage({ type: "sessionStatus", sessionID: sid, status: "idle" })
throw result.error
}
const delay = backoff(attempt, result.response?.headers)
console.log(`[Kilo New] KiloProvider: Retry on ${status}, attempt ${attempt}/${MAX_RETRIES}, delay ${delay}ms`)
this.postMessage({
type: "sessionStatus",
sessionID: sid,
status: "retry",
attempt,
message: `Error (${status}). Retrying...`,
next: Date.now() + delay,
})
// Wait for delay or until aborted
await new Promise((resolve) => {
const timer = setTimeout(resolve, delay)
abortController.signal.addEventListener("abort", () => {
clearTimeout(timer)
})
})
}
} finally {
this.retryAbortControllers.delete(sid)
}
}
/** Cancel an active retry loop for a session */
private cancelRetry(sid: string): void {
const controller = this.retryAbortControllers.get(sid)
if (controller) {
controller.abort()
this.postMessage({ type: "sessionStatus", sessionID: sid, status: "idle" })
}
}
private async handleSendMessage(
text: string,
messageID?: string,
@@ -2178,18 +2375,21 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
this.connectionService.recordMessageSessionId(messageID, resolved!.sid)
}
await this.client.session.promptAsync(
{
sessionID: resolved!.sid,
directory: resolved!.dir,
messageID,
parts,
model: providerID && modelID ? { providerID, modelID } : undefined,
agent,
variant,
editorContext,
},
{ throwOnError: true },
const sid = resolved!.sid
const dir = resolved!.dir
await this.withRetry(
() =>
this.client!.session.promptAsync({
sessionID: sid,
directory: dir,
messageID,
parts,
model: providerID && modelID ? { providerID, modelID } : undefined,
agent,
variant,
editorContext,
}),
sid,
)
} catch (error) {
console.error("[Kilo New] KiloProvider: Failed to send message:", error)
@@ -2240,19 +2440,22 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
const parts = files?.map((f) => ({ type: "file" as const, mime: f.mime, url: f.url }))
await this.client.session.command(
{
sessionID: resolved!.sid,
directory: resolved!.dir,
command,
arguments: args,
messageID,
model: providerID && modelID ? `${providerID}/${modelID}` : undefined,
agent,
variant,
parts,
},
{ throwOnError: true },
const sid = resolved!.sid
const dir = resolved!.dir
await this.withRetry(
() =>
this.client!.session.command({
sessionID: sid,
directory: dir,
command,
arguments: args,
messageID,
model: providerID && modelID ? `${providerID}/${modelID}` : undefined,
agent,
variant,
parts,
}),
sid,
)
} catch (error) {
console.error("[Kilo New] KiloProvider: Failed to send command:", error)
@@ -2545,6 +2748,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
this.sendAutocompleteSettings()
this.sendBrowserSettings()
this.sendNotificationSettings()
this.sendTimelineSetting()
// Re-send globalState items to the webview
this.postMessage({ type: "variantsLoaded", variants: {} })
@@ -2981,7 +3185,9 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
private startStatsPolling(): void {
this.statsPoller?.stop()
this.statsGitOps?.dispose()
const git = new GitOps({ log: () => {} })
this.statsGitOps = git
this.statsPoller = new GitStatsPoller({
getWorktrees: () => [],
getWorkspaceRoot: () => getWorkspaceRoot(),
@@ -3009,6 +3215,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
*/
dispose(): void {
this.statsPoller?.stop()
this.statsGitOps?.dispose()
this.unsubscribeEvent?.()
this.unsubscribeState?.()
this.unsubscribeNotificationDismiss?.()
@@ -6,8 +6,10 @@ import { getErrorMessage } from "../kilo-provider-utils"
import { isAbsolutePath } from "../path-utils"
import { WorktreeManager, type CreateWorktreeResult } from "./WorktreeManager"
import { WorktreeStateManager, remoteRef } from "./WorktreeStateManager"
import { handleSection } from "./section-handler"
import { chooseBaseBranch, normalizeBaseBranch } from "./base-branch"
import { GitStatsPoller, type WorktreePresenceResult } from "./GitStatsPoller"
import { PRStatusBridge } from "./pr-status-bridge"
import { GitOps, type ApplyConflict } from "./GitOps"
import { versionedName } from "./branch-name"
import { normalizePath, classifyWorktreeError } from "./git-import"
@@ -53,6 +55,7 @@ export class AgentManagerProvider implements Disposable {
private diffSessionId: string | undefined
private lastDiffHash: string | undefined
private statsPoller: GitStatsPoller
private prBridge!: PRStatusBridge
private gitOps: GitOps
private cachedDiffTarget: { sessionId: string; directory: string; baseBranch: string } | undefined
private staleWorktreeIds = new Set<string>()
@@ -94,6 +97,15 @@ export class AgentManagerProvider implements Disposable {
log: (...args) => this.log(...args),
git: this.gitOps,
})
this.prBridge = PRStatusBridge.create({
getWorktrees: () => this.state?.getWorktrees() ?? [],
getWorkspaceRoot: () => this.getRoot(),
postToWebview: (m) => this.postToWebview(m),
updateWorktreePR: (id, n, u, s) => this.state?.updateWorktreePR(id, n, u, s),
hasPersistedPR: (id: string) => !!this.state?.getWorktree(id)?.prNumber,
openExternal: (u) => this.host.openExternal(u),
log: (...a) => this.log(...a),
})
}
private log(...args: unknown[]) {
@@ -147,13 +159,14 @@ export class AgentManagerProvider implements Disposable {
this.stateReady = this.initializeState()
void this.sendRepoInfo()
this.sendKeybindings()
this.prBridge.attachPanel(ctx)
ctx.onDidDispose(() => {
// Only clear if this is still the active panel — a newer panel may
// have already replaced us via attachPanel.
if (this.panel === ctx) {
this.log("Panel disposed")
this.statsPoller.stop()
this.prBridge.poller.stop()
this.stopDiffPolling()
this.panel = undefined
}
@@ -209,6 +222,7 @@ export class AgentManagerProvider implements Disposable {
// ---------------------------------------------------------------------------
private async onMessage(msg: Record<string, unknown>): Promise<Record<string, unknown> | null> {
if (this.prBridge.handleMessage(msg)) return null
const m = msg as unknown as AgentManagerInMessage
if (m.type === "agentManager.createWorktree") {
@@ -292,6 +306,7 @@ export class AgentManagerProvider implements Disposable {
// already emitted before the webview was ready to receive messages.
if (this.cachedWorktreeStats) this.postToWebview(this.cachedWorktreeStats)
if (this.cachedLocalStats) this.postToWebview(this.cachedLocalStats)
this.prBridge.replay()
// Refresh sessions after pushState so the webview's sessionsLoaded
// handler is guaranteed to be registered (requestState fires from
// onMount). Without this, the initial refreshSessions() in
@@ -327,6 +342,7 @@ export class AgentManagerProvider implements Disposable {
this.state?.setSessionsCollapsed(m.collapsed)
return null
}
if (this.handleSection(m)) return null
if (m.type === "agentManager.setReviewDiffStyle") {
this.state?.setReviewDiffStyle(m.style)
return null
@@ -408,6 +424,7 @@ export class AgentManagerProvider implements Disposable {
if (m.type === "loadMessages") {
this.activeSessionId = m.sessionID
this.terminalManager.syncOnSessionSwitch(m.sessionID)
this.prBridge.poller.setActiveWorktreeId(this.state?.getSession(m.sessionID)?.worktreeId ?? undefined)
}
// After clearSession, clear active tracking and re-register worktree sessions
@@ -671,6 +688,7 @@ export class AgentManagerProvider implements Disposable {
}
// Remove from state BEFORE disk removal so pollers immediately stop targeting this worktree.
this.statsPoller.skipWorktree(worktreeId)
this.prBridge.remove(worktreeId)
const orphaned = state.removeWorktree(worktreeId)
if (shouldStopDiffPolling(worktree.path, orphaned, this.cachedDiffTarget, this.diffSessionId)) {
this.stopDiffPolling()
@@ -679,11 +697,11 @@ export class AgentManagerProvider implements Disposable {
this.pushState()
// Disk removal after state is clean — pollers no longer reference this worktree.
try {
await manager.removeWorktree(worktree.path, worktree.branch)
await manager.removeWorktree(worktree.path, worktree.originalBranch ?? worktree.branch)
} catch (error) {
this.log(`Failed to remove worktree from disk: ${error}`)
}
this.log(`Deleted worktree ${worktreeId} (${worktree.branch})`)
this.log(`Deleted worktree ${worktreeId} (${worktree.originalBranch ?? worktree.branch})`)
return null
}
@@ -1428,12 +1446,20 @@ export class AgentManagerProvider implements Disposable {
const entries = result.worktrees.filter((item) => ids.has(item.worktreeId))
if (entries.length === 0) return
// Sync branches from git worktree list (no extra git calls)
let branchChanged = false
for (const entry of entries) {
if (entry.branch && state.updateWorktreeBranch(entry.worktreeId, entry.branch)) {
branchChanged = true
}
}
const next = new Set(entries.filter((entry) => entry.missing).map((entry) => entry.worktreeId))
const changed =
const staleChanged =
next.size !== this.staleWorktreeIds.size || [...next].some((worktreeId) => !this.staleWorktreeIds.has(worktreeId))
this.staleWorktreeIds = next
if (changed) {
if (staleChanged || branchChanged) {
this.pushState()
}
}
@@ -1464,6 +1490,7 @@ export class AgentManagerProvider implements Disposable {
type: "agentManager.state",
worktrees,
sessions: state.getSessions(),
sections: state.getSections(),
staleWorktreeIds,
tabOrder: state.getTabOrder(),
worktreeOrder: state.getWorktreeOrder(),
@@ -1474,6 +1501,7 @@ export class AgentManagerProvider implements Disposable {
})
this.statsPoller.setEnabled(worktrees.length > 0 || this.panel !== undefined)
this.prBridge.poller.setEnabled(worktrees.length > 0)
}
/** Push empty state when the folder is not a git repo or has no folder open. */
@@ -1886,6 +1914,10 @@ export class AgentManagerProvider implements Disposable {
)
}
private handleSection(m: AgentManagerInMessage): boolean {
return handleSection(this.state, m, () => this.pushState())
}
public postMessage(message: unknown): void {
this.panel?.postMessage(message)
}
@@ -1893,6 +1925,8 @@ export class AgentManagerProvider implements Disposable {
public dispose(): void {
this.stopDiffPolling()
this.statsPoller.stop()
this.gitOps.dispose()
this.prBridge.poller.stop()
this.terminalManager.dispose()
this.panel?.dispose()
this.outputChannel.dispose()
@@ -7,7 +7,6 @@ import { parseWorktreeList, normalizePath } from "./git-import"
interface GitOpsOptions {
log: (...args: unknown[]) => void
refreshMs?: number
/** Override git command execution for testing. */
runGit?: (args: string[], cwd: string) => Promise<string>
}
@@ -40,26 +39,68 @@ interface ExecResult {
stderr: string
}
/**
* Build environment variables that prevent git and SSH from opening interactive
* prompts. Used for background operations (e.g. periodic fetch) so users with
* SSH keys that require passphrase confirmation are not bombarded with dialogs.
*
* Returns a full `process.env` overlay suitable for `simple-git.env()` or
* `child_process.spawn`. `GIT_SSH_COMMAND` is only overridden when the user
* hasn't already configured their own.
*/
export function nonInteractiveEnv(): NodeJS.ProcessEnv {
const env: NodeJS.ProcessEnv = {
...process.env,
GIT_TERMINAL_PROMPT: "0",
}
if (!process.env.GIT_SSH_COMMAND) {
env.GIT_SSH_COMMAND = "ssh -o BatchMode=yes"
}
return env
}
export class GitOps {
private lastFetch = new Map<string, number>()
private inflightFetch = new Map<string, Promise<void>>()
private readonly refreshMs: number
private readonly log: (...args: unknown[]) => void
private readonly runGit: (args: string[], cwd: string) => Promise<string>
private readonly controller = new AbortController()
get disposed(): boolean {
return this.controller.signal.aborted
}
constructor(options: GitOpsOptions) {
this.refreshMs = options.refreshMs ?? 120000
this.log = options.log
this.runGit =
options.runGit ??
((args, cwd) =>
simpleGit(cwd)
simpleGit(cwd, { abort: this.controller.signal })
.raw(args)
.then((out) => out.trim()))
}
dispose(): void {
if (!this.controller.signal.aborted) {
this.controller.abort()
}
}
private raw(args: string[], cwd: string): Promise<string> {
return this.runGit(args, cwd)
const signal = this.controller.signal
if (signal.aborted) return Promise.reject(new Error("GitOps disposed"))
return new Promise<string>((resolve, reject) => {
const onAbort = () => reject(new Error("GitOps disposed"))
signal.addEventListener("abort", onAbort, { once: true })
this.runGit(args, cwd).then(
(value) => {
signal.removeEventListener("abort", onAbort)
resolve(value)
},
(err) => {
signal.removeEventListener("abort", onAbort)
reject(err)
},
)
})
}
/** Return the name of the currently checked-out branch, or `"HEAD"` if detached. */
@@ -114,42 +155,15 @@ export class GitOps {
.catch(() => false)
}
async refreshRemote(cwd: string, remote: string): Promise<void> {
if (!remote) return
const commonRaw = await this.raw(["rev-parse", "--git-common-dir"], cwd).catch(() => cwd)
const common = nodePath.isAbsolute(commonRaw) ? commonRaw : nodePath.resolve(cwd, commonRaw)
const key = `${common}:${remote}`
const existing = this.inflightFetch.get(key)
if (existing) return existing
const prev = this.lastFetch.get(key) ?? 0
const now = Date.now()
if (now - prev < this.refreshMs) return
this.lastFetch.set(key, now)
const job = this.raw(["fetch", "--quiet", "--no-tags", remote], cwd)
.catch((err) => {
this.log(`Failed to refresh remote refs for ${cwd}:`, err)
})
.then(() => undefined)
.finally(() => {
this.inflightFetch.delete(key)
})
this.inflightFetch.set(key, job)
return job
}
/** Return the set of worktree paths for the repo, excluding bare entries. */
async listWorktreePaths(cwd: string): Promise<Set<string>> {
async listWorktreePaths(cwd: string): Promise<Map<string, string>> {
const raw = await this.raw(["worktree", "list", "--porcelain"], cwd)
const paths = new Set<string>()
const result = new Map<string, string>()
for (const entry of parseWorktreeList(raw)) {
if (entry.bare) continue
paths.add(normalizePath(entry.path))
result.set(normalizePath(entry.path), entry.branch)
}
return paths
return result
}
/**
@@ -211,12 +225,11 @@ export class GitOps {
/**
* Count commits ahead and behind using `rev-list --left-right --count`.
* Callers are expected to pass a fully-qualified ref (e.g. "origin/main").
* Pass `remote` explicitly to refresh the tracking ref before counting;
* the remote is NOT inferred from the ref to avoid misinterpreting
* branch names that contain slashes (e.g. "release/1.0").
* Counts are computed against local tracking refs only no fetch is
* performed, so values may be stale until an explicit git operation
* (push, pull, etc.) updates the refs.
*/
async aheadBehind(cwd: string, base: string, remote?: string): Promise<{ ahead: number; behind: number }> {
if (remote) await this.refreshRemote(cwd, remote)
async aheadBehind(cwd: string, base: string): Promise<{ ahead: number; behind: number }> {
return this.parseLeftRight(cwd, base)
}
@@ -348,10 +361,14 @@ export class GitOps {
}
private exec(args: string[], cwd: string, options?: ExecOptions): Promise<ExecResult> {
if (this.controller.signal.aborted) {
return Promise.resolve({ code: 1, stdout: "", stderr: "GitOps disposed" })
}
return new Promise((resolve) => {
const child = spawn("git", args, {
cwd,
env: options?.env,
signal: this.controller.signal,
stdio: ["pipe", "pipe", "pipe"],
})
@@ -26,6 +26,8 @@ export interface LocalStats {
export interface WorktreePresence {
worktreeId: string
missing: boolean
/** Current branch from `git worktree list`, if available. */
branch?: string
}
export interface WorktreePresenceResult {
@@ -159,7 +161,7 @@ export class GitStatsPoller {
const base = remoteRef(wt)
const [{ data: diffs }, ab] = await Promise.all([
client.worktree.diffSummary({ directory: wt.path, base }, { throwOnError: true }),
this.git.aheadBehind(wt.path, base, wt.remote),
this.git.aheadBehind(wt.path, base),
])
const files = diffs.length
const additions = diffs.reduce((sum: number, diff: FileDiff) => sum + diff.additions, 0)
@@ -231,7 +233,8 @@ export class GitStatsPoller {
() => false,
)
const missing = !exists || !tracked.has(normalized)
return { worktreeId: wt.id, missing }
const branch = tracked.get(normalized)
return { worktreeId: wt.id, missing, branch }
}),
)
@@ -248,7 +251,6 @@ export class GitStatsPoller {
const tracking = await this.git.resolveTrackingBranch(root, branch)
const base = tracking ?? (await this.git.resolveDefaultBranch(root, branch))
const remote = await this.git.resolveRemote(root, branch).catch(() => undefined)
let files: number
let additions: number
@@ -260,7 +262,7 @@ export class GitStatsPoller {
this.options.log(`Local stats: using HTTP client with base=${base}`)
const [{ data: diffs }, ab] = await Promise.all([
client.worktree.diffSummary({ directory: root, base }, { throwOnError: true }),
this.git.aheadBehind(root, base, remote),
this.git.aheadBehind(root, base),
])
files = diffs.length
additions = diffs.reduce((sum: number, d: FileDiff) => sum + d.additions, 0)
@@ -0,0 +1,521 @@
import type { Worktree } from "./WorktreeStateManager"
import type { PRStatus, PRCheck, PRComment, CheckStatus, AggregateCheckStatus, PRState, ReviewDecision } from "./types"
import { execWithShellEnv } from "./shell-env"
import { classifyPRError } from "./git-import"
interface PRStatusPollerOptions {
getWorktrees: () => Worktree[]
getWorkspaceRoot: () => string | undefined
onStatus: (worktreeId: string, pr: PRStatus | null, error?: "gh_missing" | "gh_auth" | "fetch_failed") => void
log: (...args: unknown[]) => void
intervalMs?: number
}
const GH_PROBE_TTL = 300_000 // 5 minutes — gh installation state rarely changes at runtime
const MAX_BACKOFF = 120_000 // 2 minutes — cap for exponential backoff on repeated errors
const BACKOFF_MULTIPLIER = 2
const PR_LOOKUP_TTL = 10_000 // 10 seconds — short TTL; only the active worktree polls so this stays cheap
export class PRStatusPoller {
private timer: ReturnType<typeof setTimeout> | undefined
private active = false
private visible = true
private busy = false
private lastHash = new Map<string, string>()
private lastError: string | undefined // tracks global error state for de-duplication
private failures = 0 // consecutive failure count for backoff
private ghAvailable: boolean | undefined
private ghProbeTime = 0
private activeWorktreeId: string | undefined
private cachedRepo: { owner: string; name: string; cwd: string } | undefined
private prCache = new Map<string, { result: PRResult | null; expires: number }>()
private readonly intervalMs: number
constructor(private readonly options: PRStatusPollerOptions) {
this.intervalMs = options.intervalMs ?? 15_000
}
setEnabled(enabled: boolean): void {
if (enabled) {
if (this.active) return
this.start()
return
}
this.stop()
}
/** Pause/resume polling based on panel visibility. */
setVisible(visible: boolean): void {
if (this.visible === visible) return
this.visible = visible
if (!this.active) return
if (visible) {
// Resume — expire all PR caches and fetch all worktrees once to catch up,
// then resume the normal active-only poll cycle.
if (this.timer) clearTimeout(this.timer)
this.timer = undefined
this.prCache.clear()
this.lastHash.clear()
void this.poll()
return
}
// Pause — cancel pending timer
if (this.timer) {
clearTimeout(this.timer)
this.timer = undefined
}
}
stop(): void {
this.active = false
if (this.timer) {
clearTimeout(this.timer)
this.timer = undefined
}
this.busy = false
this.lastHash.clear()
this.lastError = undefined
this.failures = 0
this.ghAvailable = undefined
this.ghProbeTime = 0
this.cachedRepo = undefined
this.prCache.clear()
}
/** Force-refresh a specific worktree immediately, bypassing the PR cache. */
refresh(worktreeId: string): void {
if (!this.active) return
const wt = this.options.getWorktrees().find((w) => w.id === worktreeId)
if (wt) this.prCache.delete(wt.branch)
void this.fetchOne(worktreeId)
}
setActiveWorktreeId(id: string | undefined): void {
const prev = this.activeWorktreeId
this.activeWorktreeId = id
// When switching to a different worktree, fetch it immediately so the
// badge updates without waiting for the next poll cycle.
if (id && id !== prev && this.active) void this.fetchOne(id)
}
private start(): void {
this.stop()
this.active = true
// Don't override this.visible — it may already be set to false by
// setVisible() before setEnabled(true) is called.
void this.poll()
}
private nextDelay(): number {
if (this.failures === 0) return this.intervalMs
return Math.min(this.intervalMs * Math.pow(BACKOFF_MULTIPLIER, this.failures), MAX_BACKOFF)
}
private schedule(): void {
if (!this.active || !this.visible) return
const delay = this.nextDelay()
this.timer = setTimeout(() => {
void this.poll()
}, delay)
}
private poll(): Promise<void> {
if (!this.active || !this.visible) return Promise.resolve()
if (this.busy) return Promise.resolve()
this.busy = true
return this.fetchAll().finally(() => {
this.busy = false
this.schedule()
})
}
private async probeGh(): Promise<boolean> {
const now = Date.now()
if (this.ghAvailable !== undefined && now - this.ghProbeTime < GH_PROBE_TTL) {
return this.ghAvailable
}
try {
await execWithShellEnv("gh", ["--version"], { timeout: 5_000 })
this.ghAvailable = true
} catch {
this.ghAvailable = false
}
this.ghProbeTime = Date.now()
return this.ghAvailable
}
private async fetchAll(): Promise<void> {
if (!(await this.probeGh())) {
// De-duplicate: only emit gh_missing once, not every poll cycle
if (this.lastError !== "gh_missing") {
this.lastError = "gh_missing"
for (const wt of this.options.getWorktrees()) {
this.options.onStatus(wt.id, null, "gh_missing")
}
}
this.failures++
return
}
this.lastError = undefined
// Only poll the active worktree on each timer tick. Inactive worktrees
// refresh when selected (setActiveWorktreeId) or manually (refresh()).
// On first poll (lastHash empty) fetch all worktrees once to populate badges.
const worktrees = this.options.getWorktrees()
const initial = this.lastHash.size === 0
const targets = initial ? worktrees : worktrees.filter((wt) => wt.id === this.activeWorktreeId)
if (targets.length === 0) {
this.failures = 0
return
}
const results = await Promise.allSettled(targets.map((wt) => this.fetchOne(wt.id)))
const ok = results.every((r) => r.status === "fulfilled")
if (ok) {
this.failures = 0
return
}
this.failures++
}
private async fetchOne(worktreeId: string): Promise<void> {
const worktrees = this.options.getWorktrees()
const wt = worktrees.find((w) => w.id === worktreeId)
if (!wt) return
if (!this.options.getWorkspaceRoot()) return
try {
const pr = await this.cachedFetchPR(wt.branch, wt.path)
if (!pr) {
const hash = `${worktreeId}:none`
if (this.lastHash.get(worktreeId) === hash) return
this.lastHash.set(worktreeId, hash)
this.options.onStatus(worktreeId, null)
return
}
const [checks, comments] = await Promise.all([
this.fetchChecks(pr.number, wt.path),
this.activeWorktreeId === worktreeId ? this.fetchComments(pr.number, wt.path) : undefined,
])
const status: PRStatus = {
number: pr.number,
title: pr.title,
url: pr.url,
state: pr.state,
review: pr.review,
checks,
...(comments && { comments }),
additions: pr.additions,
deletions: pr.deletions,
files: pr.files,
}
const hash = `${worktreeId}:${pr.number}:${pr.state}:${pr.review}:${checks.status}:${checks.passed}/${checks.total}:${comments?.total ?? ""}:${comments?.unresolved ?? ""}`
if (this.lastHash.get(worktreeId) === hash) return
this.lastHash.set(worktreeId, hash)
this.options.onStatus(worktreeId, status)
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
const kind = classifyPRError(msg)
this.options.log(`PR fetch failed for ${wt.branch}:`, msg)
const errKey = kind === "gh_missing" ? "gh_missing" : kind === "gh_auth" ? "gh_auth" : "fetch_failed"
if (kind === "gh_missing") this.ghAvailable = false
// De-duplicate: only emit if the error state changed for this worktree
const hash = `${worktreeId}:error:${errKey}`
if (this.lastHash.get(worktreeId) !== hash) {
this.lastHash.set(worktreeId, hash)
this.options.onStatus(worktreeId, null, errKey)
}
throw err // propagate so fetchAll can track failures for backoff
}
}
private static readonly PR_JSON_FIELDS =
"number,title,url,state,isDraft,reviewDecision,additions,deletions,changedFiles,headRefName,headRefOid"
/** Return a cached PR lookup if still fresh, otherwise fetch and cache.
* Keyed by branch name so multiple worktrees on the same branch share
* the cache, and a branch switch in a worktree naturally misses. */
private async cachedFetchPR(branch: string, cwd: string): Promise<PRResult | null> {
const cached = this.prCache.get(branch)
if (cached && Date.now() < cached.expires) return cached.result
const result = await this.fetchPRForBranch(branch, cwd)
this.prCache.set(branch, { result, expires: Date.now() + PR_LOOKUP_TTL })
return result
}
private async fetchPRForBranch(branch: string, cwd: string): Promise<PRResult | null> {
// Strategy 1: bare `gh pr view` — resolves via the branch's tracking ref.
// Works for fork PRs checked out with `gh pr checkout` (tracking ref = refs/pull/N/head).
// Strategy 2: `gh pr view <branch>` — works for same-repo branches pushed to origin.
// Strategy 3: `gh pr list --search "<sha>"` — last resort, finds PRs by HEAD commit SHA.
return (await this.ghPRView(cwd)) ?? (await this.ghPRView(cwd, branch)) ?? (await this.ghPRListBySHA(cwd))
}
/** Run `gh pr view [branch] --json ...` and parse the result, or return null. */
private async ghPRView(cwd: string, branch?: string): Promise<PRResult | null> {
try {
const args = ["pr", "view"]
if (branch) args.push(branch)
args.push("--json", PRStatusPoller.PR_JSON_FIELDS)
const { stdout } = await execWithShellEnv("gh", args, { cwd, timeout: 15_000 })
return parsePRResult(stdout)
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
if (msg.includes("no pull requests found") || msg.includes("Could not resolve")) return null
throw err
}
}
/** Search for PRs containing the current HEAD SHA. Finds PRs when branch name/tracking ref don't match. */
private async ghPRListBySHA(cwd: string): Promise<PRResult | null> {
try {
const { stdout: sha } = await execWithShellEnv("git", ["rev-parse", "HEAD"], { cwd, timeout: 5_000 })
const head = sha.trim()
if (!head) return null
const { stdout } = await execWithShellEnv(
"gh",
[
"pr",
"list",
"--state",
"all",
"--search",
`${head} is:pr`,
"--limit",
"5",
"--json",
PRStatusPoller.PR_JSON_FIELDS,
],
{ cwd, timeout: 15_000 },
)
const items = JSON.parse(stdout) as unknown[]
if (!Array.isArray(items) || items.length === 0) return null
// Only accept PRs where headRefOid matches our HEAD exactly
for (const item of items) {
const data = item as Record<string, unknown>
if (data.headRefOid === head) return parsePRResult(JSON.stringify(data))
}
return null
} catch {
return null
}
}
private async fetchChecks(
prNumber: number,
cwd: string,
): Promise<{
status: AggregateCheckStatus
total: number
passed: number
failed: number
pending: number
items: PRCheck[]
}> {
try {
const { stdout } = await execWithShellEnv(
"gh",
["pr", "checks", String(prNumber), "--json", "name,state,link,startedAt,completedAt"],
{ cwd, timeout: 15_000 },
)
const data = JSON.parse(stdout) as Array<{
name: string
state: string
link?: string
startedAt?: string
completedAt?: string
}>
const items: PRCheck[] = data.map((c) => ({
name: c.name,
status: mapCheckStatus(c.state),
url: c.link,
duration: formatCheckDuration(c.startedAt, c.completedAt),
}))
const total = items.length
const passed = items.filter((c) => c.status === "success").length
const failed = items.filter((c) => c.status === "failure").length
const pending = items.filter((c) => c.status === "pending").length
const status: AggregateCheckStatus =
total === 0 ? "none" : failed > 0 ? "failure" : pending > 0 ? "pending" : "success"
return { status, total, passed, failed, pending, items }
} catch {
return { status: "none", total: 0, passed: 0, failed: 0, pending: 0, items: [] }
}
}
private async getRepoInfo(cwd: string): Promise<{ owner: string; name: string }> {
if (this.cachedRepo && this.cachedRepo.cwd === cwd) {
return this.cachedRepo
}
const { stdout } = await execWithShellEnv("gh", ["repo", "view", "--json", "owner,name"], {
cwd,
timeout: 10_000,
})
const data = JSON.parse(stdout)
const info = { owner: data.owner.login as string, name: data.name as string, cwd }
this.cachedRepo = info
return info
}
private async fetchComments(
prNumber: number,
cwd: string,
): Promise<{ total: number; unresolved: number; items: PRComment[] }> {
try {
const repo = await this.getRepoInfo(cwd)
const query = `query($owner: String!, $repo: String!, $number: Int!) {
repository(owner: $owner, name: $repo) {
pullRequest(number: $number) {
reviewThreads(first: 100) {
nodes {
isResolved
comments(first: 1) {
nodes {
id
author { login avatarUrl }
body
path
line
url
createdAt
}
}
}
}
}
}
}`
const { stdout } = await execWithShellEnv(
"gh",
[
"api",
"graphql",
"-f",
`query=${query}`,
"-F",
`owner=${repo.owner}`,
"-F",
`repo=${repo.name}`,
"-F",
`number=${prNumber}`,
],
{ cwd, timeout: 15_000 },
)
const result = JSON.parse(stdout)
const threads = result?.data?.repository?.pullRequest?.reviewThreads?.nodes ?? []
const items: PRComment[] = []
for (const thread of threads) {
const first = thread.comments?.nodes?.[0]
if (!first) continue
items.push({
id: first.id,
author: first.author?.login ?? "unknown",
avatar: first.author?.avatarUrl,
body: first.body ?? "",
file: first.path,
line: first.line,
url: first.url,
resolved: thread.isResolved ?? false,
createdAt: first.createdAt ? new Date(first.createdAt).getTime() : undefined,
})
}
const total = items.length
const unresolved = items.filter((c) => !c.resolved).length
return { total, unresolved, items }
} catch (err) {
this.options.log("Failed to fetch PR comments:", err)
return { total: 0, unresolved: 0, items: [] }
}
}
}
interface PRResult {
number: number
title: string
url: string
state: PRState
review: ReviewDecision | null
additions: number
deletions: number
files: number
}
function parsePRResult(json: string): PRResult | null {
const data = JSON.parse(json)
if (!data.number) return null
return {
number: data.number,
title: data.title ?? "",
url: data.url ?? "",
state: parsePRState(data.isDraft, data.state),
review: parseReviewDecision(data.reviewDecision),
additions: data.additions ?? 0,
deletions: data.deletions ?? 0,
files: data.changedFiles ?? 0,
}
}
function parsePRState(isDraft: boolean, ghState: string): PRState {
if (isDraft) return "draft"
if (ghState === "MERGED") return "merged"
if (ghState === "CLOSED") return "closed"
return "open"
}
function parseReviewDecision(decision: string | undefined): ReviewDecision | null {
if (decision === "APPROVED") return "approved"
if (decision === "CHANGES_REQUESTED") return "changes_requested"
if (decision === "REVIEW_REQUIRED") return "pending"
return null
}
function mapCheckStatus(state: string): CheckStatus {
switch (state.toUpperCase()) {
case "SUCCESS":
return "success"
case "FAILURE":
case "ERROR":
return "failure"
case "PENDING":
case "QUEUED":
case "IN_PROGRESS":
case "REQUESTED":
case "WAITING":
return "pending"
case "SKIPPED":
return "skipped"
case "CANCELLED":
case "TIMED_OUT":
case "STALE":
case "STARTUP_FAILURE":
return "cancelled"
default:
return "pending"
}
}
function formatCheckDuration(startedAt?: string, completedAt?: string): string | undefined {
if (!startedAt || !completedAt) return undefined
const secs = Math.round((new Date(completedAt).getTime() - new Date(startedAt).getTime()) / 1000)
return secs < 60 ? `${secs}s` : `${Math.floor(secs / 60)}m ${secs % 60}s`
}
@@ -11,7 +11,7 @@ import * as fs from "fs"
import { randomUUID } from "crypto"
import simpleGit, { type SimpleGit } from "simple-git"
import { generateBranchName, sanitizeBranchName } from "./branch-name"
import type { GitOps } from "./GitOps"
import { type GitOps, nonInteractiveEnv } from "./GitOps"
import { execWithShellEnv } from "./shell-env"
import {
parsePRUrl,
@@ -662,10 +662,11 @@ export class WorktreeManager {
}
}
// Either not cached or cache is stale - do the fetch
// Either not cached or cache is stale - do the fetch.
// Use non-interactive env to prevent SSH passphrase popups.
onProgress?.("fetching", `Fetching ${remote}/${branch}...`)
try {
await this.git.fetch(remote, branch)
await simpleGit(this.root).env(nonInteractiveEnv()).fetch(remote, branch)
WorktreeManager.fetchCache.set(cacheKey, Date.now())
if (await this.refExistsLocally(`${remote}/${branch}`)) {
return {
@@ -26,6 +26,27 @@ export interface Worktree {
groupId?: string
/** User-provided display name for the worktree. */
label?: string
/** Cached PR number for instant badge display on reload. */
prNumber?: number
/** Cached PR URL for instant badge display on reload. */
prUrl?: string
/** Cached PR state for correct badge color on reload (open/merged/closed/draft). */
prState?: string
/** Original branch created with the worktree, used for cleanup on deletion.
* Set automatically when `branch` is updated via live sync. */
originalBranch?: string
/** Section this worktree belongs to, or undefined for ungrouped. */
sectionId?: string
}
export interface Section {
id: string
name: string
/** Color label (e.g. "Red", "Blue") mapped to VS Code theme CSS vars at render time, or null for default. */
color: string | null
/** Position among top-level sidebar children (interleaved with ungrouped worktrees). */
order: number
collapsed: boolean
}
/**
@@ -46,6 +67,7 @@ export interface ManagedSession {
interface StateFile {
worktrees: Record<string, Omit<Worktree, "id">>
sessions: Record<string, Omit<ManagedSession, "id">>
sections?: Record<string, Omit<Section, "id">>
tabOrder?: Record<string, string[]>
worktreeOrder?: string[]
sessionsCollapsed?: boolean
@@ -67,6 +89,7 @@ export class WorktreeStateManager {
private readonly file: string
private worktrees = new Map<string, Worktree>()
private sessions = new Map<string, ManagedSession>()
private sections = new Map<string, Section>()
private tabOrder: Record<string, string[]> = {}
private worktreeOrder: string[] = []
private collapsed = false
@@ -163,6 +186,16 @@ export class WorktreeStateManager {
return wt
}
updateWorktreeBranch(id: string, branch: string): boolean {
const wt = this.worktrees.get(id)
if (!wt || wt.branch === branch) return false
if (!wt.originalBranch) wt.originalBranch = wt.branch
this.log(`Updated worktree ${id} branch: ${wt.branch}${branch}`)
wt.branch = branch
void this.save()
return true
}
updateWorktreeLabel(id: string, label: string): void {
const wt = this.worktrees.get(id)
if (!wt) return
@@ -171,6 +204,16 @@ export class WorktreeStateManager {
void this.save()
}
updateWorktreePR(id: string, prNumber?: number, prUrl?: string, prState?: string): void {
const wt = this.worktrees.get(id)
if (!wt) return
if (wt.prNumber === prNumber && wt.prUrl === prUrl && wt.prState === prState) return
wt.prNumber = prNumber
wt.prUrl = prUrl
wt.prState = prState
void this.save()
}
removeWorktree(id: string): ManagedSession[] {
const removed = this.worktrees.delete(id)
if (!removed) return []
@@ -255,7 +298,128 @@ export class WorktreeStateManager {
}
setWorktreeOrder(order: string[]): void {
this.worktreeOrder = order
const top = new Set<string>()
for (const sec of this.sections.values()) top.add(sec.id)
for (const wt of this.worktrees.values()) {
if (!wt.sectionId) top.add(wt.id)
}
this.worktreeOrder = order.filter((id) => top.has(id))
void this.save()
}
// ---------------------------------------------------------------------------
// Sections
// ---------------------------------------------------------------------------
getSections(): Section[] {
return [...this.sections.values()]
}
getSection(id: string): Section | undefined {
return this.sections.get(id)
}
addSection(name: string, color: string | null, worktreeIds?: string[]): Section {
const id = generateId("sec")
const order = this.worktreeOrder.length
const sec: Section = { id, name, color, order, collapsed: false }
this.sections.set(id, sec)
this.worktreeOrder.push(id)
if (worktreeIds) {
for (const wtId of worktreeIds) {
const wt = this.worktrees.get(wtId)
if (wt) {
wt.sectionId = id
// Remove from top-level worktreeOrder since it's now inside a section
const idx = this.worktreeOrder.indexOf(wtId)
if (idx !== -1) this.worktreeOrder.splice(idx, 1)
}
}
}
this.log(`Added section ${id}: "${name}"`)
void this.save()
return sec
}
renameSection(id: string, name: string): void {
const sec = this.sections.get(id)
if (!sec || !name) return
sec.name = name
this.log(`Renamed section ${id} to "${name}"`)
void this.save()
}
setSectionColor(id: string, color: string | null): void {
const sec = this.sections.get(id)
if (!sec) return
sec.color = color
void this.save()
}
toggleSection(id: string): void {
const sec = this.sections.get(id)
if (!sec) return
sec.collapsed = !sec.collapsed
void this.save()
}
deleteSection(id: string): void {
if (!this.sections.delete(id)) return
// Ungroup all worktrees in this section — do NOT delete them
for (const wt of this.worktrees.values()) {
if (wt.sectionId === id) {
wt.sectionId = undefined
if (!this.worktreeOrder.includes(wt.id)) this.worktreeOrder.push(wt.id)
}
}
// Remove from sidebar order
const idx = this.worktreeOrder.indexOf(id)
if (idx !== -1) this.worktreeOrder.splice(idx, 1)
this.log(`Deleted section ${id}, ungrouped its worktrees`)
void this.save()
}
moveSection(id: string, dir: -1 | 1): void {
const top = this.worktreeOrder.filter((item) => {
if (this.sections.has(item)) return true
const wt = this.worktrees.get(item)
return !!wt && !wt.sectionId
})
const idx = top.indexOf(id)
const next = idx + dir
if (idx === -1 || next < 0 || next >= top.length) return
const target = top[next]!
const result = [...this.worktreeOrder]
const fi = result.indexOf(id)
if (fi === -1 || result.indexOf(target) === -1) return
result.splice(fi, 1)
const insertAt = result.indexOf(target) + (dir === 1 ? 1 : 0)
result.splice(insertAt, 0, id)
this.worktreeOrder = result
void this.save()
}
moveToSection(worktreeIds: string[], sectionId: string | null): void {
// Expand to include all multi-version siblings (same groupId)
const expanded = new Set(worktreeIds)
for (const wtId of worktreeIds) {
const wt = this.worktrees.get(wtId)
if (!wt?.groupId) continue
for (const sibling of this.worktrees.values()) {
if (sibling.groupId === wt.groupId) expanded.add(sibling.id)
}
}
for (const wtId of expanded) {
const wt = this.worktrees.get(wtId)
if (!wt) continue
wt.sectionId = sectionId ?? undefined
if (sectionId) {
const idx = this.worktreeOrder.indexOf(wtId)
if (idx !== -1) this.worktreeOrder.splice(idx, 1)
} else {
if (!this.worktreeOrder.includes(wtId)) this.worktreeOrder.push(wtId)
}
}
void this.save()
}
@@ -314,6 +478,7 @@ export class WorktreeStateManager {
const data = JSON.parse(content) as StateFile
this.worktrees.clear()
this.sessions.clear()
this.sections.clear()
this.tabOrder = {}
this.worktreeOrder = []
this.reviewDiffStyle = "unified"
@@ -329,12 +494,23 @@ export class WorktreeStateManager {
for (const [id, s] of Object.entries(data.sessions ?? {})) {
this.sessions.set(id, { id, ...s })
}
for (const [id, sec] of Object.entries(data.sections ?? {})) {
this.sections.set(id, { id, ...sec })
}
if (data.tabOrder) {
this.tabOrder = data.tabOrder
}
if (data.worktreeOrder) {
this.worktreeOrder = data.worktreeOrder
}
// Normalize: ensure all section IDs and ungrouped worktree IDs are in worktreeOrder
const present = new Set(this.worktreeOrder)
for (const id of this.sections.keys()) {
if (!present.has(id)) this.worktreeOrder.push(id)
}
for (const wt of this.worktrees.values()) {
if (!wt.sectionId && !present.has(wt.id)) this.worktreeOrder.push(wt.id)
}
this.collapsed = data.sessionsCollapsed ?? false
if (data.reviewDiffStyle === "split") {
this.reviewDiffStyle = "split"
@@ -404,6 +580,13 @@ export class WorktreeStateManager {
const { id: _, ...rest } = s
data.sessions[id] = rest
}
if (this.sections.size > 0) {
data.sections = {}
for (const [id, sec] of this.sections) {
const { id: _, ...rest } = sec
data.sections[id] = rest
}
}
if (Object.keys(this.tabOrder).length > 0) {
data.tabOrder = this.tabOrder
}
@@ -56,9 +56,15 @@ export interface PanelContext {
/** Whether the panel is currently the active tab. */
readonly active: boolean
/** Whether the panel is visible (may be unfocused in a split editor group). */
readonly visible: boolean
/** Session provider wired to this panel. */
readonly sessions: SessionProvider
/** Register a callback for when panel visibility changes. */
onDidChangeVisibility(cb: (visible: boolean) => void): Disposable
/** Register a callback for when the panel is disposed. */
onDidDispose(cb: () => void): Disposable
@@ -107,6 +113,9 @@ export interface Host {
/** Capture a telemetry event. */
capture(event: string, properties?: Record<string, unknown>): void
/** Open a URL in the user's default browser. */
openExternal(url: string): void
/** Ask VS Code's git extension to re-scan repositories (e.g. after worktree ref migration). */
refreshGit(): void
@@ -0,0 +1,110 @@
/**
* Bridges the PRStatusPoller with the AgentManagerProvider.
*
* Owns the poller instance, the cached PR messages, and all message/panel handling
* so the provider only needs thin delegation calls.
*/
import type { Worktree } from "./WorktreeStateManager"
import type { AgentManagerOutMessage, PRStatus } from "./types"
import type { Disposable } from "./host"
import { PRStatusPoller } from "./PRStatusPoller"
interface PRBridgeHost {
getWorktrees(): Worktree[]
getWorkspaceRoot(): string | undefined
postToWebview(msg: AgentManagerOutMessage): void
updateWorktreePR(id: string, number?: number, url?: string, state?: string): void
hasPersistedPR(id: string): boolean
openExternal(url: string): void
log(...args: unknown[]): void
}
/** Minimal panel surface needed by the bridge (subset of PanelContext). */
interface PanelLike {
readonly visible: boolean
onDidChangeVisibility(cb: (visible: boolean) => void): Disposable
}
export class PRStatusBridge {
readonly poller: PRStatusPoller
private readonly cache = new Map<string, AgentManagerOutMessage>()
private readonly host: PRBridgeHost
constructor(host: PRBridgeHost) {
this.host = host
this.poller = new PRStatusPoller(bridgePollerOpts(this, host))
}
static create(opts: {
getWorktrees: () => Worktree[]
getWorkspaceRoot: () => string | undefined
postToWebview: (msg: AgentManagerOutMessage) => void
updateWorktreePR: (id: string, n?: number, u?: string, s?: string) => void
hasPersistedPR: (id: string) => boolean
openExternal: (url: string) => void
log: (...args: unknown[]) => void
}): PRStatusBridge {
return new PRStatusBridge(opts)
}
/** Wire visibility tracking to a panel — pauses polling when hidden. */
attachPanel(panel: PanelLike): void {
this.poller.setVisible(panel.visible)
panel.onDidChangeVisibility((v) => {
this.poller.setVisible(v)
})
}
/** Replay cached PR statuses to a freshly-connected webview. */
replay(): void {
this.cache.forEach((msg) => this.host.postToWebview(msg))
}
/** Handle an incoming webview message. Returns true if handled. */
handleMessage(m: Record<string, unknown>): boolean {
if (m.type === "agentManager.refreshPR") {
this.poller.refresh(m.worktreeId as string)
return true
}
if (m.type === "agentManager.openPR") {
const wt = this.host.getWorktrees().find((w: Worktree) => w.id === m.worktreeId)
if (wt?.prUrl) this.host.openExternal(wt.prUrl)
return true
}
return false
}
/** Remove cached status for a deleted worktree. */
remove(worktreeId: string): void {
this.cache.delete(worktreeId)
}
}
/** Build PRStatusPoller options that forward events through the bridge cache. */
function bridgePollerOpts(bridge: PRStatusBridge, host: PRBridgeHost) {
return {
getWorktrees: () => host.getWorktrees(),
getWorkspaceRoot: () => host.getWorkspaceRoot(),
onStatus: (id: string, pr: PRStatus | null, err?: "gh_missing" | "gh_auth" | "fetch_failed") => {
if (err) {
// Don't forward errors to the webview when we have prior PR data
// (in-memory cache or persisted prNumber) — that would overwrite
// the live badge with pr:null. Only forward when there's truly no
// prior data (first poll failed, nothing persisted).
if (!bridge["cache"].has(id) && !host.hasPersistedPR(id))
host.postToWebview({
type: "agentManager.prStatus",
worktreeId: id,
pr: null,
error: err,
} as AgentManagerOutMessage)
return
}
const msg = { type: "agentManager.prStatus", worktreeId: id, pr, error: err } as AgentManagerOutMessage
bridge["cache"].set(id, msg)
host.postToWebview(msg)
host.updateWorktreePR(id, pr?.number, pr?.url, pr?.state)
},
log: (...args: unknown[]) => host.log(...args),
}
}
@@ -0,0 +1,21 @@
import type { WorktreeStateManager } from "./WorktreeStateManager"
import type { AgentManagerInMessage } from "./types"
/** Handle section CRUD messages. Returns true if handled. */
export function handleSection(
state: WorktreeStateManager | undefined,
m: AgentManagerInMessage,
push: () => void,
): boolean {
if (!state) return false
if (m.type === "agentManager.createSection") state.addSection(m.name, m.color ?? null, m.worktreeIds)
else if (m.type === "agentManager.renameSection") state.renameSection(m.sectionId, m.name)
else if (m.type === "agentManager.deleteSection") state.deleteSection(m.sectionId)
else if (m.type === "agentManager.setSectionColor") state.setSectionColor(m.sectionId, m.color)
else if (m.type === "agentManager.toggleSectionCollapsed") state.toggleSection(m.sectionId)
else if (m.type === "agentManager.moveToSection") state.moveToSection(m.worktreeIds, m.sectionId)
else if (m.type === "agentManager.moveSection") state.moveSection(m.sectionId, m.dir)
else return false
push()
return true
}
+122 -1
View File
@@ -8,7 +8,7 @@
*/
import type { FileDiff } from "@kilocode/sdk/v2/client"
import type { Worktree, ManagedSession } from "./WorktreeStateManager"
import type { Worktree, ManagedSession, Section } from "./WorktreeStateManager"
import type { WorktreeStats, LocalStats } from "./GitStatsPoller"
import type { ApplyConflict } from "./GitOps"
import type { BranchListItem, WorktreeSetupErrorCode } from "./git-import"
@@ -29,6 +29,58 @@ export type WorktreeDiffEntry = FileDiff & {
stamp?: string
}
// ---------------------------------------------------------------------------
// PR status types
// ---------------------------------------------------------------------------
export type PRState = "open" | "draft" | "merged" | "closed"
export type ReviewDecision = "approved" | "changes_requested" | "pending"
export type CheckStatus = "success" | "failure" | "pending" | "skipped" | "cancelled"
export type AggregateCheckStatus = "success" | "failure" | "pending" | "none"
export interface PRCheck {
name: string
status: CheckStatus
url?: string
duration?: string
}
export interface PRComment {
id: string
author: string
avatar?: string
body: string
file?: string
line?: number
url?: string
resolved: boolean
createdAt?: number
}
export interface PRStatus {
number: number
title: string
url: string
state: PRState
review: ReviewDecision | null
checks: {
status: AggregateCheckStatus
total: number
passed: number
failed: number
pending: number
items: PRCheck[]
}
comments?: {
total: number
unresolved: number
items: PRComment[]
}
additions: number
deletions: number
files: number
}
// ---------------------------------------------------------------------------
// Extension → Webview messages (postToWebview)
// ---------------------------------------------------------------------------
@@ -66,6 +118,7 @@ interface StateMessage {
type: "agentManager.state"
worktrees: Worktree[]
sessions: ManagedSession[]
sections?: Section[]
staleWorktreeIds?: string[]
tabOrder?: Record<string, string[]>
worktreeOrder?: string[]
@@ -175,6 +228,13 @@ interface WorktreeDiffFileMessage {
diff: WorktreeDiffEntry | null
}
interface PRStatusOutMessage {
type: "agentManager.prStatus"
worktreeId: string
pr: PRStatus | null
error?: "gh_missing" | "gh_auth" | "fetch_failed"
}
interface ActionOutMessage {
type: "action"
action: string
@@ -202,6 +262,7 @@ export type AgentManagerOutMessage =
| WorktreeDiffLoadingMessage
| WorktreeDiffMessage
| WorktreeDiffFileMessage
| PRStatusOutMessage
| ActionOutMessage
// ---------------------------------------------------------------------------
@@ -379,6 +440,16 @@ interface StopDiffWatchIn {
type: "agentManager.stopDiffWatch"
}
interface RefreshPRIn {
type: "agentManager.refreshPR"
worktreeId: string
}
interface OpenPRIn {
type: "agentManager.openPR"
worktreeId: string
}
interface OpenFileIn {
type: "agentManager.openFile"
sessionId: string
@@ -453,6 +524,47 @@ interface ContinueInWorktreeIn {
sessionId: string
}
interface CreateSectionIn {
type: "agentManager.createSection"
name: string
color?: string
worktreeIds?: string[]
}
interface RenameSectionIn {
type: "agentManager.renameSection"
sectionId: string
name: string
}
interface DeleteSectionIn {
type: "agentManager.deleteSection"
sectionId: string
}
interface SetSectionColorIn {
type: "agentManager.setSectionColor"
sectionId: string
color: string | null
}
interface ToggleSectionCollapsedIn {
type: "agentManager.toggleSectionCollapsed"
sectionId: string
}
interface MoveToSectionIn {
type: "agentManager.moveToSection"
worktreeIds: string[]
sectionId: string | null
}
interface MoveSectionIn {
type: "agentManager.moveSection"
sectionId: string
dir: -1 | 1
}
/** All messages the Agent Manager expects from the webview (onMessage input). */
export type AgentManagerInMessage =
| CreateWorktreeIn
@@ -489,6 +601,8 @@ export type AgentManagerInMessage =
| ApplyWorktreeDiffIn
| StartDiffWatchIn
| StopDiffWatchIn
| RefreshPRIn
| OpenPRIn
| OpenFileIn
| GenericOpenFileIn
| PreviewImageIn
@@ -498,3 +612,10 @@ export type AgentManagerInMessage =
| ClearSessionIn
| AbortIn
| ContinueInWorktreeIn
| CreateSectionIn
| RenameSectionIn
| DeleteSectionIn
| SetSectionColorIn
| ToggleSectionCollapsedIn
| MoveToSectionIn
| MoveSectionIn
@@ -92,6 +92,9 @@ export class VscodeHost implements Host {
get active() {
return panel.active
},
get visible() {
return panel.visible
},
postMessage(msg) {
void panel.webview.postMessage(msg)
},
@@ -99,6 +102,9 @@ export class VscodeHost implements Host {
panel.reveal(vscode.ViewColumn.One, preserveFocus ?? false)
},
sessions,
onDidChangeVisibility(cb) {
return panel.onDidChangeViewState((e) => cb(e.webviewPanel.visible))
},
onDidDispose(cb) {
return panel.onDidDispose(cb)
},
@@ -160,9 +166,11 @@ export class VscodeHost implements Host {
TelemetryProxy.capture(event as TelemetryEventName, properties)
}
openExternal(url: string): void {
void vscode.env.openExternal(vscode.Uri.parse(url))
}
refreshGit(): void {
// Trigger VS Code's built-in git extension to re-scan repositories.
// This picks up worktrees whose gitdir refs were just rewritten by migration.
void vscode.commands.executeCommand("git.refresh")
}
+6 -2
View File
@@ -188,11 +188,15 @@ export function activate(context: vscode.ExtensionContext) {
else provider.postMessage({ type: "action", action: "historyButtonClicked" })
}),
vscode.commands.registerCommand("kilo-code.new.cycleAgentMode", () => {
provider.postMessage({ type: "action", action: "cycleAgentMode" })
const tab = activeTabProvider()
if (tab) tab.postMessage({ type: "action", action: "cycleAgentMode" })
else provider.postMessage({ type: "action", action: "cycleAgentMode" })
agentManagerProvider.postMessage({ type: "action", action: "cycleAgentMode" })
}),
vscode.commands.registerCommand("kilo-code.new.cyclePreviousAgentMode", () => {
provider.postMessage({ type: "action", action: "cyclePreviousAgentMode" })
const tab = activeTabProvider()
if (tab) tab.postMessage({ type: "action", action: "cyclePreviousAgentMode" })
else provider.postMessage({ type: "action", action: "cyclePreviousAgentMode" })
agentManagerProvider.postMessage({ type: "action", action: "cyclePreviousAgentMode" })
}),
vscode.commands.registerCommand("kilo-code.new.profileButtonClicked", () => {
@@ -0,0 +1,34 @@
// Replicated from packages/util/src/retry.ts to avoid adding @opencode-ai/util
// as a dependency of the extension. Keep in sync with the original.
const TRANSIENT = [
"load failed",
"network connection was lost",
"network request failed",
"failed to fetch",
"fetch failed",
"econnreset",
"econnrefused",
"etimedout",
"socket hang up",
]
function transient(error: unknown): boolean {
if (!error) return false
const msg = String(error instanceof Error ? error.message : error).toLowerCase()
return TRANSIENT.some((m) => msg.includes(m))
}
export async function retry<T>(fn: () => Promise<T>, attempts = 3, delay = 500): Promise<T> {
let last: unknown
for (let i = 0; i < attempts; i++) {
try {
return await fn()
} catch (error) {
last = error
if (i === attempts - 1 || !transient(error)) throw error
await new Promise((r) => setTimeout(r, delay * 2 ** i))
}
}
throw last
}
+70
View File
@@ -0,0 +1,70 @@
/**
* Exponential backoff retry utilities for rate-limited API calls.
*
* When the CLI backend (or the upstream AI provider it proxies) returns
* HTTP 429, retries are scheduled with exponential backoff. The delay
* respects `Retry-After` / `Retry-After-MS` headers when present.
*/
/** Backoff delays per attempt: 5s -> 10s -> 30s -> 60s -> 300s */
const BACKOFF_DELAYS_MS = [5_000, 10_000, 30_000, 60_000, 300_000]
/** Maximum backoff delay in ms (5 minutes) */
const MAX_MS = 300_000
/** Maximum number of retry attempts */
const MAX_RETRIES = BACKOFF_DELAYS_MS.length
/** HTTP status codes that are safe to retry */
const RETRYABLE = new Set([408, 409, 425, 429, 500, 502, 503, 504])
/**
* Whether an HTTP status code is retryable.
*/
export function retryable(status: number): boolean {
if (RETRYABLE.has(status)) return true
return status >= 500
}
/**
* Extract a retry delay (in ms) from standard response headers.
*
* Checks `retry-after-ms` first (milliseconds), then `retry-after`
* (seconds or HTTP-date). Returns `null` when no usable header is found.
*/
export function headerDelay(headers: Headers): number | null {
const ms = headers.get("retry-after-ms")
if (ms) {
const parsed = Number.parseFloat(ms)
if (!Number.isNaN(parsed) && parsed > 0) return parsed
}
const after = headers.get("retry-after")
if (after) {
const seconds = Number.parseFloat(after)
if (!Number.isNaN(seconds) && seconds > 0) return Math.ceil(seconds * 1000)
// Try HTTP-date format
const date = Date.parse(after) - Date.now()
if (!Number.isNaN(date) && date > 0) return Math.ceil(date)
}
return null
}
/**
* Calculate backoff delay for a given attempt.
*
* If `headers` are provided and contain a `Retry-After` value, that
* value is used (capped at MAX_MS). Otherwise uses the predefined
* backoff schedule: 5s, 10s, 30s, 60s, 300s.
*/
export function backoff(attempt: number, headers?: Headers): number {
if (headers) {
const fromHeader = headerDelay(headers)
if (fromHeader !== null) return Math.min(fromHeader, MAX_MS)
}
const index = Math.min(attempt - 1, BACKOFF_DELAYS_MS.length - 1)
return BACKOFF_DELAYS_MS[index] ?? MAX_MS
}
export { MAX_RETRIES, MAX_MS }
@@ -31,6 +31,7 @@ const TSX_FILES = [
path.join(ROOT, "webview-ui/agent-manager/ApplyDialog.tsx"),
path.join(ROOT, "webview-ui/agent-manager/BranchSelect.tsx"),
path.join(ROOT, "webview-ui/agent-manager/WorktreeItem.tsx"),
path.join(ROOT, "webview-ui/agent-manager/SectionHeader.tsx"),
]
const TSX_FILE = TSX_FILES[0]
const PROVIDER_FILE = path.join(ROOT, "src/agent-manager/AgentManagerProvider.ts")
@@ -532,7 +533,7 @@ const VSCODE_ALLOWED: Record<string, { note: string }> = {
*/
const MAX_LINES: Record<string, { maxLines: number; note: string }> = {
"AgentManagerProvider.ts": {
maxLines: 1910,
maxLines: 2000,
note: "primary extraction target: break into smaller orchestrators",
},
}
+83 -85
View File
@@ -4,12 +4,12 @@ import * as os from "os"
import * as nodePath from "path"
import { GitOps } from "../../src/agent-manager/GitOps"
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
function ops(handler: (args: string[], cwd: string) => Promise<string>): GitOps {
return new GitOps({ log: () => undefined, runGit: handler })
}
function ops(handler: (args: string[], cwd: string) => Promise<string>): GitOps {
return new GitOps({ log: () => undefined, refreshMs: 120000, runGit: handler })
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}
function runGit(cwd: string, args: string[]): string {
@@ -193,106 +193,29 @@ describe("GitOps", () => {
})
})
describe("refreshRemote", () => {
it("fetches the remote", async () => {
const commands: string[][] = []
const git = ops(async (args) => {
commands.push(args)
if (args[0] === "rev-parse" && args[1] === "--git-common-dir") return "/repo/.git"
return ""
})
await git.refreshRemote("/repo", "origin")
const fetches = commands.filter((c) => c[0] === "fetch")
expect(fetches.length).toBe(1)
expect(fetches[0]![3]).toBe("origin")
})
it("skips empty remote name", async () => {
const commands: string[][] = []
const git = ops(async (args) => {
commands.push(args)
return ""
})
await git.refreshRemote("/repo", "")
expect(commands.length).toBe(0)
})
it("throttles repeated fetches for the same remote", async () => {
const commands: string[][] = []
const git = ops(async (args) => {
commands.push(args)
if (args[0] === "rev-parse" && args[1] === "--git-common-dir") return "/repo/.git"
return ""
})
await git.refreshRemote("/repo", "origin")
await git.refreshRemote("/repo", "origin")
const fetches = commands.filter((c) => c[0] === "fetch")
expect(fetches.length).toBe(1)
})
it("deduplicates inflight fetches", async () => {
const commands: string[][] = []
const git = new GitOps({
log: () => undefined,
refreshMs: 0,
runGit: async (args) => {
commands.push(args)
if (args[0] === "rev-parse" && args[1] === "--git-common-dir") return "/repo/.git"
if (args[0] === "fetch") {
await sleep(50)
return ""
}
return ""
},
})
await Promise.all([git.refreshRemote("/repo", "origin"), git.refreshRemote("/repo", "origin")])
const fetches = commands.filter((c) => c[0] === "fetch")
expect(fetches.length).toBe(1)
})
})
describe("aheadBehind", () => {
it("counts commits ahead and behind using the provided ref", async () => {
const git = ops(async (args) => {
if (args[0] === "rev-parse" && args[1] === "--git-common-dir") return ".git"
if (args[0] === "fetch") return ""
if (args[0] === "rev-list" && args[1] === "--left-right") return "1\t3"
return ""
})
expect(await git.aheadBehind("/repo", "origin/main")).toEqual({ ahead: 3, behind: 1 })
})
it("fetches the explicitly-provided remote before counting", async () => {
it("does not fetch from remote", async () => {
const commands: string[][] = []
const git = ops(async (args) => {
commands.push(args)
if (args[0] === "rev-parse" && args[1] === "--git-common-dir") return ".git"
if (args[0] === "fetch") return ""
if (args[0] === "rev-list" && args[1] === "--left-right") return "0\t4"
return ""
})
expect(await git.aheadBehind("/repo", "myfork/main", "myfork")).toEqual({ ahead: 4, behind: 0 })
const fetches = commands.filter((c) => c[0] === "fetch")
expect(fetches.length).toBe(1)
expect(fetches[0]![3]).toBe("myfork")
})
it("skips fetch when no remote is provided", async () => {
const commands: string[][] = []
const git = ops(async (args) => {
commands.push(args)
if (args[0] === "rev-list" && args[1] === "--left-right") return "0\t2"
return ""
})
expect(await git.aheadBehind("/repo", "main")).toEqual({ ahead: 2, behind: 0 })
await git.aheadBehind("/repo", "myfork/main")
const fetches = commands.filter((c) => c[0] === "fetch")
expect(fetches.length).toBe(0)
})
it("returns zeros when rev-list fails", async () => {
const git = ops(async (args) => {
if (args[0] === "rev-parse" && args[1] === "--git-common-dir") return ".git"
if (args[0] === "fetch") return ""
if (args[0] === "rev-list") throw new Error("fatal")
return ""
})
@@ -302,8 +225,6 @@ describe("GitOps", () => {
it("uses the ref directly without double-prefixing", async () => {
const refs: string[] = []
const git = ops(async (args) => {
if (args[0] === "rev-parse" && args[1] === "--git-common-dir") return ".git"
if (args[0] === "fetch") return ""
if (args[0] === "rev-list" && args[1] === "--left-right") {
refs.push(args[3]!)
return "0\t1"
@@ -558,4 +479,81 @@ describe("GitOps", () => {
})
})
})
describe("dispose", () => {
it("aborts in-flight runGit calls quickly", async () => {
let resolved = false
const git = new GitOps({
log: () => undefined,
runGit: async () => {
await sleep(5000)
resolved = true
return "should not reach"
},
})
const start = Date.now()
const pending = git.currentBranch("/repo")
git.dispose()
await pending
const elapsed = Date.now() - start
expect(elapsed).toBeLessThan(500)
expect(resolved).toBe(false)
})
it("causes subsequent runGit calls to fail immediately", async () => {
let called = false
const git = new GitOps({
log: () => undefined,
runGit: async () => {
called = true
return "ok"
},
})
git.dispose()
// currentBranch swallows errors — should return "" without calling runGit
const result = await git.currentBranch("/repo")
expect(result).toBe("")
expect(called).toBe(false)
})
it("reports disposed state", () => {
const git = ops(async () => "ok")
expect(git.disposed).toBe(false)
git.dispose()
expect(git.disposed).toBe(true)
})
it("kills in-flight exec (spawn) processes", async () => {
await withRepo(async (cwd) => {
const git = new GitOps({ log: () => undefined })
await fs.writeFile(nodePath.join(cwd, "a.txt"), "one\n", "utf8")
runGit(cwd, ["add", "-A"])
runGit(cwd, ["-c", "user.name=Test", "-c", "user.email=test@example.com", "commit", "-m", "init"])
await fs.writeFile(nodePath.join(cwd, "a.txt"), "two\n", "utf8")
const branch = runGit(cwd, ["branch", "--show-current"]) || "HEAD"
const pending = git.buildWorktreePatch(cwd, branch)
// Give spawn a moment to start, then dispose
await sleep(10)
git.dispose()
// Should either reject or return (but process should be killed)
try {
await pending
} catch {
// expected — aborted
}
expect(git.disposed).toBe(true)
})
})
it("is safe to call multiple times", () => {
const git = ops(async () => "ok")
git.dispose()
git.dispose()
expect(git.disposed).toBe(true)
})
})
})
@@ -3,7 +3,7 @@ import * as fs from "fs"
import * as os from "os"
import * as path from "path"
import type { KiloClient } from "@kilocode/sdk/v2/client"
import { GitStatsPoller } from "../../src/agent-manager/GitStatsPoller"
import { GitStatsPoller, type WorktreePresenceResult } from "../../src/agent-manager/GitStatsPoller"
import { GitOps } from "../../src/agent-manager/GitOps"
import type { Worktree } from "../../src/agent-manager/WorktreeStateManager"
@@ -66,9 +66,6 @@ describe("GitStatsPoller", () => {
log: () => undefined,
intervalMs: 5,
git: gitOps(async (args) => {
if (args[0] === "rev-parse" && args[1] === "--git-common-dir") return ".git"
if (args[0] === "rev-parse" && args[3] === "@{upstream}") return "origin/main"
if (args[0] === "fetch") return ""
if (args[0] === "rev-list" && args[1] === "--left-right") return "0\t1"
return ""
}),
@@ -106,9 +103,7 @@ describe("GitStatsPoller", () => {
log: () => undefined,
intervalMs: 5,
git: gitOps(async (args) => {
if (args[0] === "rev-parse" && args[1] === "--git-common-dir") return ".git"
if (args[0] === "rev-parse" && args[3] === "@{upstream}") return "origin/main"
if (args[0] === "fetch") return ""
if (args[0] === "rev-list" && args[1] === "--left-right") return "0\t2"
return ""
}),
@@ -133,7 +128,7 @@ describe("GitStatsPoller", () => {
const wtPath = path.join(root, "wt-a")
fs.mkdirSync(wtPath, { recursive: true })
const presence: Array<{ worktrees: Array<{ worktreeId: string; missing: boolean }>; degraded: boolean }> = []
const presence: WorktreePresenceResult[] = []
const poller = new GitStatsPoller({
getWorktrees: () => [{ ...worktree("a"), path: wtPath }],
@@ -159,7 +154,10 @@ describe("GitStatsPoller", () => {
poller.stop()
fs.rmSync(root, { recursive: true, force: true })
expect(presence[0]).toEqual({ worktrees: [{ worktreeId: "a", missing: false }], degraded: false })
expect(presence[0]).toEqual({
worktrees: [{ worktreeId: "a", missing: false, branch: "branch-a" }],
degraded: false,
})
})
it("emits degraded probe when git worktree listing fails", async () => {
@@ -167,7 +165,7 @@ describe("GitStatsPoller", () => {
const wtPath = path.join(root, "wt-a")
fs.mkdirSync(wtPath, { recursive: true })
const presence: Array<{ worktrees: Array<{ worktreeId: string; missing: boolean }>; degraded: boolean }> = []
const presence: WorktreePresenceResult[] = []
const poller = new GitStatsPoller({
getWorktrees: () => [{ ...worktree("a"), path: wtPath }],
@@ -204,7 +202,7 @@ describe("GitStatsPoller", () => {
const calls: string[] = []
const emitted: Array<Array<{ worktreeId: string; additions: number; deletions: number; commits: number }>> = []
const presence: Array<{ worktrees: Array<{ worktreeId: string; missing: boolean }>; degraded: boolean }> = []
const presence: WorktreePresenceResult[] = []
const client = {
worktree: {
@@ -231,9 +229,7 @@ describe("GitStatsPoller", () => {
if (args[0] === "worktree") {
return `worktree ${wtAPath}\nbranch refs/heads/branch-a\n`
}
if (args[0] === "rev-parse" && args[1] === "--git-common-dir") return ".git"
if (args[0] === "rev-parse" && args[3] === "@{upstream}") return "origin/main"
if (args[0] === "fetch") return ""
if (args[0] === "rev-list") return "1"
return ""
}),
@@ -247,8 +243,8 @@ describe("GitStatsPoller", () => {
expect(calls.some((cwd) => cwd === wtBPath)).toBe(false)
expect(presence[0]).toEqual({
worktrees: [
{ worktreeId: "a", missing: false },
{ worktreeId: "b", missing: true },
{ worktreeId: "a", missing: false, branch: "branch-a" },
{ worktreeId: "b", missing: true, branch: undefined },
],
degraded: false,
})
@@ -287,8 +283,6 @@ describe("GitStatsPoller", () => {
git: gitOps(async (args) => {
if (args[0] === "rev-parse" && args[1] === "--abbrev-ref" && args[2] === "HEAD") return "feature"
if (args[0] === "rev-parse" && args[1] === "--abbrev-ref" && args[2] === "@{upstream}") return "origin/feature"
if (args[0] === "rev-parse" && args[1] === "--git-common-dir") return ".git"
if (args[0] === "fetch") return ""
if (args[0] === "rev-list" && args[1] === "--left-right") return "0\t3"
if (args[0] === "branch") return "feature"
if (args[0] === "config") return "origin"
@@ -341,8 +335,6 @@ describe("GitStatsPoller", () => {
// myfork/HEAD resolves to the default branch
if (args[0] === "symbolic-ref" && args[2] === "refs/remotes/myfork/HEAD") return "myfork/develop"
if (args[0] === "branch") return "my-feature"
if (args[0] === "rev-parse" && args[1] === "--git-common-dir") return ".git"
if (args[0] === "fetch") return ""
if (args[0] === "rev-list" && args[1] === "--left-right") return "0\t5"
return ""
}),
@@ -406,7 +398,7 @@ describe("GitStatsPoller", () => {
})
})
it("refreshes upstream remote once for concurrent worktrees", async () => {
it("does not fetch from remote for ahead/behind counts", async () => {
const commands: string[][] = []
const emitted: Array<
Array<{ worktreeId: string; files: number; additions: number; deletions: number; ahead: number; behind: number }>
@@ -416,7 +408,6 @@ describe("GitStatsPoller", () => {
worktree: { diffSummary: async () => ({ data: diff(0, 0) }) },
} as unknown as KiloClient
// Worktrees store remote="upstream" so aheadBehind receives "upstream/main"
const poller = new GitStatsPoller({
getWorktrees: () => [worktree("a", "upstream"), worktree("b", "upstream")],
getWorkspaceRoot: () => undefined,
@@ -427,8 +418,6 @@ describe("GitStatsPoller", () => {
intervalMs: 500,
git: gitOps(async (args) => {
commands.push(args)
if (args[0] === "rev-parse" && args[1] === "--git-common-dir") return "/repo/.git"
if (args[0] === "fetch") return ""
if (args[0] === "rev-list" && args[1] === "--left-right") return "0\t0"
return ""
}),
@@ -439,9 +428,6 @@ describe("GitStatsPoller", () => {
poller.stop()
const fetches = commands.filter((cmd) => cmd[0] === "fetch")
expect(fetches.length).toBe(1)
const fetch = fetches[0]
if (!fetch) throw new Error("expected fetch command")
expect(fetch[3]).toBe("upstream")
expect(fetches.length).toBe(0)
})
})
@@ -0,0 +1,81 @@
import { describe, it, expect } from "bun:test"
import { retry } from "../../src/services/cli-backend/retry"
describe("retry", () => {
it("returns on first success", async () => {
const result = await retry(() => Promise.resolve(42))
expect(result).toBe(42)
})
it("retries transient errors and succeeds", async () => {
let calls = 0
const result = await retry(
() => {
calls++
if (calls < 3) throw new TypeError("fetch failed")
return Promise.resolve("ok")
},
3,
10,
)
expect(result).toBe("ok")
expect(calls).toBe(3)
})
it("throws immediately on non-transient errors", async () => {
let calls = 0
await expect(
retry(
() => {
calls++
throw new Error("404 not found")
},
3,
10,
),
).rejects.toThrow("404 not found")
expect(calls).toBe(1)
})
it("throws after exhausting attempts on transient errors", async () => {
let calls = 0
await expect(
retry(
() => {
calls++
throw new Error("ECONNREFUSED")
},
3,
10,
),
).rejects.toThrow("ECONNREFUSED")
expect(calls).toBe(3)
})
it("detects all transient error messages", async () => {
const messages = [
"load failed",
"network connection was lost",
"network request failed",
"failed to fetch",
"fetch failed",
"ECONNRESET",
"ECONNREFUSED",
"ETIMEDOUT",
"socket hang up",
]
for (const msg of messages) {
let calls = 0
await retry(
() => {
calls++
if (calls === 1) throw new Error(msg)
return Promise.resolve(true)
},
2,
10,
)
expect(calls).toBe(2)
}
})
})
@@ -0,0 +1,200 @@
import { describe, it, expect } from "bun:test"
import { acronymMatch, searchMatch } from "../../webview-ui/src/utils/search-match"
// ---------------------------------------------------------------------------
// acronymMatch — low-level word-boundary matching
// ---------------------------------------------------------------------------
describe("acronymMatch", () => {
describe("basic word boundary matching", () => {
it("matches at word start", () => {
expect(acronymMatch("fool org", "foo")).toBe(true)
expect(acronymMatch("the fool", "foo")).toBe(true)
})
it("does not match arbitrary substrings", () => {
expect(acronymMatch("faoboc", "foo")).toBe(false)
expect(acronymMatch("barfoo", "foo")).toBe(false)
})
it("matches prefix of a single word", () => {
expect(acronymMatch("foobar", "foob")).toBe(true)
})
it("matches exact word", () => {
expect(acronymMatch("test", "test")).toBe(true)
expect(acronymMatch("testing", "test")).toBe(true)
expect(acronymMatch("the test", "test")).toBe(true)
})
})
describe("word separators", () => {
it("recognizes space", () => {
expect(acronymMatch("hello world", "wor")).toBe(true)
})
it("recognizes hyphen", () => {
expect(acronymMatch("hello-world", "wor")).toBe(true)
})
it("recognizes underscore", () => {
expect(acronymMatch("hello_world", "wor")).toBe(true)
})
it("recognizes slash", () => {
expect(acronymMatch("hello/world", "wor")).toBe(true)
})
it("recognizes dot", () => {
expect(acronymMatch("hello.world", "wor")).toBe(true)
})
it("recognizes parentheses", () => {
expect(acronymMatch("Grok Code Fast 1 (free)", "free")).toBe(true)
})
})
describe("acronym matching", () => {
it("matches acronyms from word starts", () => {
expect(acronymMatch("Claude Sonnet", "clso")).toBe(true)
})
it("matches partial acronyms", () => {
expect(acronymMatch("Claude Sonnet 3.5", "cls")).toBe(true)
})
it("matches direct and acronym", () => {
expect(acronymMatch("clso tool", "clso")).toBe(true)
expect(acronymMatch("Claude Sonnet", "clso")).toBe(true)
})
it("does not match non-boundary acronym", () => {
expect(acronymMatch("aclbso", "clso")).toBe(false)
})
})
describe("camelCase and PascalCase", () => {
it("recognizes camelCase boundary", () => {
expect(acronymMatch("gitRebase", "gr")).toBe(true)
})
it("matches PascalCase acronyms", () => {
expect(acronymMatch("NewFileCreation", "nfc")).toBe(true)
})
it("splits camelCase at uppercase transitions", () => {
expect(acronymMatch("parseMarkdownContent", "pmc")).toBe(true)
})
it("handles mixed case scenarios", () => {
expect(acronymMatch("gitRebase", "gitr")).toBe(true)
expect(acronymMatch("GitRebase", "gitr")).toBe(true)
})
})
describe("backtracking", () => {
it("matches word that appears later in text", () => {
expect(acronymMatch("google gemini", "gemini")).toBe(true)
expect(acronymMatch("gemini pro", "gemini")).toBe(true)
expect(acronymMatch("google em emini", "gemini")).toBe(true)
})
it("matches partial word that appears later", () => {
expect(acronymMatch("Microsoft Copilot", "copilot")).toBe(true)
expect(acronymMatch("GitHub Copilot", "copilot")).toBe(true)
})
it("still respects word boundaries with backtracking", () => {
expect(acronymMatch("google gemini", "gemini")).toBe(true)
expect(acronymMatch("googlegemini", "gemini")).toBe(false)
})
})
describe("edge cases", () => {
it("handles empty text", () => {
expect(acronymMatch("", "foo")).toBe(false)
})
it("handles empty query", () => {
expect(acronymMatch("foo", "")).toBe(true)
})
it("handles special characters in text", () => {
expect(acronymMatch("foo-bar", "foob")).toBe(true)
})
})
})
// ---------------------------------------------------------------------------
// searchMatch — high-level search with trimming and multi-word support
// ---------------------------------------------------------------------------
describe("searchMatch", () => {
describe("empty and whitespace queries", () => {
it("returns true for empty query", () => {
expect(searchMatch("", "anything")).toBe(true)
})
it("returns true for whitespace-only query", () => {
expect(searchMatch(" ", "anything")).toBe(true)
})
})
describe("case insensitivity", () => {
it("matches case-insensitively", () => {
expect(searchMatch("foo", "Foo Bar")).toBe(true)
expect(searchMatch("foo", "FOO BAZ")).toBe(true)
expect(searchMatch("FoO", "foo qux")).toBe(true)
})
})
describe("trimming", () => {
it("trims leading spaces", () => {
expect(searchMatch(" foo", "foo bar")).toBe(true)
})
it("trims trailing spaces", () => {
expect(searchMatch("foo ", "foo bar")).toBe(true)
})
it("trims spaces on both sides", () => {
expect(searchMatch(" foo ", "foo bar")).toBe(true)
})
})
describe("multi-word queries", () => {
it("matches when all words present", () => {
expect(searchMatch("claude sonnet", "Claude Sonnet 3.5")).toBe(true)
})
it("does not match when any word missing", () => {
expect(searchMatch("claude sonnet", "Claude Opus")).toBe(false)
expect(searchMatch("claude sonnet", "GPT Sonnet")).toBe(false)
})
})
describe("real-world model search", () => {
it("finds model with hyphen in query", () => {
expect(searchMatch("gpt-5", "OpenAI: gpt-5 mini")).toBe(true)
expect(searchMatch("gpt-5", "OpenAI: gpt-4")).toBe(false)
})
it("finds model when hyphen omitted from query", () => {
expect(searchMatch("gpt5", "OpenAI: gpt-5 mini")).toBe(true)
})
it("finds all models with trailing hyphen", () => {
expect(searchMatch("gpt-", "OpenAI: gpt-5 mini")).toBe(true)
expect(searchMatch("gpt-", "OpenAI: gpt-4")).toBe(true)
expect(searchMatch("gpt-", "Anthropic: claude-3")).toBe(false)
})
it("matches file paths", () => {
expect(searchMatch("code", "src/services/code-index/manager.ts")).toBe(true)
})
it("matches mode selector options by label+value", () => {
expect(searchMatch("cod", "Code code Write code")).toBe(true)
})
})
})
@@ -0,0 +1,114 @@
import { describe, it, expect } from "bun:test"
import { buildTopLevelItems, isGrouped, isGroupStart, isGroupEnd } from "../../webview-ui/agent-manager/section-helpers"
import type { WorktreeState, SectionState } from "../../webview-ui/src/types/messages"
function wt(id: string, opts: Partial<WorktreeState> = {}): WorktreeState {
return {
id,
branch: `branch-${id}`,
path: `/tmp/${id}`,
parentBranch: "main",
createdAt: "2024-01-01",
...opts,
}
}
function sec(id: string, order: number, opts: Partial<SectionState> = {}): SectionState {
return { id, name: `Section ${id}`, color: null, order, collapsed: false, ...opts }
}
describe("buildTopLevelItems", () => {
it("returns flat worktree list when no sections", () => {
const all = [wt("a"), wt("b"), wt("c")]
const result = buildTopLevelItems([], [], all, [])
expect(result).toHaveLength(3)
expect(result.every((r) => r.kind === "worktree")).toBe(true)
})
it("interleaves sections and worktrees per order", () => {
const s1 = sec("s1", 0)
const w1 = wt("w1")
const s2 = sec("s2", 1)
const result = buildTopLevelItems([s1, s2], [w1], [w1], ["s1", "w1", "s2"])
expect(result).toHaveLength(3)
expect(result[0]).toEqual({ kind: "section", section: s1 })
expect(result[1]).toEqual({ kind: "worktree", wt: w1 })
expect(result[2]).toEqual({ kind: "section", section: s2 })
})
it("appends unordered sections and worktrees at the end", () => {
const s1 = sec("s1", 0)
const s2 = sec("s2", 1)
const w1 = wt("w1")
const w2 = wt("w2")
// Only s1 is in the order array
const result = buildTopLevelItems([s1, s2], [w1, w2], [w1, w2], ["s1", "w1"])
expect(result).toHaveLength(4)
expect(result[0]).toEqual({ kind: "section", section: s1 })
expect(result[1]).toEqual({ kind: "worktree", wt: w1 })
// unordered items appended
expect(result[2]).toEqual({ kind: "section", section: s2 })
expect(result[3]).toEqual({ kind: "worktree", wt: w2 })
})
it("skips duplicate ids in order array", () => {
const s1 = sec("s1", 0)
const w1 = wt("w1")
const result = buildTopLevelItems([s1], [w1], [w1], ["s1", "w1", "s1", "w1"])
expect(result).toHaveLength(2)
})
})
describe("isGrouped", () => {
it("returns true when groupId is set", () => {
expect(isGrouped(wt("a", { groupId: "g1" }))).toBe(true)
})
it("returns false when groupId is undefined", () => {
expect(isGrouped(wt("a"))).toBe(false)
})
it("returns false when groupId is empty string", () => {
expect(isGrouped(wt("a", { groupId: "" }))).toBe(false)
})
})
describe("isGroupStart", () => {
const list = [wt("a", { groupId: "g1" }), wt("b", { groupId: "g1" }), wt("c", { groupId: "g2" }), wt("d")]
it("returns true for first item in group", () => {
expect(isGroupStart(list[0]!, 0, list)).toBe(true)
})
it("returns false for middle/end item in same group", () => {
expect(isGroupStart(list[1]!, 1, list)).toBe(false)
})
it("returns true when previous item has different groupId", () => {
expect(isGroupStart(list[2]!, 2, list)).toBe(true)
})
it("returns false for ungrouped worktree", () => {
expect(isGroupStart(list[3]!, 3, list)).toBe(false)
})
})
describe("isGroupEnd", () => {
const list = [wt("a", { groupId: "g1" }), wt("b", { groupId: "g1" }), wt("c", { groupId: "g2" }), wt("d")]
it("returns false for start/middle of group", () => {
expect(isGroupEnd(list[0]!, 0, list)).toBe(false)
})
it("returns true for last item in group when next has different groupId", () => {
expect(isGroupEnd(list[1]!, 1, list)).toBe(true)
})
it("returns true for last item in list with groupId", () => {
expect(isGroupEnd(list[2]!, 2, list)).toBe(true)
})
it("returns false for ungrouped worktree", () => {
expect(isGroupEnd(list[3]!, 3, list)).toBe(false)
})
})
@@ -0,0 +1,108 @@
import { describe, it, expect } from "vitest"
import { color, palette, label } from "../../webview-ui/src/utils/timeline/colors"
import type {
Part,
ToolPart,
TextPart,
ReasoningPart,
StepStartPart,
StepFinishPart,
} from "../../webview-ui/src/types/messages"
function mkText(text = "hello"): TextPart {
return { id: "t1", type: "text", text }
}
function mkReasoning(text = "thinking..."): ReasoningPart {
return { id: "r1", type: "reasoning", text }
}
function mkTool(name: string, status: "pending" | "running" | "completed" | "error" = "completed"): ToolPart {
const base = { id: "tool1", type: "tool" as const, tool: name }
if (status === "pending") return { ...base, state: { status: "pending", input: {} } }
if (status === "running") return { ...base, state: { status: "running", input: {} } }
if (status === "error") return { ...base, state: { status: "error", input: {}, error: "fail" } }
return { ...base, state: { status: "completed", input: {}, output: "ok", title: name } }
}
function mkStepStart(): StepStartPart {
return { id: "ss1", type: "step-start" }
}
function mkStepFinish(): StepFinishPart {
return { id: "sf1", type: "step-finish", reason: "done" }
}
describe("timeline colors", () => {
it("classifies text parts as text color", () => {
expect(color(mkText())).toBe(palette.text)
})
it("classifies reasoning parts as reasoning color", () => {
expect(color(mkReasoning())).toBe(palette.reasoning)
})
it("classifies read tools as read color", () => {
expect(color(mkTool("read"))).toBe(palette.read)
expect(color(mkTool("glob"))).toBe(palette.read)
expect(color(mkTool("grep"))).toBe(palette.read)
expect(color(mkTool("ls"))).toBe(palette.read)
expect(color(mkTool("diagnostics"))).toBe(palette.read)
expect(color(mkTool("warpgrep"))).toBe(palette.read)
})
it("classifies write tools as write color", () => {
expect(color(mkTool("edit"))).toBe(palette.write)
expect(color(mkTool("write"))).toBe(palette.write)
expect(color(mkTool("patch"))).toBe(palette.write)
expect(color(mkTool("multiedit"))).toBe(palette.write)
expect(color(mkTool("apply_patch"))).toBe(palette.write)
})
it("classifies generic tools as tool color", () => {
expect(color(mkTool("bash"))).toBe(palette.tool)
expect(color(mkTool("task"))).toBe(palette.tool)
expect(color(mkTool("browser"))).toBe(palette.tool)
})
it("classifies errored tools as error color", () => {
expect(color(mkTool("bash", "error"))).toBe(palette.error)
expect(color(mkTool("read", "error"))).toBe(palette.error)
})
it("classifies step-start as step color", () => {
expect(color(mkStepStart())).toBe(palette.step)
})
it("classifies step-finish as success color", () => {
expect(color(mkStepFinish())).toBe(palette.success)
})
it("returns fallback for unknown part types", () => {
const weird = { id: "w1", type: "snapshot" } as unknown as Part
expect(color(weird)).toBe(palette.fallback)
})
})
describe("timeline labels", () => {
it("returns 'Text' for text parts", () => {
expect(label(mkText())).toBe("Text")
})
it("returns 'Reasoning' for reasoning parts", () => {
expect(label(mkReasoning())).toBe("Reasoning")
})
it("returns tool name for tool parts", () => {
expect(label(mkTool("bash"))).toBe("bash")
expect(label(mkTool("read"))).toBe("read")
})
it("returns 'Step start' for step-start", () => {
expect(label(mkStepStart())).toBe("Step start")
})
it("returns 'Step finish' for step-finish", () => {
expect(label(mkStepFinish())).toBe("Step finish")
})
})
@@ -0,0 +1,92 @@
import { describe, it, expect } from "vitest"
import { sizes, MAX_HEIGHT } from "../../webview-ui/src/utils/timeline/sizes"
import type { Part, TextPart, ToolPart, StepFinishPart } from "../../webview-ui/src/types/messages"
function mkText(text: string): TextPart {
return { id: `t-${text.length}`, type: "text", text }
}
function mkTool(name: string, input: Record<string, unknown> = {}, output = ""): ToolPart {
return {
id: `tool-${name}`,
type: "tool",
tool: name,
state: { status: "completed", input, output, title: name },
}
}
function mkStepFinish(input = 100, output = 50): StepFinishPart {
return {
id: "sf",
type: "step-finish",
reason: "done",
tokens: { input, output },
}
}
describe("timeline sizes", () => {
it("returns empty array for empty input", () => {
expect(sizes([])).toEqual([])
})
it("returns one entry per part", () => {
const parts: Part[] = [mkText("a"), mkText("bb"), mkText("ccc")]
const result = sizes(parts)
expect(result).toHaveLength(3)
})
it("all bars have uniform width", () => {
const parts: Part[] = [mkText("short"), mkText("a".repeat(500)), mkText("medium")]
const result = sizes(parts)
const w = result[0]!.width
for (const bar of result) {
expect(bar.width).toBe(w)
}
})
it("height stays within bounds", () => {
const parts: Part[] = [mkText("short"), mkText("a".repeat(500)), mkText("medium length text")]
const result = sizes(parts)
for (const bar of result) {
expect(bar.height).toBeGreaterThanOrEqual(8)
expect(bar.height).toBeLessThanOrEqual(MAX_HEIGHT)
}
})
it("larger content produces taller bars", () => {
const parts: Part[] = [mkText("x"), mkText("x".repeat(1000))]
const result = sizes(parts)
expect(result[1]!.height).toBeGreaterThan(result[0]!.height)
})
it("handles tool parts with input/output content", () => {
const parts: Part[] = [
mkTool("bash", { command: "ls" }, "file1\nfile2\nfile3"),
mkTool("read", { path: "README.md" }, "a".repeat(200)),
]
const result = sizes(parts)
expect(result).toHaveLength(2)
expect(result[0]!.content).toBeGreaterThan(0)
expect(result[1]!.content).toBeGreaterThan(result[0]!.content)
})
it("handles step-finish parts using token counts", () => {
const parts: Part[] = [mkStepFinish(1000, 500), mkStepFinish(100, 50)]
const result = sizes(parts)
expect(result).toHaveLength(2)
expect(result[0]!.content).toBeGreaterThan(result[1]!.content)
})
it("handles single-part input without crashing", () => {
const result = sizes([mkText("only one")])
expect(result).toHaveLength(1)
})
it("returns integer values for height", () => {
const parts: Part[] = [mkText("a"), mkText("bb"), mkText("ccc"), mkText("dddd")]
const result = sizes(parts)
for (const bar of result) {
expect(Number.isInteger(bar.height)).toBe(true)
}
})
})
@@ -0,0 +1,286 @@
import { describe, it, expect, beforeEach, afterEach } from "bun:test"
import * as fs from "fs"
import * as path from "path"
import * as os from "os"
import { WorktreeStateManager } from "../../src/agent-manager/WorktreeStateManager"
describe("WorktreeStateManager sections", () => {
let root: string
let mgr: WorktreeStateManager
beforeEach(() => {
root = fs.mkdtempSync(path.join(os.tmpdir(), "wtsm-sec-"))
fs.mkdirSync(path.join(root, ".kilo"), { recursive: true })
mgr = new WorktreeStateManager(root, () => {})
})
afterEach(async () => {
await mgr.flush()
fs.rmSync(root, { recursive: true, force: true })
})
describe("addSection", () => {
it("creates a section with correct defaults", () => {
const sec = mgr.addSection("Backend", "Blue")
expect(sec.id).toMatch(/^sec-/)
expect(sec.name).toBe("Backend")
expect(sec.color).toBe("Blue")
expect(sec.collapsed).toBe(false)
expect(mgr.getWorktreeOrder()).toContain(sec.id)
})
it("adds section id to worktreeOrder", () => {
const wt = mgr.addWorktree({ branch: "a", path: "/tmp/a", parentBranch: "main" })
mgr.setWorktreeOrder([wt.id])
const sec = mgr.addSection("Infra", null)
expect(mgr.getWorktreeOrder()).toEqual([wt.id, sec.id])
})
it("moves specified worktrees into the section", () => {
const wt1 = mgr.addWorktree({ branch: "a", path: "/tmp/a", parentBranch: "main" })
const wt2 = mgr.addWorktree({ branch: "b", path: "/tmp/b", parentBranch: "main" })
mgr.setWorktreeOrder([wt1.id, wt2.id])
const sec = mgr.addSection("Group", "Red", [wt1.id])
expect(mgr.getWorktree(wt1.id)?.sectionId).toBe(sec.id)
expect(mgr.getWorktree(wt2.id)?.sectionId).toBeUndefined()
// wt1 removed from top-level order, wt2 remains
expect(mgr.getWorktreeOrder()).not.toContain(wt1.id)
expect(mgr.getWorktreeOrder()).toContain(wt2.id)
})
})
describe("renameSection", () => {
it("updates the name", () => {
const sec = mgr.addSection("Old", null)
mgr.renameSection(sec.id, "New")
expect(mgr.getSection(sec.id)?.name).toBe("New")
})
it("rejects empty string", () => {
const sec = mgr.addSection("Keep", null)
mgr.renameSection(sec.id, "")
expect(mgr.getSection(sec.id)?.name).toBe("Keep")
})
it("is a no-op for unknown id", () => {
mgr.renameSection("nonexistent", "Foo")
expect(mgr.getSections()).toHaveLength(0)
})
})
describe("setSectionColor", () => {
it("updates color", () => {
const sec = mgr.addSection("X", null)
mgr.setSectionColor(sec.id, "Green")
expect(mgr.getSection(sec.id)?.color).toBe("Green")
})
it("accepts null for default", () => {
const sec = mgr.addSection("X", "Red")
mgr.setSectionColor(sec.id, null)
expect(mgr.getSection(sec.id)?.color).toBeNull()
})
})
describe("toggleSection", () => {
it("flips collapsed state", () => {
const sec = mgr.addSection("T", null)
expect(sec.collapsed).toBe(false)
mgr.toggleSection(sec.id)
expect(mgr.getSection(sec.id)?.collapsed).toBe(true)
mgr.toggleSection(sec.id)
expect(mgr.getSection(sec.id)?.collapsed).toBe(false)
})
})
describe("deleteSection", () => {
it("removes section and ungroups its worktrees", () => {
const wt = mgr.addWorktree({ branch: "a", path: "/tmp/a", parentBranch: "main" })
const sec = mgr.addSection("Del", null, [wt.id])
expect(mgr.getWorktree(wt.id)?.sectionId).toBe(sec.id)
mgr.deleteSection(sec.id)
expect(mgr.getSection(sec.id)).toBeUndefined()
// worktree still exists but sectionId cleared
expect(mgr.getWorktree(wt.id)).toBeTruthy()
expect(mgr.getWorktree(wt.id)?.sectionId).toBeUndefined()
})
it("removes section from worktreeOrder", () => {
const sec = mgr.addSection("Gone", null)
expect(mgr.getWorktreeOrder()).toContain(sec.id)
mgr.deleteSection(sec.id)
expect(mgr.getWorktreeOrder()).not.toContain(sec.id)
})
it("is a no-op for unknown id", () => {
mgr.deleteSection("nonexistent")
expect(mgr.getSections()).toHaveLength(0)
})
})
describe("moveToSection", () => {
it("sets sectionId and removes from worktreeOrder", () => {
const wt = mgr.addWorktree({ branch: "a", path: "/tmp/a", parentBranch: "main" })
mgr.setWorktreeOrder([wt.id])
const sec = mgr.addSection("Target", null)
mgr.moveToSection([wt.id], sec.id)
expect(mgr.getWorktree(wt.id)?.sectionId).toBe(sec.id)
expect(mgr.getWorktreeOrder()).not.toContain(wt.id)
})
it("ungroups worktrees with null sectionId and adds back to order", () => {
const wt = mgr.addWorktree({ branch: "a", path: "/tmp/a", parentBranch: "main" })
const sec = mgr.addSection("Temp", null, [wt.id])
expect(mgr.getWorktreeOrder()).not.toContain(wt.id)
mgr.moveToSection([wt.id], null)
expect(mgr.getWorktree(wt.id)?.sectionId).toBeUndefined()
expect(mgr.getWorktreeOrder()).toContain(wt.id)
})
it("expands to multi-version siblings with same groupId", () => {
const wt1 = mgr.addWorktree({ branch: "a", path: "/tmp/a", parentBranch: "main", groupId: "g1" })
const wt2 = mgr.addWorktree({ branch: "b", path: "/tmp/b", parentBranch: "main", groupId: "g1" })
const wt3 = mgr.addWorktree({ branch: "c", path: "/tmp/c", parentBranch: "main" })
mgr.setWorktreeOrder([wt1.id, wt2.id, wt3.id])
const sec = mgr.addSection("Multi", null)
// Move only wt1 — wt2 should follow because of shared groupId
mgr.moveToSection([wt1.id], sec.id)
expect(mgr.getWorktree(wt1.id)?.sectionId).toBe(sec.id)
expect(mgr.getWorktree(wt2.id)?.sectionId).toBe(sec.id)
expect(mgr.getWorktree(wt3.id)?.sectionId).toBeUndefined()
})
it("does not duplicate in worktreeOrder when ungrouping already-present id", () => {
const wt = mgr.addWorktree({ branch: "a", path: "/tmp/a", parentBranch: "main" })
mgr.setWorktreeOrder([wt.id])
// Ungroup when already in order
mgr.moveToSection([wt.id], null)
const count = mgr.getWorktreeOrder().filter((id) => id === wt.id).length
expect(count).toBe(1)
})
})
describe("getSections", () => {
it("returns all sections", () => {
mgr.addSection("A", null)
mgr.addSection("B", "Red")
mgr.addSection("C", "Blue")
expect(mgr.getSections()).toHaveLength(3)
})
it("returns empty array when no sections", () => {
expect(mgr.getSections()).toEqual([])
})
})
describe("moveSection", () => {
it("moves a section up within mixed top-level order", () => {
const wt = mgr.addWorktree({ branch: "a", path: "/tmp/a", parentBranch: "main" })
mgr.setWorktreeOrder([wt.id])
const a = mgr.addSection("A", null)
const b = mgr.addSection("B", null)
expect(mgr.getWorktreeOrder()).toEqual([wt.id, a.id, b.id])
mgr.moveSection(b.id, -1)
expect(mgr.getWorktreeOrder()).toEqual([wt.id, b.id, a.id])
})
it("moves a section down within mixed top-level order", () => {
const wt = mgr.addWorktree({ branch: "a", path: "/tmp/a", parentBranch: "main" })
mgr.setWorktreeOrder([wt.id])
const a = mgr.addSection("A", null)
const b = mgr.addSection("B", null)
expect(mgr.getWorktreeOrder()).toEqual([wt.id, a.id, b.id])
mgr.moveSection(a.id, 1)
expect(mgr.getWorktreeOrder()).toEqual([wt.id, b.id, a.id])
})
it("is a no-op at boundaries", () => {
const a = mgr.addSection("A", null)
const b = mgr.addSection("B", null)
mgr.moveSection(a.id, -1)
expect(mgr.getWorktreeOrder()).toEqual([a.id, b.id])
mgr.moveSection(b.id, 1)
expect(mgr.getWorktreeOrder()).toEqual([a.id, b.id])
})
it("does not change section membership", () => {
const wt1 = mgr.addWorktree({ branch: "a", path: "/tmp/a", parentBranch: "main" })
const wt2 = mgr.addWorktree({ branch: "b", path: "/tmp/b", parentBranch: "main" })
const a = mgr.addSection("A", null, [wt1.id, wt2.id])
const b = mgr.addSection("B", null)
mgr.moveSection(b.id, -1)
expect(mgr.getWorktree(wt1.id)?.sectionId).toBe(a.id)
expect(mgr.getWorktree(wt2.id)?.sectionId).toBe(a.id)
})
it("persists reordered sections across save/load", async () => {
const a = mgr.addSection("A", null)
const b = mgr.addSection("B", null)
mgr.moveSection(b.id, -1)
await mgr.flush()
await mgr.save()
const loaded = new WorktreeStateManager(root, () => {})
await loaded.load()
expect(loaded.getWorktreeOrder()).toEqual([b.id, a.id])
})
})
describe("persistence", () => {
it("saves and loads sections", async () => {
const wt = mgr.addWorktree({ branch: "a", path: "/tmp/a", parentBranch: "main" })
const sec = mgr.addSection("Persist", "Green", [wt.id])
mgr.toggleSection(sec.id)
await mgr.flush()
await mgr.save()
const loaded = new WorktreeStateManager(root, () => {})
await loaded.load()
const secs = loaded.getSections()
expect(secs).toHaveLength(1)
const first = secs[0]!
expect(first.name).toBe("Persist")
expect(first.color).toBe("Green")
expect(first.collapsed).toBe(true)
expect(loaded.getWorktree(wt.id)?.sectionId).toBe(sec.id)
})
it("normalizes worktreeOrder on load to include missing section IDs", async () => {
const wt = mgr.addWorktree({ branch: "a", path: "/tmp/a", parentBranch: "main" })
const sec = mgr.addSection("S", null)
await mgr.flush()
await mgr.save()
// Simulate stale data: manually remove section from worktreeOrder
const file = path.join(root, ".kilo", "agent-manager.json")
const data = JSON.parse(fs.readFileSync(file, "utf-8"))
data.worktreeOrder = [wt.id] // section ID missing
fs.writeFileSync(file, JSON.stringify(data))
const loaded = new WorktreeStateManager(root, () => {})
await loaded.load()
expect(loaded.getWorktreeOrder()).toContain(sec.id)
expect(loaded.getWorktreeOrder()).toContain(wt.id)
})
it("normalizes worktreeOrder on load to include missing ungrouped worktrees", async () => {
const wt = mgr.addWorktree({ branch: "a", path: "/tmp/a", parentBranch: "main" })
await mgr.flush()
await mgr.save()
const file = path.join(root, ".kilo", "agent-manager.json")
const data = JSON.parse(fs.readFileSync(file, "utf-8"))
data.worktreeOrder = [] // worktree ID missing
fs.writeFileSync(file, JSON.stringify(data))
const loaded = new WorktreeStateManager(root, () => {})
await loaded.load()
expect(loaded.getWorktreeOrder()).toContain(wt.id)
})
})
})
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:ed6e36e191174cd14d61fc3480014d59c9a92bd526f4f889dc71bdbab4b44e66
size 2106
oid sha256:270e0dafc2601996e7d048873e55db4680e8803d6800dc44b1fa6ce6239827cc
size 2017
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:b313dd76197ff089105d6b80ff22bdf570b878f67dcc99e5d050e85573c52d60
size 1959
oid sha256:5feb854ee2693b655ab504b8e2ed8794af77a357c3d716e2deb9668a08f3aa0c
size 1847
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:e386126977ece9eae41cce0b13677ae4243b985b39a7ee0977e9b5da32415796
size 5837
oid sha256:937c4398730fd3e07bc064b4cb370717a5bc280ffaced87fa6f3c626774878a8
size 5598
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:24d02a1a394c5e18e3783b19c8c7145eafbd8cabfb624c53c82137369d2c5784
size 2570
oid sha256:1d9935cc07b9eefe0c1eca1852b00ac2b39b76cdf12ee2f01c52e4ac0771b39e
size 2910
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:07039f10de94b6aa2eba14674a9f3370fef2f8b23d6712e91f43ddd77f169c34
size 1930
oid sha256:54e7142d591391c6e99631c9bf87b011321e52f17a8ee39e5aeeaa9b1350f2f9
size 1831
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:519310e5cb841302d651f450b4fc281936c331646a4d3759de0c010d1761fe63
size 2687
oid sha256:7e4a37b5b7d71e0eb7f043176f410030385a8a61f3913bd6419b1d96c6cad2de
size 2422
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:6442d82e137e36d165e5f10ca7dbf02445badf109d442eac05714c50593068fe
size 7819
oid sha256:e929c1f6dd90495d14edea552acfd2f1a3a609d1c3713651f5e11f40aac19309
size 8120
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:00c00caa93376d82f4128001416fe14ee71f786f0bff3d6550dc97ff9a675037
size 27167
oid sha256:024ac7b12c5c6455d30c349dbfde0faff8d8c864761d0d79f0f9e08dbf7144e0
size 26331
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:7576fe3532b45c91e0d4d65c97b515565e7b3ad26d6d713d0d52fdab0e0c0565
size 29627
oid sha256:2ff364be7ea176328dc7f4500e3568aed460a2caa9dd4dedca614cd1db273185
size 25150
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:a69ac58dc72885950a702661b75bc1989a28e8b07b3136037255891b7e5a3b06
size 12839
oid sha256:070f9f8cb76fc01ceba027cbcaf198e0530545a0aa78ea1ead75f1e09bb252a1
size 14152
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:30346f68598419d1fe80a3f33d3326cc844a30356ebedb022a841be4096514c6
size 53909
oid sha256:5094e214a52bac4627f8fe280f4818c986753c49f160bf5295f21122054ea72f
size 53684
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:e654374e5c12a4a184a0d27709cd773684e31e08a3d7b6305358849cf30c75be
size 50986
oid sha256:83a70ef1260f77c3017db90a918ee77c332c065bfc047e0f206948bb5dde83b9
size 50888
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:b7e17426d82ff00ac8194963a3c3be946994018e2f6c9553dfaee237cb043fd9
size 11324
oid sha256:b569b91c3437b55163cc48a2fcf6f5b0ebd2c6a93aff425b14d70f6055201f20
size 12716
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:e2eb5ca750d8a590df5a4c7a1be493290aa90535a98c5dc0f6fadfc802872567
size 3520
oid sha256:e2bb93bd5830bcfd208615e64c8b2b88855d1faa97cd7980df9d95cfada533d3
size 3561
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:1e5fd252dcc1acaeefae5608621a1c32282f99c24f7ee464d375cd149722c4fc
size 3915
oid sha256:862a22d4123e5c512d4ae87eb881c5923f14ac5207bf77b4707063b3dcc53422
size 3991
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:2da4bbf78414fd58ef84b03f421954741edae16df0a0fccc4ad4e99e752e70ff
size 3882
oid sha256:c49479d1fffe96d14300617d48be269e5bffe41bc50f555b1ee3fbb09d8919e1
size 3896
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:ca37ae191123410c4488e6c54a2f104c830c4adb98b2dc3f5a2ac344e4b64356
size 4107
oid sha256:6f0e205d89dc56438971d53e7210f1de1b261e641666ccebf0b1a2f36c3811aa
size 4195
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:3a6043cb26f5fe15cf84106a8a63c0f04a96ca04b23b9e55c276cc5db8f8104a
size 4159
oid sha256:e5b5b806af5e943fc69ad70a13d446e38807d733f66ac2647ee4057957976dea
size 4211
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:22b2d117440cfcd773be53b8d20ac1ed5561f02c7b07f307efc54e722341e14b
size 4606
oid sha256:88d2598f448e68b32b3daef5420a3e31ae5fdafe77ced20aadbc4fdf4c1ab84a
size 4682
@@ -7,7 +7,6 @@ import {
createSignal,
createMemo,
createEffect,
createRoot,
on,
onMount,
onCleanup,
@@ -34,7 +33,10 @@ import type {
WorktreeGitStats,
LocalGitStats,
WorktreeState,
PRStatus,
AgentManagerPRStatusMessage,
ManagedSessionState,
SectionState,
SessionInfo,
BranchInfo,
} from "../src/types/messages"
@@ -45,9 +47,8 @@ import {
SortableProvider,
closestCenter,
createSortable,
useDragDropContext,
} from "@thisbeyond/solid-dnd"
import type { DragEvent, Transformer } from "@thisbeyond/solid-dnd"
import type { DragEvent } from "@thisbeyond/solid-dnd"
import { ThemeProvider } from "@kilocode/kilo-ui/theme"
import { DialogProvider, useDialog } from "@kilocode/kilo-ui/context/dialog"
import { Dialog } from "@kilocode/kilo-ui/dialog"
@@ -90,6 +91,11 @@ import { groupApplyConflicts } from "./apply-conflicts"
import type { ReviewComment } from "./review-comments"
import { BranchSelect } from "./BranchSelect"
import { WorktreeItem } from "./WorktreeItem"
import SectionHeader from "./SectionHeader"
import { randomColor } from "./section-colors"
import { buildTopLevelItems, isGrouped, isGroupStart, isGroupEnd, type TopLevelItem } from "./section-helpers"
import { sectionAwareDetector } from "./section-dnd"
import { ConstrainDragXAxis } from "./constrain-drag-x"
import { mergeWorktreeDiffs } from "./diff-state"
import "./agent-manager.css"
import "./agent-manager-review.css"
@@ -120,6 +126,8 @@ interface ApplyState {
/** 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" | null
const isMac = typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.userAgent)
// Fallback keybindings before extension sends resolved ones
@@ -194,7 +202,6 @@ function useTabScroll(activeTabs: Accessor<SessionInfo[]>, activeId: Accessor<st
})
})
// Auto-scroll active tab into view
createEffect(() => {
const id = activeId()
const el = ref()
@@ -321,6 +328,7 @@ const AgentManagerContent: Component = () => {
const [localSessionIDs, setLocalSessionIDs] = createSignal<string[]>(persisted?.localSessionIDs ?? [])
const [sidebarWidth, setSidebarWidth] = createSignal(persisted?.sidebarWidth ?? DEFAULT_SIDEBAR_WIDTH)
const [sessionsCollapsed, setSessionsCollapsed] = createSignal(false)
const [sections, setSections] = createSignal<SectionState[]>([])
// rAF coalescing for resize handlers — at most one signal write per frame
let sidebarRaf: number | undefined
@@ -328,8 +336,8 @@ const AgentManagerContent: Component = () => {
let diffRaf: number | undefined
let pendingDiffWidth: number | undefined
// Diff panel state
const [diffOpen, setDiffOpen] = 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>>>({})
@@ -345,6 +353,9 @@ const AgentManagerContent: Component = () => {
// Per-worktree git stats (diff additions/deletions, commits missing from origin)
const [worktreeStats, setWorktreeStats] = createSignal<Record<string, WorktreeGitStats>>({})
// Per-worktree PR status data
const [prStatuses, setPrStatuses] = createSignal<Record<string, PRStatus | null>>({})
// Local repo git stats (branch name, diff additions/deletions, commits)
const [localStats, setLocalStats] = createSignal<LocalGitStats | undefined>()
@@ -609,6 +620,8 @@ const AgentManagerContent: Component = () => {
// Sidebar worktree order (persisted to extension state)
const [sidebarWorktreeOrder, setSidebarWorktreeOrder] = createSignal<string[]>([])
const [draggingWorktree, setDraggingWorktree] = createSignal<string | undefined>()
const [renamingSection, setRenamingSection] = createSignal<string | null>(null)
let pendingNewSection = false
const addPendingTab = () => {
const id = `${PENDING_PREFIX}${++pendingCounter}`
@@ -786,6 +799,11 @@ const AgentManagerContent: Component = () => {
return firstOrderedTitle(sessions, worktreeTabOrder()[wt.id], wt.branch)
}
const worktreeSubtitle = (wt: WorktreeState): string | undefined => {
const label = worktreeLabel(wt)
return label !== wt.branch ? wt.branch : undefined
}
const isStaleWorktree = (worktreeId: string): boolean => staleWorktreeIds().has(worktreeId)
/** True when any session in the given ID list is actively working (busy/retry and not blocked by permissions/questions). */
@@ -849,23 +867,23 @@ const AgentManagerContent: Component = () => {
return result
})
/** Check if this worktree is part of a group. */
const isGrouped = (wt: WorktreeState) => !!wt.groupId
/** Check if this is the first item in its group. */
const isGroupStart = (wt: WorktreeState, idx: number) => {
if (!wt.groupId) return false
const list = sortedWorktrees()
if (idx === 0) return true
return list[idx - 1]?.groupId !== wt.groupId
}
/** Check if this is the last item in its group. */
const isGroupEnd = (wt: WorktreeState, idx: number) => {
if (!wt.groupId) return false
const list = sortedWorktrees()
if (idx === list.length - 1) return true
return list[idx + 1]?.groupId !== wt.groupId
const worktreesInSection = (id: string) => sortedWorktrees().filter((wt) => wt.sectionId === id)
const ungrouped = createMemo(() => sortedWorktrees().filter((wt) => !wt.sectionId))
const topLevelItems = createMemo((): TopLevelItem[] =>
buildTopLevelItems(sections(), ungrouped(), sortedWorktrees(), sidebarWorktreeOrder()),
)
const moveToSection = (ids: string[], sec: string | null) =>
vscode.postMessage({ type: "agentManager.moveToSection", worktreeIds: ids, sectionId: sec })
const moveSection = (sectionId: string, dir: -1 | 1) =>
vscode.postMessage({ type: "agentManager.moveSection", sectionId, dir })
const newSection = (ids?: string[]) => {
pendingNewSection = true
vscode.postMessage({
type: "agentManager.createSection",
name: t("agentManager.section.defaultName"),
color: randomColor(),
worktreeIds: ids,
})
}
const scrollIntoView = (el: HTMLElement) => {
@@ -1012,9 +1030,9 @@ const AgentManagerContent: Component = () => {
} else if (msg.action === "toggleDiff") {
if (reviewActive()) {
closeReviewTab()
setDiffOpen(true)
setSidePanel("diff")
} else {
setDiffOpen((prev) => !prev)
setSidePanel((prev) => (prev === "diff" ? null : "diff"))
}
} else if (msg.action === "newTab") handleNewTabForCurrentSelection()
else if (msg.action === "closeTab") closeActiveTab()
@@ -1153,7 +1171,7 @@ const AgentManagerContent: Component = () => {
setSelection(ev.worktreeId)
}
// Close diff/review panels — nothing to show during setup
setDiffOpen(false)
setSidePanel(null)
setReviewActive(false)
setSetup({ active: true, message: ev.message, branch: ev.branch, worktreeId: ev.worktreeId })
}
@@ -1194,6 +1212,14 @@ const AgentManagerContent: Component = () => {
// When not a git repo, also mark sessions as loaded since the Kilo
// server won't connect to send the sessionsLoaded message.
if (state.isGitRepo === false && !sessionsLoaded()) setSessionsLoaded(true)
const prev = new Set(sections().map((s) => s.id)),
incoming = state.sections ?? []
setSections(incoming)
if (pendingNewSection) {
pendingNewSection = false
const c = incoming.find((s) => !prev.has(s.id))
if (c) setRenamingSection(c.id)
}
if (state.tabOrder) setWorktreeTabOrder(state.tabOrder)
if (state.worktreeOrder) setSidebarWorktreeOrder(state.worktreeOrder)
if (state.reviewDiffStyle === "split" || state.reviewDiffStyle === "unified") {
@@ -1383,6 +1409,11 @@ const AgentManagerContent: Component = () => {
setLocalStats(ev.stats)
setRepoBranch(ev.stats.branch)
}
if (msg.type === "agentManager.prStatus") {
const ev = msg as AgentManagerPRStatusMessage
setPrStatuses((prev) => ({ ...prev, [ev.worktreeId]: ev.pr }))
}
})
onCleanup(() => {
@@ -1458,7 +1489,7 @@ const AgentManagerContent: Component = () => {
const openReviewTab = () => {
const sel = selection()
if (sel === null) return
setDiffOpen(false)
setSidePanel(null)
setReviewOpenForContext(sel, true)
setReviewActive(true)
}
@@ -2128,6 +2159,11 @@ const AgentManagerContent: Component = () => {
))}
</span>
</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Item onSelect={() => newSection()}>
<Icon name="plus" size="small" />
<DropdownMenu.ItemLabel>{t("agentManager.worktree.newSection")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu>
@@ -2216,56 +2252,40 @@ const AgentManagerContent: Component = () => {
setRenamingWt(null)
}
const hasSections = createMemo(() => sections().length > 0)
const wtIds = createMemo(() => sortedWorktrees().map((wt) => wt.id))
const secIds = createMemo(() => new Set(sections().map((s) => s.id)))
const home = () => new Map(sortedWorktrees().map((w) => [w.id, w.sectionId] as const))
const sectionAware = sectionAwareDetector(secIds, home)
const onWtDragStart = (event: DragEvent) => {
const id = event.draggable?.id
if (typeof id === "string") setDraggingWorktree(id)
document.body.classList.add("am-wt-dragging-active")
}
const onWtDragOver = (event: DragEvent) => {
const from = event.draggable?.id
const to = event.droppable?.id
if (typeof from !== "string" || typeof to !== "string") return
if (secIds().has(to)) return
setSidebarWorktreeOrder((prev) => {
const current = applyTabOrder(
sortedWorktrees().map((wt) => ({ id: wt.id })),
const cur = applyTabOrder(
sortedWorktrees().map((w) => ({ id: w.id })),
prev,
).map((item) => item.id)
return reorderTabs(current, from, to) ?? prev
).map((i) => i.id)
return reorderTabs(cur, from, to) ?? prev
})
}
const onWtDragEnd = () => {
const onWtDragEnd = (event: DragEvent) => {
const from = event.draggable?.id
const to = event.droppable?.id
setDraggingWorktree(undefined)
document.body.classList.remove("am-wt-dragging-active")
const order = sortedWorktrees().map((wt) => wt.id)
if (order.length > 0) {
vscode.postMessage({ type: "agentManager.setWorktreeOrder", order })
if (typeof from === "string" && typeof to === "string" && secIds().has(to)) {
moveToSection([from], to)
return
}
}
/** Lock drag movement to the Y axis (vertical-only worktree dragging). */
const ConstrainDragXAxis: Component = () => {
const ctx = useDragDropContext()
if (!ctx) return null
const [
,
{ onDragStart: onStart, onDragEnd: onEnd, addTransformer: add, removeTransformer: remove },
] = ctx
const xform: Transformer = { id: "constrain-x-axis", order: 100, callback: (t) => ({ ...t, x: 0 }) }
const dispose = createRoot((dispose) => {
onStart(({ draggable }) => {
if (draggable) add("draggables", draggable.id as string, xform)
})
onEnd(({ draggable }) => {
if (draggable) remove("draggables", draggable.id as string, xform.id)
})
return dispose
})
onCleanup(dispose)
return null
vscode.postMessage({ type: "agentManager.setWorktreeOrder", order: sidebarWorktreeOrder() })
}
return (
@@ -2273,33 +2293,35 @@ const AgentManagerContent: Component = () => {
onDragStart={onWtDragStart}
onDragEnd={onWtDragEnd}
onDragOver={onWtDragOver}
collisionDetector={closestCenter}
collisionDetector={sectionAware}
>
<DragDropSensors />
<ConstrainDragXAxis />
<SortableProvider ids={wtIds()}>
<For each={sortedWorktrees()}>
{(wt, idx) => {
const sessions = createMemo(() => managedSessions().filter((ms) => ms.worktreeId === wt.id))
const navHint = () => {
const flat = [
LOCAL as string,
...sortedWorktrees().map((w) => w.id),
...unassignedSessions().map((s) => s.id),
]
const active = selection() ?? session.currentSessionID() ?? ""
return adjacentHint(
{(() => {
const renderWt = (
wt: WorktreeState,
idx: () => number,
inSection?: boolean,
list?: WorktreeState[],
) => {
const wtSessions = createMemo(() =>
managedSessions().filter((ms) => ms.worktreeId === wt.id),
)
const navHint = () =>
adjacentHint(
wt.id,
active,
flat,
selection() ?? session.currentSessionID() ?? "",
[
LOCAL as string,
...sortedWorktrees().map((w) => w.id),
...unassignedSessions().map((s) => s.id),
],
kb().previousSession ?? "",
kb().nextSession ?? "",
)
}
const groupSize = () => {
if (!wt.groupId) return 0
return sortedWorktrees().filter((w) => w.groupId === wt.groupId).length
}
const groupSize = () =>
!wt.groupId ? 0 : sortedWorktrees().filter((w) => w.groupId === wt.groupId).length
const sortable = createSortable(wt.id)
void sortable
return (
@@ -2310,23 +2332,34 @@ const AgentManagerContent: Component = () => {
<WorktreeItem
worktree={wt}
label={worktreeLabel(wt)}
subtitle={worktreeSubtitle(wt)}
active={selection() === wt.id}
pendingDelete={pendingDelete() === wt.id}
busy={busyWorktrees().has(wt.id)}
working={isAgentBusy(wt.id)}
stale={isStaleWorktree(wt.id)}
shortcut={idx() + 2}
shortcut={inSection ? undefined : idx() + 2}
stats={worktreeStats()[wt.id]}
navHint={navHint()}
sessions={sessions().length}
sessions={wtSessions().length}
grouped={isGrouped(wt)}
groupStart={isGroupStart(wt, idx())}
groupEnd={isGroupEnd(wt, idx())}
groupStart={isGroupStart(wt, idx(), list ?? sortedWorktrees())}
groupEnd={isGroupEnd(wt, idx(), list ?? sortedWorktrees())}
groupSize={groupSize()}
renaming={renamingWt() === wt.id}
renameValue={renameValue()}
closeKeybind={kb().closeWorktree ?? ""}
openKeybind={kb().openWorktree ?? ""}
pr={
prStatuses()[wt.id] !== undefined ? (prStatuses()[wt.id] ?? undefined) : undefined
}
onOpenPR={() =>
vscode.postMessage({ type: "agentManager.openPR", worktreeId: wt.id })
}
sections={sections()}
currentSectionId={wt.sectionId}
onMoveToSection={(secId) => moveToSection([wt.id], secId)}
onMoveToNewSection={() => newSection()}
onClick={() => {
if (pendingDelete() === wt.id) {
confirmDeleteWorktree(wt.id)
@@ -2340,17 +2373,60 @@ const AgentManagerContent: Component = () => {
onCommitRename={() => commitRename(wt.id)}
onCancelRename={cancelRename}
onRemoveStale={() => confirmRemoveStaleWorktree(wt.id)}
onCopyPath={() =>
vscode.postMessage({ type: "agentManager.copyToClipboard", text: wt.path })
}
onCopyPath={() => navigator.clipboard.writeText(wt.path)}
onOpen={() =>
vscode.postMessage({ type: "agentManager.openWorktree", worktreeId: wt.id })
}
/>
</div>
)
}}
</For>
}
if (hasSections()) {
const post = vscode.postMessage.bind(vscode)
return (
<For each={topLevelItems()}>
{(item, idx) => {
if (item.kind === "section") {
const sec = item.section
const members = createMemo(() => worktreesInSection(sec.id))
return (
<SectionHeader
section={sec}
count={members().length}
autoRename={renamingSection() === sec.id}
onRenameEnd={() => setRenamingSection(null)}
onToggle={() =>
post({ type: "agentManager.toggleSectionCollapsed", sectionId: sec.id })
}
onRename={(name) =>
post({ type: "agentManager.renameSection", sectionId: sec.id, name })
}
onDelete={() => post({ type: "agentManager.deleteSection", sectionId: sec.id })}
onSetColor={(color) =>
post({ type: "agentManager.setSectionColor", sectionId: sec.id, color })
}
isFirst={idx() === 0}
isLast={idx() === topLevelItems().length - 1}
onMoveUp={() => moveSection(sec.id, -1)}
onMoveDown={() => moveSection(sec.id, 1)}
>
<Show when={!sec.collapsed}>
<div class="am-section-group-body">
<For each={members()}>
{(wt, wtIdx) => renderWt(wt, wtIdx, true, members())}
</For>
</div>
</Show>
</SectionHeader>
)
}
return renderWt(item.wt, idx)
}}
</For>
)
}
return <For each={sortedWorktrees()}>{(wt, idx) => renderWt(wt, idx)}</For>
})()}
</SortableProvider>
<DragOverlay>
{(() => {
@@ -2622,10 +2698,10 @@ const AgentManagerContent: Component = () => {
onClick={() => {
if (reviewActive()) {
closeReviewTab()
setDiffOpen(true)
setSidePanel("diff")
return
}
setDiffOpen((prev) => !prev)
setSidePanel((prev) => (prev === "diff" ? null : "diff"))
}}
title={t("agentManager.diff.toggle")}
>
@@ -2752,7 +2828,7 @@ const AgentManagerContent: Component = () => {
<Show when={!contextEmpty()}>
{/* Chat + side diff panel (hidden when review tab is active) */}
<div
class={`am-detail-content ${diffOpen() ? "am-detail-split" : ""}`}
class={`am-detail-content ${sidePanel() !== null ? "am-detail-split" : ""}`}
style={{ display: reviewActive() ? "none" : undefined }}
>
<div class="am-chat-wrapper">
@@ -2810,7 +2886,7 @@ const AgentManagerContent: Component = () => {
</div>
</Show>
</div>
<Show when={diffOpen()}>
<Show when={sidePanel() !== null}>
<div class="am-diff-resize" style={{ width: `${diffWidth()}px` }}>
<ResizeHandle
direction="horizontal"
@@ -2829,25 +2905,27 @@ const AgentManagerContent: Component = () => {
}}
/>
<div class="am-diff-panel-wrapper">
<DiffPanel
diffs={reviewDiffs()}
loading={diffLoading()}
loadingFiles={diffFileLoadingForCurrent()}
sessionId={currentDiffSessionId()}
sessionKey={diffSessionKey()}
diffStyle={reviewDiffStyle()}
onDiffStyleChange={setSharedDiffStyle}
comments={reviewComments()}
onCommentsChange={setReviewCommentsForSelection}
onClose={() => setDiffOpen(false)}
onExpand={selection() !== null ? openReviewTab : undefined}
onRequestDiff={requestDiffFile}
onOpenFile={(file) => {
const id = currentDiffSessionId()
if (id) vscode.postMessage({ type: "agentManager.openFile", sessionId: id, filePath: file })
else if (selection() === LOCAL) vscode.postMessage({ type: "openFile", filePath: file })
}}
/>
<Show when={sidePanel() === "diff"}>
<DiffPanel
diffs={reviewDiffs()}
loading={diffLoading()}
loadingFiles={diffFileLoadingForCurrent()}
sessionId={currentDiffSessionId()}
sessionKey={diffSessionKey()}
diffStyle={reviewDiffStyle()}
onDiffStyleChange={setSharedDiffStyle}
comments={reviewComments()}
onCommentsChange={setReviewCommentsForSelection}
onClose={() => setSidePanel(null)}
onExpand={selection() !== null ? openReviewTab : undefined}
onRequestDiff={requestDiffFile}
onOpenFile={(file) => {
const id = currentDiffSessionId()
if (id) vscode.postMessage({ type: "agentManager.openFile", sessionId: id, filePath: file })
else if (selection() === LOCAL) vscode.postMessage({ type: "openFile", filePath: file })
}}
/>
</Show>
</div>
</div>
</Show>
@@ -307,6 +307,11 @@ export const DiffPanel: Component<DiffPanelProps> = (props) => {
sendAllToChat()
}
const handleExpandAll = () => {
const allOpen = open().length === props.diffs.length
setOpen(allOpen ? [] : props.diffs.map((d) => d.file))
}
const totals = createMemo(() => ({
files: props.diffs.length,
additions: props.diffs.reduce((sum, diff) => sum + diff.additions, 0),
@@ -354,6 +359,28 @@ export const DiffPanel: Component<DiffPanelProps> = (props) => {
</Show>
</div>
<div class="am-diff-header-actions">
<Show when={props.diffs.length > 0}>
<Tooltip
value={
open().length === props.diffs.length
? t("ui.sessionReview.collapseAll")
: t("ui.sessionReview.expandAll")
}
placement="bottom"
>
<IconButton
icon="chevron-grabber-vertical"
size="small"
variant="ghost"
label={
open().length === props.diffs.length
? t("ui.sessionReview.collapseAll")
: t("ui.sessionReview.expandAll")
}
onClick={handleExpandAll}
/>
</Tooltip>
</Show>
<Show when={props.onExpand}>
<Tooltip value={t("command.review.toggle")} placement="bottom">
<IconButton
@@ -0,0 +1,152 @@
import { Component, Show, createEffect, createSignal, type JSX } from "solid-js"
import { createDroppable } from "@thisbeyond/solid-dnd"
import { ContextMenu } from "@kilocode/kilo-ui/context-menu"
import { Icon } from "@kilocode/kilo-ui/icon"
import type { SectionState } from "../src/types/messages"
import { SECTION_COLORS, colorCss } from "./section-colors"
import { useLanguage } from "../src/context/language"
interface Props {
section: SectionState
count: number
children?: JSX.Element
/** When true, auto-enter rename mode (e.g. after creation). */
autoRename?: boolean
onToggle: () => void
onRename: (name: string) => void
onDelete: () => void
onSetColor: (color: string | null) => void
/** Called when rename ends (commit or cancel) so parent clears autoRename. */
onRenameEnd?: () => void
onMoveUp?: () => void
onMoveDown?: () => void
isFirst?: boolean
isLast?: boolean
}
const SectionHeader: Component<Props> = (props) => {
const { t } = useLanguage()
const [renaming, setRenaming] = createSignal(false)
const [value, setValue] = createSignal("")
const border = () => colorCss(props.section.color) ?? "var(--vscode-panel-border)"
const startRename = () => {
setValue(props.section.name)
setRenaming(true)
}
const commit = () => {
const trimmed = value().trim()
setRenaming(false)
props.onRenameEnd?.()
if (trimmed && trimmed !== props.section.name) {
props.onRename(trimmed)
}
}
const cancel = () => {
setRenaming(false)
props.onRenameEnd?.()
}
createEffect(() => {
if (props.autoRename && !renaming()) startRename()
})
const handleClick = (e: MouseEvent) => {
if (e.button !== 0 || renaming()) return
props.onToggle()
}
const droppable = createDroppable(props.section.id)
return (
<div
ref={droppable.ref}
class={`am-section-group ${droppable.isActiveDroppable ? "am-section-group-drop" : ""}`}
style={{ "--section-color": border() }}
>
<ContextMenu>
<ContextMenu.Trigger class="am-section-group-header" onClick={handleClick}>
<div class="am-section-group-left">
<Icon
name="chevron-down"
size="small"
class={`am-section-group-chevron ${props.section.collapsed ? "am-section-group-chevron-collapsed" : ""}`}
/>
<Show
when={!renaming()}
fallback={
<input
class="am-section-group-rename"
value={value()}
onInput={(e) => setValue(e.currentTarget.value)}
onKeyDown={(e) => {
if (e.key === "Enter") commit()
if (e.key === "Escape") cancel()
}}
onBlur={commit}
onClick={(e) => e.stopPropagation()}
ref={(el) =>
requestAnimationFrame(() =>
requestAnimationFrame(() => {
el.focus()
el.select()
}),
)
}
/>
}
>
<span class="am-section-group-name">{props.section.name}</span>
</Show>
</div>
<span class="am-section-group-count">{props.count}</span>
</ContextMenu.Trigger>
<ContextMenu.Portal>
<ContextMenu.Content class="am-ctx-menu">
<ContextMenu.Item onSelect={startRename}>
<Icon name="edit" size="small" />
<ContextMenu.ItemLabel>{t("agentManager.section.rename")}</ContextMenu.ItemLabel>
</ContextMenu.Item>
<ContextMenu.Separator />
<ContextMenu.Group>
<ContextMenu.GroupLabel>{t("agentManager.section.setColor")}</ContextMenu.GroupLabel>
<div class="am-color-grid">
<ContextMenu.Item onSelect={() => props.onSetColor(null)} class="am-color-grid-item">
<span class="am-color-swatch am-color-swatch-default"></span>
</ContextMenu.Item>
{SECTION_COLORS.map((c) => (
<ContextMenu.Item onSelect={() => props.onSetColor(c.label)} class="am-color-grid-item">
<span
class={`am-color-swatch ${props.section.color === c.label ? "am-color-swatch-active" : ""}`}
style={{ background: c.css }}
></span>
</ContextMenu.Item>
))}
</div>
</ContextMenu.Group>
<ContextMenu.Separator />
<ContextMenu.Item onSelect={() => props.onMoveUp?.()} disabled={props.isFirst}>
<Icon name="arrow-up" size="small" />
<ContextMenu.ItemLabel>{t("agentManager.section.moveUp")}</ContextMenu.ItemLabel>
</ContextMenu.Item>
<ContextMenu.Item onSelect={() => props.onMoveDown?.()} disabled={props.isLast}>
<Icon name="arrow-up" size="small" class="am-icon-flip" />
<ContextMenu.ItemLabel>{t("agentManager.section.moveDown")}</ContextMenu.ItemLabel>
</ContextMenu.Item>
<ContextMenu.Separator />
<ContextMenu.Item onSelect={props.onDelete} class="am-ctx-menu-danger">
<Icon name="trash" size="small" />
<ContextMenu.ItemLabel>{t("agentManager.section.delete")}</ContextMenu.ItemLabel>
</ContextMenu.Item>
</ContextMenu.Content>
</ContextMenu.Portal>
</ContextMenu>
{props.children}
</div>
)
}
export default SectionHeader
@@ -2,7 +2,7 @@
* Sidebar worktree item with inline delete confirmation, HoverCard, rename, and stats.
* Extracted from AgentManagerApp for reuse and visual-regression testing via Storybook.
*/
import { Component, Show, createSignal } from "solid-js"
import { Component, For, Show, createSignal } from "solid-js"
import { Icon } from "@kilocode/kilo-ui/icon"
import { IconButton } from "@kilocode/kilo-ui/icon-button"
import { Spinner } from "@kilocode/kilo-ui/spinner"
@@ -10,7 +10,8 @@ import { Tooltip, TooltipKeybind } from "@kilocode/kilo-ui/tooltip"
import { HoverCard } from "@kilocode/kilo-ui/hover-card"
import { ContextMenu } from "@kilocode/kilo-ui/context-menu"
import { Button } from "@kilocode/kilo-ui/button"
import type { WorktreeState, WorktreeGitStats } from "../src/types/messages"
import type { WorktreeState, WorktreeGitStats, PRStatus, SectionState } from "../src/types/messages"
import { colorCss } from "./section-colors"
import { useLanguage } from "../src/context/language"
import { formatRelativeDate } from "../src/utils/date"
@@ -22,14 +23,16 @@ interface WorktreeItemProps {
worktree: WorktreeState
/** Display label (resolved from label, first session title, or branch). */
label: string
/** Branch name shown as subtitle when it differs from the label. */
subtitle?: string
active: boolean
pendingDelete: boolean
busy: boolean
/** Whether an agent session on this worktree is actively working (shows spinner instead of branch icon). */
working: boolean
stale: boolean
/** 1-indexed shortcut number shown as ⌘2, ⌘3, etc. Pass 0 or >9 to hide. */
shortcut: number
/** 1-indexed shortcut number shown as ⌘2, ⌘3, etc. Pass 0, >9, or undefined to hide. */
shortcut?: number
stats?: WorktreeGitStats
/** Navigation hint text shown in the hover card (e.g. "⌘⌥↑"). */
navHint?: string
@@ -51,6 +54,18 @@ interface WorktreeItemProps {
closeKeybind: string
/** Keybinding string for the open-in-vscode action. */
openKeybind: string
/** PR status for this worktree's branch, or null if no PR. */
pr?: PRStatus | null
/** Callback when the PR badge is clicked. */
onOpenPR?: () => void
/** Available sections for the "Move to Section" submenu. */
sections?: SectionState[]
/** ID of the section this worktree currently belongs to (for disabling current item). */
currentSectionId?: string
/** Move this worktree to a section (or null for ungrouped). */
onMoveToSection?: (sectionId: string | null) => void
/** Move this worktree to a new section. */
onMoveToNewSection?: () => void
onClick: () => void
onDelete: (e: MouseEvent) => void
@@ -68,11 +83,41 @@ const MAX_SHORTCUT = 9
const hasStats = (s: WorktreeGitStats | undefined): s is WorktreeGitStats =>
!!s && (s.files > 0 || s.additions > 0 || s.deletions > 0 || s.ahead > 0 || s.behind > 0)
/** Returns the accent color for a PR badge based on state priority. */
export function prAccentColor(pr: PRStatus): string {
if (pr.state === "draft") return "var(--text-weaker)"
if (pr.state === "merged") return "#a78bfa"
if (pr.state === "closed") return "#f87171"
if (pr.checks.status === "failure") return "#ef4444"
if (pr.review === "changes_requested") return "#fbbf24"
if (pr.checks.status === "pending") return "#fbbf24"
return "#34d399"
}
function prStateLabel(state: PRStatus["state"]): string {
if (state === "draft") return "Draft"
if (state === "merged") return "Merged"
if (state === "closed") return "Closed"
return "Open"
}
function reviewLabel(review: string): string {
if (review === "approved") return "Approved"
if (review === "changes_requested") return "Changes Requested"
return "Pending"
}
export const WorktreeItem: Component<WorktreeItemProps> = (props) => {
const { t } = useLanguage()
const [hovered, setHovered] = createSignal(false)
const [overClose, setOverClose] = createSignal(false)
const handleOpenPR = (e: MouseEvent) => {
e.stopPropagation()
e.preventDefault()
props.onOpenPR?.()
}
return (
<>
<Show when={props.groupStart}>
@@ -102,128 +147,164 @@ export const WorktreeItem: Component<WorktreeItemProps> = (props) => {
data-sidebar-id={props.worktree.id}
onClick={() => props.onClick()}
>
<Show when={!props.busy && !props.working} fallback={<Spinner class="am-worktree-spinner" />}>
<Icon name="branch" size="small" />
</Show>
<Show when={props.stale}>
<Tooltip
value={t("agentManager.worktree.staleTooltip")}
placement="top"
contentClass="am-tooltip-wrap"
>
<span class="am-worktree-stale-badge">
<Icon name="warning" size="small" />
</span>
</Tooltip>
</Show>
<Show
when={props.renaming}
fallback={
<span
class="am-worktree-branch"
onDblClick={(e) => {
e.stopPropagation()
props.onStartRename(props.label)
}}
title={t("agentManager.worktree.doubleClickRename")}
>
{props.label}
</span>
}
>
<input
class="am-worktree-rename-input"
value={props.renameValue}
onInput={(e) => props.onRenameInput(e.currentTarget.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault()
props.onCommitRename()
}
if (e.key === "Escape") {
e.preventDefault()
props.onCancelRename()
}
}}
onBlur={() => props.onCommitRename()}
onClick={(e) => e.stopPropagation()}
ref={(el) =>
requestAnimationFrame(() =>
requestAnimationFrame(() => {
el.focus()
el.select()
}),
)
}
/>
</Show>
<Show when={props.shortcut >= 2 && props.shortcut <= MAX_SHORTCUT}>
<span class="am-shortcut-badge">
{isMac ? "⌘" : "Ctrl+"}
{props.shortcut}
</span>
</Show>
<Show when={props.stats === undefined}>
<div class="am-worktree-stats-skeleton">
<div class="am-worktree-stats-skeleton-row" />
<div class="am-worktree-stats-skeleton-row" style={{ width: "70%" }} />
</div>
</Show>
<Show when={hasStats(props.stats)}>
<div class="am-worktree-stats">
<div class="am-wt-icon">
<Show when={!props.busy && !props.working} fallback={<Spinner class="am-worktree-spinner" />}>
<Icon name="branch" size="small" />
</Show>
</div>
<div class="am-wt-content">
{/* Row 1: label + stale badge + stats/hover-actions overlay */}
<div class="am-wt-row1">
<Show when={props.stale}>
<Tooltip
value={t("agentManager.worktree.staleTooltip")}
placement="top"
contentClass="am-tooltip-wrap"
>
<span class="am-worktree-stale-badge">
<Icon name="warning" size="small" />
</span>
</Tooltip>
</Show>
<Show
when={props.stats!.additions > 0 || props.stats!.deletions > 0}
when={props.renaming}
fallback={
<Show when={props.stats!.files > 0}>
<span class="am-stat-files">{props.stats!.files}f</span>
<span
class="am-worktree-branch"
onDblClick={(e) => {
e.stopPropagation()
props.onStartRename(props.label)
}}
title={t("agentManager.worktree.doubleClickRename")}
>
{props.label}
</span>
}
>
<input
class="am-worktree-rename-input"
value={props.renameValue}
onInput={(e) => props.onRenameInput(e.currentTarget.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault()
props.onCommitRename()
}
if (e.key === "Escape") {
e.preventDefault()
props.onCancelRename()
}
}}
onBlur={() => props.onCommitRename()}
onClick={(e) => e.stopPropagation()}
ref={(el) =>
requestAnimationFrame(() =>
requestAnimationFrame(() => {
el.focus()
el.select()
}),
)
}
/>
</Show>
{/* Grid cell: stats visible by default, hover actions on top */}
<div class="am-wt-actions-cell">
<Show when={props.stats === undefined}>
<div class="am-worktree-stats-skeleton">
<div class="am-worktree-stats-skeleton-row" />
</div>
</Show>
<Show when={hasStats(props.stats)}>
<div class="am-worktree-stats">
<Show when={props.stats!.behind > 0}>
<span class="am-worktree-behind">{props.stats!.behind}</span>
</Show>
<Show when={props.stats!.ahead > 0}>
<span class="am-worktree-commits">{props.stats!.ahead}</span>
</Show>
<Show
when={props.stats!.additions > 0 || props.stats!.deletions > 0}
fallback={
<Show when={props.stats!.files > 0}>
<span class="am-stat-files">{props.stats!.files}f</span>
</Show>
}
>
<Show when={props.stats!.additions > 0}>
<span class="am-stat-additions">+{props.stats!.additions}</span>
</Show>
<Show when={props.stats!.deletions > 0}>
<span class="am-stat-deletions">{props.stats!.deletions}</span>
</Show>
</Show>
</div>
</Show>
<Show when={props.pendingDelete && !props.busy}>
<span class="am-worktree-delete-hint">{t("agentManager.worktree.confirmDelete")}</span>
</Show>
<div class="am-wt-hover-actions">
<Show
when={props.shortcut !== undefined && props.shortcut >= 2 && props.shortcut <= MAX_SHORTCUT}
>
<span class="am-shortcut-badge">
{isMac ? "⌘" : "Ctrl+"}
{props.shortcut}
</span>
</Show>
<Show when={!props.busy && !props.pendingDelete}>
<div
class="am-worktree-close"
onMouseEnter={() => setOverClose(true)}
onMouseLeave={() => setOverClose(false)}
>
<TooltipKeybind
title={t("agentManager.worktree.delete")}
keybind={props.closeKeybind}
placement="top"
>
<IconButton
icon="trash"
size="small"
variant="ghost"
label={t("agentManager.worktree.delete")}
onClick={(e: MouseEvent) => props.onDelete(e)}
/>
</TooltipKeybind>
</div>
</Show>
</div>
</div>
</div>
{/* Row 2: branch subtitle + PR badge */}
<div class="am-wt-row2">
<Show when={props.subtitle}>
<span class="am-worktree-subtitle">{props.subtitle}</span>
</Show>
<Show
when={props.pr}
fallback={
<Show when={props.stats === undefined}>
<div class="am-pr-badge-skeleton" />
</Show>
}
>
<div class="am-worktree-stats-row">
<Show when={props.stats!.additions > 0}>
<span class="am-stat-additions">+{props.stats!.additions}</span>
</Show>
<Show when={props.stats!.deletions > 0}>
<span class="am-stat-deletions">{props.stats!.deletions}</span>
</Show>
</div>
</Show>
<Show when={props.stats!.ahead > 0 || props.stats!.behind > 0}>
<div class="am-worktree-stats-row">
<Show when={props.stats!.ahead > 0}>
<span class="am-worktree-commits">{props.stats!.ahead}</span>
</Show>
<Show when={props.stats!.behind > 0}>
<span class="am-worktree-behind">{props.stats!.behind}</span>
</Show>
</div>
{(pr) => {
const accent = () => prAccentColor(pr())
return (
<span
class="am-pr-badge"
style={{ "--pr-accent": accent() }}
data-pending={pr().state === "open" && pr().checks.status === "pending" ? "" : undefined}
onClick={handleOpenPR}
>
<Icon name="branch" size="small" />
<span class="am-pr-badge-number">#{pr().number}</span>
</span>
)
}}
</Show>
</div>
</Show>
<Show when={props.pendingDelete && !props.busy}>
<span class="am-worktree-delete-hint">{t("agentManager.worktree.confirmDelete")}</span>
</Show>
<Show when={!props.busy && !props.pendingDelete}>
<div
class="am-worktree-close"
onMouseEnter={() => setOverClose(true)}
onMouseLeave={() => setOverClose(false)}
>
<TooltipKeybind
title={t("agentManager.worktree.delete")}
keybind={props.closeKeybind}
placement="top"
>
<IconButton
icon="trash"
size="small"
variant="ghost"
label={t("agentManager.worktree.delete")}
onClick={(e: MouseEvent) => props.onDelete(e)}
/>
</TooltipKeybind>
</div>
</Show>
</div>
</div>
</ContextMenu.Trigger>
}
@@ -313,6 +394,34 @@ export const WorktreeItem: Component<WorktreeItemProps> = (props) => {
</div>
</Show>
</Show>
<Show when={props.pr}>
{(pr) => (
<>
<div class="am-hover-card-divider" />
<div class="am-hover-card-row">
<span class="am-hover-card-row-label">PR #{pr().number}</span>
<span class="am-hover-card-row-value">
<span class="am-pr-link" onClick={handleOpenPR}>
<Icon name="link" size="small" />
</span>
{prStateLabel(pr().state)}
</span>
</div>
<Show when={pr().review}>
<div class="am-hover-card-row">
<span class="am-hover-card-row-label">Review</span>
<span class="am-hover-card-row-value">{reviewLabel(pr().review!)}</span>
</div>
</Show>
<div class="am-hover-card-row">
<span class="am-hover-card-row-label">Checks</span>
<span class="am-hover-card-row-value">
{pr().checks.passed}/{pr().checks.total} passed
</span>
</div>
</>
)}
</Show>
<div class="am-hover-card-divider" />
<div class="am-hover-card-hint">
<Icon name="edit" size="small" />
@@ -353,6 +462,38 @@ export const WorktreeItem: Component<WorktreeItemProps> = (props) => {
<Icon name="copy" size="small" />
<ContextMenu.ItemLabel>{t("agentManager.worktree.copyPath")}</ContextMenu.ItemLabel>
</ContextMenu.Item>
<ContextMenu.Separator />
<ContextMenu.Item onSelect={() => props.onMoveToNewSection?.()}>
<Icon name="plus" size="small" />
<ContextMenu.ItemLabel>{t("agentManager.worktree.newSection")}</ContextMenu.ItemLabel>
</ContextMenu.Item>
<Show when={props.sections && props.sections.length > 0}>
<ContextMenu.Separator />
<ContextMenu.Item onSelect={() => props.onMoveToSection?.(null)}>
<Show when={!props.currentSectionId}>
<Icon name="check" size="small" />
</Show>
<ContextMenu.ItemLabel>{t("agentManager.worktree.ungrouped")}</ContextMenu.ItemLabel>
</ContextMenu.Item>
<For each={props.sections}>
{(sec) => (
<ContextMenu.Item onSelect={() => props.onMoveToSection?.(sec.id)}>
<Show
when={props.currentSectionId === sec.id}
fallback={
<span
class="am-color-swatch am-color-swatch-sm"
style={{ background: colorCss(sec.color) ?? "var(--vscode-panel-border)" }}
/>
}
>
<Icon name="check" size="small" />
</Show>
<ContextMenu.ItemLabel>{sec.name}</ContextMenu.ItemLabel>
</ContextMenu.Item>
)}
</For>
</Show>
</ContextMenu.Content>
</ContextMenu.Portal>
</ContextMenu>
@@ -62,8 +62,7 @@
}
.am-local-item-active .am-local-branch {
color: var(--text-on-interactive-base);
opacity: 0.7;
color: color-mix(in srgb, var(--text-on-interactive-base) 70%, transparent);
}
.am-local-item-active .am-stat-files {
@@ -92,8 +91,8 @@
}
.am-local-branch {
font-size: var(--font-size-small);
color: var(--text-weak);
font-size: 10px;
color: var(--text-weaker);
line-height: 1.2;
overflow: hidden;
text-overflow: ellipsis;
@@ -181,12 +180,11 @@ button.am-section-toggle:hover .am-section-label {
}
.am-worktree-item {
position: relative;
display: flex;
align-items: center;
align-items: flex-start;
gap: 8px;
padding: 8px 10px;
min-height: 40px;
padding: 6px 10px;
min-height: 36px;
box-sizing: border-box;
border-radius: var(--radius-sm);
cursor: pointer;
@@ -197,6 +195,87 @@ button.am-section-toggle:hover .am-section-label {
transition: background 200ms ease;
}
/* Left icon column — shrink-0, vertically centered with first row */
.am-wt-icon {
flex-shrink: 0;
display: flex;
align-items: center;
height: 20px;
}
/* Right content column — flex-1 with two rows */
.am-wt-content {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
/* Row 1: label + stats/hover-actions */
.am-wt-row1 {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
}
/* Grid overlay cell: stats visible by default, hover actions stacked on top */
.am-wt-actions-cell {
position: relative;
flex-shrink: 0;
display: grid;
align-items: center;
margin-left: auto;
}
.am-wt-actions-cell > * {
grid-column: 1;
grid-row: 1;
}
/* Hover actions (shortcut badge + close button) — hidden by default, shown on hover */
.am-wt-hover-actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 2px;
opacity: 0;
visibility: hidden;
}
.am-worktree-item:hover .am-wt-hover-actions {
opacity: 1;
visibility: visible;
}
/* Stats fade out on hover (replaced by hover actions in same grid cell) */
.am-worktree-item:hover .am-wt-actions-cell > .am-worktree-stats,
.am-worktree-item:hover .am-wt-actions-cell > .am-worktree-stats-skeleton {
opacity: 0;
visibility: hidden;
}
/* Row 2: always present for consistent height; PR badge right-aligned */
.am-wt-row2 {
display: flex;
align-items: center;
gap: 6px;
min-height: 14px;
}
.am-worktree-subtitle {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 10px;
color: var(--text-weaker);
line-height: 1.2;
min-width: 0;
}
.am-worktree-item-active .am-worktree-subtitle {
color: color-mix(in srgb, var(--text-on-interactive-base) 70%, transparent);
}
.am-worktree-item:hover {
background: var(--surface-inset-base-hover);
}
@@ -238,13 +317,9 @@ button.am-section-toggle:hover .am-section-label {
outline: none;
}
/* Shortcut badge — hover-reveal index badge (⌘1, ⌘2, etc.) matching Superset style */
/* Shortcut badge — hover-reveal index badge (⌘1, ⌘2, etc.) */
.am-shortcut-badge {
position: absolute;
right: 30px;
opacity: 0;
transition: opacity 150ms ease;
font-size: 10px;
font-variant-numeric: tabular-nums;
color: var(--text-weaker);
@@ -256,8 +331,7 @@ button.am-section-toggle:hover .am-section-label {
right: 8px;
}
.am-local-item:hover .am-shortcut-badge,
.am-worktree-item:hover .am-shortcut-badge {
.am-local-item:hover .am-shortcut-badge {
opacity: 1;
}
@@ -278,29 +352,27 @@ button.am-section-toggle:hover .am-section-label {
color: var(--text-weak);
}
.am-worktree-item:has(.am-worktree-rename-input) .am-shortcut-badge {
opacity: 0;
.am-worktree-item:has(.am-worktree-rename-input) .am-wt-row2 {
display: none;
}
.am-worktree-close {
position: absolute;
right: 4px;
flex-shrink: 0;
}
/* Compact close button inside worktree items */
.am-worktree-close [data-component="icon-button"] {
width: 18px;
height: 18px;
padding: 2px;
}
.am-worktree-close [data-component="icon"] {
width: 14px;
height: 14px;
}
.am-worktree-item:has(.am-worktree-rename-input) .am-wt-hover-actions {
opacity: 0;
}
.am-worktree-item:hover .am-worktree-branch {
mask-image: linear-gradient(to right, black calc(100% - 72px), transparent calc(100% - 36px));
-webkit-mask-image: linear-gradient(to right, black calc(100% - 72px), transparent calc(100% - 36px));
}
.am-worktree-item:hover .am-worktree-close {
opacity: 1;
}
.am-worktree-item:has(.am-worktree-rename-input) .am-worktree-close {
opacity: 0;
pointer-events: none;
visibility: hidden;
}
/* Inline delete confirmation */
@@ -315,7 +387,8 @@ button.am-section-toggle:hover .am-section-label {
background: color-mix(in srgb, var(--surface-critical-strong) 25%, transparent);
}
.am-worktree-pending-delete .am-worktree-branch {
.am-worktree-pending-delete .am-worktree-branch,
.am-worktree-pending-delete .am-worktree-subtitle {
opacity: 0.5;
transition: opacity 200ms ease;
}
@@ -325,16 +398,22 @@ button.am-section-toggle:hover .am-section-label {
display: none;
}
.am-worktree-pending-delete .am-shortcut-badge {
.am-worktree-pending-delete .am-wt-row2 {
visibility: hidden;
}
.am-worktree-pending-delete .am-wt-hover-actions {
opacity: 0 !important;
visibility: hidden !important;
}
.am-worktree-delete-hint {
position: absolute;
right: 0;
font-size: var(--font-size-small);
color: var(--text-on-brand-base);
background: var(--surface-critical-strong);
white-space: nowrap;
flex-shrink: 0;
padding: 1px 8px;
border-radius: 4px;
cursor: pointer;
@@ -383,16 +462,14 @@ button.am-section-toggle:hover .am-section-label {
.am-worktree-spinner {
width: 16px;
height: 16px;
flex-shrink: 0;
}
/* Per-worktree git stats (diff lines + commits missing from origin) */
.am-worktree-stats {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 1px;
align-items: center;
gap: 4px;
flex-shrink: 0;
font-family: var(--font-mono, monospace);
font-size: 10px;
@@ -408,9 +485,7 @@ button.am-section-toggle:hover .am-section-label {
.am-worktree-stats-skeleton {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 3px;
align-items: center;
flex-shrink: 0;
}
@@ -422,8 +497,15 @@ button.am-section-toggle:hover .am-section-label {
animation: am-skeleton-pulse 1.5s ease-in-out infinite;
}
.am-worktree-stats-skeleton-row:nth-child(2) {
animation-delay: 0.15s;
.am-pr-badge-skeleton {
width: 52px;
height: 14px;
border-radius: 6px;
background: var(--text-base);
animation: am-skeleton-pulse 1.5s ease-in-out infinite;
animation-delay: 0.3s;
opacity: 0;
margin-left: auto;
}
.am-stat-files {
@@ -438,6 +520,76 @@ button.am-section-toggle:hover .am-section-label {
color: #f87171;
}
/* PR pill badge second row inside am-wt-row2.
Uses --pr-accent custom property (set inline) to derive all colors. */
.am-pr-badge {
display: inline-flex;
align-items: center;
gap: 2px;
padding: 2px 7px 2px 4px;
border-radius: 6px;
border: none;
background: color-mix(in srgb, var(--pr-accent) 12%, transparent);
color: var(--pr-accent);
cursor: pointer;
font-size: 11px;
font-weight: 500;
font-variant-numeric: tabular-nums;
transition: background 150ms ease;
margin-left: auto;
}
.am-pr-badge:hover {
background: color-mix(in srgb, var(--pr-accent) 25%, transparent);
}
.am-pr-badge [data-component="icon"] {
width: 12px;
height: 12px;
color: var(--pr-accent);
}
.am-pr-badge-number {
line-height: 1;
color: var(--text-muted);
}
.am-pr-badge:hover .am-pr-badge-number {
color: var(--pr-accent);
}
.am-pr-badge[data-pending] {
animation: am-pr-pulse 1.5s ease-in-out infinite;
}
@keyframes am-pr-pulse {
0%,
100% {
opacity: 0.9;
}
50% {
opacity: 0.4;
}
}
.am-worktree-item-active .am-pr-badge {
background: color-mix(in srgb, var(--pr-accent) 18%, transparent);
}
.am-worktree-item-active .am-pr-badge:hover {
background: color-mix(in srgb, var(--pr-accent) 30%, transparent);
}
/* Clickable link icon in hover card to open PR on GitHub */
.am-pr-link {
display: inline-flex;
align-items: center;
margin-right: 3px;
cursor: pointer;
opacity: 0.5;
transition: opacity 100ms ease;
}
.am-pr-link:hover {
opacity: 1;
}
.am-pr-link [data-component="icon"] {
width: 11px;
height: 11px;
}
.am-worktree-item-active .am-stat-files {
color: rgba(255, 255, 255, 0.7);
}
@@ -470,13 +622,156 @@ button.am-section-toggle:hover .am-section-label {
color: #fca5a5;
}
.am-worktree-item:hover .am-worktree-stats,
.am-worktree-item:hover .am-worktree-stats-skeleton,
.am-local-item:hover .am-worktree-stats,
.am-local-item:hover .am-worktree-stats-skeleton {
opacity: 0;
}
/* User-defined sections — collapsible, color-coded groups */
.am-section-group {
margin-top: 2px;
border-left: 2px solid var(--section-color, var(--vscode-panel-border));
border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
transition: border-color 120ms;
}
.am-section-group-drop {
background: color-mix(in srgb, var(--section-color) 10%, transparent);
border-color: var(--section-color);
border-style: solid;
}
.am-section-group-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 5px 10px 5px 6px;
cursor: pointer;
user-select: none;
border-radius: var(--radius-sm);
font-size: 11px;
font-weight: 500;
letter-spacing: 0.3px;
text-transform: uppercase;
color: var(--text-weak);
}
.am-section-group-header:hover {
background: var(--surface-interactive-hover, var(--vscode-list-hoverBackground));
}
.am-section-group-left {
display: flex;
align-items: center;
gap: 4px;
min-width: 0;
overflow: hidden;
}
.am-section-group-chevron {
flex-shrink: 0;
opacity: 0.7;
transition: transform 120ms ease;
}
.am-section-group-chevron-collapsed {
transform: rotate(-90deg);
}
.am-section-group-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.am-section-group-count {
flex-shrink: 0;
font-size: 10px;
color: var(--text-weaker);
min-width: 14px;
text-align: center;
}
.am-section-group-body {
padding-left: 4px;
}
.am-section-group-rename {
background: var(--input-base, var(--vscode-input-background, #3c3c3c));
color: var(--vscode-input-foreground);
border: 1px solid var(--border-focus, var(--vscode-focusBorder, #007fd4));
border-radius: var(--radius-sm);
padding: 1px 4px;
font-size: 11px;
font-weight: 500;
letter-spacing: 0.3px;
text-transform: uppercase;
outline: none;
width: 100%;
min-width: 0;
}
/* Color picker grid in section context menu */
.am-color-grid {
display: flex;
flex-wrap: wrap;
gap: 2px;
padding: 4px 8px;
}
.am-color-grid-item {
padding: 3px !important;
border-radius: 4px !important;
min-height: unset !important;
}
.am-color-swatch {
display: block;
width: 16px;
height: 16px;
border-radius: 50%;
flex-shrink: 0;
border: 1.5px solid transparent;
}
.am-color-swatch-active {
border-color: var(--vscode-foreground);
box-shadow: 0 0 0 1px var(--vscode-editor-background);
}
.am-color-swatch-default {
background: var(--vscode-panel-border);
position: relative;
}
.am-color-swatch-default::after {
content: "";
position: absolute;
top: 50%;
left: 50%;
width: 70%;
height: 1.5px;
background: var(--text-weaker);
transform: translate(-50%, -50%) rotate(-45deg);
}
.am-color-swatch-sm {
width: 10px;
height: 10px;
}
.am-icon-flip {
transform: rotate(180deg);
}
/* Context menu danger item (delete section) */
.am-ctx-menu-danger {
color: var(--text-error, var(--vscode-errorForeground, #f44));
}
/* Grouped worktrees — visual grouping with header and left accent */
.am-wt-group-header {
@@ -0,0 +1,21 @@
import { type Component, createRoot, onCleanup } from "solid-js"
import { useDragDropContext, type Transformer } from "@thisbeyond/solid-dnd"
/** Lock drag movement to the Y axis (vertical-only worktree dragging). */
export const ConstrainDragXAxis: Component = () => {
const ctx = useDragDropContext()
if (!ctx) return null
const [, { onDragStart, onDragEnd, addTransformer, removeTransformer }] = ctx
const xform: Transformer = { id: "constrain-x-axis", order: 100, callback: (t) => ({ ...t, x: 0 }) }
const dispose = createRoot((d) => {
onDragStart(({ draggable }) => {
if (draggable) addTransformer("draggables", draggable.id as string, xform)
})
onDragEnd(({ draggable }) => {
if (draggable) removeTransformer("draggables", draggable.id as string, xform.id)
})
return d
})
onCleanup(dispose)
return null
}
+11
View File
@@ -19,6 +19,17 @@ export const dict = {
"agentManager.worktree.copyPath": "نسخ المسار",
"agentManager.worktree.openInVscode": "فتح في VS Code",
"agentManager.worktree.rename": "إعادة تسمية",
"agentManager.worktree.moveToSection": "نقل إلى القسم",
"agentManager.worktree.newSection": "قسم جديد",
"agentManager.worktree.ungrouped": "غير مجمع",
"agentManager.section.rename": "إعادة تسمية القسم",
"agentManager.section.setColor": "تعيين اللون",
"agentManager.section.delete": "حذف القسم",
"agentManager.section.defaultColor": "افتراضي",
"agentManager.section.defaultName": "قسم جديد",
"agentManager.section.moveUp": "تحريك لأعلى",
"agentManager.section.moveDown": "تحريك لأسفل",
"agentManager.section.count": "{{count}}",
"agentManager.hoverCard.branch": "الفرع",
"agentManager.hoverCard.base": "الأساس",
"agentManager.hoverCard.sessions": "الجلسات",
+11
View File
@@ -19,6 +19,17 @@ export const dict = {
"agentManager.worktree.copyPath": "Copiar Caminho",
"agentManager.worktree.openInVscode": "Abrir no VS Code",
"agentManager.worktree.rename": "Renomear",
"agentManager.worktree.moveToSection": "Mover para a Seção",
"agentManager.worktree.newSection": "Nova Seção",
"agentManager.worktree.ungrouped": "Não Agrupado",
"agentManager.section.rename": "Renomear Seção",
"agentManager.section.setColor": "Definir Cor",
"agentManager.section.delete": "Excluir Seção",
"agentManager.section.defaultColor": "Padrão",
"agentManager.section.defaultName": "Nova Seção",
"agentManager.section.moveUp": "Mover para Cima",
"agentManager.section.moveDown": "Mover para Baixo",
"agentManager.section.count": "{{count}}",
"agentManager.hoverCard.branch": "BRANCH",
"agentManager.hoverCard.base": "Base",
"agentManager.hoverCard.sessions": "Sessões",
+11
View File
@@ -19,6 +19,17 @@ export const dict = {
"agentManager.worktree.copyPath": "Kopiraj putanju",
"agentManager.worktree.openInVscode": "Otvori u VS Code",
"agentManager.worktree.rename": "Preimenuj",
"agentManager.worktree.moveToSection": "Premjesti u sekciju",
"agentManager.worktree.newSection": "Nova sekcija",
"agentManager.worktree.ungrouped": "Bez grupe",
"agentManager.section.rename": "Preimenuj sekciju",
"agentManager.section.setColor": "Postavi boju",
"agentManager.section.delete": "Izbriši sekciju",
"agentManager.section.defaultColor": "Zadano",
"agentManager.section.defaultName": "Nova sekcija",
"agentManager.section.moveUp": "Pomjeri gore",
"agentManager.section.moveDown": "Pomjeri dolje",
"agentManager.section.count": "{{count}}",
"agentManager.hoverCard.branch": "BRANCH",
"agentManager.hoverCard.base": "Baza",
"agentManager.hoverCard.sessions": "Sesije",
+11
View File
@@ -19,6 +19,17 @@ export const dict = {
"agentManager.worktree.copyPath": "Kopier sti",
"agentManager.worktree.openInVscode": "Åbn i VS Code",
"agentManager.worktree.rename": "Omdøb",
"agentManager.worktree.moveToSection": "Flyt til sektion",
"agentManager.worktree.newSection": "Ny sektion",
"agentManager.worktree.ungrouped": "Ugrupperet",
"agentManager.section.rename": "Omdøb sektion",
"agentManager.section.setColor": "Indstil farve",
"agentManager.section.delete": "Slet sektion",
"agentManager.section.defaultColor": "Standard",
"agentManager.section.defaultName": "Ny sektion",
"agentManager.section.moveUp": "Flyt op",
"agentManager.section.moveDown": "Flyt ned",
"agentManager.section.count": "{{count}}",
"agentManager.hoverCard.branch": "BRANCH",
"agentManager.hoverCard.base": "Base",
"agentManager.hoverCard.sessions": "Sessioner",
+11
View File
@@ -19,6 +19,17 @@ export const dict = {
"agentManager.worktree.copyPath": "Pfad kopieren",
"agentManager.worktree.openInVscode": "In VS Code öffnen",
"agentManager.worktree.rename": "Umbenennen",
"agentManager.worktree.moveToSection": "In Bereich verschieben",
"agentManager.worktree.newSection": "Neuer Bereich",
"agentManager.worktree.ungrouped": "Nicht gruppiert",
"agentManager.section.rename": "Bereich umbenennen",
"agentManager.section.setColor": "Farbe festlegen",
"agentManager.section.delete": "Bereich löschen",
"agentManager.section.defaultColor": "Standard",
"agentManager.section.defaultName": "Neuer Bereich",
"agentManager.section.moveUp": "Nach oben verschieben",
"agentManager.section.moveDown": "Nach unten verschieben",
"agentManager.section.count": "{{count}}",
"agentManager.hoverCard.branch": "BRANCH",
"agentManager.hoverCard.base": "Basis",
"agentManager.hoverCard.sessions": "Sitzungen",
@@ -20,6 +20,17 @@ export const dict = {
"agentManager.worktree.copyPath": "Copy Path",
"agentManager.worktree.openInVscode": "Open in VS Code",
"agentManager.worktree.rename": "Rename",
"agentManager.worktree.moveToSection": "Move to Section",
"agentManager.worktree.newSection": "New Section",
"agentManager.worktree.ungrouped": "Ungrouped",
"agentManager.section.rename": "Rename Section",
"agentManager.section.setColor": "Set Color",
"agentManager.section.delete": "Delete Section",
"agentManager.section.defaultColor": "Default",
"agentManager.section.defaultName": "New Section",
"agentManager.section.moveUp": "Move Up",
"agentManager.section.moveDown": "Move Down",
"agentManager.section.count": "{{count}}",
"agentManager.hoverCard.branch": "BRANCH",
"agentManager.hoverCard.base": "Base",
+11
View File
@@ -19,6 +19,17 @@ export const dict = {
"agentManager.worktree.copyPath": "Copiar ruta",
"agentManager.worktree.openInVscode": "Abrir en VS Code",
"agentManager.worktree.rename": "Renombrar",
"agentManager.worktree.moveToSection": "Mover a la sección",
"agentManager.worktree.newSection": "Nueva sección",
"agentManager.worktree.ungrouped": "Desagrupado",
"agentManager.section.rename": "Renombrar sección",
"agentManager.section.setColor": "Establecer color",
"agentManager.section.delete": "Eliminar sección",
"agentManager.section.defaultColor": "Predeterminado",
"agentManager.section.defaultName": "Nueva sección",
"agentManager.section.moveUp": "Mover hacia arriba",
"agentManager.section.moveDown": "Mover hacia abajo",
"agentManager.section.count": "{{count}}",
"agentManager.hoverCard.branch": "BRANCH",
"agentManager.hoverCard.base": "Base",
"agentManager.hoverCard.sessions": "Sesiones",
+11
View File
@@ -19,6 +19,17 @@ export const dict = {
"agentManager.worktree.copyPath": "Copier le chemin",
"agentManager.worktree.openInVscode": "Ouvrir dans VS Code",
"agentManager.worktree.rename": "Renommer",
"agentManager.worktree.moveToSection": "Déplacer vers la section",
"agentManager.worktree.newSection": "Nouvelle section",
"agentManager.worktree.ungrouped": "Non groupé",
"agentManager.section.rename": "Renommer la section",
"agentManager.section.setColor": "Définir la couleur",
"agentManager.section.delete": "Supprimer la section",
"agentManager.section.defaultColor": "Par défaut",
"agentManager.section.defaultName": "Nouvelle section",
"agentManager.section.moveUp": "Déplacer vers le haut",
"agentManager.section.moveDown": "Déplacer vers le bas",
"agentManager.section.count": "{{count}}",
"agentManager.hoverCard.branch": "BRANCHE",
"agentManager.hoverCard.base": "Base",
"agentManager.hoverCard.sessions": "Sessions",
+11
View File
@@ -19,6 +19,17 @@ export const dict = {
"agentManager.worktree.copyPath": "パスをコピー",
"agentManager.worktree.openInVscode": "VS Codeで開く",
"agentManager.worktree.rename": "名前を変更",
"agentManager.worktree.moveToSection": "セクションに移動",
"agentManager.worktree.newSection": "新しいセクション",
"agentManager.worktree.ungrouped": "グループなし",
"agentManager.section.rename": "セクションの名前変更",
"agentManager.section.setColor": "色の設定",
"agentManager.section.delete": "セクションの削除",
"agentManager.section.defaultColor": "デフォルト",
"agentManager.section.defaultName": "新しいセクション",
"agentManager.section.moveUp": "上に移動",
"agentManager.section.moveDown": "下に移動",
"agentManager.section.count": "{{count}}",
"agentManager.hoverCard.branch": "ブランチ",
"agentManager.hoverCard.base": "ベース",
"agentManager.hoverCard.sessions": "セッション",
+11
View File
@@ -19,6 +19,17 @@ export const dict = {
"agentManager.worktree.copyPath": "경로 복사",
"agentManager.worktree.openInVscode": "VS Code에서 열기",
"agentManager.worktree.rename": "이름 바꾸기",
"agentManager.worktree.moveToSection": "섹션으로 이동",
"agentManager.worktree.newSection": "새 섹션",
"agentManager.worktree.ungrouped": "그룹 해제됨",
"agentManager.section.rename": "섹션 이름 바꾸기",
"agentManager.section.setColor": "색상 설정",
"agentManager.section.delete": "섹션 삭제",
"agentManager.section.defaultColor": "기본값",
"agentManager.section.defaultName": "새 섹션",
"agentManager.section.moveUp": "위로 이동",
"agentManager.section.moveDown": "아래로 이동",
"agentManager.section.count": "{{count}}",
"agentManager.hoverCard.branch": "브랜치",
"agentManager.hoverCard.base": "베이스",
"agentManager.hoverCard.sessions": "세션",
+11
View File
@@ -20,6 +20,17 @@ export const dict = {
"agentManager.worktree.copyPath": "Pad kopiëren",
"agentManager.worktree.openInVscode": "Openen in VS Code",
"agentManager.worktree.rename": "Hernoemen",
"agentManager.worktree.moveToSection": "Verplaatsen naar sectie",
"agentManager.worktree.newSection": "Nieuwe sectie",
"agentManager.worktree.ungrouped": "Niet gegroepeerd",
"agentManager.section.rename": "Sectie hernoemen",
"agentManager.section.setColor": "Kleur instellen",
"agentManager.section.delete": "Sectie verwijderen",
"agentManager.section.defaultColor": "Standaard",
"agentManager.section.defaultName": "Nieuwe sectie",
"agentManager.section.moveUp": "Omhoog verplaatsen",
"agentManager.section.moveDown": "Omlaag verplaatsen",
"agentManager.section.count": "{{count}}",
"agentManager.hoverCard.branch": "BRANCH",
"agentManager.hoverCard.base": "Basis",
+11
View File
@@ -19,6 +19,17 @@ export const dict = {
"agentManager.worktree.copyPath": "Kopier sti",
"agentManager.worktree.openInVscode": "Åpne i VS Code",
"agentManager.worktree.rename": "Gi nytt navn",
"agentManager.worktree.moveToSection": "Flytt til seksjon",
"agentManager.worktree.newSection": "Ny seksjon",
"agentManager.worktree.ungrouped": "Ugruppert",
"agentManager.section.rename": "Gi seksjon nytt navn",
"agentManager.section.setColor": "Angi farge",
"agentManager.section.delete": "Slett seksjon",
"agentManager.section.defaultColor": "Standard",
"agentManager.section.defaultName": "Ny seksjon",
"agentManager.section.moveUp": "Flytt opp",
"agentManager.section.moveDown": "Flytt ned",
"agentManager.section.count": "{{count}}",
"agentManager.hoverCard.branch": "BRANCH",
"agentManager.hoverCard.base": "Base",
"agentManager.hoverCard.sessions": "Økter",
+11
View File
@@ -19,6 +19,17 @@ export const dict = {
"agentManager.worktree.copyPath": "Kopiuj ścieżkę",
"agentManager.worktree.openInVscode": "Otwórz w VS Code",
"agentManager.worktree.rename": "Zmień nazwę",
"agentManager.worktree.moveToSection": "Przenieś do sekcji",
"agentManager.worktree.newSection": "Nowa sekcja",
"agentManager.worktree.ungrouped": "Niezgrupowane",
"agentManager.section.rename": "Zmień nazwę sekcji",
"agentManager.section.setColor": "Ustaw kolor",
"agentManager.section.delete": "Usuń sekcję",
"agentManager.section.defaultColor": "Domyślny",
"agentManager.section.defaultName": "Nowa sekcja",
"agentManager.section.moveUp": "Przenieś w górę",
"agentManager.section.moveDown": "Przenieś w dół",
"agentManager.section.count": "{{count}}",
"agentManager.hoverCard.branch": "BRANCH",
"agentManager.hoverCard.base": "Baza",
"agentManager.hoverCard.sessions": "Sesje",
+11
View File
@@ -19,6 +19,17 @@ export const dict = {
"agentManager.worktree.copyPath": "Копировать путь",
"agentManager.worktree.openInVscode": "Открыть в VS Code",
"agentManager.worktree.rename": "Переименовать",
"agentManager.worktree.moveToSection": "Переместить в раздел",
"agentManager.worktree.newSection": "Новый раздел",
"agentManager.worktree.ungrouped": "Без группы",
"agentManager.section.rename": "Переименовать раздел",
"agentManager.section.setColor": "Задать цвет",
"agentManager.section.delete": "Удалить раздел",
"agentManager.section.defaultColor": "По умолчанию",
"agentManager.section.defaultName": "Новый раздел",
"agentManager.section.moveUp": "Переместить вверх",
"agentManager.section.moveDown": "Переместить вниз",
"agentManager.section.count": "{{count}}",
"agentManager.hoverCard.branch": "ВЕТКА",
"agentManager.hoverCard.base": "Основа",
"agentManager.hoverCard.sessions": "Сессии",
+11
View File
@@ -19,6 +19,17 @@ export const dict = {
"agentManager.worktree.copyPath": "คัดลอกเส้นทาง",
"agentManager.worktree.openInVscode": "เปิดใน VS Code",
"agentManager.worktree.rename": "เปลี่ยนชื่อ",
"agentManager.worktree.moveToSection": "ย้ายไปที่ส่วน",
"agentManager.worktree.newSection": "ส่วนใหม่",
"agentManager.worktree.ungrouped": "ไม่ได้จัดกลุ่ม",
"agentManager.section.rename": "เปลี่ยนชื่อส่วน",
"agentManager.section.setColor": "ตั้งค่าสี",
"agentManager.section.delete": "ลบส่วน",
"agentManager.section.defaultColor": "ค่าเริ่มต้น",
"agentManager.section.defaultName": "ส่วนใหม่",
"agentManager.section.moveUp": "เลื่อนขึ้น",
"agentManager.section.moveDown": "เลื่อนลง",
"agentManager.section.count": "{{count}}",
"agentManager.hoverCard.branch": "BRANCH",
"agentManager.hoverCard.base": "ฐาน",
"agentManager.hoverCard.sessions": "เซสชัน",
+11
View File
@@ -20,6 +20,17 @@ export const dict = {
"agentManager.worktree.copyPath": "Yolu Kopyala",
"agentManager.worktree.openInVscode": "VS Code'da Aç",
"agentManager.worktree.rename": "Yeniden Adlandır",
"agentManager.worktree.moveToSection": "Bölüme Taşı",
"agentManager.worktree.newSection": "Yeni Bölüm",
"agentManager.worktree.ungrouped": "Gruplandırılmamış",
"agentManager.section.rename": "Bölümü Yeniden Adlandır",
"agentManager.section.setColor": "Renk Ayarla",
"agentManager.section.delete": "Bölümü Sil",
"agentManager.section.defaultColor": "Varsayılan",
"agentManager.section.defaultName": "Yeni Bölüm",
"agentManager.section.moveUp": "Yukarı Taşı",
"agentManager.section.moveDown": "Aşağı Taşı",
"agentManager.section.count": "{{count}}",
"agentManager.hoverCard.branch": "DAL",
"agentManager.hoverCard.base": "Temel",

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