mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-09-24 16:02:55 +08:00
Merge remote-tracking branch 'origin/main' into feat/suggest-code-review
# Conflicts: # packages/kilo-vscode/src/KiloProvider.ts # packages/opencode/src/kilocode/plan-followup.ts # packages/sdk/openapi.json
This commit is contained in:
@@ -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
|
||||
@@ -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 }}
|
||||
|
||||
@@ -18,6 +18,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
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
+4
-4
@@ -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
@@ -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.21",
|
||||
"version": "7.2.0",
|
||||
"peerDependencies": {}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "7.1.21",
|
||||
"version": "7.2.0",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@opencode-ai/desktop-electron",
|
||||
"private": true,
|
||||
"version": "7.1.21",
|
||||
"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,7 +1,7 @@
|
||||
{
|
||||
"name": "@opencode-ai/desktop",
|
||||
"private": true,
|
||||
"version": "7.1.21",
|
||||
"version": "7.2.0",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
id = "kilo"
|
||||
name = "Kilo"
|
||||
description = "The open source coding agent."
|
||||
version = "7.1.21"
|
||||
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.21/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.21/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.21/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.21/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.21/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"]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@kilocode/kilo-docs",
|
||||
"version": "7.1.21",
|
||||
"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,6 +91,43 @@ 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)
|
||||
|
||||
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. |
|
||||
| `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
|
||||
"limit": {
|
||||
"context": 131072,
|
||||
"output": 16384
|
||||
}
|
||||
```
|
||||
|
||||
#### How limits are resolved
|
||||
|
||||
Kilo resolves token limits in this order:
|
||||
|
||||
1. **Your config** — values you set under `provider.<id>.models.<model>.limit`
|
||||
2. **Built-in catalog** — Kilo ships a snapshot of [models.dev](https://models.dev) and refreshes it hourly. If your model ID matches a known model, catalog values are used as defaults.
|
||||
3. **Fallback** — if neither source provides a value, `context` and `output` default to `0`.
|
||||
|
||||
#### What happens when limits are `0`
|
||||
|
||||
If you use a custom or local model and don't specify limits — and the model isn't in the built-in catalog — both `context` and `output` resolve to `0`. This has meaningful side effects:
|
||||
|
||||
- **Compaction is disabled.** Kilo uses `context` to detect when the conversation exceeds the model's window and needs to be summarized. With `context: 0`, overflow detection is skipped and conversations will grow unbounded until the provider rejects the request.
|
||||
- **Output falls back to 32,000 tokens.** When `output` is `0`, Kilo uses its internal default of 32,000 tokens (configurable via the `KILO_EXPERIMENTAL_OUTPUT_TOKEN_MAX` environment variable).
|
||||
- **No context usage tracking.** Usage metrics that depend on knowing the context size are skipped.
|
||||
|
||||
{% callout type="warning" %}
|
||||
For custom and local models, always set `limit.context` and `limit.output` to match the model's actual capabilities. Without these values, automatic context management is disabled.
|
||||
{% /callout %}
|
||||
|
||||
## Examples
|
||||
|
||||
### Local model with LM Studio
|
||||
@@ -226,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:
|
||||
|
||||
@@ -313,5 +350,6 @@ Control which models appear in the model picker for a provider using allowlists
|
||||
**Model errors or unexpected behavior:**
|
||||
|
||||
- Set `tool_call: true` if you need the model to use tools (file editing, terminal, etc.)
|
||||
- Set `limit.context` and `limit.output` to match the model's actual capabilities
|
||||
- Set `limit.context` and `limit.output` to match the model's actual capabilities — see [Token Limits](#token-limits-limit) above for details and defaults
|
||||
- If conversations seem to grow without being compacted, your `limit.context` is likely `0` (unset)
|
||||
- For local models, ensure your inference server is running and accessible at the configured URL
|
||||
|
||||
@@ -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.
|
||||
|
||||

|
||||
|
||||
## Step 2: Generate API Key
|
||||
|
||||
Click the **Generate API Key** button to create your new Codestral API key.
|
||||
|
||||

|
||||
|
||||
## Step 3: Copy Your API Key
|
||||
|
||||
Once generated, click the **copy** button next to your API key to copy it to your clipboard.
|
||||
|
||||

|
||||
|
||||
{% 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
|
||||
|
||||

|
||||
|
||||
{% /tab %}
|
||||
{% /tabs %}
|
||||
|
||||
## Next Steps
|
||||
|
||||
- Learn more about [Autocomplete features](/docs/code-with-ai/features/autocomplete)
|
||||
|
||||
@@ -37,8 +37,20 @@ See [Auto-Approving Actions](/docs/getting-started/settings/auto-approving-actio
|
||||
|
||||
### Is the context progress graph still available?
|
||||
|
||||
The context progress graph is being evaluated for the new extension.
|
||||
This feature may be reintroduced in a future update.
|
||||
The context progress graph will be [added soon](https://github.com/Kilo-Org/kilocode/issues/8210) for users who like to see it.
|
||||
|
||||
### I like to closely monitor and approve the behavior of the agent. How can I do that better in the new version?
|
||||
|
||||
We are working to improve the experience in closely managing an agent. Identified improvements and progress are being tracked in a [GitHub issue](https://github.com/Kilo-Org/kilocode/issues/8415).
|
||||
|
||||
In the meantime we suggest exploring:
|
||||
|
||||
- [Auto-approval](https://kilo.ai/docs/getting-started/settings/auto-approving-actions) of actions: to control what the agent is allowed to do, and require approval when desired
|
||||
- [Agents](https://kilo.ai/docs/code-with-ai/agents/using-agents) (previously known as Modes): Managing the agent types in the extension, adding new ones, and setting the default models for each.
|
||||
|
||||
### How can I control which models each agent/mode uses?
|
||||
|
||||
Modes have been renamed to Agents in the new extension. You can set the default model for each agent in `Settings -> Models -> Model per Mode`. For more information please check the [agents documentation](https://kilo.ai/docs/code-with-ai/agents/using-agents).
|
||||
|
||||
### Where is the diff view for file changes?
|
||||
|
||||
@@ -50,6 +62,18 @@ 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.
|
||||
|
||||
### Where did orchestrator mode go?
|
||||
|
||||
Orchestrator mode is deprecated.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -25,3 +25,45 @@ For security reasons, SSH access is currently disabled for all KiloClaw instance
|
||||
Do **not** click **Update Now** inside the OpenClaw Control UI — this is not supported for KiloClaw instances and may break your setup.
|
||||
|
||||
Updates are managed by the KiloClaw platform team to ensure stability. When a new version is available, it will be announced in the **Changelog** on your dashboard. To apply the update, click **Upgrade & Redeploy** from the [KiloClaw Dashboard](/docs/kiloclaw/dashboard#redeploy).
|
||||
|
||||
## How do I migrate my OpenClaw?
|
||||
|
||||
Whether you're migrating from another OpenClaw provider to KiloClaw, moving between KiloClaw instances (e.g., individual to org or vice versa), or leaving KiloClaw for another OpenClaw provider, you should plan to migrate your workspace, memory, and context so your new Claw retains the same knowledge as before.
|
||||
|
||||
You should plan to reconfigure integrations in the new instance as these are often tied to the instance and will break if you attempt migration.
|
||||
|
||||
### 1. Back up your workspace
|
||||
|
||||
Have your current instance export the workspace. We recommend creating a GitHub repo or `tar` archive file for easy loading.
|
||||
|
||||
If you are on KiloClaw, you can use
|
||||
|
||||
**GitHub export** — make sure [GitHub is configured](/docs/kiloclaw/development-tools/github) and ask your instance:
|
||||
|
||||
> Create a new GitHub repo and push your entire workspace there with the `gh` CLI. Tell me the URL of the repo you used.
|
||||
|
||||
**Google Drive** — make sure [Google Drive is configured](/docs/kiloclaw/development-tools/google) and ask your instance:
|
||||
|
||||
> Tar compress your workspace and push the file to Google Drive with the `gog` CLI. Then share the filename you used.
|
||||
|
||||
### 2. Stand up the new instance
|
||||
|
||||
### 3. Reconfigure integrations on the new instance
|
||||
|
||||
If you are using GitHub or Google Drive for the migration, prioritize that configuration.
|
||||
|
||||
### 4. Restore the workspace on the new instance
|
||||
|
||||
On your new Claw, restore the workspace from whichever backup method you used:
|
||||
|
||||
**From GitHub:**
|
||||
|
||||
> The GitHub repo `<repo>` has a backup of your workspace. Pull the workspace from the repo with the `gh` CLI and overwrite the existing workspace directory with the repo's contents.
|
||||
|
||||
**From Google Drive:**
|
||||
|
||||
> The Google Drive file `<filename>` has a backup of your workspace. Pull the tar file from Google Drive with the `gog` CLI and overwrite the existing workspace directory with its contents.
|
||||
|
||||
{% callout type="note" %}
|
||||
Replace `<repo>` or `<filename>` with the actual repository URL or filename from the backup step.
|
||||
{% /callout %}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@kilocode/kilo-gateway",
|
||||
"version": "7.1.21",
|
||||
"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,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@kilocode/kilo-i18n",
|
||||
"version": "7.1.21",
|
||||
"version": "7.2.0",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"description": "Kilo-specific i18n translations and overrides",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@kilocode/kilo-telemetry",
|
||||
"version": "7.1.21",
|
||||
"version": "7.2.0",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"description": "Telemetry for Kilo CLI - PostHog analytics integration",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@kilocode/kilo-ui",
|
||||
"version": "7.1.21",
|
||||
"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": {
|
||||
|
||||
@@ -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.21",
|
||||
"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,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",
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -38,8 +38,10 @@ import { getWorkspaceRoot } from "./review-utils"
|
||||
import { MarketplaceService } 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,
|
||||
@@ -69,7 +71,11 @@ import {
|
||||
fetchAndSendPendingPermissions,
|
||||
type PermissionContext,
|
||||
} from "./kilo-provider/handlers/permission-handler"
|
||||
import { handleQuestionReply, handleQuestionReject } from "./kilo-provider/handlers/question"
|
||||
import {
|
||||
handleQuestionReply,
|
||||
handleQuestionReject,
|
||||
fetchAndSendPendingQuestions,
|
||||
} from "./kilo-provider/handlers/question"
|
||||
import {
|
||||
fetchAndSendPendingSuggestions,
|
||||
handleSuggestionAccept,
|
||||
@@ -126,6 +132,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 }[] = []
|
||||
@@ -170,6 +177,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.
|
||||
@@ -283,7 +291,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({
|
||||
@@ -338,8 +346,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)
|
||||
})
|
||||
|
||||
@@ -529,6 +539,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
break
|
||||
}
|
||||
case "abort":
|
||||
this.cancelRetry(message.sessionID ?? "")
|
||||
await this.handleAbort(message.sessionID)
|
||||
break
|
||||
case "revertSession":
|
||||
@@ -805,6 +816,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),
|
||||
@@ -1055,6 +1069,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
|
||||
@@ -1065,6 +1082,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
await this.syncWebviewState("sse-connected")
|
||||
await this.flushPendingSessionRefresh("sse-connected")
|
||||
await fetchAndSendPendingPermissions(this.permissionCtx)
|
||||
await fetchAndSendPendingQuestions(this.questionCtx)
|
||||
await fetchAndSendPendingSuggestions(this.suggestionCtx)
|
||||
} catch (error) {
|
||||
console.error("[Kilo New] KiloProvider: ❌ Failed during connected state handling:", error)
|
||||
@@ -1130,6 +1148,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")
|
||||
|
||||
@@ -1144,6 +1170,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()
|
||||
@@ -1227,9 +1254,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
|
||||
@@ -1331,9 +1360,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) => ({
|
||||
@@ -1351,6 +1379,12 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
sessionID,
|
||||
messages,
|
||||
})
|
||||
|
||||
// Recover any missed permission/question prompts emitted by the child before
|
||||
// we started tracking it. Both run fire-and-forget after messagesLoaded so
|
||||
// the webview isn't blocked.
|
||||
void fetchAndSendPendingPermissions(this.permissionCtx)
|
||||
void fetchAndSendPendingQuestions(this.questionCtx)
|
||||
} catch (err) {
|
||||
this.syncedChildSessions.delete(sessionID)
|
||||
console.error("[Kilo New] KiloProvider: Failed to sync child session:", err)
|
||||
@@ -1597,7 +1631,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)
|
||||
|
||||
@@ -1631,7 +1667,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",
|
||||
@@ -1654,7 +1692,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",
|
||||
@@ -1676,7 +1716,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)
|
||||
@@ -1784,7 +1824,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
|
||||
@@ -1895,7 +1935,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",
|
||||
@@ -1942,7 +1984,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) {
|
||||
@@ -1950,6 +1992,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.
|
||||
@@ -1975,7 +2060,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))
|
||||
@@ -2044,6 +2129,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)
|
||||
@@ -2082,7 +2175,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 })
|
||||
@@ -2139,6 +2232,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,
|
||||
@@ -2181,18 +2353,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)
|
||||
@@ -2243,19 +2418,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)
|
||||
@@ -2375,6 +2553,8 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
return {
|
||||
client: this.client,
|
||||
currentSessionId: this.currentSession?.id,
|
||||
trackedSessionIds: this.trackedSessionIds,
|
||||
sessionDirectories: this.sessionDirectories,
|
||||
postMessage: (msg: unknown) => this.postMessage(msg),
|
||||
getWorkspaceDirectory: (sid?: string) => this.getWorkspaceDirectory(sid),
|
||||
}
|
||||
@@ -2557,6 +2737,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: {} })
|
||||
@@ -2679,6 +2860,24 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
this.contextSessionID = event.properties.info.id
|
||||
}
|
||||
|
||||
// Auto-adopt child sessions as soon as the task tool part reveals their ID.
|
||||
// This means the child's permission/question events are tracked immediately —
|
||||
// before the webview renderer has a chance to call syncSession — eliminating
|
||||
// the race where the child blocks on a prompt that the UI never sees.
|
||||
if (event.type === "message.part.updated") {
|
||||
const part = event.properties.part as {
|
||||
type?: string
|
||||
tool?: string
|
||||
metadata?: { sessionId?: string }
|
||||
sessionID?: string
|
||||
}
|
||||
const childId = part.type === "tool" && part.tool === "task" ? part.metadata?.sessionId : undefined
|
||||
if (childId && !this.trackedSessionIds.has(childId)) {
|
||||
console.log("[Kilo New] KiloProvider: 🔗 Auto-adopting child session from task tool", { childId })
|
||||
void this.handleSyncSession(childId, part.sessionID ?? sessionID)
|
||||
}
|
||||
}
|
||||
|
||||
const msg = mapSSEEventToWebviewMessage(event, sessionID)
|
||||
if (msg) {
|
||||
if (msg.type === "partUpdated") {
|
||||
@@ -2975,7 +3174,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(),
|
||||
@@ -3003,6 +3204,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
*/
|
||||
dispose(): void {
|
||||
this.statsPoller?.stop()
|
||||
this.statsGitOps?.dispose()
|
||||
this.unsubscribeEvent?.()
|
||||
this.unsubscribeState?.()
|
||||
this.unsubscribeNotificationDismiss?.()
|
||||
|
||||
@@ -8,6 +8,7 @@ import { WorktreeManager, type CreateWorktreeResult } from "./WorktreeManager"
|
||||
import { WorktreeStateManager, remoteRef } from "./WorktreeStateManager"
|
||||
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 +54,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 +96,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 +158,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 +221,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 +305,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
|
||||
@@ -408,6 +422,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 +686,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 +695,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 +1444,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()
|
||||
}
|
||||
}
|
||||
@@ -1474,6 +1498,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. */
|
||||
@@ -1893,6 +1918,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,486 @@
|
||||
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
|
||||
|
||||
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 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 — poll immediately then schedule normally
|
||||
if (this.timer) clearTimeout(this.timer)
|
||||
this.timer = undefined
|
||||
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
|
||||
}
|
||||
|
||||
/** Force-refresh a specific worktree immediately. */
|
||||
refresh(worktreeId: string): void {
|
||||
if (!this.active) return
|
||||
void this.fetchOne(worktreeId)
|
||||
}
|
||||
|
||||
setActiveWorktreeId(id: string | undefined): void {
|
||||
this.activeWorktreeId = 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
|
||||
const worktrees = this.options.getWorktrees()
|
||||
const results = await Promise.allSettled(worktrees.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.fetchPRForBranch(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"
|
||||
|
||||
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,15 @@ 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
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -163,6 +172,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 +190,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 []
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -175,6 +227,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 +261,7 @@ export type AgentManagerOutMessage =
|
||||
| WorktreeDiffLoadingMessage
|
||||
| WorktreeDiffMessage
|
||||
| WorktreeDiffFileMessage
|
||||
| PRStatusOutMessage
|
||||
| ActionOutMessage
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -379,6 +439,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
|
||||
@@ -489,6 +559,8 @@ export type AgentManagerInMessage =
|
||||
| ApplyWorktreeDiffIn
|
||||
| StartDiffWatchIn
|
||||
| StopDiffWatchIn
|
||||
| RefreshPRIn
|
||||
| OpenPRIn
|
||||
| OpenFileIn
|
||||
| GenericOpenFileIn
|
||||
| PreviewImageIn
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* Question handlers — extracted from KiloProvider.
|
||||
*
|
||||
* Manages question reply and reject flows from the tool question UI.
|
||||
* Manages question reply and reject flows from the tool question UI,
|
||||
* plus recovery of pending questions after SSE reconnections or child-session syncs.
|
||||
* No vscode dependency.
|
||||
*/
|
||||
|
||||
@@ -10,10 +11,46 @@ import type { KiloClient } from "@kilocode/sdk/v2/client"
|
||||
interface QuestionContext {
|
||||
readonly client: KiloClient | null
|
||||
readonly currentSessionId: string | undefined
|
||||
readonly trackedSessionIds: Set<string>
|
||||
readonly sessionDirectories: ReadonlyMap<string, string>
|
||||
postMessage(msg: unknown): void
|
||||
getWorkspaceDirectory(sessionId?: string): string
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all pending questions from the backend and forward any that belong
|
||||
* to tracked sessions to the webview. Mirrors fetchAndSendPendingPermissions —
|
||||
* called after child-session sync and after SSE reconnects so missed
|
||||
* question.asked events don't leave the server blocked indefinitely.
|
||||
*/
|
||||
export async function fetchAndSendPendingQuestions(ctx: QuestionContext): Promise<void> {
|
||||
if (!ctx.client) return
|
||||
try {
|
||||
const dirs = new Set<string>([ctx.getWorkspaceDirectory(), ...ctx.sessionDirectories.values()])
|
||||
const seen = new Set<string>()
|
||||
for (const dir of dirs) {
|
||||
const { data } = await ctx.client.question.list({ directory: dir })
|
||||
if (!data) continue
|
||||
for (const q of data) {
|
||||
if (seen.has(q.id)) continue
|
||||
seen.add(q.id)
|
||||
if (!ctx.trackedSessionIds.has(q.sessionID)) continue
|
||||
ctx.postMessage({
|
||||
type: "questionRequest",
|
||||
question: {
|
||||
id: q.id,
|
||||
sessionID: q.sessionID,
|
||||
questions: q.questions,
|
||||
tool: q.tool,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[Kilo New] KiloProvider: Failed to fetch pending questions:", error)
|
||||
}
|
||||
}
|
||||
|
||||
/** Handle question reply from the webview. */
|
||||
export async function handleQuestionReply(
|
||||
ctx: QuestionContext,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -532,7 +532,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",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -366,11 +366,21 @@ describe("mapSSEEventToWebviewMessage", () => {
|
||||
properties: {
|
||||
id: "q1",
|
||||
sessionID: "sess-1",
|
||||
questions: [],
|
||||
questions: [
|
||||
{
|
||||
question: "Ready to implement?",
|
||||
header: "Implement",
|
||||
options: [{ label: "Implement", description: "Switch to code", mode: "code" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
const msg = mapSSEEventToWebviewMessage(event, "sess-1")
|
||||
expect(msg?.type).toBe("questionRequest")
|
||||
if (msg?.type === "questionRequest") {
|
||||
const questions = msg.question.questions as Array<{ options?: Array<{ mode?: string }> }>
|
||||
expect(questions[0]?.options?.[0]?.mode).toBe("code")
|
||||
}
|
||||
})
|
||||
|
||||
it("maps question.replied to questionResolved", () => {
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { describe, it, expect } from "bun:test"
|
||||
import { toggleAnswer } from "../../webview-ui/src/components/chat/question-dock-utils"
|
||||
import {
|
||||
resolveOptimisticQuestionAgent,
|
||||
resolveQuestionMode,
|
||||
resolveSelectedQuestionMode,
|
||||
toggleAnswer,
|
||||
} from "../../webview-ui/src/components/chat/question-dock-utils"
|
||||
|
||||
describe("toggleAnswer", () => {
|
||||
it("adds answer when not present", () => {
|
||||
@@ -36,3 +41,90 @@ describe("toggleAnswer", () => {
|
||||
expect(result).toEqual(["a"])
|
||||
})
|
||||
})
|
||||
|
||||
describe("resolveQuestionMode", () => {
|
||||
it("returns mode for matching predefined option", () => {
|
||||
const result = resolveQuestionMode(
|
||||
[
|
||||
{ label: "Implement", description: "Switch to code", mode: "code" },
|
||||
{ label: "Stay", description: "Remain here" },
|
||||
],
|
||||
"Implement",
|
||||
)
|
||||
|
||||
expect(result).toBe("code")
|
||||
})
|
||||
|
||||
it("returns undefined for unknown answer", () => {
|
||||
const result = resolveQuestionMode([{ label: "Implement", description: "Switch", mode: "code" }], "Custom")
|
||||
expect(result).toBeUndefined()
|
||||
})
|
||||
|
||||
it("returns undefined when option has no mode", () => {
|
||||
const result = resolveQuestionMode([{ label: "Stay", description: "Remain here" }], "Stay")
|
||||
expect(result).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("resolveSelectedQuestionMode", () => {
|
||||
it("returns the selected mode from predefined answers", () => {
|
||||
const result = resolveSelectedQuestionMode(
|
||||
[
|
||||
[
|
||||
{ label: "Implement", description: "Switch to code", mode: "code" },
|
||||
{ label: "Stay", description: "Remain here" },
|
||||
],
|
||||
].map((options) => ({ options })),
|
||||
[["Implement"]],
|
||||
)
|
||||
|
||||
expect(result).toBe("code")
|
||||
})
|
||||
|
||||
it("ignores custom answers that replace a mode option", () => {
|
||||
const result = resolveSelectedQuestionMode(
|
||||
[{ options: [{ label: "Implement", description: "Switch to code", mode: "code" }] }],
|
||||
[["Implement custom flow"]],
|
||||
)
|
||||
|
||||
expect(result).toBeUndefined()
|
||||
})
|
||||
|
||||
it("keeps mode answers from other questions", () => {
|
||||
const result = resolveSelectedQuestionMode(
|
||||
[
|
||||
{ options: [{ label: "Implement", description: "Switch to code", mode: "code" }] },
|
||||
{ options: [{ label: "Stay", description: "Remain here" }] },
|
||||
],
|
||||
[["Implement"], ["Stay"]],
|
||||
)
|
||||
|
||||
expect(result).toBe("code")
|
||||
})
|
||||
})
|
||||
|
||||
describe("resolveOptimisticQuestionAgent", () => {
|
||||
it("stores the previous agent when applying an optimistic mode", () => {
|
||||
const result = resolveOptimisticQuestionAgent(undefined, "ask", "code")
|
||||
|
||||
expect(result).toEqual({ base: "ask", agent: "code" })
|
||||
})
|
||||
|
||||
it("reverts to the stored previous agent when the mode is cleared", () => {
|
||||
const result = resolveOptimisticQuestionAgent("ask", "code", undefined)
|
||||
|
||||
expect(result).toEqual({ base: undefined, agent: "ask" })
|
||||
})
|
||||
|
||||
it("avoids switching when the selected mode already matches the current agent", () => {
|
||||
const result = resolveOptimisticQuestionAgent(undefined, "code", "code")
|
||||
|
||||
expect(result).toEqual({ base: undefined, agent: undefined })
|
||||
})
|
||||
|
||||
it("keeps the original base agent while changing between mode answers", () => {
|
||||
const result = resolveOptimisticQuestionAgent("ask", "code", "architect")
|
||||
|
||||
expect(result).toEqual({ base: "ask", agent: "architect" })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,60 @@
|
||||
import { describe, it, expect } from "bun:test"
|
||||
import { resolveSessionAgent } from "../../webview-ui/src/context/session-agent"
|
||||
import type { Message } from "../../webview-ui/src/types/messages"
|
||||
|
||||
function makeMessage(overrides: Partial<Message> = {}): Message {
|
||||
return {
|
||||
id: "msg-1",
|
||||
sessionID: "sess-1",
|
||||
role: "user",
|
||||
createdAt: new Date(0).toISOString(),
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe("resolveSessionAgent", () => {
|
||||
it("returns the latest valid user agent", () => {
|
||||
const result = resolveSessionAgent(
|
||||
[
|
||||
makeMessage({ id: "1", agent: "plan" }),
|
||||
makeMessage({ id: "2", role: "assistant", agent: "ask" }),
|
||||
makeMessage({ id: "3", agent: "code" }),
|
||||
],
|
||||
new Set(["plan", "code", "ask"]),
|
||||
)
|
||||
|
||||
expect(result).toBe("code")
|
||||
})
|
||||
|
||||
it("ignores assistant messages", () => {
|
||||
const result = resolveSessionAgent(
|
||||
[makeMessage({ role: "assistant", agent: "code" }), makeMessage({ agent: "plan" })],
|
||||
new Set(["plan", "code"]),
|
||||
)
|
||||
|
||||
expect(result).toBe("plan")
|
||||
})
|
||||
|
||||
it("ignores unknown agent names", () => {
|
||||
const result = resolveSessionAgent(
|
||||
[makeMessage({ agent: "missing" }), makeMessage({ agent: "code" })],
|
||||
new Set(["code"]),
|
||||
)
|
||||
|
||||
expect(result).toBe("code")
|
||||
})
|
||||
|
||||
it("ignores empty agent values", () => {
|
||||
const result = resolveSessionAgent([makeMessage({ agent: " " })], new Set(["code"]))
|
||||
expect(result).toBeUndefined()
|
||||
})
|
||||
|
||||
it("returns undefined when no valid user agent exists", () => {
|
||||
const result = resolveSessionAgent(
|
||||
[makeMessage({ role: "assistant", agent: "code" }), makeMessage({ agent: undefined })],
|
||||
new Set(["code"]),
|
||||
)
|
||||
|
||||
expect(result).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:ed6e36e191174cd14d61fc3480014d59c9a92bd526f4f889dc71bdbab4b44e66
|
||||
size 2106
|
||||
oid sha256:270e0dafc2601996e7d048873e55db4680e8803d6800dc44b1fa6ce6239827cc
|
||||
size 2017
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:b313dd76197ff089105d6b80ff22bdf570b878f67dcc99e5d050e85573c52d60
|
||||
size 1959
|
||||
oid sha256:5feb854ee2693b655ab504b8e2ed8794af77a357c3d716e2deb9668a08f3aa0c
|
||||
size 1847
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:e386126977ece9eae41cce0b13677ae4243b985b39a7ee0977e9b5da32415796
|
||||
size 5837
|
||||
oid sha256:937c4398730fd3e07bc064b4cb370717a5bc280ffaced87fa6f3c626774878a8
|
||||
size 5598
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:24d02a1a394c5e18e3783b19c8c7145eafbd8cabfb624c53c82137369d2c5784
|
||||
size 2570
|
||||
oid sha256:1d9935cc07b9eefe0c1eca1852b00ac2b39b76cdf12ee2f01c52e4ac0771b39e
|
||||
size 2910
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:07039f10de94b6aa2eba14674a9f3370fef2f8b23d6712e91f43ddd77f169c34
|
||||
size 1930
|
||||
oid sha256:54e7142d591391c6e99631c9bf87b011321e52f17a8ee39e5aeeaa9b1350f2f9
|
||||
size 1831
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:519310e5cb841302d651f450b4fc281936c331646a4d3759de0c010d1761fe63
|
||||
size 2687
|
||||
oid sha256:7e4a37b5b7d71e0eb7f043176f410030385a8a61f3913bd6419b1d96c6cad2de
|
||||
size 2422
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:6442d82e137e36d165e5f10ca7dbf02445badf109d442eac05714c50593068fe
|
||||
size 7819
|
||||
oid sha256:e929c1f6dd90495d14edea552acfd2f1a3a609d1c3713651f5e11f40aac19309
|
||||
size 8120
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:00c00caa93376d82f4128001416fe14ee71f786f0bff3d6550dc97ff9a675037
|
||||
size 27167
|
||||
oid sha256:024ac7b12c5c6455d30c349dbfde0faff8d8c864761d0d79f0f9e08dbf7144e0
|
||||
size 26331
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:7576fe3532b45c91e0d4d65c97b515565e7b3ad26d6d713d0d52fdab0e0c0565
|
||||
size 29627
|
||||
oid sha256:2ff364be7ea176328dc7f4500e3568aed460a2caa9dd4dedca614cd1db273185
|
||||
size 25150
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:a69ac58dc72885950a702661b75bc1989a28e8b07b3136037255891b7e5a3b06
|
||||
size 12839
|
||||
oid sha256:070f9f8cb76fc01ceba027cbcaf198e0530545a0aa78ea1ead75f1e09bb252a1
|
||||
size 14152
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:30346f68598419d1fe80a3f33d3326cc844a30356ebedb022a841be4096514c6
|
||||
size 53909
|
||||
oid sha256:5094e214a52bac4627f8fe280f4818c986753c49f160bf5295f21122054ea72f
|
||||
size 53684
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:e654374e5c12a4a184a0d27709cd773684e31e08a3d7b6305358849cf30c75be
|
||||
size 50986
|
||||
oid sha256:83a70ef1260f77c3017db90a918ee77c332c065bfc047e0f206948bb5dde83b9
|
||||
size 50888
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:b7e17426d82ff00ac8194963a3c3be946994018e2f6c9553dfaee237cb043fd9
|
||||
size 11324
|
||||
oid sha256:b569b91c3437b55163cc48a2fcf6f5b0ebd2c6a93aff425b14d70f6055201f20
|
||||
size 12716
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:e2eb5ca750d8a590df5a4c7a1be493290aa90535a98c5dc0f6fadfc802872567
|
||||
size 3520
|
||||
oid sha256:e2bb93bd5830bcfd208615e64c8b2b88855d1faa97cd7980df9d95cfada533d3
|
||||
size 3561
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:1e5fd252dcc1acaeefae5608621a1c32282f99c24f7ee464d375cd149722c4fc
|
||||
size 3915
|
||||
oid sha256:862a22d4123e5c512d4ae87eb881c5923f14ac5207bf77b4707063b3dcc53422
|
||||
size 3991
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:2da4bbf78414fd58ef84b03f421954741edae16df0a0fccc4ad4e99e752e70ff
|
||||
size 3882
|
||||
oid sha256:c49479d1fffe96d14300617d48be269e5bffe41bc50f555b1ee3fbb09d8919e1
|
||||
size 3896
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:ca37ae191123410c4488e6c54a2f104c830c4adb98b2dc3f5a2ac344e4b64356
|
||||
size 4107
|
||||
oid sha256:6f0e205d89dc56438971d53e7210f1de1b261e641666ccebf0b1a2f36c3811aa
|
||||
size 4195
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:3a6043cb26f5fe15cf84106a8a63c0f04a96ca04b23b9e55c276cc5db8f8104a
|
||||
size 4159
|
||||
oid sha256:e5b5b806af5e943fc69ad70a13d446e38807d733f66ac2647ee4057957976dea
|
||||
size 4211
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:22b2d117440cfcd773be53b8d20ac1ed5561f02c7b07f307efc54e722341e14b
|
||||
size 4606
|
||||
oid sha256:88d2598f448e68b32b3daef5420a3e31ae5fdafe77ced20aadbc4fdf4c1ab84a
|
||||
size 4682
|
||||
|
||||
@@ -34,6 +34,8 @@ import type {
|
||||
WorktreeGitStats,
|
||||
LocalGitStats,
|
||||
WorktreeState,
|
||||
PRStatus,
|
||||
AgentManagerPRStatusMessage,
|
||||
ManagedSessionState,
|
||||
SessionInfo,
|
||||
BranchInfo,
|
||||
@@ -120,6 +122,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
|
||||
@@ -328,8 +332,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 +349,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>()
|
||||
|
||||
@@ -786,6 +793,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). */
|
||||
@@ -1012,9 +1024,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 +1165,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 })
|
||||
}
|
||||
@@ -1383,6 +1395,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 +1475,7 @@ const AgentManagerContent: Component = () => {
|
||||
const openReviewTab = () => {
|
||||
const sel = selection()
|
||||
if (sel === null) return
|
||||
setDiffOpen(false)
|
||||
setSidePanel(null)
|
||||
setReviewOpenForContext(sel, true)
|
||||
setReviewActive(true)
|
||||
}
|
||||
@@ -2310,6 +2327,7 @@ 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)}
|
||||
@@ -2327,6 +2345,34 @@ const AgentManagerContent: Component = () => {
|
||||
renameValue={renameValue()}
|
||||
closeKeybind={kb().closeWorktree ?? ""}
|
||||
openKeybind={kb().openWorktree ?? ""}
|
||||
pr={
|
||||
prStatuses()[wt.id] !== undefined
|
||||
? prStatuses()[wt.id]
|
||||
: wt.prNumber
|
||||
? {
|
||||
number: wt.prNumber,
|
||||
title: "",
|
||||
url: wt.prUrl ?? "",
|
||||
state: (wt.prState ?? "open") as PRStatus["state"],
|
||||
review: null,
|
||||
checks: {
|
||||
status: "none" as const,
|
||||
total: 0,
|
||||
passed: 0,
|
||||
failed: 0,
|
||||
pending: 0,
|
||||
items: [],
|
||||
},
|
||||
comments: { total: 0, unresolved: 0, items: [] },
|
||||
additions: 0,
|
||||
deletions: 0,
|
||||
files: 0,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onOpenPR={() =>
|
||||
vscode.postMessage({ type: "agentManager.openPR", worktreeId: wt.id })
|
||||
}
|
||||
onClick={() => {
|
||||
if (pendingDelete() === wt.id) {
|
||||
confirmDeleteWorktree(wt.id)
|
||||
@@ -2622,10 +2668,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 +2798,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 +2856,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 +2875,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>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Reusable branch selector: search input + scrollable list with keyboard navigation
|
||||
|
||||
import { type Component, For, Show, onMount } from "solid-js"
|
||||
import { type Component, For, Show } from "solid-js"
|
||||
import type { BranchInfo } from "../src/types/messages"
|
||||
import { Icon } from "@kilocode/kilo-ui/icon"
|
||||
import { Spinner } from "@kilocode/kilo-ui/spinner"
|
||||
@@ -34,10 +34,6 @@ interface BranchSelectProps {
|
||||
}
|
||||
|
||||
export const BranchSelect: Component<BranchSelectProps> = (props) => {
|
||||
let ref: HTMLInputElement | undefined
|
||||
|
||||
onMount(() => requestAnimationFrame(() => ref?.focus()))
|
||||
|
||||
const isDefault = (branch: BranchInfo) => {
|
||||
if (props.defaultName) return branch.name === props.defaultName
|
||||
return branch.isDefault
|
||||
@@ -48,7 +44,7 @@ export const BranchSelect: Component<BranchSelectProps> = (props) => {
|
||||
<div class="am-dropdown-search">
|
||||
<Icon name="magnifying-glass" size="small" />
|
||||
<input
|
||||
ref={ref}
|
||||
data-autofocus
|
||||
class="am-dropdown-search-input"
|
||||
type="text"
|
||||
placeholder={props.searchPlaceholder}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -380,8 +380,10 @@ export const NewWorktreeDialog: Component<{ onClose: () => void; defaultBaseBran
|
||||
setHighlightedIndex(0)
|
||||
}
|
||||
}}
|
||||
placement="bottom-start"
|
||||
placement="top-start"
|
||||
flip={false}
|
||||
sameWidth
|
||||
portal={false}
|
||||
class="am-dropdown"
|
||||
trigger={
|
||||
<button class="am-selector-trigger" type="button">
|
||||
@@ -625,8 +627,10 @@ export const NewWorktreeDialog: Component<{ onClose: () => void; defaultBaseBran
|
||||
<Popover
|
||||
open={branchOpen()}
|
||||
onOpenChange={setBranchOpen}
|
||||
placement="bottom-start"
|
||||
placement="top-start"
|
||||
flip={false}
|
||||
sameWidth
|
||||
portal={false}
|
||||
class="am-dropdown"
|
||||
trigger={
|
||||
<button class="am-selector-trigger" disabled={isPending()} type="button">
|
||||
|
||||
@@ -10,7 +10,7 @@ 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 } from "../src/types/messages"
|
||||
import { useLanguage } from "../src/context/language"
|
||||
import { formatRelativeDate } from "../src/utils/date"
|
||||
|
||||
@@ -22,6 +22,8 @@ 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
|
||||
@@ -51,6 +53,10 @@ 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
|
||||
|
||||
onClick: () => void
|
||||
onDelete: (e: MouseEvent) => void
|
||||
@@ -68,11 +74,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 +138,162 @@ 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 >= 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 +383,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" />
|
||||
|
||||
@@ -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,8 +622,6 @@ 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;
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* ContextProgress — three-segment progress bar showing context window usage.
|
||||
*
|
||||
* Segments:
|
||||
* 1. Used tokens (foreground color, turns red when >= 50%)
|
||||
* 2. Reserved for output (medium gray)
|
||||
* 3. Available (transparent / background)
|
||||
*
|
||||
* Token counts flanking the bar: used on left, total on right.
|
||||
*/
|
||||
|
||||
import { Component, createMemo, Show } from "solid-js"
|
||||
import { Tooltip } from "@kilocode/kilo-ui/tooltip"
|
||||
import { useSession } from "../../context/session"
|
||||
import { useProvider } from "../../context/provider"
|
||||
|
||||
function fmt(n: number): string {
|
||||
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`
|
||||
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`
|
||||
return String(n)
|
||||
}
|
||||
|
||||
export const ContextProgress: Component = () => {
|
||||
const session = useSession()
|
||||
const provider = useProvider()
|
||||
|
||||
const data = createMemo(() => {
|
||||
const usage = session.contextUsage()
|
||||
if (!usage || usage.tokens === 0) return undefined
|
||||
|
||||
const sel = session.selected()
|
||||
const model = sel ? provider.findModel(sel) : undefined
|
||||
const limit = model?.limit?.context ?? model?.contextLength ?? 0
|
||||
const output = model?.limit?.output ?? 0
|
||||
|
||||
if (limit === 0) return undefined
|
||||
|
||||
const used = Math.min(usage.tokens, limit)
|
||||
const reserved = Math.min(output, limit - used)
|
||||
const available = Math.max(0, limit - used - reserved)
|
||||
|
||||
const pctUsed = (used / limit) * 100
|
||||
const pctReserved = (reserved / limit) * 100
|
||||
const pctAvail = (available / limit) * 100
|
||||
|
||||
return { used, reserved, available, limit, pctUsed, pctReserved, pctAvail, output }
|
||||
})
|
||||
|
||||
const tip = createMemo(() => {
|
||||
const d = data()
|
||||
if (!d) return ""
|
||||
const lines = [`${fmt(d.used)} / ${fmt(d.limit)} tokens used`]
|
||||
if (d.output > 0) lines.push(`${fmt(d.output)} reserved for output`)
|
||||
if (d.available > 0) lines.push(`${fmt(d.available)} available`)
|
||||
return lines.join("\n")
|
||||
})
|
||||
|
||||
return (
|
||||
<Show when={data()}>
|
||||
{(d) => (
|
||||
<div class="context-progress">
|
||||
<span class="context-progress-count">{fmt(d().used)}</span>
|
||||
<Tooltip value={tip()} placement="top">
|
||||
<div class="context-progress-bar">
|
||||
<div
|
||||
class="context-progress-used"
|
||||
classList={{ "context-progress-used--hot": d().pctUsed >= 50 }}
|
||||
style={{ width: `${d().pctUsed}%` }}
|
||||
/>
|
||||
<div class="context-progress-reserved" style={{ width: `${d().pctReserved}%` }} />
|
||||
<Show when={d().pctAvail > 0}>
|
||||
<div class="context-progress-available" style={{ width: `${d().pctAvail}%` }} />
|
||||
</Show>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<span class="context-progress-count">{fmt(d().limit)}</span>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
@@ -62,12 +62,16 @@ export const KiloNotifications: Component = () => {
|
||||
return true
|
||||
})
|
||||
|
||||
const MAX_NAME = 30
|
||||
|
||||
const suggestedName = createMemo(() => {
|
||||
const suggestion = suggestedModel()
|
||||
if (!suggestion) return undefined
|
||||
const model = provider.findModel(suggestion)
|
||||
if (!model?.name) return undefined
|
||||
return stripSubProviderPrefix(model.name)
|
||||
const name = stripSubProviderPrefix(model.name)
|
||||
if (name.length > MAX_NAME) return undefined
|
||||
return name
|
||||
})
|
||||
|
||||
const handleTryModel = () => {
|
||||
@@ -98,7 +102,9 @@ export const KiloNotifications: Component = () => {
|
||||
<div class="kilo-notifications-cta-group">
|
||||
<Show when={canSwitchModel()}>
|
||||
<button class="kilo-notifications-action-btn" onClick={handleTryModel}>
|
||||
{language.t("notifications.action.tryModel", { model: suggestedName() ?? "" })}
|
||||
{suggestedName()
|
||||
? language.t("notifications.action.tryModel", { model: suggestedName()! })
|
||||
: language.t("notifications.action.tryModelGeneric")}
|
||||
</button>
|
||||
</Show>
|
||||
<Show when={current()?.action}>
|
||||
|
||||
@@ -244,7 +244,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
window.addEventListener("compactSession", onCompact)
|
||||
onCleanup(() => window.removeEventListener("compactSession", onCompact))
|
||||
|
||||
const isBusy = () => session.status() === "busy"
|
||||
const isBusy = () => session.status() !== "idle"
|
||||
const isDisabled = () => !server.isConnected()
|
||||
const hasInput = () => text().trim().length > 0 || imageAttach.images().length > 0 || reviewComments().length > 0
|
||||
const canSend = () => hasInput() && !isDisabled() && !props.blocked?.()
|
||||
|
||||
@@ -4,14 +4,15 @@
|
||||
* Uses kilo-ui's DockPrompt component for proper surface styling.
|
||||
*/
|
||||
|
||||
import { Component, For, Show, createMemo, createEffect } from "solid-js"
|
||||
import { For, Show, createMemo, createEffect } from "solid-js"
|
||||
import type { Component } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { Button } from "@kilocode/kilo-ui/button"
|
||||
import { Icon } from "@kilocode/kilo-ui/icon"
|
||||
import { useSession } from "../../context/session"
|
||||
import { useLanguage } from "../../context/language"
|
||||
import type { QuestionRequest } from "../../types/messages"
|
||||
import { toggleAnswer } from "./question-dock-utils"
|
||||
import { resolveOptimisticQuestionAgent, resolveSelectedQuestionMode, toggleAnswer } from "./question-dock-utils"
|
||||
|
||||
export const QuestionDock: Component<{ request: QuestionRequest }> = (props) => {
|
||||
const session = useSession()
|
||||
@@ -24,17 +25,23 @@ export const QuestionDock: Component<{ request: QuestionRequest }> = (props) =>
|
||||
tab: 0,
|
||||
answers: [] as string[][],
|
||||
custom: [] as string[],
|
||||
kinds: [] as Record<string, "option" | "custom">[],
|
||||
editing: false,
|
||||
sending: false,
|
||||
collapsed: false,
|
||||
})
|
||||
|
||||
let root!: HTMLDivElement
|
||||
let prevAgent: string | undefined
|
||||
|
||||
// Reset sending state when an error occurs for this question
|
||||
// Reset sending state and roll back the optimistic agent change on error
|
||||
createEffect(() => {
|
||||
if (session.questionErrors().has(props.request.id)) {
|
||||
setStore("sending", false)
|
||||
if (prevAgent !== undefined) {
|
||||
session.selectAgent(prevAgent)
|
||||
prevAgent = undefined
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -64,6 +71,8 @@ export const QuestionDock: Component<{ request: QuestionRequest }> = (props) =>
|
||||
setStore("sending", true)
|
||||
session.replyToQuestion(props.request.id, answers)
|
||||
focusPrompt()
|
||||
// prevAgent is intentionally left set until either questionError (rollback)
|
||||
// or the question is dismissed (success — the question unmounts, so no cleanup needed)
|
||||
}
|
||||
|
||||
const reject = () => {
|
||||
@@ -83,17 +92,33 @@ export const QuestionDock: Component<{ request: QuestionRequest }> = (props) =>
|
||||
setStore("editing", false)
|
||||
}
|
||||
|
||||
const syncAgent = (answers: string[][], kinds: Record<string, "option" | "custom">[] = store.kinds) => {
|
||||
const mode = resolveSelectedQuestionMode(questions(), answers, kinds)
|
||||
const next = resolveOptimisticQuestionAgent(prevAgent, session.selectedAgent(), mode)
|
||||
|
||||
prevAgent = next.base
|
||||
if (!next.agent) return
|
||||
if (next.agent === session.selectedAgent()) return
|
||||
session.selectAgent(next.agent)
|
||||
}
|
||||
|
||||
const pick = (answer: string, custom = false) => {
|
||||
const answers = [...store.answers]
|
||||
answers[store.tab] = [answer]
|
||||
setStore("answers", answers)
|
||||
|
||||
const kinds = [...store.kinds]
|
||||
kinds[store.tab] = { [answer]: custom ? "custom" : "option" }
|
||||
setStore("kinds", kinds)
|
||||
|
||||
if (custom) {
|
||||
const inputs = [...store.custom]
|
||||
inputs[store.tab] = answer
|
||||
setStore("custom", inputs)
|
||||
}
|
||||
|
||||
syncAgent(answers, kinds)
|
||||
|
||||
if (!single() && !multi()) {
|
||||
setStore("tab", store.tab + 1)
|
||||
}
|
||||
@@ -104,6 +129,13 @@ export const QuestionDock: Component<{ request: QuestionRequest }> = (props) =>
|
||||
const answers = [...store.answers]
|
||||
answers[store.tab] = next
|
||||
setStore("answers", answers)
|
||||
const kinds = [...store.kinds]
|
||||
const current = { ...(kinds[store.tab] ?? {}) }
|
||||
if (next.includes(answer)) current[answer] = "option"
|
||||
else delete current[answer]
|
||||
kinds[store.tab] = current
|
||||
setStore("kinds", kinds)
|
||||
syncAgent(answers, kinds)
|
||||
}
|
||||
|
||||
const selectTab = (index: number) => {
|
||||
@@ -167,6 +199,12 @@ export const QuestionDock: Component<{ request: QuestionRequest }> = (props) =>
|
||||
const answers = [...store.answers]
|
||||
answers[store.tab] = next
|
||||
setStore("answers", answers)
|
||||
const kinds = [...store.kinds]
|
||||
const current = { ...(kinds[store.tab] ?? {}) }
|
||||
current[value] = "custom"
|
||||
kinds[store.tab] = current
|
||||
setStore("kinds", kinds)
|
||||
syncAgent(answers, kinds)
|
||||
setStore("editing", false)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -3,16 +3,22 @@
|
||||
* Sticky header above the chat messages showing session title,
|
||||
* cost, context usage, and a compact button.
|
||||
* Also shows todo progress when the session has todos.
|
||||
*
|
||||
* When expanded, shows the task timeline (colored bars representing
|
||||
* session activity) and a context window progress bar.
|
||||
*/
|
||||
|
||||
import { Component, For, Show, createMemo, createSignal } from "solid-js"
|
||||
import { Component, For, Show, createMemo, createSignal, onMount, onCleanup } from "solid-js"
|
||||
import { IconButton } from "@kilocode/kilo-ui/icon-button"
|
||||
import { Tooltip } from "@kilocode/kilo-ui/tooltip"
|
||||
import { Icon } from "@kilocode/kilo-ui/icon"
|
||||
import { Checkbox } from "@kilocode/kilo-ui/checkbox"
|
||||
import { useSession } from "../../context/session"
|
||||
import { useLanguage } from "../../context/language"
|
||||
import type { TodoItem } from "../../types/messages"
|
||||
import { useVSCode } from "../../context/vscode"
|
||||
import { TaskTimeline } from "./TaskTimeline"
|
||||
import { ContextProgress } from "./ContextProgress"
|
||||
import type { TodoItem, ExtensionMessage } from "../../types/messages"
|
||||
|
||||
interface TaskHeaderProps {
|
||||
readonly?: boolean
|
||||
@@ -55,6 +61,42 @@ export const TaskHeader: Component<TaskHeaderProps> = (props) => {
|
||||
return { tokens, pct }
|
||||
})
|
||||
|
||||
// Token breakdown from the last assistant message — only return if at least one value is > 0
|
||||
const tokens = createMemo(() => {
|
||||
const msgs = session.messages()
|
||||
for (let i = msgs.length - 1; i >= 0; i--) {
|
||||
const m = msgs[i]
|
||||
if (m.role !== "assistant" || !m.tokens) continue
|
||||
const tk = m.tokens
|
||||
const has = tk.input > 0 || tk.output > 0 || (tk.cache?.write ?? 0) > 0 || (tk.cache?.read ?? 0) > 0
|
||||
if (has) return tk
|
||||
}
|
||||
return undefined
|
||||
})
|
||||
|
||||
const fmtNum = (n: number): string => {
|
||||
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`
|
||||
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`
|
||||
return String(n)
|
||||
}
|
||||
|
||||
const vscode = useVSCode()
|
||||
const [expanded, setExpanded] = createSignal(true)
|
||||
|
||||
// Read initial value from VS Code settings
|
||||
onMount(() => vscode.postMessage({ type: "requestTimelineSetting" }))
|
||||
const handler = (e: MessageEvent<ExtensionMessage>) => {
|
||||
if (e.data.type === "timelineSettingLoaded") setExpanded(e.data.visible)
|
||||
}
|
||||
window.addEventListener("message", handler)
|
||||
onCleanup(() => window.removeEventListener("message", handler))
|
||||
|
||||
const toggle = () => {
|
||||
const next = !expanded()
|
||||
setExpanded(next)
|
||||
vscode.postMessage({ type: "updateSetting", key: "showTaskTimeline", value: next })
|
||||
}
|
||||
|
||||
const todos = createMemo(() => session.todos())
|
||||
const hasTodos = createMemo(() => todos().length > 0)
|
||||
const doneCount = createMemo(() => todos().filter((t: TodoItem) => t.status === "completed").length)
|
||||
@@ -107,8 +149,58 @@ export const TaskHeader: Component<TaskHeaderProps> = (props) => {
|
||||
/>
|
||||
</Tooltip>
|
||||
</Show>
|
||||
<Show when={hasMessages()}>
|
||||
<button
|
||||
data-slot="task-header-expand"
|
||||
onClick={toggle}
|
||||
aria-expanded={expanded()}
|
||||
aria-label="Toggle timeline"
|
||||
>
|
||||
<Icon name="chevron-down" size="small" style={expanded() ? { transform: "rotate(180deg)" } : undefined} />
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
{/* Expanded graph section: timeline + context bar + token breakdown */}
|
||||
<Show when={expanded() && session.messages().some((m) => m.role === "assistant")}>
|
||||
<div data-component="task-header-graph">
|
||||
<TaskTimeline />
|
||||
<div data-slot="task-header-graph-row">
|
||||
<ContextProgress />
|
||||
</div>
|
||||
<Show when={tokens()}>
|
||||
{(tk) => (
|
||||
<div class="task-header-tokens">
|
||||
<span class="task-header-tokens-label">Tokens</span>
|
||||
<Show when={tk().input > 0}>
|
||||
<span class="task-header-tokens-value">
|
||||
<Icon name="arrow-up" size="small" />
|
||||
{fmtNum(tk().input)}
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={tk().output > 0}>
|
||||
<span class="task-header-tokens-value">
|
||||
<Icon name="arrow-down-to-line" size="small" />
|
||||
{fmtNum(tk().output)}
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={tk().cache?.write && tk().cache!.write > 0}>
|
||||
<span class="task-header-tokens-value">
|
||||
<Icon name="arrow-up" size="small" />
|
||||
cache {fmtNum(tk().cache!.write)}
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={tk().cache?.read && tk().cache!.read > 0}>
|
||||
<span class="task-header-tokens-value">
|
||||
<Icon name="arrow-down-to-line" size="small" />
|
||||
cache {fmtNum(tk().cache!.read)}
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={hasTodos()}>
|
||||
<div data-component="task-header-todos">
|
||||
<button
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
/**
|
||||
* TaskTimeline — horizontal strip of colored bars representing session activity.
|
||||
*
|
||||
* Each bar = one Part from assistant messages.
|
||||
* Color = part type (read=blue, write=dark blue, tool=indigo, error=red, text=gray).
|
||||
* Width = proportional to time between parts.
|
||||
* Height = proportional to content length.
|
||||
*
|
||||
* Interactions: drag scroll, mouse wheel, auto-scroll to latest.
|
||||
*
|
||||
* No virtualization needed: SolidJS <Index> creates each element once and
|
||||
* updates bindings in place (unlike React). Even 1000+ bars are fine.
|
||||
*/
|
||||
|
||||
import { Component, Index, createMemo, createEffect, on, onCleanup } from "solid-js"
|
||||
import { Tooltip } from "@kilocode/kilo-ui/tooltip"
|
||||
import { useSession } from "../../context/session"
|
||||
import { color, label } from "../../utils/timeline/colors"
|
||||
import { sizes, MAX_HEIGHT } from "../../utils/timeline/sizes"
|
||||
import type { Part, Message } from "../../types/messages"
|
||||
|
||||
export interface TimelineBar {
|
||||
bg: string
|
||||
tip: string
|
||||
width: number
|
||||
height: number
|
||||
idx: number
|
||||
}
|
||||
|
||||
function collect(messages: Message[], parts: Record<string, Part[]>): TimelineBar[] {
|
||||
const result: Part[] = []
|
||||
|
||||
for (const msg of messages) {
|
||||
if (msg.role === "user") continue
|
||||
const ps = parts[msg.id]
|
||||
if (!ps) continue
|
||||
for (const p of ps) {
|
||||
if (p.type === "step-start") continue
|
||||
result.push(p)
|
||||
}
|
||||
}
|
||||
|
||||
const sz = sizes(result)
|
||||
return result.map((p, i) => ({
|
||||
bg: color(p),
|
||||
tip: label(p),
|
||||
width: sz[i]!.width,
|
||||
height: sz[i]!.height,
|
||||
idx: i,
|
||||
}))
|
||||
}
|
||||
|
||||
export const TaskTimeline: Component = () => {
|
||||
const session = useSession()
|
||||
let ref: HTMLDivElement | undefined
|
||||
let dragging = false
|
||||
let startX = 0
|
||||
let startScroll = 0
|
||||
|
||||
const messages = () => session.messages()
|
||||
const allParts = () => {
|
||||
const msgs = messages()
|
||||
const result: Record<string, Part[]> = {}
|
||||
for (const m of msgs) {
|
||||
const p = session.getParts(m.id)
|
||||
if (p.length > 0) result[m.id] = p
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
const bars = createMemo(() => collect(messages(), allParts()))
|
||||
const busy = () => session.status() === "busy"
|
||||
|
||||
// Auto-scroll to the latest bar when new bars appear
|
||||
let prev = 0
|
||||
createEffect(
|
||||
on(
|
||||
() => bars().length,
|
||||
(len) => {
|
||||
if (len > prev && ref) {
|
||||
ref.scrollLeft = ref.scrollWidth
|
||||
}
|
||||
prev = len
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
// ── Drag scroll ──────────────────────────────────────────────────
|
||||
const onPointerDown = (e: PointerEvent) => {
|
||||
if (!ref) return
|
||||
dragging = true
|
||||
startX = e.clientX
|
||||
startScroll = ref.scrollLeft
|
||||
ref.setPointerCapture(e.pointerId)
|
||||
ref.style.cursor = "grabbing"
|
||||
ref.style.userSelect = "none"
|
||||
}
|
||||
|
||||
const onPointerMove = (e: PointerEvent) => {
|
||||
if (!dragging || !ref) return
|
||||
ref.scrollLeft = startScroll - (e.clientX - startX)
|
||||
}
|
||||
|
||||
const onPointerUp = (e: PointerEvent) => {
|
||||
if (!ref) return
|
||||
dragging = false
|
||||
ref.releasePointerCapture(e.pointerId)
|
||||
ref.style.cursor = "grab"
|
||||
ref.style.userSelect = ""
|
||||
}
|
||||
|
||||
// ── Wheel → horizontal scroll ────────────────────────────────────
|
||||
const onWheel = (e: WheelEvent) => {
|
||||
if (!ref) return
|
||||
e.preventDefault()
|
||||
ref.scrollLeft += e.deltaY || e.deltaX
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
const el = ref
|
||||
if (!el) return
|
||||
el.addEventListener("wheel", onWheel, { passive: false })
|
||||
onCleanup(() => el.removeEventListener("wheel", onWheel))
|
||||
})
|
||||
|
||||
return (
|
||||
<div class="task-timeline-outer">
|
||||
<div
|
||||
ref={ref}
|
||||
class="task-timeline"
|
||||
style={{ height: `${MAX_HEIGHT}px` }}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
onPointerCancel={onPointerUp}
|
||||
>
|
||||
<Index each={bars()}>
|
||||
{(bar) => {
|
||||
const active = () => busy() && bar().idx === bars().length - 1
|
||||
return (
|
||||
<Tooltip value={bar().tip} placement="top">
|
||||
<div
|
||||
class="task-timeline-bar"
|
||||
style={{
|
||||
width: `${bar().width}px`,
|
||||
height: `${MAX_HEIGHT}px`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
class="task-timeline-bar-fill task-timeline-bar-fill--new"
|
||||
classList={{
|
||||
"task-timeline-bar-fill--active": active(),
|
||||
}}
|
||||
style={{
|
||||
background: bar().bg,
|
||||
height: `${(bar().height / MAX_HEIGHT) * 100}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Tooltip>
|
||||
)
|
||||
}}
|
||||
</Index>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { QuestionOption } from "../../types/messages"
|
||||
|
||||
export function toggleAnswer(existing: string[], answer: string): string[] {
|
||||
const next = [...existing]
|
||||
const index = next.indexOf(answer)
|
||||
@@ -5,3 +7,47 @@ export function toggleAnswer(existing: string[], answer: string): string[] {
|
||||
if (index !== -1) next.splice(index, 1)
|
||||
return next
|
||||
}
|
||||
|
||||
export function resolveQuestionMode(options: QuestionOption[], answer: string): string | undefined {
|
||||
return options.find((item) => item.label === answer)?.mode
|
||||
}
|
||||
|
||||
export function resolveSelectedQuestionMode(
|
||||
questions: Array<{ options?: QuestionOption[] }>,
|
||||
answers: string[][],
|
||||
kinds: Record<string, "option" | "custom">[] = [],
|
||||
): string | undefined {
|
||||
let mode: string | undefined
|
||||
|
||||
for (const [i, list] of answers.entries()) {
|
||||
const options = questions[i]?.options ?? []
|
||||
for (const answer of list) {
|
||||
if (kinds[i]?.[answer] === "custom") continue
|
||||
const next = resolveQuestionMode(options, answer)
|
||||
if (next) mode = next
|
||||
}
|
||||
}
|
||||
|
||||
return mode
|
||||
}
|
||||
|
||||
export function resolveOptimisticQuestionAgent(base: string | undefined, current: string, mode: string | undefined) {
|
||||
if (!mode) {
|
||||
return {
|
||||
base: undefined,
|
||||
agent: base,
|
||||
}
|
||||
}
|
||||
|
||||
if (base === undefined && current === mode) {
|
||||
return {
|
||||
base: undefined,
|
||||
agent: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
base: base ?? current,
|
||||
agent: mode,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -643,6 +643,9 @@ const MigrationWizard: Component<MigrationWizardProps> = (props) => {
|
||||
<a href="https://blog.kilo.ai/p/new-kilo-for-vs-code-is-live">
|
||||
{language.t("migration.whatsNew.blogLink")} <span>→</span>
|
||||
</a>
|
||||
<a href="https://kilo.ai/docs/code-with-ai/platforms/vscode/whats-new">
|
||||
{language.t("migration.whatsNew.docsLink")} <span>→</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="migration-wizard__footer">
|
||||
|
||||
@@ -130,7 +130,10 @@
|
||||
|
||||
/* Blog link */
|
||||
.migration-wizard__blog-link {
|
||||
text-align: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
|
||||
@@ -1004,7 +1004,15 @@ const AgentBehaviourTab: Component = () => {
|
||||
>
|
||||
{path}
|
||||
</span>
|
||||
<IconButton size="small" variant="ghost" icon="close" onClick={() => removeInstruction(index())} />
|
||||
<div style={{ display: "flex", "align-items": "center", gap: "4px" }}>
|
||||
<IconButton
|
||||
size="small"
|
||||
variant="ghost"
|
||||
icon="pencil-line"
|
||||
onClick={() => vscode.postMessage({ type: "openFile", filePath: path })}
|
||||
/>
|
||||
<IconButton size="small" variant="ghost" icon="close" onClick={() => removeInstruction(index())} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
sanitizeName,
|
||||
} from "./model-selector-utils"
|
||||
import { ModelPreview } from "./ModelPreview"
|
||||
import { searchMatch } from "../../utils/search-match"
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Row / group key helpers — single source of truth for key formatting
|
||||
@@ -167,11 +168,13 @@ export const ModelSelectorBase: Component<ModelSelectorBaseProps> = (props) => {
|
||||
|
||||
// Flat filtered list for keyboard navigation
|
||||
const filtered = createMemo(() => {
|
||||
const q = debouncedSearch().toLowerCase()
|
||||
const q = debouncedSearch().trim()
|
||||
if (!q) {
|
||||
return visibleModels()
|
||||
}
|
||||
return visibleModels().filter((m) => m.name.toLowerCase().includes(q))
|
||||
return visibleModels().filter(
|
||||
(m) => searchMatch(q, m.name) || searchMatch(q, m.id) || searchMatch(q, m.providerName),
|
||||
)
|
||||
})
|
||||
|
||||
// Live set of favorited keys — drives star icon visual state (filled vs outline).
|
||||
|
||||
@@ -4,14 +4,17 @@
|
||||
* Matches the v1.0.25 working indicator UX.
|
||||
*/
|
||||
|
||||
import { Component, Show, createSignal, createEffect, onCleanup } from "solid-js"
|
||||
import { type Component, Show, createSignal, createEffect, onCleanup } from "solid-js"
|
||||
import { Spinner } from "@kilocode/kilo-ui/spinner"
|
||||
import { Button } from "@kilocode/kilo-ui/button"
|
||||
import { useSession } from "../../context/session"
|
||||
import { useLanguage } from "../../context/language"
|
||||
import { useVSCode } from "../../context/vscode"
|
||||
|
||||
export const WorkingIndicator: Component = () => {
|
||||
const session = useSession()
|
||||
const language = useLanguage()
|
||||
const vscode = useVSCode()
|
||||
|
||||
const [elapsed, setElapsed] = createSignal(0)
|
||||
const [retryCountdown, setRetryCountdown] = createSignal(0)
|
||||
@@ -80,6 +83,15 @@ export const WorkingIndicator: Component = () => {
|
||||
return perms.length > 0 || questions.length > 0
|
||||
}
|
||||
|
||||
const isRetrying = () => session.statusInfo().type === "retry"
|
||||
|
||||
const handleCancelRetry = () => {
|
||||
const sid = session.currentSessionID()
|
||||
if (sid) {
|
||||
vscode.postMessage({ type: "abort", sessionID: sid })
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Show when={session.status() !== "idle" && !blocked()}>
|
||||
<div class="working-indicator">
|
||||
@@ -88,6 +100,17 @@ export const WorkingIndicator: Component = () => {
|
||||
<Show when={elapsed() > 0}>
|
||||
<span class="working-elapsed">{formatElapsed()}</span>
|
||||
</Show>
|
||||
<Show when={isRetrying()}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="small"
|
||||
onClick={handleCancelRetry}
|
||||
class="working-cancel"
|
||||
style={{ "font-weight": "600", color: "var(--vscode-errorForeground, #f85149)" }}
|
||||
>
|
||||
{language.t("ui.sessionTurn.cancel") || "Cancel"}
|
||||
</Button>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { Message } from "../types/messages"
|
||||
|
||||
export function resolveSessionAgent(messages: Message[], names: Set<string>): string | undefined {
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const msg = messages[i]
|
||||
if (msg.role !== "user") continue
|
||||
const name = msg.agent?.trim()
|
||||
if (!name) continue
|
||||
if (!names.has(name)) continue
|
||||
return name
|
||||
}
|
||||
}
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
} from "./session-utils"
|
||||
import { Identifier } from "../utils/id"
|
||||
import { resolveModelSelection } from "./model-selection"
|
||||
import { resolveSessionAgent } from "./session-agent"
|
||||
import { KILO_AUTO, parseModelString } from "../../../src/shared/provider-model"
|
||||
|
||||
const RECENT_LIMIT = 5
|
||||
@@ -352,6 +353,8 @@ export const SessionProvider: ParentComponent = (props) => {
|
||||
return pendingAgentSelection() ?? defaultAgent()
|
||||
})
|
||||
|
||||
const agentNames = createMemo(() => new Set(agents().map((agent) => agent.name)))
|
||||
|
||||
/** Per-mode model from config (e.g. config.agent.code.model). */
|
||||
function getModeModel(agentName: string): ModelSelection | null {
|
||||
return parseModelString(config().agent?.[agentName]?.model)
|
||||
@@ -421,6 +424,10 @@ export const SessionProvider: ParentComponent = (props) => {
|
||||
|
||||
function selectModel(providerID: string, modelID: string) {
|
||||
applyModel(selectedAgentName(), { providerID, modelID })
|
||||
const sid = currentSessionID()
|
||||
if (sid) {
|
||||
setStore("messages", sid, (msgs = []) => msgs.filter((m) => !m.error))
|
||||
}
|
||||
}
|
||||
|
||||
/** The config/default model for the current mode (what settings says). */
|
||||
@@ -490,6 +497,17 @@ export const SessionProvider: ParentComponent = (props) => {
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
// Rescan already-loaded message history so sessions whose messagesLoaded
|
||||
// arrived before agentsLoaded (and therefore got no agent selection) are
|
||||
// backfilled now that we know the valid agent names.
|
||||
batch(() => {
|
||||
for (const [sid, msgs] of Object.entries(store.messages)) {
|
||||
if (store.agentSelections[sid]) continue
|
||||
const agent = resolveSessionAgent(msgs, names)
|
||||
if (agent) setStore("agentSelections", sid, agent)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// Request agents in case the initial push was missed.
|
||||
@@ -823,6 +841,11 @@ export const SessionProvider: ParentComponent = (props) => {
|
||||
setStore("parts", msg.id, reconcile(msg.parts, { key: "id" }))
|
||||
}
|
||||
}
|
||||
|
||||
const agent = resolveSessionAgent(messages, agentNames())
|
||||
if (agent) {
|
||||
setStore("agentSelections", sessionID, agent)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -857,6 +880,13 @@ export const SessionProvider: ParentComponent = (props) => {
|
||||
return [...msgs, message]
|
||||
})
|
||||
|
||||
if (message.role === "user") {
|
||||
const agent = message.agent?.trim()
|
||||
if (agent && agentNames().has(agent)) {
|
||||
setStore("agentSelections", message.sessionID, agent)
|
||||
}
|
||||
}
|
||||
|
||||
if (message.parts && message.parts.length > 0) {
|
||||
setStore("parts", message.id, message.parts)
|
||||
}
|
||||
@@ -1829,12 +1859,23 @@ export const SessionProvider: ParentComponent = (props) => {
|
||||
const statusText = createMemo<string | undefined>(() => {
|
||||
if (status() === "idle") return undefined
|
||||
const fallback = language.t("ui.sessionTurn.status.consideringNextSteps")
|
||||
const id = currentSessionID()
|
||||
const msgs = messages()
|
||||
for (let i = msgs.length - 1; i >= 0; i--) {
|
||||
if (msgs[i].role !== "assistant") continue
|
||||
const parts = getParts(msgs[i].id)
|
||||
if (parts.length === 0) break
|
||||
return computeStatus(parts[parts.length - 1], language.t) ?? fallback
|
||||
const raw = computeStatus(parts[parts.length - 1], language.t) ?? fallback
|
||||
// When delegating to a subagent and that subagent is blocked on a prompt,
|
||||
// replace the generic "Delegating work" label with a more informative one
|
||||
// so the user understands why nothing appears to be happening.
|
||||
if (raw === language.t("ui.sessionTurn.status.delegating")) {
|
||||
const scoped = scopedPermissions(id)
|
||||
if (scoped.length > 0) return language.t("ui.sessionTurn.status.delegatingWaitingPermission")
|
||||
const scopedQ = scopedQuestions(id)
|
||||
if (scopedQ.length > 0) return language.t("ui.sessionTurn.status.delegatingWaitingQuestion")
|
||||
}
|
||||
return raw
|
||||
}
|
||||
return fallback
|
||||
})
|
||||
|
||||
+6
@@ -935,6 +935,10 @@ export const dict = {
|
||||
"session.status.retrying": "...إعادة المحاولة (المحاولة {{ attempt }})… {{ message }}",
|
||||
"session.status.working": "...جارٍ العمل",
|
||||
|
||||
"ui.sessionTurn.cancel": "إلغاء",
|
||||
"ui.sessionTurn.status.thinking": "...جارٍ التفكير",
|
||||
"ui.sessionTurn.status.consideringNextSteps": "...جارٍ التفكير في الخطوات التالية",
|
||||
|
||||
"dialog.model.noProviders": "لا يوجد موفرون",
|
||||
|
||||
"prompt.placeholder.connecting": "جارٍ الاتصال بالخادم...",
|
||||
@@ -1247,6 +1251,7 @@ export const dict = {
|
||||
"migration.whatsNew.features.foundation.detail":
|
||||
"نواة صغيرة وفعالة واحدة عبر كل منتجات Kilo. تجربة مألوفة بغض النظر عن طريقة عملك.",
|
||||
"migration.whatsNew.blogLink": "اقرأ الإعلان الكامل",
|
||||
"migration.whatsNew.docsLink": "الجديد والأسئلة الشائعة",
|
||||
"migration.whatsNew.continue": "متابعة",
|
||||
|
||||
// Screen 2 — Migrate Settings
|
||||
@@ -1329,4 +1334,5 @@ export const dict = {
|
||||
"notifications.action.next": "التالي",
|
||||
"notifications.action.close": "إغلاق",
|
||||
"notifications.action.tryModel": "جرّب {{model}}",
|
||||
"notifications.action.tryModelGeneric": "جرّب النموذج",
|
||||
}
|
||||
|
||||
+6
@@ -943,6 +943,10 @@ export const dict = {
|
||||
"session.status.retrying": "Tentando novamente (tentativa {{ attempt }})… {{ message }}",
|
||||
"session.status.working": "Trabalhando…",
|
||||
|
||||
"ui.sessionTurn.cancel": "Cancelar",
|
||||
"ui.sessionTurn.status.thinking": "Pensando...",
|
||||
"ui.sessionTurn.status.consideringNextSteps": "Considerando próximos passos...",
|
||||
|
||||
"dialog.model.noProviders": "Nenhum provedor",
|
||||
|
||||
"prompt.placeholder.connecting": "Conectando ao servidor...",
|
||||
@@ -1276,6 +1280,7 @@ export const dict = {
|
||||
"migration.whatsNew.features.foundation.detail":
|
||||
"Um núcleo pequeno e eficiente em todos os produtos Kilo. Uma experiência familiar independentemente de como você escolha trabalhar.",
|
||||
"migration.whatsNew.blogLink": "Leia o anúncio completo",
|
||||
"migration.whatsNew.docsLink": "Novidades e perguntas frequentes",
|
||||
"migration.whatsNew.continue": "Continuar",
|
||||
|
||||
// Screen 2 — Migrate Settings
|
||||
@@ -1358,4 +1363,5 @@ export const dict = {
|
||||
"notifications.action.next": "Próximo",
|
||||
"notifications.action.close": "Fechar",
|
||||
"notifications.action.tryModel": "Experimentar {{model}}",
|
||||
"notifications.action.tryModelGeneric": "Experimentar modelo",
|
||||
}
|
||||
|
||||
+6
@@ -948,6 +948,10 @@ export const dict = {
|
||||
"session.status.retrying": "Ponovni pokušaj (pokušaj {{ attempt }})… {{ message }}",
|
||||
"session.status.working": "Radim…",
|
||||
|
||||
"ui.sessionTurn.cancel": "Otkaži",
|
||||
"ui.sessionTurn.status.thinking": "Razmišljam...",
|
||||
"ui.sessionTurn.status.consideringNextSteps": "Razmatram sljedeće korake...",
|
||||
|
||||
"dialog.model.noProviders": "Nema pružatelja",
|
||||
|
||||
"prompt.placeholder.connecting": "Povezivanje na server...",
|
||||
@@ -1272,6 +1276,7 @@ export const dict = {
|
||||
"migration.whatsNew.features.foundation.detail":
|
||||
"Jedno malo, efikasno jezgro kroz sve Kilo proizvode. Poznato iskustvo bez obzira na to kako odaberete raditi.",
|
||||
"migration.whatsNew.blogLink": "Pročitajte cijeli oglas",
|
||||
"migration.whatsNew.docsLink": "Novosti i često postavljana pitanja",
|
||||
"migration.whatsNew.continue": "Nastavi",
|
||||
|
||||
// Screen 2 — Migrate Settings
|
||||
@@ -1354,4 +1359,5 @@ export const dict = {
|
||||
"notifications.action.next": "Sljedeći",
|
||||
"notifications.action.close": "Zatvori",
|
||||
"notifications.action.tryModel": "Probaj {{model}}",
|
||||
"notifications.action.tryModelGeneric": "Probaj model",
|
||||
}
|
||||
|
||||
+6
@@ -941,6 +941,10 @@ export const dict = {
|
||||
"session.status.retrying": "Prøver igen (forsøg {{ attempt }})… {{ message }}",
|
||||
"session.status.working": "Arbejder…",
|
||||
|
||||
"ui.sessionTurn.cancel": "Annuller",
|
||||
"ui.sessionTurn.status.thinking": "Tænker...",
|
||||
"ui.sessionTurn.status.consideringNextSteps": "Overvejer næste trin...",
|
||||
|
||||
"dialog.model.noProviders": "Ingen udbydere",
|
||||
|
||||
"prompt.placeholder.connecting": "Opretter forbindelse til server...",
|
||||
@@ -1262,6 +1266,7 @@ export const dict = {
|
||||
"migration.whatsNew.features.foundation.detail":
|
||||
"Én lille, effektiv kerne på tværs af alle Kilo-produkter. En velkendt oplevelse uanset hvordan du vælger at arbejde.",
|
||||
"migration.whatsNew.blogLink": "Læs den fulde meddelelse",
|
||||
"migration.whatsNew.docsLink": "Nyheder og ofte stillede spørgsmål",
|
||||
"migration.whatsNew.continue": "Fortsæt",
|
||||
|
||||
// Screen 2 — Migrate Settings
|
||||
@@ -1345,4 +1350,5 @@ export const dict = {
|
||||
"notifications.action.next": "Næste",
|
||||
"notifications.action.close": "Luk",
|
||||
"notifications.action.tryModel": "Prøv {{model}}",
|
||||
"notifications.action.tryModelGeneric": "Prøv model",
|
||||
}
|
||||
|
||||
+6
@@ -953,6 +953,10 @@ export const dict = {
|
||||
"session.status.retrying": "Erneuter Versuch ({{ attempt }})… {{ message }}",
|
||||
"session.status.working": "Wird bearbeitet…",
|
||||
|
||||
"ui.sessionTurn.cancel": "Abbrechen",
|
||||
"ui.sessionTurn.status.thinking": "Denke nach...",
|
||||
"ui.sessionTurn.status.consideringNextSteps": "Überlege nächste Schritte...",
|
||||
|
||||
"dialog.model.noProviders": "Keine Anbieter",
|
||||
|
||||
"prompt.placeholder.connecting": "Verbindung zum Server wird hergestellt...",
|
||||
@@ -1289,6 +1293,7 @@ export const dict = {
|
||||
"migration.whatsNew.features.foundation.detail":
|
||||
"Ein kleiner, effizienter Kern über alle Kilo-Produkte hinweg. Ein vertrautes Erlebnis, egal wie Sie arbeiten.",
|
||||
"migration.whatsNew.blogLink": "Die vollständige Ankündigung lesen",
|
||||
"migration.whatsNew.docsLink": "Neuigkeiten & häufige Fragen",
|
||||
"migration.whatsNew.continue": "Weiter",
|
||||
|
||||
// Screen 2 — Migrate Settings
|
||||
@@ -1372,4 +1377,5 @@ export const dict = {
|
||||
"notifications.action.next": "Weiter",
|
||||
"notifications.action.close": "Schließen",
|
||||
"notifications.action.tryModel": "{{model}} ausprobieren",
|
||||
"notifications.action.tryModelGeneric": "Modell ausprobieren",
|
||||
} satisfies Partial<Record<Keys, string>>
|
||||
|
||||
@@ -941,6 +941,10 @@ export const dict = {
|
||||
"session.status.retrying": "Retrying (attempt {{ attempt }})… {{ message }}",
|
||||
"session.status.working": "Working...",
|
||||
|
||||
"ui.sessionTurn.cancel": "Cancel",
|
||||
"ui.sessionTurn.status.thinking": "Thinking...",
|
||||
"ui.sessionTurn.status.consideringNextSteps": "Considering next steps...",
|
||||
|
||||
"dialog.model.noProviders": "No providers",
|
||||
|
||||
"prompt.placeholder.connecting": "Connecting to server...",
|
||||
@@ -1171,7 +1175,8 @@ export const dict = {
|
||||
"settings.agentBehaviour.editMode.prompt": "System Prompt",
|
||||
"settings.agentBehaviour.editMode.save": "Done",
|
||||
"settings.agentBehaviour.editMode.back": "Back to list",
|
||||
"settings.agentBehaviour.editMode.native": "Built-in mode (read-only definition)",
|
||||
"settings.agentBehaviour.editMode.native":
|
||||
"This is a built-in mode. Its base definition cannot be changed, but you can configure overrides below.",
|
||||
"settings.agentBehaviour.editMode.promptOverride": "Custom prompt override for this built-in mode",
|
||||
|
||||
"settings.autoApprove.description":
|
||||
@@ -1272,6 +1277,7 @@ export const dict = {
|
||||
"migration.whatsNew.features.foundation.detail":
|
||||
"One small, efficient core across every Kilo product. A familiar experience however you choose to work.",
|
||||
"migration.whatsNew.blogLink": "Read the full announcement",
|
||||
"migration.whatsNew.docsLink": "What's new & FAQ",
|
||||
"migration.whatsNew.continue": "Continue",
|
||||
|
||||
// Screen 2 — Migrate Settings
|
||||
@@ -1355,4 +1361,5 @@ export const dict = {
|
||||
"notifications.action.next": "Next",
|
||||
"notifications.action.close": "Close",
|
||||
"notifications.action.tryModel": "Try {{model}}",
|
||||
"notifications.action.tryModelGeneric": "Try Model",
|
||||
}
|
||||
|
||||
+6
@@ -949,6 +949,10 @@ export const dict = {
|
||||
"session.status.retrying": "Reintentando (intento {{ attempt }})… {{ message }}",
|
||||
"session.status.working": "Trabajando…",
|
||||
|
||||
"ui.sessionTurn.cancel": "Cancelar",
|
||||
"ui.sessionTurn.status.thinking": "Pensando...",
|
||||
"ui.sessionTurn.status.consideringNextSteps": "Considerando siguientes pasos...",
|
||||
|
||||
"dialog.model.noProviders": "Sin proveedores",
|
||||
|
||||
"prompt.placeholder.connecting": "Conectando al servidor...",
|
||||
@@ -1279,6 +1283,7 @@ export const dict = {
|
||||
"migration.whatsNew.features.foundation.detail":
|
||||
"Un núcleo pequeño y eficiente en todos los productos Kilo. Una experiencia familiar sin importar cómo elijas trabajar.",
|
||||
"migration.whatsNew.blogLink": "Leer el anuncio completo",
|
||||
"migration.whatsNew.docsLink": "Novedades y preguntas frecuentes",
|
||||
"migration.whatsNew.continue": "Continuar",
|
||||
|
||||
// Screen 2 — Migrate Settings
|
||||
@@ -1361,4 +1366,5 @@ export const dict = {
|
||||
"notifications.action.next": "Siguiente",
|
||||
"notifications.action.close": "Cerrar",
|
||||
"notifications.action.tryModel": "Probar {{model}}",
|
||||
"notifications.action.tryModelGeneric": "Probar modelo",
|
||||
}
|
||||
|
||||
+6
@@ -955,6 +955,10 @@ export const dict = {
|
||||
"session.status.retrying": "Nouvelle tentative (essai {{ attempt }})… {{ message }}",
|
||||
"session.status.working": "En cours…",
|
||||
|
||||
"ui.sessionTurn.cancel": "Annuler",
|
||||
"ui.sessionTurn.status.thinking": "Réflexion...",
|
||||
"ui.sessionTurn.status.consideringNextSteps": "Envisage les prochaines étapes...",
|
||||
|
||||
"dialog.model.noProviders": "Aucun fournisseur",
|
||||
|
||||
"prompt.placeholder.connecting": "Connexion au serveur...",
|
||||
@@ -1292,6 +1296,7 @@ export const dict = {
|
||||
"migration.whatsNew.features.foundation.detail":
|
||||
"Un noyau petit et efficace partagé par tous les produits Kilo. Une expérience familière quelle que soit votre façon de travailler.",
|
||||
"migration.whatsNew.blogLink": "Lire l'annonce complète",
|
||||
"migration.whatsNew.docsLink": "Nouveautés & questions fréquentes",
|
||||
"migration.whatsNew.continue": "Continuer",
|
||||
|
||||
// Screen 2 — Migrate Settings
|
||||
@@ -1377,4 +1382,5 @@ export const dict = {
|
||||
"notifications.action.next": "Suivant",
|
||||
"notifications.action.close": "Fermer",
|
||||
"notifications.action.tryModel": "Essayer {{model}}",
|
||||
"notifications.action.tryModelGeneric": "Essayer le modèle",
|
||||
}
|
||||
|
||||
+6
@@ -940,6 +940,10 @@ export const dict = {
|
||||
"session.status.retrying": "再試行中({{ attempt }}回目)… {{ message }}",
|
||||
"session.status.working": "作業中…",
|
||||
|
||||
"ui.sessionTurn.cancel": "キャンセル",
|
||||
"ui.sessionTurn.status.thinking": "考え中...",
|
||||
"ui.sessionTurn.status.consideringNextSteps": "次のステップを検討中...",
|
||||
|
||||
"dialog.model.noProviders": "プロバイダーなし",
|
||||
|
||||
"prompt.placeholder.connecting": "サーバーに接続中...",
|
||||
@@ -1262,6 +1266,7 @@ export const dict = {
|
||||
"migration.whatsNew.features.foundation.detail":
|
||||
"すべてのKilo製品で共有される小さく効率的なコア。どのような作業スタイルでも馴染みのある体験を提供します。",
|
||||
"migration.whatsNew.blogLink": "完全なアナウンスを読む",
|
||||
"migration.whatsNew.docsLink": "新機能とよくある質問",
|
||||
"migration.whatsNew.continue": "続行",
|
||||
|
||||
// Screen 2 — Migrate Settings
|
||||
@@ -1344,4 +1349,5 @@ export const dict = {
|
||||
"notifications.action.next": "次へ",
|
||||
"notifications.action.close": "閉じる",
|
||||
"notifications.action.tryModel": "{{model}}を試す",
|
||||
"notifications.action.tryModelGeneric": "モデルを試す",
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user