Merge branch 'main' into docs/mcp-ask-permissions

This commit is contained in:
Joshua Lambert
2026-04-10 01:51:48 -04:00
committed by GitHub
439 changed files with 16138 additions and 16213 deletions
+4
View File
@@ -18,3 +18,7 @@ packages/kilo-vscode/tests/**/*.png filter=lfs diff=lfs merge=lfs -text
**/i18n/parity.test.ts linguist-generated=false
packages/kilo-i18n/src/*.ts linguist-generated=true
packages/kilo-i18n/src/en.ts linguist-generated=false
# Auto-generated CLI reference docs
packages/kilo-docs/markdoc/partials/cli-commands-table.md linguist-generated=true
packages/kilo-docs/pages/code-with-ai/platforms/cli-reference.md linguist-generated=true
@@ -0,0 +1,32 @@
name: Check opencode annotations
on:
pull_request:
paths:
- "packages/opencode/**"
- "script/check-opencode-annotations.ts"
- ".github/workflows/check-opencode-annotations.yml"
workflow_dispatch:
jobs:
check-annotations:
name: Check kilocode_change annotations
if: github.repository == 'Kilo-Org/kilocode'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0
- uses: oven-sh/setup-bun@v2
- name: Check kilocode_change annotations in shared opencode files
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
run: |
if [ -n "$BASE_SHA" ]; then
bun run script/check-opencode-annotations.ts --base "$BASE_SHA"
else
echo "No PR base SHA available (workflow_dispatch without PR context) — skipping."
fi
+3 -1
View File
@@ -23,7 +23,7 @@ on:
required: false
type: string
pre_release:
description: "Publish as pre-release (VS Code marketplace)"
description: "Publish as pre-release (VS Code marketplace + npm rc channel)"
required: false
type: boolean
default: false
@@ -58,6 +58,7 @@ jobs:
GH_REPO: ${{ github.repository }}
KILO_BUMP: ${{ inputs.bump }}
KILO_VERSION: ${{ inputs.version }}
KILO_PRE_RELEASE: ${{ inputs.pre_release }}
KILO_API_KEY: ${{ secrets.KILO_API_KEY }}
KILO_ORG_ID: ${{ secrets.KILO_ORG_ID }}
outputs:
@@ -83,6 +84,7 @@ jobs:
env:
KILO_VERSION: ${{ needs.version.outputs.version }}
KILO_RELEASE: ${{ needs.version.outputs.release }}
KILO_PRE_RELEASE: ${{ inputs.pre_release }}
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
+19
View File
@@ -10,6 +10,7 @@ Kilo CLI is an open source AI coding agent that generates code from natural lang
## Build and Dev
- **Dev**: `bun run dev` (runs from root) or `bun run --cwd packages/opencode --conditions=browser src/index.ts`
- **Dev with params**: `bun dev -- help`
- **Extension**: `bun run extension` (build + launch VS Code with the extension in dev mode). Pass `--no-build` to skip the build.
- **Typecheck**: `bun turbo typecheck` (uses `tsgo`, not `tsc`)
- **Test**: `bun test` from `packages/opencode/` (NOT from root -- root blocks tests)
@@ -18,6 +19,7 @@ Kilo CLI is an open source AI coding agent that generates code from natural lang
- **Knip** (unused exports): `bun run knip` from `packages/kilo-vscode/`. CI runs this — all exported types/functions must be imported somewhere. Remove or unexport unused exports before pushing.
- **Source links**: After adding or changing URLs in `packages/kilo-vscode/`, `packages/kilo-vscode/webview-ui/`, or `packages/opencode/src/`, run `bun run script/extract-source-links.ts` from the repo root and commit the updated `packages/kilo-docs/source-links.md`. CI runs this check — the build fails if the file is stale.
- **kilocode_change check**: `bun run check-kilocode-change` from `packages/kilo-vscode/`. CI runs this — `kilocode_change` is a marker for upstream merge conflicts and must not appear in `packages/kilo-vscode/` or `packages/kilo-ui/` (these are entirely Kilo Code additions). Remove the markers before pushing.
- **opencode annotation check**: `bun run script/check-opencode-annotations.ts` from repo root. CI runs this on PRs touching `packages/opencode/` — every Kilo-specific change in shared opencode files must be annotated with `kilocode_change` markers. Exempt paths (no markers needed): `packages/opencode/src/kilocode/`, `packages/opencode/test/kilocode/`, and any path containing `kilocode` in the name.
## Products
@@ -173,6 +175,8 @@ Tests MUST test actual implementation, do not duplicate logic into a test.
Kilo CLI is a fork of [opencode](https://github.com/anomalyco/opencode).
**Very important**: when planning or coding, update shared files with OpenCode as last resort! Everything is shared code from OpenCode, except folders that contain `kilo` in the name or have a parent directory that contains `kilo` in the name. Example of kilo specific folders: `packages/opencode/src/kilocode/` and `packages/kilo-docs/`. Always look for ways to implement your feature or fix in a way that minimizes changes to shared code.
### Minimizing Merge Conflicts
We regularly merge upstream changes from opencode. To minimize merge conflicts and keep the sync process smooth:
@@ -216,6 +220,21 @@ const bar = 2
// kilocode_change - new file
```
<!-- prettier-ignore -->
**JSX/TSX (inside JSX templates):**
<!-- prettier-ignore -->
```tsx
{/* kilocode_change */}
```
<!-- prettier-ignore -->
```tsx
{/* kilocode_change start */}
<MyComponent />
{/* kilocode_change end */}
```
#### When markers are NOT needed
Code in these paths is Kilo Code-specific and does NOT need `kilocode_change` markers:
-2
View File
@@ -72,12 +72,10 @@ During development, `bun dev` is the local equivalent of the built `kilo` comman
# Development (from project root)
bun dev --help # Show all available commands
bun dev serve # Start headless API server
bun dev web # Start server + open web interface
# Production
kilo --help # Show all available commands
kilo serve # Start headless API server
kilo web # Start server + open web interface
```
### Testing with a local backend
+2 -2
View File
@@ -18,7 +18,7 @@
- ⚡ Inline autocomplete suggestions
- 🤖 Latest AI models
- 🎁 API keys optional
- 💡 **Get $20 in bonus credits when you top-up for the first time** Credits can be used with 500+ models like Gemini 3.1 Pro, Claude 4.6 Sonnet & Opus, and GPT-5.2
- 💡 **Get $20 in bonus credits when you top-up for the first time** Credits can be used with 500+ models like Gemini 3.1 Pro, Claude 4.6 Sonnet & Opus, and GPT-5.4
## Quick Links
@@ -38,7 +38,7 @@
## Get Started in Visual Studio Code
1. Install the Kilo Code extension from the [VS Code Marketplace](https://marketplace.visualstudio.com/items?itemName=kilocode.Kilo-Code).
2. Create your account to access 500+ cutting-edge AI models including Gemini 3 Pro, Claude 4.5 Sonnet & Opus, and GPT-5 with transparent pricing that matches provider rates exactly.
2. Create your account to access 500+ cutting-edge AI models including Gemini 3.1 Pro, Claude 4.6 Sonnet & Opus, and GPT-5.4 with transparent pricing that matches provider rates exactly.
3. Start coding with AI that adapts to your workflow. Watch our quick-start guide to see Kilo in action:
<a href="https://youtu.be/pqGfYXgrhig"><img src="https://img.youtube.com/vi/pqGfYXgrhig/maxresdefault.jpg" alt="Watch the video" width="640" height="360"></a>
+17 -17
View File
@@ -1,6 +1,6 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"configVersion": 0,
"workspaces": {
"": {
"name": "@kilocode/kilo",
@@ -27,7 +27,7 @@
},
"packages/app": {
"name": "@opencode-ai/app",
"version": "7.1.23",
"version": "7.2.3",
"dependencies": {
"@kilocode/kilo-i18n": "workspace:*",
"@kilocode/kilo-ui": "workspace:*",
@@ -79,7 +79,7 @@
},
"packages/desktop": {
"name": "@opencode-ai/desktop",
"version": "7.1.23",
"version": "7.2.3",
"dependencies": {
"@opencode-ai/app": "workspace:*",
"@opencode-ai/ui": "workspace:*",
@@ -112,7 +112,7 @@
},
"packages/desktop-electron": {
"name": "@opencode-ai/desktop-electron",
"version": "7.1.23",
"version": "7.2.3",
"dependencies": {
"@opencode-ai/app": "workspace:*",
"@opencode-ai/ui": "workspace:*",
@@ -142,7 +142,7 @@
},
"packages/kilo-docs": {
"name": "@kilocode/kilo-docs",
"version": "7.1.23",
"version": "7.2.3",
"dependencies": {
"@docsearch/css": "^4",
"@docsearch/js": "^4",
@@ -171,7 +171,7 @@
},
"packages/kilo-gateway": {
"name": "@kilocode/kilo-gateway",
"version": "7.1.23",
"version": "7.2.3",
"dependencies": {
"@ai-sdk/anthropic": "2.0.65",
"@ai-sdk/openai": "2.0.101",
@@ -206,7 +206,7 @@
},
"packages/kilo-i18n": {
"name": "@kilocode/kilo-i18n",
"version": "7.1.23",
"version": "7.2.3",
"devDependencies": {
"@tsconfig/node22": "catalog:",
"@types/bun": "catalog:",
@@ -219,7 +219,7 @@
},
"packages/kilo-telemetry": {
"name": "@kilocode/kilo-telemetry",
"version": "7.1.23",
"version": "7.2.3",
"dependencies": {
"@kilocode/kilo-gateway": "workspace:*",
"@opentelemetry/api": "1.9.0",
@@ -239,7 +239,7 @@
},
"packages/kilo-ui": {
"name": "@kilocode/kilo-ui",
"version": "7.1.23",
"version": "7.2.3",
"dependencies": {
"@kobalte/core": "0.13.11",
"@opencode-ai/util": "workspace:*",
@@ -274,7 +274,7 @@
},
"packages/kilo-vscode": {
"name": "kilo-code",
"version": "7.1.23",
"version": "7.2.3",
"dependencies": {
"@anthropic-ai/sdk": "^0.39.0",
"@kilocode/kilo-i18n": "workspace:*",
@@ -327,7 +327,7 @@
},
"packages/opencode": {
"name": "@kilocode/cli",
"version": "7.1.23",
"version": "7.2.3",
"bin": {
"kilo": "./bin/kilo",
"kilocode": "./bin/kilo",
@@ -451,7 +451,7 @@
},
"packages/plugin": {
"name": "@kilocode/plugin",
"version": "7.1.23",
"version": "7.2.3",
"dependencies": {
"@kilocode/sdk": "workspace:*",
"zod": "catalog:",
@@ -465,14 +465,14 @@
},
"packages/script": {
"name": "@opencode-ai/script",
"version": "7.1.23",
"version": "7.2.3",
"devDependencies": {
"@types/bun": "catalog:",
},
},
"packages/sdk/js": {
"name": "@kilocode/sdk",
"version": "7.1.23",
"version": "7.2.3",
"devDependencies": {
"@hey-api/openapi-ts": "0.90.10",
"@tsconfig/node22": "catalog:",
@@ -483,7 +483,7 @@
},
"packages/storybook": {
"name": "@opencode-ai/storybook",
"version": "7.1.23",
"version": "7.2.3",
"devDependencies": {
"@opencode-ai/ui": "workspace:*",
"@solidjs/meta": "catalog:",
@@ -506,7 +506,7 @@
},
"packages/ui": {
"name": "@opencode-ai/ui",
"version": "7.1.23",
"version": "7.2.3",
"dependencies": {
"@kilocode/sdk": "workspace:*",
"@kobalte/core": "catalog:",
@@ -553,7 +553,7 @@
},
"packages/util": {
"name": "@opencode-ai/util",
"version": "7.1.23",
"version": "7.2.3",
"dependencies": {
"zod": "catalog:",
},
+1 -1
View File
@@ -122,6 +122,6 @@
"@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.23",
"version": "7.2.3",
"peerDependencies": {}
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/app",
"version": "7.1.23",
"version": "7.2.3",
"description": "",
"type": "module",
"exports": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/desktop-electron",
"private": true,
"version": "7.1.23",
"version": "7.2.3",
"type": "module",
"license": "MIT",
"homepage": "https://opencode.ai",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/desktop",
"private": true,
"version": "7.1.23",
"version": "7.2.3",
"type": "module",
"license": "MIT",
"scripts": {
+6 -6
View File
@@ -1,7 +1,7 @@
id = "kilo"
name = "Kilo"
description = "The open source coding agent."
version = "7.1.23"
version = "7.2.3"
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.23/opencode-darwin-arm64.zip"
archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.3/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.23/opencode-darwin-x64.zip"
archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.3/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.23/opencode-linux-arm64.tar.gz"
archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.3/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.23/opencode-linux-x64.tar.gz"
archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.3/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.23/opencode-windows-x64.zip"
archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.3/opencode-windows-x64.zip"
cmd = "./opencode.exe"
args = ["acp"]
+69 -10
View File
@@ -58,19 +58,19 @@ export function ThemeToggle() {
if (!mounted) {
return (
<button className="theme-toggle" aria-label="Toggle theme" style={{ width: "32px", height: "32px" }}>
<span style={{ opacity: 0 }}>🌙</span>
<span style={{ opacity: 0 }}><MoonIcon /></span>
</button>
)
}
const getIcon = () => {
if (theme === "system") {
return "💻"
return <SystemIcon />
}
if (theme === "dark") {
return "🌙"
return <MoonIcon />
}
return "☀️"
return <SunIcon />
}
const getLabel = () => {
@@ -86,7 +86,7 @@ export function ThemeToggle() {
return (
<>
<button onClick={cycleTheme} className="theme-toggle" aria-label={getLabel()} title={getLabel()}>
<span>{getIcon()}</span>
{getIcon()}
</button>
<style jsx>{`
.theme-toggle {
@@ -99,19 +99,78 @@ export function ThemeToggle() {
border: 1px solid var(--border-color);
border-radius: 6px;
background: var(--bg-secondary);
color: var(--text-color);
cursor: pointer;
font-size: 16px;
transition:
background-color 0.2s ease,
border-color 0.2s ease;
border-color 0.2s ease,
color 0.2s ease;
}
.theme-toggle:hover {
background: var(--border-color);
}
.theme-toggle span {
line-height: 1;
}
`}</style>
</>
)
}
function SunIcon() {
return (
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<circle cx="8" cy="8" r="3.5" />
<path d="M8 2.5V1" />
<path d="M8 15v-1.5" />
<path d="M11.889 4.111l.707-.707" />
<path d="M3.404 12.596l.707-.707" />
<path d="M13.5 8h1.5" />
<path d="M1 8h1.5" />
<path d="M11.889 11.889l.707.707" />
<path d="M3.404 3.404l.707.707" />
</svg>
)
}
function MoonIcon() {
return (
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M14 8.526A6 6 0 1 1 7.473 2 4.666 4.666 0 0 0 14 8.526z" />
</svg>
)
}
function SystemIcon() {
return (
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect x="2" y="3" width="12" height="8" rx="1" />
<path d="M5 13h6" />
<path d="M8 11v2" />
</svg>
)
}
+5 -5
View File
@@ -14,7 +14,11 @@ export const CodeWithAiNav: NavSection[] = [
href: "/code-with-ai/platforms/jetbrains",
children: "JetBrains Extension",
},
{ href: "/code-with-ai/platforms/cli", children: "CLI" },
{
href: "/code-with-ai/platforms/cli",
children: "CLI",
subLinks: [{ href: "/code-with-ai/platforms/cli-reference", children: "Command Reference" }],
},
{ href: "/code-with-ai/platforms/cloud-agent", children: "Cloud Agent" },
{ href: "/code-with-ai/platforms/mobile", children: "Mobile Apps" },
{ href: "/code-with-ai/platforms/slack", children: "Slack" },
@@ -45,10 +49,6 @@ export const CodeWithAiNav: NavSection[] = [
children: "Custom Models",
platform: "new",
},
{
href: "/code-with-ai/agents/free-and-budget-models",
children: "Free & Budget Models",
},
{
href: "/code-with-ai/agents/using-agents",
children: "Agents",
-1
View File
@@ -33,7 +33,6 @@
| Using Modes | `basic-usage/using-modes` |
| Orchestrator Mode | `basic-usage/orchestrator-mode` |
| Model Selection | `basic-usage/model-selection-guide` |
| Free & Budget Models | `advanced-usage/free-and-budget-models` |
| **Features** (subheader) | |
| Autocomplete | `basic-usage/autocomplete/index`, `basic-usage/autocomplete/mistral-setup` |
| Code Actions | `features/code-actions` |
@@ -0,0 +1,25 @@
<!-- Auto-generated by script/generate-cli-docs.ts — do not edit manually -->
| Command | Description |
| --- | --- |
| `kilo acp` | start ACP (Agent Client Protocol) server |
| `kilo mcp` | manage MCP (Model Context Protocol) servers |
| `kilo [project]` | start kilo tui |
| `kilo attach <url>` | attach to a running kilo server |
| `kilo run [message..]` | run kilo with a message |
| `kilo debug` | debugging and troubleshooting tools |
| `kilo auth` | manage credentials |
| `kilo agent` | manage agents |
| `kilo upgrade [target]` | upgrade kilo to the latest or a specific version |
| `kilo uninstall` | uninstall kilo and remove all related files |
| `kilo serve` | starts a headless kilo server |
| `kilo models [provider]` | list all available models |
| `kilo stats` | show token usage and cost statistics |
| `kilo export [sessionID]` | export session data as JSON |
| `kilo import <file>` | import session data from JSON file or URL |
| `kilo pr <number>` | fetch and checkout a GitHub PR branch, then run kilo |
| `kilo session` | manage sessions |
| `kilo remote` | enable remote connection for real-time session relay |
| `kilo db` | database tools |
| `kilo help [command]` | show full CLI reference |
| `kilo completion` | generate shell completion script |
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@kilocode/kilo-docs",
"version": "7.1.23",
"version": "7.2.3",
"private": true,
"scripts": {
"dev": "next dev --webpack --port 3002",
@@ -31,6 +31,17 @@ The panel opens as an editor tab and stays active across focus changes.
Each Agent Manager session runs in an isolated git worktree on a separate branch, keeping your main branch clean.
### PR Status Badges
Worktree items in the sidebar display a **PR status badge** when the branch has an associated pull request:
- **Open** — badge indicating the PR is open (its color can also reflect review and check status)
- **Merged** — purple badge indicating the PR has been merged
- **Closed** — red badge indicating the PR was closed without merging
- **Draft** — gray badge indicating the PR is in draft state
The badge appears on the right side of each worktree item and updates automatically via polling. If the worktree's branch doesn't have a PR yet, no badge is shown.
### Creating a New Worktree Session
1. Click **New Worktree** or press `Cmd+N` (macOS) / `Ctrl+N` (Windows/Linux) to create a new worktree
@@ -132,4 +132,4 @@ Auto Model is actively being improved. We'd love to hear how it's working for yo
- [Model Selection Guide](/docs/code-with-ai/agents/model-selection) - General guidance on choosing models
- [Using Agents](/docs/code-with-ai/agents/using-agents) - Learn about different Kilo Code agents
- [Free & Budget Models](/docs/code-with-ai/agents/free-and-budget-models) - Cost-effective alternatives
- [Using Kilo for Free](/docs/getting-started/using-kilo-for-free) - Cost-effective alternatives
@@ -1,290 +0,0 @@
---
title: Free and Budget Models
description: Learn how to use Kilo Code effectively while minimizing or eliminating costs through free models, budget-friendly alternatives, and smart usage strategies.
---
# Free and Budget Models
**Why this matters:** AI model costs can add up quickly during development. This guide shows you how to use Kilo Code effectively while minimizing or eliminating costs through free models, budget-friendly alternatives, and smart usage strategies.
## Completely Free Options
### Kilo Gateway Free Models
From time to time, Kilo works with AI inference providers to offer free models. These are available through the Kilo Gateway. Currently, we are offering these free models:
- **MiniMax M2.1 (free)** - A capable model from MiniMax with strong general-purpose performance.
- **Z.AI: GLM 4.7 (free)** - Latest variant of the GLM family, purpose-built for agent-centric applications.
- **MoonshotAI: Kimi K2.5 (free)** - Optimized for agentic capabilities, including advanced tool use, reasoning, and code synthesis.
- **Giga Potato (free)** - A stealth release model that is free in its evaluation period.
- **Arcee AI: Trinity Large Preview (free)** - A preview model from Arcee AI with strong capabilities.
### OpenRouter Free Tier Models
OpenRouter offers several models with generous free tiers. **Note:** You'll need to create a free OpenRouter account to access these models.
**Setup:**
1. Create a free [OpenRouter account](https://openrouter.ai)
2. Get your API key from the dashboard
3. Configure Kilo Code with the OpenRouter provider
**Available free models:**
- **Qwen3 Coder (free)** - Optimized for agentic coding tasks such as function calling, tool use, and long-context reasoning over repositories.
- **Z.AI: GLM 4.5 Air (free)** - Lightweight variant of the GLM-4.5 family, purpose-built for agent-centric applications.
- **DeepSeek: R1 0528 (free)** - Performance on par with OpenAI o1, but open-sourced and with fully open reasoning tokens.
- **MoonshotAI: Kimi K2 (free)** - Optimized for agentic capabilities, including advanced tool use, reasoning, and code synthesis.
## Cost-Effective Premium Models
When you need more capability than free models provide, these options deliver excellent value:
### Ultra-Budget Champions (Under $0.50 per million tokens)
**Mistral Devstral Small**
- **Cost:** ~$0.20 per million input tokens
- **Best for:** Code generation, debugging, refactoring
- **Performance:** 85% of premium model capability at 10% of the cost
**Llama 4 Maverick**
- **Cost:** ~$0.30 per million input tokens
- **Best for:** Complex reasoning, architecture planning
- **Performance:** Excellent for most development tasks
**DeepSeek v3**
- **Cost:** ~$0.27 per million input tokens
- **Best for:** Code analysis, large codebase understanding
- **Performance:** Strong technical reasoning
### Mid-Range Value Models ($0.50-$2.00 per million tokens)
**Qwen3 235B**
- **Cost:** ~$1.20 per million input tokens
- **Best for:** Complex projects requiring high accuracy
- **Performance:** Near-premium quality at 40% of the cost
## Smart Usage Strategies
### The 50% Rule
**Principle:** Use budget models for 50% of your tasks, premium models for the other 50%.
**Budget model tasks:**
- Code reviews and analysis
- Documentation writing
- Simple bug fixes
- Boilerplate generation
- Refactoring existing code
**Premium model tasks:**
- Complex architecture decisions
- Debugging difficult issues
- Performance optimization
- New feature design
- Critical production code
### Context Management for Cost Savings
**Minimize context size:**
```typescript
// Instead of mentioning entire files
@src/components/UserProfile.tsx
// Mention specific functions or sections
@src/components/UserProfile.tsx:45-67
```
**Reuse context effectively:**
- Keep key project notes in your repository (e.g., a AGENTS.md or docs folder)
- Reduces need to re-explain project details
- Saves tokens per conversation
**Strategic file mentions:**
- Only include files directly relevant to the task
- Use [`@folder/`](/docs/code-with-ai/agents/context-mentions) for broad context, specific files for targeted work
### Model Switching Strategies
**Start cheap, escalate when needed:**
1. **Begin with free models** (Qwen3 Coder, GLM-4.5-Air)
2. **Switch to budget models** if free models struggle
3. **Escalate to premium models** only for complex tasks
**Use API Configuration Profiles:**
- Set up [multiple profiles](/docs/ai-providers) for different cost tiers
- Quick switching between free, budget, and premium models
- Match model capability to task complexity
### Mode-Based Cost Optimization
**Use appropriate modes to limit expensive operations:**
- **[Ask Agent](/docs/code-with-ai/agents/using-agents#ask):** Information gathering without code changes
- **[Plan Agent](/docs/code-with-ai/agents/using-agents#plan):** Planning without expensive file operations
- **[Debug Agent](/docs/code-with-ai/agents/using-agents#debug):** Focused troubleshooting
**Custom modes for budget control:**
- Create modes that restrict expensive tools
- Limit file access to specific directories
- Control which operations are auto-approved
## Real-World Performance Comparisons
### Code Generation Tasks
**Simple function creation:**
- **Mistral Devstral Small:** 95% success rate
- **GPT-4:** 98% success rate
- **Cost difference:** Free vs $0.20 vs $30 per million tokens
**Complex refactoring:**
- **Budget models:** 70-80% success rate
- **Premium models:** 90-95% success rate
- **Recommendation:** Start with budget, escalate if needed
### Debugging Performance
**Simple bugs:**
- **Free models:** Usually sufficient
- **Budget models:** Excellent performance
- **Premium models:** Overkill for most cases
**Complex system issues:**
- **Free models:** 40-60% success rate
- **Budget models:** 60-80% success rate
- **Premium models:** 85-95% success rate
## Hybrid Approach Recommendations
### Daily Development Workflow
**Morning planning session:**
- Use **Architect mode** with **DeepSeek R1**
- Plan features and architecture
- Create task breakdowns
**Implementation phase:**
- Use **Code mode** with **budget models**
- Generate and modify code
- Handle routine development tasks
**Complex problem solving:**
- Switch to **premium models** when stuck
- Use for critical debugging
- Architecture decisions affecting multiple systems
### Project Phase Strategy
**Early development:**
- Free and budget models for prototyping
- Rapid iteration without cost concerns
- Establish patterns and structure
**Production preparation:**
- Premium models for critical code review
- Performance optimization
- Security considerations
## Cost Monitoring and Control
### Track Your Usage
**Monitor credit consumption:**
- Check cost estimates in chat history
- Review monthly usage patterns
- Identify high-cost operations
**Set spending limits:**
- Use provider billing alerts
- Configure [provider rate limits](/docs/ai-providers) to control usage
- Set daily/monthly budgets
### Cost-Saving Tips
**Reduce system prompt size:**
- [Disable MCP](/docs/automate/mcp/using-in-kilo-code) if not using external tools
- Use focused custom modes
- Minimize unnecessary context
**Optimize conversation length:**
- Use [Checkpoints](/docs/code-with-ai/features/checkpoints) to reset context
- Start fresh conversations for unrelated tasks
- Archive completed work
**Batch similar tasks:**
- Group related code changes
- Handle multiple files in single requests
- Reduce conversation overhead
## Getting Started with Budget Models
### Quick Setup Guide
1. **Create OpenRouter account** for free models
2. **Configure multiple providers** in Kilo Code
3. **Set up API Configuration Profiles** for easy switching
4. **Escalate to budget models** when needed
5. **Reserve premium models** for complex work
### Recommended Provider Mix
**Free tier foundation:**
- [OpenRouter](/docs/ai-providers/openrouter) - Free models
- [Groq](/docs/ai-providers/groq) - Fast inference for supported models
- [Z.ai](https://z.ai/model-api) - Provides a free model GLM-4.5-Flash
**Budget tier options:**
- [DeepSeek](/docs/ai-providers/deepseek) - Excellent value models
- [Mistral](/docs/ai-providers/mistral) - Specialized coding models
**Premium tier backup:**
- [Anthropic](/docs/ai-providers/anthropic) - Claude for complex reasoning
- [OpenAI](/docs/ai-providers/openai) - GPT-4 for critical tasks
## Measuring Success
**Track these metrics:**
- Monthly AI costs vs. development productivity
- Task completion rates by model tier
- Time saved vs. money spent
- Code quality improvements
**Success indicators:**
- 70%+ of tasks completed with free/budget models
- Monthly costs under your target budget
- Maintained or improved code quality
- Faster development cycles
By combining free models, strategic budget model usage, and smart optimization techniques, you can harness the full power of AI-assisted development while keeping costs minimal. Start with free options and gradually incorporate budget models as your needs and comfort with costs grow.
@@ -38,7 +38,6 @@ Kilo uses specialized agents to help with different tasks:
- [**Model Selection**](/docs/code-with-ai/agents/model-selection) — Choose the right AI model for each task
- [**Context Mentions**](/docs/code-with-ai/agents/context-mentions) — Reference files, functions, and symbols
- [**Orchestrator Mode**](/docs/code-with-ai/agents/orchestrator-mode) — Legacy orchestration (now built into all agents)
- [**Free & Budget Models**](/docs/code-with-ai/agents/free-and-budget-models) — Cost-effective AI options
## Features
@@ -0,0 +1,816 @@
---
title: "CLI Command Reference"
description: "Complete reference for all Kilo CLI commands and subcommands"
---
# CLI Command Reference
<!-- Auto-generated by script/generate-cli-docs.ts — do not edit manually -->
## kilo acp
```
start ACP (Agent Client Protocol) server
Options:
--help Show help [boolean]
--version Show version number [boolean]
--port port to listen on [number] [default: 0]
--hostname hostname to listen on [string] [default: "127.0.0.1"]
--mdns enable mDNS service discovery (defaults hostname to 0.0.0.0) [boolean] [default: false]
--mdns-domain custom domain name for mDNS service (default: kilo.local) [string] [default: "kilo.local"]
--cors additional domains to allow for CORS [array] [default: []]
--cwd working directory [string] [default: "."]
```
## kilo mcp
```
manage MCP (Model Context Protocol) servers
Commands:
kilo mcp add add an MCP server
kilo mcp list list MCP servers and their status [aliases: ls]
kilo mcp auth [name] authenticate with an OAuth-enabled MCP server
kilo mcp logout [name] remove OAuth credentials for an MCP server
kilo mcp debug <name> debug OAuth connection for an MCP server
Options:
--help Show help [boolean]
--version Show version number [boolean]
```
### kilo mcp add
```
add an MCP server
Options:
--help Show help [boolean]
--version Show version number [boolean]
```
### kilo mcp list
```
list MCP servers and their status
Options:
--help Show help [boolean]
--version Show version number [boolean]
```
### kilo mcp auth
```
authenticate with an OAuth-enabled MCP server
Commands:
kilo mcp auth list list OAuth-capable MCP servers and their auth status [aliases: ls]
Positionals:
name name of the MCP server [string]
Options:
--help Show help [boolean]
--version Show version number [boolean]
```
### kilo mcp auth list
```
list OAuth-capable MCP servers and their auth status
Options:
--help Show help [boolean]
--version Show version number [boolean]
```
### kilo mcp logout
```
remove OAuth credentials for an MCP server
Positionals:
name name of the MCP server [string]
Options:
--help Show help [boolean]
--version Show version number [boolean]
```
### kilo mcp debug
```
debug OAuth connection for an MCP server
Positionals:
name name of the MCP server [string]
Options:
--help Show help [boolean]
--version Show version number [boolean]
```
## kilo [project]
```
start kilo tui
Positionals:
project path to start kilo in [string]
Options:
--help Show help [boolean]
--version Show version number [boolean]
--port port to listen on [number] [default: 0]
--hostname hostname to listen on [string] [default: "127.0.0.1"]
--mdns enable mDNS service discovery (defaults hostname to 0.0.0.0) [boolean] [default: false]
--mdns-domain custom domain name for mDNS service (default: kilo.local) [string] [default: "kilo.local"]
--cors additional domains to allow for CORS [array] [default: []]
-m, --model model to use in the format of provider/model [string]
-c, --continue continue the last session [boolean]
-s, --session session id to continue [string]
--fork fork the session when continuing (use with --continue or --session) [boolean]
--cloud-fork fetch session from cloud and continue locally (use with --session) [boolean]
--prompt prompt to use [string]
--agent agent to use [string]
```
## kilo attach
```
attach to a running kilo server
Positionals:
url http://localhost:4096 [string]
Options:
--help Show help [boolean]
--version Show version number [boolean]
--dir directory to run in [string]
-c, --continue continue the last session [boolean]
-s, --session session id to continue [string]
--fork fork the session when continuing (use with --continue or --session) [boolean]
--cloud-fork fetch session from cloud and continue locally (use with --session) [boolean]
-p, --password basic auth password (defaults to KILO_SERVER_PASSWORD) [string]
```
## kilo run
```
run kilo with a message
Positionals:
message message to send [string] [default: []]
Options:
--help Show help [boolean]
--version Show version number [boolean]
--command the command to run, use message for args [string]
-c, --continue continue the last session [boolean]
-s, --session session id to continue [string]
--fork fork the session before continuing (requires --continue or --session) [boolean]
--cloud-fork fetch session from cloud and continue locally (requires --session) [boolean]
--share share the session [boolean]
-m, --model model to use in the format of provider/model [string]
--agent agent to use [string]
--format format: default (formatted) or json (raw JSON events) [string] [choices: "default", "json"] [default: "default"]
-f, --file file(s) to attach to message [array]
--title title for the session (uses truncated prompt if no value provided) [string]
--attach attach to a running opencode server (e.g., http://localhost:4096) [string]
-p, --password basic auth password (defaults to KILO_SERVER_PASSWORD) [string]
--dir directory to run in, path on remote server if attaching [string]
--port port for the local server (defaults to random port if no value provided) [number]
--variant model variant (provider-specific reasoning effort, e.g., high, max, minimal) [string]
--thinking show thinking blocks [boolean] [default: false]
--auto auto-approve all permissions (for autonomous/pipeline usage) [boolean] [default: false]
```
## kilo debug
```
debugging and troubleshooting tools
Commands:
kilo debug config show resolved configuration
kilo debug lsp LSP debugging utilities
kilo debug rg ripgrep debugging utilities
kilo debug file file system debugging utilities
kilo debug scrap list all known projects
kilo debug skill list all available skills
kilo debug snapshot snapshot debugging utilities
kilo debug agent <name> show agent configuration details
kilo debug paths show global paths (data, config, cache, state)
kilo debug wait wait indefinitely (for debugging)
Options:
--help Show help [boolean]
--version Show version number [boolean]
```
### kilo debug config
```
show resolved configuration
Options:
--help Show help [boolean]
--version Show version number [boolean]
```
### kilo debug lsp
```
LSP debugging utilities
Commands:
kilo debug lsp diagnostics <file> get diagnostics for a file
kilo debug lsp symbols <query> search workspace symbols
kilo debug lsp document-symbols <uri> get symbols from a document
Options:
--help Show help [boolean]
--version Show version number [boolean]
```
### kilo debug lsp diagnostics
```
get diagnostics for a file
Positionals:
file [string]
Options:
--help Show help [boolean]
--version Show version number [boolean]
```
### kilo debug lsp symbols
```
search workspace symbols
Positionals:
query [string]
Options:
--help Show help [boolean]
--version Show version number [boolean]
```
### kilo debug lsp document-symbols
```
get symbols from a document
Positionals:
uri [string]
Options:
--help Show help [boolean]
--version Show version number [boolean]
```
### kilo debug rg
```
ripgrep debugging utilities
Commands:
kilo debug rg tree show file tree using ripgrep
kilo debug rg files list files using ripgrep
kilo debug rg search <pattern> search file contents using ripgrep
Options:
--help Show help [boolean]
--version Show version number [boolean]
```
### kilo debug rg tree
```
show file tree using ripgrep
Options:
--help Show help [boolean]
--version Show version number [boolean]
--limit [number]
```
### kilo debug rg files
```
list files using ripgrep
Options:
--help Show help [boolean]
--version Show version number [boolean]
--query Filter files by query [string]
--glob Glob pattern to match files [string]
--limit Limit number of results [number]
```
### kilo debug rg search
```
search file contents using ripgrep
Positionals:
pattern Search pattern [string]
Options:
--help Show help [boolean]
--version Show version number [boolean]
--glob File glob patterns [array]
--limit Limit number of results [number]
```
### kilo debug file
```
file system debugging utilities
Commands:
kilo debug file read <path> read file contents as JSON
kilo debug file status show file status information
kilo debug file list <path> list files in a directory
kilo debug file search <query> search files by query
kilo debug file tree [dir] show directory tree
Options:
--help Show help [boolean]
--version Show version number [boolean]
```
### kilo debug file read
```
read file contents as JSON
Positionals:
path File path to read [string]
Options:
--help Show help [boolean]
--version Show version number [boolean]
```
### kilo debug file status
```
show file status information
Options:
--help Show help [boolean]
--version Show version number [boolean]
```
### kilo debug file list
```
list files in a directory
Positionals:
path File path to list [string]
Options:
--help Show help [boolean]
--version Show version number [boolean]
```
### kilo debug file search
```
search files by query
Positionals:
query Search query [string]
Options:
--help Show help [boolean]
--version Show version number [boolean]
```
### kilo debug file tree
```
show directory tree
Positionals:
dir Directory to tree [string] [default: "."]
Options:
--help Show help [boolean]
--version Show version number [boolean]
```
### kilo debug scrap
```
list all known projects
Options:
--help Show help [boolean]
--version Show version number [boolean]
```
### kilo debug skill
```
list all available skills
Options:
--help Show help [boolean]
--version Show version number [boolean]
```
### kilo debug snapshot
```
snapshot debugging utilities
Commands:
kilo debug snapshot track track current snapshot state
kilo debug snapshot patch <hash> show patch for a snapshot hash
kilo debug snapshot diff <hash> show diff for a snapshot hash
Options:
--help Show help [boolean]
--version Show version number [boolean]
```
### kilo debug snapshot track
```
track current snapshot state
Options:
--help Show help [boolean]
--version Show version number [boolean]
```
### kilo debug snapshot patch
```
show patch for a snapshot hash
Positionals:
hash hash [string]
Options:
--help Show help [boolean]
--version Show version number [boolean]
```
### kilo debug snapshot diff
```
show diff for a snapshot hash
Positionals:
hash hash [string]
Options:
--help Show help [boolean]
--version Show version number [boolean]
```
### kilo debug agent
```
show agent configuration details
Positionals:
name Agent name [string]
Options:
--help Show help [boolean]
--version Show version number [boolean]
--tool Tool id to execute [string]
--params Tool params as JSON or a JS object literal [string]
```
### kilo debug paths
```
show global paths (data, config, cache, state)
Options:
--help Show help [boolean]
--version Show version number [boolean]
```
### kilo debug wait
```
wait indefinitely (for debugging)
Options:
--help Show help [boolean]
--version Show version number [boolean]
```
## kilo auth
```
manage credentials
Commands:
kilo auth login [url] log in to a provider
kilo auth logout log out from a configured provider
kilo auth list list providers [aliases: ls]
Options:
--help Show help [boolean]
--version Show version number [boolean]
```
### kilo auth login
```
log in to a provider
Positionals:
url kilo auth provider [string]
Options:
--help Show help [boolean]
--version Show version number [boolean]
-p, --provider provider id or name to log in to (skips provider selection) [string]
-m, --method login method label (skips method selection) [string]
```
### kilo auth logout
```
log out from a configured provider
Options:
--help Show help [boolean]
--version Show version number [boolean]
```
### kilo auth list
```
list providers
Options:
--help Show help [boolean]
--version Show version number [boolean]
```
## kilo agent
```
manage agents
Commands:
kilo agent create create a new agent
kilo agent list list all available agents
Options:
--help Show help [boolean]
--version Show version number [boolean]
```
### kilo agent create
```
create a new agent
Options:
--help Show help [boolean]
--version Show version number [boolean]
--path directory path to generate the agent file [string]
--description what the agent should do [string]
--mode agent mode [string] [choices: "all", "primary", "subagent"]
--tools comma-separated list of tools to enable (default: all). Available: "bash, read, write, edit, list, glob, grep, webfetch, task, todowrite, todoread" [string]
-m, --model model to use in the format of provider/model [string]
```
### kilo agent list
```
list all available agents
Options:
--help Show help [boolean]
--version Show version number [boolean]
```
## kilo upgrade
```
upgrade kilo to the latest or a specific version
Positionals:
target version to upgrade to, for ex '0.1.48' or 'v0.1.48' [string]
Options:
--help Show help [boolean]
--version Show version number [boolean]
-m, --method installation method to use [string] [choices: "curl", "npm", "pnpm", "bun", "brew", "choco", "scoop"]
```
## kilo uninstall
```
uninstall kilo and remove all related files
Options:
--help Show help [boolean]
--version Show version number [boolean]
-c, --keep-config keep configuration files [boolean] [default: false]
-d, --keep-data keep session data and snapshots [boolean] [default: false]
--dry-run show what would be removed without removing [boolean] [default: false]
-f, --force skip confirmation prompts [boolean] [default: false]
```
## kilo serve
```
starts a headless kilo server
Options:
--help Show help [boolean]
--version Show version number [boolean]
--port port to listen on [number] [default: 0]
--hostname hostname to listen on [string] [default: "127.0.0.1"]
--mdns enable mDNS service discovery (defaults hostname to 0.0.0.0) [boolean] [default: false]
--mdns-domain custom domain name for mDNS service (default: kilo.local) [string] [default: "kilo.local"]
--cors additional domains to allow for CORS [array] [default: []]
```
## kilo models
```
list all available models
Positionals:
provider provider ID to filter models by [string]
Options:
--help Show help [boolean]
--version Show version number [boolean]
--verbose use more verbose model output (includes metadata like costs) [boolean]
--refresh refresh the models cache from models.dev [boolean]
```
## kilo stats
```
show token usage and cost statistics
Options:
--help Show help [boolean]
--version Show version number [boolean]
--days show stats for the last N days (default: all time) [number]
--tools number of tools to show (default: all) [number]
--models show model statistics (default: hidden). Pass a number to show top N, otherwise shows all
--project filter by project (default: all projects, empty string: current project) [string]
```
## kilo export
```
export session data as JSON
Positionals:
sessionID session id to export [string]
Options:
--help Show help [boolean]
--version Show version number [boolean]
```
## kilo import
```
import session data from JSON file or URL
Positionals:
file path to JSON file or share URL [string]
Options:
--help Show help [boolean]
--version Show version number [boolean]
```
## kilo pr
```
fetch and checkout a GitHub PR branch, then run kilo
Positionals:
number PR number to checkout [number]
Options:
--help Show help [boolean]
--version Show version number [boolean]
```
## kilo session
```
manage sessions
Commands:
kilo session list list sessions
kilo session delete <sessionID> delete a session
Options:
--help Show help [boolean]
--version Show version number [boolean]
```
### kilo session list
```
list sessions
Options:
--help Show help [boolean]
--version Show version number [boolean]
-n, --max-count limit to N most recent sessions [number]
--format output format [string] [choices: "table", "json"] [default: "table"]
```
### kilo session delete
```
delete a session
Positionals:
sessionID session ID to delete [string]
Options:
--help Show help [boolean]
--version Show version number [boolean]
```
## kilo remote
```
enable remote connection for real-time session relay
Options:
--help Show help [boolean]
--version Show version number [boolean]
```
## kilo db
```
database tools
Commands:
kilo db [query] open an interactive sqlite3 shell or run a query [default]
kilo db path print the database path
kilo db migrate migrate JSON data to SQLite (merges with existing data)
Positionals:
query SQL query to execute [string]
Options:
--help Show help [boolean]
--version Show version number [boolean]
--format Output format [string] [choices: "json", "tsv"] [default: "tsv"]
```
### kilo db path
```
print the database path
Options:
--help Show help [boolean]
--version Show version number [boolean]
```
### kilo db migrate
```
migrate JSON data to SQLite (merges with existing data)
Options:
--help Show help [boolean]
--version Show version number [boolean]
```
## kilo help
```
show full CLI reference
Positionals:
command command to show help for [string]
Options:
--help Show help [boolean]
--version Show version number [boolean]
--all show help for all commands [boolean] [default: false]
--format output format [string] [choices: "md", "text"] [default: "md"]
```
@@ -61,27 +61,9 @@ Or use npm:
### Top-Level CLI Commands
| Command | Description |
| ------------------------- | ------------------------------------------ |
| `kilo [project]` | Start the TUI (Terminal User Interface) |
| `kilo run [message..]` | Run with a message (non-interactive mode) |
| `kilo attach <url>` | Attach to a running kilo server |
| `kilo serve` | Start a headless server |
| `kilo web` | Start server and open web interface |
| `kilo auth` | Manage credentials (login, logout, list) |
| `kilo agent` | Manage agents (create, list) |
| `kilo mcp` | Manage MCP servers (list, add, auth) |
| `kilo models [provider]` | List available models |
| `kilo stats` | Show token usage and cost statistics |
| `kilo session` | Manage sessions (list) |
| `kilo export [sessionID]` | Export session data as JSON |
| `kilo import <file>` | Import session data from JSON file or URL |
| `kilo upgrade [target]` | Upgrade kilo to latest or specific version |
| `kilo uninstall` | Uninstall kilo and remove related files |
| `kilo pr <number>` | Fetch and checkout a GitHub PR branch |
| `kilo github` | Manage GitHub agent (install, run) |
| `kilo debug` | Debugging and troubleshooting tools |
| `kilo completion` | Generate shell completion script |
{% partial file="cli-commands-table.md" /%}
For detailed help on every command and subcommand, see the [CLI Command Reference](/docs/code-with-ai/platforms/cli-reference).
### Global Options
@@ -139,10 +121,11 @@ Or use npm:
#### Kilo Gateway Commands (when connected)
| Command | Aliases | Description |
| ---------- | ------------------------ | --------------------------------- |
| `/profile` | `/me`, `/whoami` | View your Kilo Gateway profile |
| `/teams` | `/team`, `/org`, `/orgs` | Switch between Kilo Gateway teams |
| Command | Aliases | Description |
| ---------- | ------------------------ | ----------------------------------------- |
| `/profile` | `/me`, `/whoami` | View your Kilo Gateway profile |
| `/teams` | `/team`, `/org`, `/orgs` | Switch between Kilo Gateway teams |
| `/remote` | - | Toggle remote mode for Cloud Agent access |
#### Built-in Commands
@@ -460,6 +443,44 @@ kilo --continue
- Cannot be used with a prompt argument
- Only works when there's at least one previous session in the workspace
## Remote Connections
Remote Connections let you access your local CLI sessions from the Cloud Agents web interface. Requires [Kilo Gateway](/docs/gateway) connection.
### Enabling Remote Mode
**Toggle during a session:**
```
/remote
```
Requires connection to Kilo Gateway. The `/remote` command appears only when authenticated.
**Enable by default:**
Add to `~/.config/kilo/config.json`:
```json
{
"remote_control": true
}
```
### Using Remote Mode
Once enabled, start a CLI session and open [Cloud Agents](https://app.kilo.ai/cloud). Your local session appears in the dashboard. See [Cloud Agent Remote Connections](/docs/code-with-ai/platforms/cloud-agent#remote-connections) for details.
### Requirements
- Connection to Kilo Gateway
- Same Kilo account on CLI and Cloud Agent
- CLI must remain running with internet connection
{% callout type="warning" title="Security Warning" %}
Anyone with access to your Kilo account can send messages to your computer when remote mode is enabled.
{% /callout %}
## Environment Variable Overrides
The CLI supports overriding config values with environment variables. The supported environment variables are:
@@ -482,6 +503,6 @@ Your selection is persisted locally so it carries over to future sessions.
There is no `--org` or `--team` flag on `kilo run`. Instead, the organization is determined from the following sources, in order of priority (highest first):
1. **`KILO_ORG_ID` environment variable** — Best for non-interactive and CI environments.
1. **`KILO_ORG_ID` environment variable** — Best for non-interactive and CI environments.
2. **`Persisted selection from the last `/teams` pick`** — If you've run an interactive session and selected an organization via `/teams`, that selection is stored in the CLI auth file and reused automatically.
@@ -102,6 +102,33 @@ Cloud Agents support project-level [skills](/docs/code-with-ai/platforms/cli#ski
Global skills (`~/.kilocode/skills/`) are not available in Cloud Agents since there is no persistent user home directory.
{% /callout %}
## Remote Connections
Remote Connections let you access and control local CLI sessions from the Cloud Agents web interface. Your computer handles the compute; the cloud gives you a window into it from any device.
### How It Works
When remote mode is enabled in the CLI, your active local sessions appear in the Cloud Agents dashboard alongside cloud sessions. The connection is two-way:
- **Messages and responses** sync in real-time
- **Agent questions** appear in both places — answer wherever you are
- **Permission requests** route to your active connection
- **Full editing capabilities** work remotely
### Enabling Remote Mode
Remote mode must be enabled from the CLI. See [CLI Remote Connections](/docs/code-with-ai/platforms/cli#remote-connections) for setup instructions.
### Requirements
- Same Kilo account on both CLI and Cloud Agent
- Active internet connection on the local machine
- CLI must remain running
{% callout type="warning" title="Security Warning" %}
Anyone with access to your Kilo account can send messages to your computer when remote mode is enabled.
{% /callout %}
## Perfect For
Cloud Agents are great for:
@@ -37,7 +37,13 @@ See [Auto-Approving Actions](/docs/getting-started/settings/auto-approving-actio
### Is the context progress graph still available?
The context progress graph will be [added soon](https://github.com/Kilo-Org/kilocode/issues/8210) for users who like to see it.
Yes — the context progress graph (also known as the task timeline) is now available. It appears at the top of the chat panel and shows:
- **Timeline bars** — colored bars representing session activity (different colors for read, write, tool, error, and text parts)
- **Context window progress** — a three-segment bar showing used, reserved, and available tokens, with a visual indicator when usage exceeds 50%
- **Token breakdown** — input, output, cache writes, and cache reads display
You can expand or collapse the graph — your preference is saved in the `kilo-code.new.showTaskTimeline` setting.
### I like to closely monitor and approve the behavior of the agent. How can I do that better in the new version?
@@ -1,92 +1,77 @@
---
title: "Using Kilo for Free"
description: "Learn how to use Kilo Code without spending money by configuring free models for agentic tasks, autocomplete, and CLI background tasks"
description: "How to use Kilo Code for free — Auto Model Free, finding free models, free autocomplete, and free background tasks"
---
# Using Kilo for Free
Kilo Code can be used completely free of charge, but you need to understand where Kilo uses AI models and configure each one appropriately.
Kilo Code can be used completely free of charge. There are three places where Kilo uses AI model inference, and each can be configured to use free models.
## When Kilo Uses Model Inference
## Where Kilo Uses Models
Kilo uses AI model inference in three places:
1. **Agentic interactions** — Conversations with coding agents in IDE extensions (VS Code, JetBrains), CLI, and cloud services like App Builder and Code Reviewer
2. **Autocomplete** — In-editor code completions as you type (IDE extensions only)
3. **Background tasks** — Automatic session titles and context summarization
1. **Agentic interactions** - Coding assistant conversations in IDE extensions (VS Code, JetBrains), CLI, and cloud services like App Builder and Code Reviewer
2. **Autocomplete** - In-editor code completions as you type (IDE extensions only)
3. **CLI Background tasks** - Automatic session titles and context summarization (CLI only)
Each of these can consume credits by default. **For a completely free Kilo experience, you must configure all three to use free models.**
Each of these consumes credits by default. **To use Kilo entirely for free, configure all three to use free models.**
## Free Agentic Usage
Kilo Code provides access to [free models](/docs/code-with-ai/agents/free-and-budget-models) for your coding tasks through the Kilo Gateway and partner providers.
Kilo provides free models for coding tasks through the Kilo Gateway and partner providers.
### Finding Free Models
### Auto Model Free
Free models are clearly labeled in the model picker across all Kilo platforms. To find and use them:
The easiest way to get started is [**Auto Model Free**](/docs/code-with-ai/agents/auto-model) (`kilo-auto/free`). This is a Kilo-provided model tier that automatically routes your requests to the best available free models — no configuration needed.
### Finding Other Free Models
You can also browse and select individual free models. In the model picker, type `free` to filter the list — free models are clearly labeled across all platforms.
**In the IDE Extensions (VS Code, JetBrains):**
1. Click on the current model below the chat window
2. Browse the model list—free models are labeled as "(free)"
3. Select your preferred free model
2. Type `free` in the search box
3. Select any model labeled "(free)"
**In the CLI:**
1. Open the CLI by running `kilo`
2. Use the `/models` command to browse available models
3. Free models are labeled as "free"
4. Select a free model for your tasks
1. Run `kilo` to open the CLI
2. Use the `/models` command
3. Type `free` to filter the list
### Free Models for Cloud Tasks
{% callout type="note" %}
Some free models may be rate limited by the upstream provider. If you hit a rate limit, try switching to a different free model.
{% /callout %}
Kilo's cloud services—including App Builder, Code Reviewer, and other cloud-based features—also support free models. When configuring a cloud task:
### Cloud Tasks
1. Look for the model selection dropdown
2. Free models are labeled as "(free)" in the dropdown
3. Select any free model to avoid using credits
Kilo's cloud services — App Builder, Code Reviewer, and others — also support free models. Select any model labeled "(free)" in the model dropdown when configuring a cloud task.
{% callout type="tip" %}
The available free models change over time as Kilo partners with different AI inference providers. Check our [free and budget models guide](/docs/code-with-ai/agents/free-and-budget-models) for the latest options, and subscribe to our blog or join our Discord for updates.
Available free models change over time as Kilo partners with different inference providers. Subscribe to our blog or join our [Discord](https://kilo.ai/discord) for updates.
{% /callout %}
## Free Autocomplete
Kilo Code's autocomplete feature provides AI-powered code completions as you type in the IDE extensions.
Kilo's autocomplete feature provides AI-powered code completions as you type in IDE extensions.
### Default Behavior
By default, autocomplete is routed through the Kilo Code provider and uses credits from your account.
### If You Don't Have Credits
If you run out of credits and haven't configured a free alternative, autocomplete will stop working. Your main coding workflow won't be affected -- you just won't get AI-powered completions.
By default, autocomplete routes through the Kilo provider and uses credits. If you run out of credits without a free alternative configured, autocomplete stops working — but your main coding workflow is unaffected.
### How to Get It Free
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.
Add your own Mistral AI (Codestral) API key via **BYOK (Bring Your Own Key)** on the Kilo Gateway. Mistral offers a free tier for Codestral. When you configure a BYOK key, autocomplete requests use your key directly — at no cost on your Kilo balance.
For step-by-step instructions, see our [Mistral Setup Guide](/docs/code-with-ai/features/autocomplete/mistral-setup).
See the [Mistral Setup Guide](/docs/code-with-ai/features/autocomplete/mistral-setup) for step-by-step instructions.
## Free CLI Background Tasks
## Free Background Tasks
The Kilo CLI uses AI in the background for quality-of-life features that enhance your experience like context compression and titling sessions.
Kilo uses a small model in the background for tasks like session titling. By default this is Kilo Auto Small, which consumes credits. If the small model is unavailable, Kilo falls back to your primary model — which may also consume credits if it's a paid model.
### Default Behavior
To avoid credit usage for background tasks, set the small model to a free model:
By default, CLI background tasks use `gpt-5-nano`, which consumes credits.
**In the VS Code extension:** Go to **Settings → Models** and change the small model to any free model.
### If You Don't Have Credits
Background tasks degrade gracefully when you don't have credits:
- **Session titles** fall back to truncating your first message instead of generating a smart summary
- **Context management** uses simple truncation instead of intelligent summarization
- **Your main workflow continues uninterrupted** - these are convenience features, not requirements
### How to Get It Free
Configure the `small_model` parameter in `~/.config/kilo/config.json` to use a free model:
**In the CLI:** Set the `small_model` parameter in `~/.config/kilo/config.json`:
```json
{
@@ -94,11 +79,11 @@ Configure the `small_model` parameter in `~/.config/kilo/config.json` to use a f
}
```
Replace `your-preferred-free-model` with any free model available in the model picker.
Replace `your-preferred-free-model` with any free model from the model picker.
## 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 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
- [Auto Model](/docs/code-with-ai/agents/auto-model) — Smart model routing including the free tier
- [Mistral Setup Guide](/docs/code-with-ai/features/autocomplete/mistral-setup) — Free autocomplete via BYOK
- [Autocomplete](/docs/code-with-ai/features/autocomplete) Full autocomplete documentation
- [CLI Documentation](/docs/code-with-ai/platforms/cli) Complete CLI reference
@@ -47,10 +47,7 @@ You need two tokens from Slack:
## Step 4: Pair Slack with KiloClaw
1. In Slack, DM the app and type your slash command (e.g., `/claw`) followed by anything — this triggers the pairing flow
> 📝 **Note**
> The slash command is whatever you defined in the manifest. Any text after the command will work to trigger pairing.
1. In Slack, DM the app and send any message — this triggers the pairing flow
2. The app will return a pairing code
3. Return to [app.kilocode.ai/claw](https://app.kilocode.ai/claw) and confirm the pairing code and approve
@@ -691,7 +691,13 @@ module.exports = [
},
{
source: "/docs/advanced-usage/free-and-budget-models",
destination: "/docs/code-with-ai/agents/free-and-budget-models",
destination: "/docs/getting-started/using-kilo-for-free",
basePath: false,
permanent: true,
},
{
source: "/docs/code-with-ai/agents/free-and-budget-models",
destination: "/docs/getting-started/using-kilo-for-free",
basePath: false,
permanent: true,
},
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@kilocode/kilo-gateway",
"version": "7.1.23",
"version": "7.2.3",
"type": "module",
"license": "MIT",
"description": "Unified Kilo Gateway package for OpenCode - authentication, provider, and API integration",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@kilocode/kilo-i18n",
"version": "7.1.23",
"version": "7.2.3",
"type": "module",
"license": "MIT",
"description": "Kilo-specific i18n translations and overrides",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@kilocode/kilo-telemetry",
"version": "7.1.23",
"version": "7.2.3",
"type": "module",
"license": "MIT",
"description": "Telemetry for Kilo CLI - PostHog analytics integration",
+3
View File
@@ -25,6 +25,9 @@ export enum TelemetryEvent {
MCP_SERVER_CONNECTED = "MCP Server Connected",
MCP_SERVER_ERROR = "MCP Server Error",
// Remote Events
REMOTE_CONNECTION_OPENED = "Remote Connection Opened",
// Auth Events
AUTH_SUCCESS = "Auth Success",
AUTH_LOGOUT = "Auth Logout",
+5
View File
@@ -184,6 +184,11 @@ export namespace Telemetry {
track(TelemetryEvent.MCP_SERVER_ERROR, { server, error })
}
// Remote
export function trackRemoteConnectionOpened() {
track(TelemetryEvent.REMOTE_CONNECTION_OPENED)
}
// Auth
export function trackAuthSuccess(provider: string) {
track(TelemetryEvent.AUTH_SUCCESS, { provider })
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@kilocode/kilo-ui",
"version": "7.1.23",
"version": "7.2.3",
"type": "module",
"license": "MIT",
"exports": {
+19
View File
@@ -142,6 +142,24 @@ export function Diff<T>(props: DiffProps<T>) {
host.removeAttribute("data-color-scheme")
}
// Patch a bug in @pierre/diffs where `grid-template-columns: 100% auto` is set
// for `line-info-basic` separators under `@media (pointer: fine)`, causing the
// expand button to consume 100% of the gutter width and overlap the separator
// content text. We inject into `@layer unsafe` which overrides `@layer base`.
let separatorPatchSheet: CSSStyleSheet | null = null
const patchSeparatorLayout = () => {
const root = getRoot()
if (!root) return
if (!separatorPatchSheet) {
separatorPatchSheet = new CSSStyleSheet()
separatorPatchSheet.replaceSync(
`@layer unsafe { @media (pointer: fine) { [data-separator='line-info-basic'][data-expand-index] [data-separator-wrapper] { grid-template-columns: 34px auto; } } }`,
)
}
if (!root.adoptedStyleSheets.includes(separatorPatchSheet))
root.adoptedStyleSheets = [...root.adoptedStyleSheets, separatorPatchSheet]
}
const lineIndex = (split: boolean, element: HTMLElement) => {
const raw = element.dataset.lineIndex
if (!raw) return
@@ -576,6 +594,7 @@ export function Diff<T>(props: DiffProps<T>) {
})
applyScheme()
patchSeparatorLayout()
setRendered((value) => value + 1)
notifyRendered()
@@ -19,6 +19,18 @@
height: 20px;
}
}
[data-slot="assistant-copy-wrapper"] {
display: flex;
align-items: center;
justify-content: flex-start;
margin-top: 2px;
[data-component="icon-button"] {
width: 20px;
height: 20px;
}
}
}
/* Prevent long title/path from hiding the collapsible expand arrow */
@@ -949,6 +949,7 @@ export function Part(props: MessagePartProps) {
export interface ToolProps {
input: Record<string, any>
metadata: Record<string, any>
partMetadata?: Record<string, any>
tool: string
partID?: string
callID?: string
@@ -1060,7 +1061,8 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) {
const input = () => part.state?.input ?? emptyInput
// @ts-expect-error
const partMetadata = () => part.state?.metadata ?? emptyMetadata
const meta = () => part.state?.metadata ?? emptyMetadata
const top = () => part.metadata ?? emptyMetadata
const render = createMemo(() => ToolRegistry.render(part.tool) ?? McpTool)
@@ -1122,7 +1124,8 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) {
tool={part.tool}
partID={part.id}
callID={part.callID}
metadata={partMetadata()}
metadata={meta()}
partMetadata={top()}
// @ts-expect-error
output={part.state.output}
status={part.state.status}
@@ -1155,6 +1158,7 @@ PART_MAPPING["compaction"] = function CompactionPartDisplay() {
PART_MAPPING["text"] = function TextPartDisplay(props) {
const data = useData()
const i18n = useI18n()
const part = () => props.part as TextPart
const displayText = () => (part().text ?? "").trim()
@@ -1166,6 +1170,21 @@ PART_MAPPING["text"] = function TextPartDisplay(props) {
return props.turnDiffSummary
})
const showCopy = createMemo(() => {
if (props.message.role !== "assistant") return false
if (props.showAssistantCopyPartID === null) return false
return props.showAssistantCopyPartID === part().id
})
const [copied, setCopied] = createSignal(false)
const handleCopy = async () => {
const content = displayText()
if (!content) return
await navigator.clipboard.writeText(content)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
const handleMarkdownClick = (e: MouseEvent) => {
if (!data.openFile) return
const target = e.target
@@ -1200,6 +1219,24 @@ PART_MAPPING["text"] = function TextPartDisplay(props) {
<div data-slot="text-part-body">
<Markdown text={throttledText()} cacheKey={part().id} onClick={handleMarkdownClick} />
</div>
<Show when={showCopy()}>
<div data-slot="assistant-copy-wrapper">
<Tooltip
value={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copyResponse")}
placement="right"
gutter={4}
>
<IconButton
icon={copied() ? "check" : "copy"}
size="normal"
variant="ghost"
onMouseDown={(e) => e.preventDefault()}
onClick={handleCopy}
aria-label={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copyResponse")}
/>
</Tooltip>
</div>
</Show>
<Show when={summary()}>
{(render) => (
<GrowBox animate={!!props.animate} fade gap={4} class="w-full min-w-0">
@@ -1220,6 +1257,9 @@ const streamed = new Set<string>()
// Tracks parts that have already been auto-collapsed once, so component
// recreation (from store updates while other parts stream) won't collapse again.
const autocollapsed = new Set<string>()
// Tracks parts that the user has explicitly opened, so auto-collapse won't
// override the user's intent when reasoning finishes or a tool call starts.
const userOpened = new Set<string>()
// Overrides upstream flat markdown render with streaming reasoning block + auto-collapse.
// Also filters encrypted reasoning data from OpenRouter that appears as [REDACTED].
@@ -1249,14 +1289,24 @@ PART_MAPPING["reasoning"] = function ReasoningPartDisplay(props: MessagePartProp
// Streaming → open. Just finished (was streaming, now done) → open briefly
// then collapse. Historical → collapsed from the start.
const [open, setOpen] = createSignal(!done() || was)
// Restore user's explicit open preference across component recreations.
const [open, setOpen] = createSignal(!done() || was || userOpened.has(id))
// Propagate user intent to the module-level set so it survives component
// recreations (e.g. when a tool call arrives while reading reasoning).
const track = (value: boolean) => {
if (value) userOpened.add(id)
else userOpened.delete(id)
setOpen(value)
}
// Auto-collapse once when reasoning finishes (streaming → done transition).
// Collapses immediately so the grid transition runs in sync with the
// streaming-height removal. Module-level Set prevents re-triggering on
// component recreation or when the user manually reopens.
// component recreation. Skipped entirely if the user has explicitly opened
// the block, so reading is not interrupted by a subsequent tool call.
createEffect(() => {
if (done() && open() && !autocollapsed.has(id)) {
if (done() && open() && !autocollapsed.has(id) && !userOpened.has(id)) {
autocollapsed.add(id)
setOpen(false)
}
@@ -1264,13 +1314,32 @@ PART_MAPPING["reasoning"] = function ReasoningPartDisplay(props: MessagePartProp
onCleanup(() => {
if (done()) streamed.delete(id)
// userOpened is intentionally NOT deleted here. The component recreates
// frequently while other parts stream (same as autocollapsed), so removing
// the entry on unmount would discard the user's explicit preference and
// re-collapse the block on the next remount.
})
// Auto-scroll the content container while streaming
// Auto-scroll the content container while streaming.
// Use a plain mutable flag rather than checking dist inside the reactive
// effect: by the time the effect runs the DOM has already grown, so reading
// scrollHeight post-update incorrectly reports the user as scrolled away
// whenever a streaming chunk is > 10px tall.
let ref: HTMLDivElement | undefined
let scrolled = false
const onScroll = (e: Event) => {
const el = e.currentTarget as HTMLDivElement
if (el.scrollHeight - el.clientHeight - el.scrollTop < 10) scrolled = false
}
const onWheel = (e: WheelEvent) => {
if (e.deltaY < 0) scrolled = true
}
createEffect(() => {
display()
if (!done() && ref) {
if (!done() && ref && !scrolled) {
ref.scrollTop = ref.scrollHeight
}
})
@@ -1278,7 +1347,7 @@ PART_MAPPING["reasoning"] = function ReasoningPartDisplay(props: MessagePartProp
return (
<Show when={display()}>
<div data-component="reasoning-part" data-streaming={!done() ? "" : undefined}>
<Collapsible open={open()} onOpenChange={setOpen} class="tool-collapsible">
<Collapsible open={open()} onOpenChange={track} class="tool-collapsible">
<Collapsible.Trigger>
<div data-slot="reasoning-header">
<Icon name="brain" size="small" />
@@ -1287,7 +1356,7 @@ PART_MAPPING["reasoning"] = function ReasoningPartDisplay(props: MessagePartProp
<Collapsible.Arrow />
</Collapsible.Trigger>
<Collapsible.Content>
<div data-slot="reasoning-content" ref={ref}>
<div data-slot="reasoning-content" ref={ref} onScroll={onScroll} onWheel={onWheel}>
<Markdown text={display()} cacheKey={id} />
</div>
</Collapsible.Content>
@@ -201,6 +201,7 @@ export function createAutoScroll(options: AutoScrollOptions) {
cleanup = undefined
}
lastScrollTop = undefined
scroll = el
if (!el) return
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:6b0f41c54aca88874a3c74151b77ef20f0f17fa9bb2f149c13adfaf4f48de286
size 14702
oid sha256:cc32d99eeff1cb3061caa4a75e3353e30e6373073bbd7ebf17048c917367e11a
size 28005
@@ -5,6 +5,19 @@
Image attachments and `@file` path mentions work. Non-image file content attachments are missing.
## Supported Image Types
The following image formats are supported for paste and drag-and-drop:
- PNG (`image/png`)
- JPEG (`image/jpeg`)
- GIF (`image/gif`)
- WebP (`image/webp`)
## Drag-and-Drop (Shift Required)
VS Code disables webview pointer-events during drag operations so it can handle drops in the editor area. To drop images into the chat input, **hold Shift while dragging**. This re-enables the webview to receive drop events (VS Code 1.91+, see [microsoft/vscode#182449](https://github.com/microsoft/vscode/issues/182449)).
## Remaining Work
- Add a file attachment button to the chat input toolbar (paperclip icon or similar)
+12 -1
View File
@@ -191,23 +191,34 @@ async function main() {
// Build Diff Viewer webview (SolidJS, reuses Agent Manager diff components)
const diffViewerCtx = await createBrowserWebviewContext("webview-ui/diff-viewer/index.tsx", "dist/diff-viewer.js")
// Build Diff Virtual webview (lightweight single-file diff for permission approval)
const diffVirtualCtx = await createBrowserWebviewContext("webview-ui/diff-virtual/index.tsx", "dist/diff-virtual.js")
// Build webview
const webviewCtx = await createBrowserWebviewContext("webview-ui/src/index.tsx", "dist/webview.js")
if (watch) {
await Promise.all([extensionCtx.watch(), webviewCtx.watch(), agentManagerCtx.watch(), diffViewerCtx.watch()])
await Promise.all([
extensionCtx.watch(),
webviewCtx.watch(),
agentManagerCtx.watch(),
diffViewerCtx.watch(),
diffVirtualCtx.watch(),
])
} else {
await Promise.all([
extensionCtx.rebuild(),
webviewCtx.rebuild(),
agentManagerCtx.rebuild(),
diffViewerCtx.rebuild(),
diffVirtualCtx.rebuild(),
])
await Promise.all([
extensionCtx.dispose(),
webviewCtx.dispose(),
agentManagerCtx.dispose(),
diffViewerCtx.dispose(),
diffVirtualCtx.dispose(),
])
}
}
+66 -3
View File
@@ -29,13 +29,76 @@ export default [
eqeqeq: "warn",
"no-throw-literal": "warn",
"max-lines": ["error", 3000],
complexity: ["error", 20],
},
},
// ── Complexity exceptions ─────────────────────────────────────────
// Existing violations capped at their current max.
// New code must stay ≤ 20. Do not raise these caps; refactor instead.
{
files: ["src/KiloProvider.ts"],
rules: {
"max-lines": ["error", 3200],
},
rules: { complexity: ["error", 140], "max-lines": ["error", 3300] },
},
{
files: ["webview-ui/agent-manager/AgentManagerApp.tsx"],
rules: { complexity: ["error", 74], "max-lines": ["error", 3100] },
},
{
files: ["src/agent-manager/AgentManagerProvider.ts"],
rules: { complexity: ["error", 64] },
},
{
files: ["webview-ui/src/components/chat/PromptInput.tsx"],
rules: { complexity: ["error", 48] },
},
{
files: ["src/legacy-migration/migration-service.ts"],
rules: { complexity: ["error", 45] },
},
{
files: ["webview-ui/src/components/migration/MigrationWizard.tsx"],
rules: { complexity: ["error", 37] },
},
{
files: ["webview-ui/src/context/session.tsx"],
rules: { complexity: ["error", 31] },
},
{
files: ["src/services/autocomplete/classic-auto-complete/AutocompleteInlineCompletionProvider.ts"],
rules: { complexity: ["error", 30] },
},
{
files: ["src/agent-manager/WorktreeManager.ts", "webview-ui/src/components/chat/QuestionDock.tsx"],
rules: { complexity: ["error", 28] },
},
{
files: [
"src/kilo-provider-utils.ts",
"src/services/autocomplete/continuedev/core/autocomplete/postprocessing/index.ts",
],
rules: { complexity: ["error", 27] },
},
{
files: ["webview-ui/src/components/settings/CustomProviderDialog.tsx"],
rules: { complexity: ["error", 26] },
},
{
files: ["src/agent-manager/WorktreeStateManager.ts"],
rules: { complexity: ["error", 24] },
},
{
files: ["webview-ui/src/utils/errorUtils.ts"],
rules: { complexity: ["error", 23] },
},
{
files: ["src/services/autocomplete/continuedev/core/autocomplete/filtering/BracketMatchingService.ts"],
rules: { complexity: ["error", 22] },
},
{
files: ["webview-ui/src/context/server.tsx"],
rules: { complexity: ["error", 21] },
},
eslintConfigPrettier,
]
+1 -1
View File
@@ -4,6 +4,7 @@
"src/extension.ts",
"webview-ui/agent-manager/index.tsx",
"webview-ui/diff-viewer/index.tsx",
"webview-ui/diff-virtual/index.tsx",
"webview-ui/src/index.tsx",
"src/**/__tests__/**/*.{ts,spec.ts}",
"src/**/*.test.ts",
@@ -11,7 +12,6 @@
"script/*.ts"
],
"project": ["src/**/*.ts", "webview-ui/**/*.{ts,tsx}"],
"ignore": ["src/services/autocomplete/**"],
"ignoreExportsUsedInFile": true,
"exclude": ["dependencies", "devDependencies", "optionalPeerDependencies", "unlisted", "unresolved", "binaries"]
}
+12 -1
View File
@@ -2,7 +2,7 @@
"name": "kilo-code",
"displayName": "Kilo Code: AI Coding Agent, Copilot, and Autocomplete",
"description": "Open Source AI coding agent that generates code from natural language, automates tasks, and runs terminal commands. Features inline autocomplete, browser automation, automated refactoring, and custom modes for planning, coding, and debugging. Supports 500+ AI models including Claude (Anthropic), Gemini, Grok, GPT, Codex and GLM.",
"version": "7.1.23",
"version": "7.2.3",
"icon": "assets/icons/logo-outline-black.png",
"galleryBanner": {
"color": "#FFFFFF",
@@ -50,6 +50,17 @@
],
"main": "./dist/extension.js",
"contributes": {
"taskDefinitions": [
{
"type": "kilo-worktree-setup",
"properties": {
"script": {
"type": "string",
"description": "The setup script command to execute"
}
}
}
],
"viewsContainers": {
"activitybar": [
{
@@ -212,6 +212,7 @@ export class DiffViewerProvider implements vscode.Disposable {
public dispose(): void {
this.stopDiffPolling()
this.gitOps.dispose()
this.panel?.dispose()
this.outputChannel.dispose()
}
@@ -0,0 +1,107 @@
import * as vscode from "vscode"
import { buildWebviewHtml } from "./utils"
import { appendOutput, getWorkspaceRoot } from "./review-utils"
export interface DiffVirtualFile {
file: string
before: string
after: string
additions: number
deletions: number
}
/**
* DiffVirtualProvider opens a lightweight diff viewer for a single in-memory
* file diff (not backed by git). Used by the permission approval dock to show
* edit changes before the user approves or rejects them.
*/
export class DiffVirtualProvider implements vscode.Disposable {
private panel: vscode.WebviewPanel | undefined
private pending: DiffVirtualFile | undefined
private outputChannel: vscode.OutputChannel
constructor(private readonly extensionUri: vscode.Uri) {
this.outputChannel = vscode.window.createOutputChannel("Kilo Diff Virtual")
}
private log(...args: unknown[]) {
appendOutput(this.outputChannel, "DiffVirtual", ...args)
}
public open(diff: DiffVirtualFile): void {
this.pending = diff
const filename = diff.file.split("/").pop() ?? diff.file
const title = `Changes: ${filename}`
if (this.panel) {
this.panel.title = title
this.panel.reveal(vscode.ViewColumn.One)
this.pushData()
return
}
const panel = vscode.window.createWebviewPanel("kilo-code.new.DiffVirtualPanel", title, vscode.ViewColumn.One, {
enableScripts: true,
retainContextWhenHidden: true,
localResourceRoots: [this.extensionUri],
})
panel.iconPath = {
light: vscode.Uri.joinPath(this.extensionUri, "assets", "icons", "kilo-light.svg"),
dark: vscode.Uri.joinPath(this.extensionUri, "assets", "icons", "kilo-dark.svg"),
}
panel.webview.html = this.getHtml(panel.webview)
panel.webview.onDidReceiveMessage((msg) => this.onMessage(msg))
panel.onDidDispose(() => {
this.log("Panel disposed")
this.panel = undefined
this.pending = undefined
})
this.panel = panel
}
private onMessage(msg: Record<string, unknown>): void {
const type = msg.type as string
if (type === "webviewReady") {
this.post({
type: "ready",
vscodeLanguage: vscode.env.language,
languageOverride: vscode.workspace.getConfiguration("kilo-code.new").get<string>("language"),
workspaceDirectory: getWorkspaceRoot(),
})
this.pushData()
return
}
if (type === "diffVirtual.close") {
this.panel?.dispose()
}
}
private pushData(): void {
if (!this.pending) return
this.post({ type: "diffVirtual.data", diff: this.pending })
}
private post(message: Record<string, unknown>): void {
if (this.panel?.webview) void this.panel.webview.postMessage(message)
}
private getHtml(webview: vscode.Webview): string {
return buildWebviewHtml(webview, {
scriptUri: webview.asWebviewUri(vscode.Uri.joinPath(this.extensionUri, "dist", "diff-virtual.js")),
styleUri: webview.asWebviewUri(vscode.Uri.joinPath(this.extensionUri, "dist", "diff-virtual.css")),
iconsBaseUri: webview.asWebviewUri(vscode.Uri.joinPath(this.extensionUri, "assets", "icons")),
title: "Diff Virtual",
extraStyles: "#root { display: flex; flex-direction: column; height: 100%; }",
})
}
public dispose(): void {
this.panel?.dispose()
this.outputChannel.dispose()
}
}
+405 -123
View File
@@ -1,3 +1,4 @@
/* eslint-disable max-lines -- TODO: refactor to reduce file size and remove this disable */
import * as path from "path"
import * as vscode from "vscode"
import { z } from "zod"
@@ -35,11 +36,15 @@ import {
import { GitOps } from "./agent-manager/GitOps"
import { GitStatsPoller, type LocalStats } from "./agent-manager/GitStatsPoller"
import { getWorkspaceRoot } from "./review-utils"
import { MarketplaceService } from "./services/marketplace"
import { MarketplaceService, type MarketplaceItem, type RemoveResult } from "./services/marketplace"
import type { RemoteStatusService } from "./services/RemoteStatusService"
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 { childID } from "./kilo-provider/task-session"
import { retryable, backoff, MAX_RETRIES } from "./util/retry"
// legacy-migration start
import {
checkAndShowMigrationWizard,
@@ -88,14 +93,29 @@ import {
saveCustomProvider as saveCustomProviderAction,
} from "./provider-actions"
import { fetchOpenAIModels, FetchModelsError } from "./shared/fetch-models"
import type { Agent } from "@kilocode/sdk/v2/client"
type KiloProviderOptions = {
projectDirectory?: string | null
slimEditMetadata?: boolean
}
// Helper to map agent data to the subset of fields sent to the webview
const mapAgent = (a: Agent) => ({
name: a.name,
displayName: a.displayName,
description: a.description,
mode: a.mode,
native: a.native,
hidden: a.hidden,
color: a.color,
deprecated: a.deprecated,
permission: a.permission,
})
export class KiloProvider implements vscode.WebviewViewProvider, TelemetryPropertiesProvider {
public static readonly viewType = "kilo-code.SidebarProvider"
private readonly instanceId = crypto.randomUUID()
private webview: vscode.Webview | null = null
private currentSession: Session | null = null
@@ -124,10 +144,13 @@ 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 }[] = []
private readyResolvers: (() => void)[] = []
private promptRecoveryQueued = false
private promptRecovery: Promise<void> | null = null
private trackedSessionIds: Set<string> = new Set()
private syncedChildSessions: Set<string> = new Set()
/** Tracks the latest status for each session, used to warn before destructive config operations. */
@@ -156,6 +179,8 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
private unsubscribeDirectoryProvider: (() => void) | null = null
private initConnectionPromise: Promise<void> | null = null
private webviewMessageDisposable: vscode.Disposable | null = null
private viewStateDisposable: vscode.Disposable | null = null
private visibilityDisposable: vscode.Disposable | null = null
/** Lazily initialized ignore controller for .kilocodeignore filtering */
private ignoreController: FileIgnoreController | null = null
@@ -168,6 +193,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.
@@ -179,6 +205,10 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
| ((sessionId: string, progress: (status: string, detail?: string, error?: string) => void) => Promise<void>)
| null = null
private diffVirtualProvider: import("./DiffVirtualProvider").DiffVirtualProvider | undefined
private remoteService: RemoteStatusService | null = null
private unsubscribeRemote: (() => void) | null = null
constructor(
private readonly extensionUri: vscode.Uri,
private readonly connectionService: KiloConnectionService,
@@ -191,12 +221,29 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
TelemetryProxy.getInstance().setProvider(this)
}
setRemoteService(service: RemoteStatusService): void {
this.remoteService = service
this.unsubscribeRemote = service.onChange(() => this.sendRemoteStatus())
}
private sendRemoteStatus(): void {
const s = this.remoteService?.getState()
if (s) this.postMessage({ type: "remoteStatus", enabled: s.enabled, connected: s.connected })
}
private focusSession(id?: string): void {
if (id) this.connectionService.registerFocused(this.instanceId, id)
else this.connectionService.unregisterFocused(this.instanceId)
}
public setProjectDirectory(directory: string | null): void {
if (this.projectDirectory === directory) return
this.projectDirectory = directory
this.postMessage({ type: "workspaceDirectoryChanged", directory: directory ?? "" })
}
public setDiffVirtualProvider(provider: import("./DiffVirtualProvider").DiffVirtualProvider): void {
this.diffVirtualProvider = provider
}
getTelemetryProperties(): Record<string, unknown> {
return {
appName: "kilo-code",
@@ -281,7 +328,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({
@@ -300,6 +347,8 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
// authoritative and reconciliation risks race-resetting busy sessions.
const reconcile = this.sessionStatusMap.size === 0
void this.seedSessionStatusMap(reconcile)
this.sendRemoteStatus()
}
// legacy-migration start
@@ -330,20 +379,16 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
localResourceRoots: [this.extensionUri],
}
// Set HTML content
webviewView.webview.html = this._getHtmlForWebview(webviewView.webview)
// Handle messages from webview (shared handler)
this.setupWebviewMessageHandler(webviewView.webview)
// Track sidebar visibility for keybinding when-clauses and stats polling
vscode.commands.executeCommand("setContext", "kilo-code.new.sidebarVisible", webviewView.visible)
webviewView.onDidChangeVisibility(() => {
this.visibilityDisposable?.dispose()
this.visibilityDisposable = webviewView.onDidChangeVisibility(() => {
vscode.commands.executeCommand("setContext", "kilo-code.new.sidebarVisible", webviewView.visible)
this.statsPoller?.setEnabled(webviewView.visible)
this.focusSession(webviewView.visible ? this.currentSession?.id : undefined)
})
// Initialize connection to CLI backend
this.initializeConnection()
}
@@ -362,9 +407,11 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
panel.webview.html = this._getHtmlForWebview(panel.webview)
// Handle messages from webview (shared handler)
this.setupWebviewMessageHandler(panel.webview)
this.viewStateDisposable?.dispose()
this.viewStateDisposable = panel.onDidChangeViewState(() =>
this.focusSession(panel.active ? this.currentSession?.id : undefined),
)
this.initializeConnection()
}
@@ -420,6 +467,30 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
void this.handleLoadSessions()
}
/** Recover permission/question prompts after sessions and directories are tracked. */
public recoverPendingPrompts(): void {
this.promptRecoveryQueued = true
if (!this.isWebviewReady) return
if (!this.client) return
if (this.promptRecovery) return
this.promptRecovery = this.flushPendingPrompts().finally(() => {
this.promptRecovery = null
if (this.promptRecoveryQueued && this.isWebviewReady && this.client) this.recoverPendingPrompts()
})
}
private async flushPendingPrompts(): Promise<void> {
while (this.promptRecoveryQueued && this.isWebviewReady) {
if (!this.client) return
this.promptRecoveryQueued = false
await Promise.all([
fetchAndSendPendingPermissions(this.permissionCtx),
fetchAndSendPendingQuestions(this.questionCtx),
])
}
}
public openCloudSession(sessionId: string): void {
this.postMessage({ type: "openCloudSession", sessionId })
}
@@ -456,6 +527,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
*/
private setupWebviewMessageHandler(webview: vscode.Webview): void {
this.webviewMessageDisposable?.dispose()
// eslint-disable-next-line complexity -- TODO: refactor to reduce complexity and remove this disable
this.webviewMessageDisposable = webview.onDidReceiveMessage(async (message) => {
// Run interceptor if attached (e.g., AgentManagerProvider worktree logic)
if (this.onBeforeMessage) {
@@ -475,6 +547,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
this.isWebviewReady = true
await this.syncWebviewState("webviewReady")
this.flushPendingReviewComments()
this.recoverPendingPrompts()
this.readyResolvers.splice(0).forEach((r) => r())
break
case "sendMessage": {
@@ -529,6 +602,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
break
}
case "abort":
this.cancelRetry(message.sessionID ?? "")
await this.handleAbort(message.sessionID)
break
case "revertSession":
@@ -557,6 +631,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
case "clearSession":
this.contextSessionID = this.currentSession?.id ?? this.contextSessionID
this.currentSession = null
this.focusSession()
break
case "loadMessages":
// Don't await: allow parallel loads so rapid session switching
@@ -608,6 +683,11 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
case "openChanges":
vscode.commands.executeCommand("kilo-code.new.showChanges")
break
case "openDiffVirtual":
if (this.diffVirtualProvider && message.diff) {
this.diffVirtualProvider.open(message.diff)
}
break
case "continueInWorktree":
if (message.sessionId && this.continueInWorktreeHandler) {
this.continueInWorktreeHandler(message.sessionId, (status: string, detail?: string, error?: string) => {
@@ -787,6 +867,16 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
case "renameSession":
await this.handleRenameSession(message.sessionID, message.title)
break
case "toggleRemote":
case "setRemoteEnabled":
case "requestRemoteStatus":
this.remoteService
?.handleMessage(message.type, message.enabled)
.then((s) => {
if (s) this.sendRemoteStatus()
})
.catch((err) => console.error("[Kilo New] remote message failed:", err))
break
case "updateSetting":
await this.handleUpdateSetting(message.key, message.value)
break
@@ -965,12 +1055,8 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
break
}
case "removeInstalledMarketplaceItem": {
const workspace = this.getProjectDirectory(this.currentSession?.id)
const scope = message.mpInstallOptions?.target ?? "project"
const result = await this.getMarketplace().remove(message.mpItem, scope, workspace)
if (result.success) {
await this.invalidateAfterMarketplaceChange(scope)
}
const result = await this.removeMarketplaceItem(message.mpItem, scope)
this.postMessage({
type: "marketplaceRemoveResult",
success: result.success,
@@ -1022,6 +1108,8 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
// Subscribe to SSE events for this webview (filtered by tracked sessions)
this.unsubscribeEvent = this.connectionService.onEventFiltered(
(event) => {
// Remote status events are global and should always pass through
if (event.type === "kilo-sessions.remote-status-changed") return true
const sessionId = this.connectionService.resolveEventSessionId(event)
// message.part.updated and message.part.delta are always session-scoped; drop if session unknown.
@@ -1052,6 +1140,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
@@ -1061,8 +1152,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)
this.recoverPendingPrompts()
} catch (error) {
console.error("[Kilo New] KiloProvider: ❌ Failed during connected state handling:", error)
this.postMessage({
@@ -1127,8 +1217,17 @@ 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")
this.recoverPendingPrompts()
// Fetch providers, agents, skills, config, notifications, and session statuses in parallel
await Promise.all([
@@ -1142,6 +1241,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
])
this.sendNotificationSettings()
this.sendTimelineSetting()
this.postMessage({ type: "extensionDataReady" })
// Start polling worktree diff stats for the sidebar badge
this.startStatsPolling()
@@ -1206,6 +1306,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
private async handleLoadMessages(sessionID: string): Promise<void> {
// Track the session so we receive its SSE events
this.trackedSessionIds.add(sessionID)
this.focusSession(sessionID)
this.contextSessionID = sessionID
if (!this.client) {
@@ -1225,9 +1326,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
@@ -1289,9 +1392,8 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
messages,
})
// Recover any permission.asked events that were missed while the webview
// was loading or during an SSE reconnection (fire-and-forget).
void fetchAndSendPendingPermissions(this.permissionCtx)
// Recover any prompts missed while the webview was loading or during an SSE reconnection.
this.recoverPendingPrompts()
} catch (error) {
// Silently ignore aborted requests — the user switched to a different session
if (abort.signal.aborted) return
@@ -1328,9 +1430,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) => ({
@@ -1349,11 +1450,8 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
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)
// Recover any prompts emitted by the child before we started tracking it.
this.recoverPendingPrompts()
} catch (err) {
this.syncedChildSessions.delete(sessionID)
console.error("[Kilo New] KiloProvider: Failed to sync child session:", err)
@@ -1600,21 +1698,16 @@ 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)
const message = {
type: "agentsLoaded",
agents: visible.map((a) => ({
name: a.name,
displayName: a.displayName,
description: a.description,
mode: a.mode,
native: a.native,
color: a.color,
deprecated: a.deprecated,
})),
agents: visible.map(mapAgent),
allAgents: agents.map(mapAgent),
defaultAgent,
}
this.cachedAgentsMessage = message
@@ -1634,7 +1727,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",
@@ -1657,7 +1752,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",
@@ -1679,7 +1776,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)
@@ -1724,59 +1821,87 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
*/
private async handleRemoveMode(name: string): Promise<void> {
if (!this.client) return
let removed = false
// 1. Try CLI removal (handles .md files and legacy .kilocodemodes)
try {
const dir = this.getWorkspaceDirectory()
const result = await this.client.kilocode.removeAgent({ name, directory: dir })
if (!result.error) removed = true
if (!result.error) {
this.cachedAgentsMessage = null
await this.fetchAndSendAgents()
return
}
} catch {
// CLI removal failed — agent may be in kilo.json instead
}
// 2. Try removing from kilo.json (handles marketplace-installed modes)
if (!removed) {
const workspace = this.getProjectDirectory(this.currentSession?.id)
const mp = this.getMarketplace()
const stub = { id: name, type: "mode" as const, name, description: "", content: "" }
const project = await mp.remove(stub, "project", workspace)
const global = await mp.remove(stub, "global", workspace)
if (project.success || global.success) {
await this.disposeCliInstance("global")
removed = true
}
}
const stub = { id: name, type: "mode" as const, name, description: "", content: "" }
const removed = await this.removeMarketplaceItemFromAllScopes(stub)
if (!removed) {
console.error("[Kilo New] KiloProvider: Failed to remove mode:", name)
}
this.cachedAgentsMessage = null
await this.fetchAndSendAgents()
}
private async handleRemoveMcp(name: string): Promise<void> {
const workspace = this.getProjectDirectory(this.currentSession?.id)
const mp = this.getMarketplace()
// Remove from legacy files first so that the subsequent invalidation
// causes the CLI to re-read config without the legacy entry.
await this.removeLegacyMcp(name)
const stub = { id: name, type: "mcp" as const, name, description: "", url: "", content: "" }
// Remove from both scopes — an MCP could exist in project, global, or both
const project = await mp.remove(stub, "project", workspace)
const global = await mp.remove(stub, "global", workspace)
if (project.success || global.success) {
// Use global scope when removed from global (or both) so the global
// config cache is also invalidated; project scope is a subset.
const scope = global.success ? "global" : "project"
await this.disposeCliInstance(scope)
this.cachedConfigMessage = null
await this.fetchAndSendConfig()
} else {
const removed = await this.removeMarketplaceItemFromAllScopes(stub)
if (!removed) {
console.error("[Kilo New] KiloProvider: Failed to remove MCP server:", name)
}
}
/**
* Remove an MCP server from legacy config files (.kilo/mcp.json, .kilocode/mcp.json,
* and the VS Code global storage mcp_settings.json). These files are read by the
* CLI-side McpMigrator and merged into config at the lowest precedence level.
* Returns true if the entry was found and removed from at least one file.
*/
private async removeLegacyMcp(name: string): Promise<boolean> {
const workspace = this.getProjectDirectory(this.currentSession?.id)
const files: vscode.Uri[] = []
// Project-level legacy files
if (workspace) {
files.push(vscode.Uri.file(path.join(workspace, ".kilo", "mcp.json")))
files.push(vscode.Uri.file(path.join(workspace, ".kilocode", "mcp.json")))
}
// Global legacy file (VS Code extension global storage)
const storage = this.extensionContext?.globalStorageUri
if (storage) {
files.push(vscode.Uri.joinPath(storage, "settings", "mcp_settings.json"))
}
let removed = false
for (const uri of files) {
const bytes = await vscode.workspace.fs.readFile(uri).then(
(b) => b,
() => null,
)
if (!bytes) continue
try {
const parsed = JSON.parse(Buffer.from(bytes).toString("utf8")) as Record<string, unknown>
const servers = parsed.mcpServers as Record<string, unknown> | undefined
if (!servers?.[name]) continue
delete servers[name]
const content = Buffer.from(JSON.stringify(parsed, null, 2), "utf8")
await vscode.workspace.fs.writeFile(uri, content)
removed = true
} catch (err) {
console.warn("[Kilo New] KiloProvider: Failed to remove legacy MCP from", uri.fsPath, err)
}
}
return removed
}
private async fetchAndSendMcpStatus(): Promise<void> {
if (!this.client) {
if (this.cachedMcpStatusMessage) {
@@ -1787,7 +1912,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
@@ -1823,23 +1948,35 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
}
/**
* Dispose the CLI backend instance so it re-reads config from disk.
* Call after any marketplace install/remove that writes config files directly.
* Global-scope changes need global.dispose() to also reset the global config cache.
* Remove a marketplace item from a single scope and invalidate CLI caches.
*/
private async disposeCliInstance(scope: "project" | "global"): Promise<void> {
if (!this.client) return
if (scope === "global") {
await this.client.global.dispose().catch((e: unknown) => {
console.warn("[Kilo New] global.dispose() after marketplace change failed:", e)
})
private async removeMarketplaceItem(item: MarketplaceItem, scope: "project" | "global"): Promise<RemoveResult> {
const workspace = this.getProjectDirectory(this.currentSession?.id)
const result = await this.getMarketplace().remove(item, scope, workspace)
if (result.success) {
await this.invalidateAfterMarketplaceChange(scope)
}
// Always dispose the per-project instance so it rebuilds state from
// the (possibly updated) global + project config on the next request.
const dir = this.getWorkspaceDirectory()
await this.client.instance.dispose({ directory: dir }).catch((e: unknown) => {
console.warn("[Kilo New] instance.dispose() after marketplace change failed:", e)
})
return result
}
/**
* Remove a marketplace item from both project and global scopes.
* mp.remove returns success even when the entry doesn't exist (no-op),
* so we must attempt both scopes to cover dual-scope installations.
* Returns true if at least one scope removal succeeded.
*/
private async removeMarketplaceItemFromAllScopes(item: MarketplaceItem): Promise<boolean> {
const workspace = this.getProjectDirectory(this.currentSession?.id)
const mp = this.getMarketplace()
const project = await mp.remove(item, "project", workspace)
const global = await mp.remove(item, "global", workspace)
if (project.success || global.success) {
const scope = global.success ? "global" : "project"
await this.invalidateAfterMarketplaceChange(scope)
return true
}
return false
}
/**
@@ -1898,7 +2035,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",
@@ -1945,7 +2084,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) {
@@ -1953,6 +2092,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.
@@ -1978,7 +2160,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))
@@ -2093,7 +2275,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 })
@@ -2150,6 +2332,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,
@@ -2192,18 +2453,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)
@@ -2254,19 +2518,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)
@@ -2614,6 +2881,11 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
* Filters events by project ID and tracked session IDs so each webview only sees its own sessions.
*/
private handleEvent(event: Event): void {
if (event.type === "kilo-sessions.remote-status-changed") {
this.remoteService?.updateFromEvent({ enabled: event.properties.enabled, connected: event.properties.connected })
return
}
// Drop session events from other projects before any tracking logic.
// This must come first: the trackedSessionIds guard below would otherwise
// let a foreign session through if it was accidentally tracked.
@@ -2691,9 +2963,10 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
type?: string
tool?: string
metadata?: { sessionId?: string }
state?: { metadata?: { sessionId?: string } }
sessionID?: string
}
const childId = part.type === "tool" && part.tool === "task" ? part.metadata?.sessionId : undefined
const childId = childID(part)
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)
@@ -2996,7 +3269,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(),
@@ -3023,7 +3298,10 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
* Does NOT kill the server — that's the connection service's job.
*/
dispose(): void {
this.unsubscribeRemote?.()
this.focusSession()
this.statsPoller?.stop()
this.statsGitOps?.dispose()
this.unsubscribeEvent?.()
this.unsubscribeState?.()
this.unsubscribeNotificationDismiss?.()
@@ -3033,7 +3311,11 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
this.unsubscribeMigrationComplete?.()
this.unsubscribeClearPendingPrompts?.()
this.unsubscribeDirectoryProvider?.()
this.viewStateDisposable?.dispose()
this.visibilityDisposable?.dispose()
this.webviewMessageDisposable?.dispose()
this.isWebviewReady = false
this.promptRecoveryQueued = false
this.trackedSessionIds.clear()
this.syncedChildSessions.clear()
this.sessionDirectories.clear()
@@ -2,6 +2,7 @@ import * as vscode from "vscode"
import { KiloProvider } from "./KiloProvider"
import { resolvePanelProjectDirectory } from "./project-directory"
import type { KiloConnectionService } from "./services/cli-backend"
import type { RemoteStatusService } from "./services/RemoteStatusService"
type PanelView = "settings" | "profile" | "marketplace"
@@ -26,6 +27,7 @@ export class SettingsEditorProvider implements vscode.Disposable {
private panels = new Map<PanelView, vscode.WebviewPanel>()
private providers = new Map<PanelView, KiloProvider>()
private tabs = new Map<PanelView, string>()
private remoteService: RemoteStatusService | null = null
constructor(
private readonly extensionUri: vscode.Uri,
@@ -101,6 +103,9 @@ export class SettingsEditorProvider implements vscode.Disposable {
const provider = new KiloProvider(this.extensionUri, this.connectionService, this.context, {
projectDirectory,
})
if (this.remoteService) {
provider.setRemoteService(this.remoteService)
}
provider.resolveWebviewPanel(panel)
// Listen for closePanel from the webview (back button in panel mode)
@@ -144,6 +149,14 @@ export class SettingsEditorProvider implements vscode.Disposable {
})
}
setRemoteService(service: RemoteStatusService): void {
this.remoteService = service
// Apply to any existing providers
for (const [, provider] of this.providers) {
provider.setRemoteService(service)
}
}
dispose(): void {
for (const [, panel] of this.panels) {
panel.dispose()
@@ -6,8 +6,10 @@ import { getErrorMessage } from "../kilo-provider-utils"
import { isAbsolutePath } from "../path-utils"
import { WorktreeManager, type CreateWorktreeResult } from "./WorktreeManager"
import { WorktreeStateManager, remoteRef } from "./WorktreeStateManager"
import { handleSection } from "./section-handler"
import { chooseBaseBranch, normalizeBaseBranch } from "./base-branch"
import { GitStatsPoller, type WorktreePresenceResult } from "./GitStatsPoller"
import { PRStatusBridge } from "./pr-status-bridge"
import { GitOps, type ApplyConflict } from "./GitOps"
import { versionedName } from "./branch-name"
import { normalizePath, classifyWorktreeError } from "./git-import"
@@ -23,6 +25,7 @@ import { continueInWorktree } from "./continue-in-worktree"
import { shouldStopDiffPolling } from "./delete-worktree"
import { buildKeybindingMap } from "./format-keybinding"
import { resolveVersionModels, buildInitialMessages, type CreatedVersion } from "./multi-version"
import { Semaphore } from "./semaphore"
import { PLATFORM } from "./constants"
import type { AgentManagerOutMessage, AgentManagerInMessage } from "./types"
import { hashFileDiffs, resolveLocalDiffTarget } from "../review-utils"
@@ -53,6 +56,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>()
@@ -73,11 +77,13 @@ export class AgentManagerProvider implements Disposable {
(msg) => this.outputChannel.appendLine(`[SessionTerminal] ${msg}`),
createTerminalHost(),
)
this.gitOps = new GitOps({ log: (...args) => this.log(...args) })
const semaphore = new Semaphore(3)
this.gitOps = new GitOps({ log: (...args) => this.log(...args), semaphore })
this.statsPoller = new GitStatsPoller({
getWorktrees: () => this.state?.getWorktrees() ?? [],
getWorkspaceRoot: () => this.getRoot(),
getClient: () => this.connectionService.getClient(),
semaphore,
onStats: (stats) => {
const msg = { type: "agentManager.worktreeStats" as const, stats }
this.cachedWorktreeStats = msg
@@ -94,6 +100,16 @@ 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),
semaphore,
})
}
private log(...args: unknown[]) {
@@ -147,13 +163,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
}
@@ -184,31 +201,34 @@ export class AgentManagerProvider implements Disposable {
this.host.refreshGit()
}
// Do not auto-remove stale worktrees on load.
// Presence checks run in the shared poller and require explicit user cleanup.
// Register all worktree sessions with the session provider
for (const worktree of state.getWorktrees()) {
for (const session of state.getSessions(worktree.id)) {
this.panel?.sessions.setSessionDirectory(session.id, worktree.path)
this.panel?.sessions.trackSession(session.id)
for (const wt of state.getWorktrees()) {
for (const s of state.getSessions(wt.id)) {
this.panel?.sessions.setSessionDirectory(s.id, wt.path)
this.panel?.sessions.trackSession(s.id)
}
}
// Push full state to webview
for (const s of state.getSessions()) if (!s.worktreeId) this.panel?.sessions.trackSession(s.id)
this.pushState()
// Refresh sessions so worktree sessions appear in the list
if (state.getSessions().length > 0) {
this.panel?.sessions.refreshSessions()
}
// Recover any pending permission/question prompts that were missed during
// panel recreation or SSE reconnection. Must run after all worktree sessions
// are registered with their directory overrides so the recovery queries the
// correct CLI backend Instances.
this.panel?.sessions.recoverPendingPrompts()
}
// ---------------------------------------------------------------------------
// Message interceptor
// ---------------------------------------------------------------------------
// eslint-disable-next-line complexity -- TODO: refactor to reduce complexity and remove this disable
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") {
@@ -218,8 +238,12 @@ export class AgentManagerProvider implements Disposable {
if (m.type === "agentManager.removeStaleWorktree") return this.onRemoveStaleWorktree(m.worktreeId)
if (m.type === "agentManager.promoteSession") return this.onPromoteSession(m.sessionId)
if (m.type === "agentManager.openLocally") {
if (!this.panel) return null
this.panel.sessions.clearSessionDirectory(m.sessionId)
this.panel?.sessions.clearSessionDirectory(m.sessionId)
const st = this.getStateManager()
if (st?.getSession(m.sessionId)) {
st.moveSession(m.sessionId, null)
this.pushState()
}
return null
}
if (m.type === "continueInWorktree") {
@@ -231,6 +255,15 @@ export class AgentManagerProvider implements Disposable {
if (m.type === "agentManager.addSessionToWorktree") return this.onAddSessionToWorktree(m.worktreeId)
if (m.type === "agentManager.forkSession") return this.onForkSession(m.sessionId, m.worktreeId)
if (m.type === "agentManager.closeSession") return this.onCloseSession(m.sessionId)
if (m.type === "agentManager.persistSession" || m.type === "agentManager.forgetSession") {
const persist = m.type === "agentManager.persistSession"
void this.stateReady?.then(() => {
const st = this.getStateManager()
if (st)
persist ? !st.getSession(m.sessionId) && st.addSession(m.sessionId, null) : st.removeSession(m.sessionId)
})
return null
}
if ((m.type === "sendMessage" || m.type === "sendCommand") && m.draftID && !m.sessionID) {
this.activeSessionId = m.draftID
}
@@ -292,6 +325,7 @@ export class AgentManagerProvider implements Disposable {
// already emitted before the webview was ready to receive messages.
if (this.cachedWorktreeStats) this.postToWebview(this.cachedWorktreeStats)
if (this.cachedLocalStats) this.postToWebview(this.cachedLocalStats)
this.prBridge.replay()
// Refresh sessions after pushState so the webview's sessionsLoaded
// handler is guaranteed to be registered (requestState fires from
// onMount). Without this, the initial refreshSessions() in
@@ -327,6 +361,7 @@ export class AgentManagerProvider implements Disposable {
this.state?.setSessionsCollapsed(m.collapsed)
return null
}
if (this.handleSection(m)) return null
if (m.type === "agentManager.setReviewDiffStyle") {
this.state?.setReviewDiffStyle(m.style)
return null
@@ -376,10 +411,18 @@ export class AgentManagerProvider implements Disposable {
void this.onApplyWorktreeDiff(m.worktreeId, selectedFiles)
return null
}
if (m.type === "agentManager.revertWorktreeFile") {
void this.onRevertWorktreeFile(m.sessionId, m.file)
return null
}
if (m.type === "agentManager.startDiffWatch") {
this.startDiffPolling(m.sessionId)
return null
}
if (m.type === "agentManager.openSessions") {
this.connectionService.registerOpen("agent-manager", m.sessionIDs)
return null
}
if (m.type === "agentManager.stopDiffWatch") {
this.stopDiffPolling()
return null
@@ -407,12 +450,15 @@ export class AgentManagerProvider implements Disposable {
// uses the correct session even before the session provider's async session.get completes.
if (m.type === "loadMessages") {
this.activeSessionId = m.sessionID
this.connectionService.registerFocused("agent-manager", 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
if (m.type === "clearSession") {
this.activeSessionId = undefined
this.connectionService.unregisterFocused("agent-manager")
void Promise.resolve().then(() => {
if (!this.panel || !this.state) return
for (const id of this.state.worktreeSessionIds()) {
@@ -671,6 +717,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 +726,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
}
@@ -889,9 +936,9 @@ export class AgentManagerProvider implements Disposable {
continue
}
await this.runSetupScriptForWorktree(wt.result.path, wt.result.branch)
await this.runSetupScriptForWorktree(wt.result.path, wt.result.branch, wt.worktree.id)
const session = await this.createSessionInWorktree(wt.result.path, wt.result.branch)
const session = await this.createSessionInWorktree(wt.result.path, wt.result.branch, wt.worktree.id)
if (!session) {
const state = this.getStateManager()
const manager = this.getWorktreeManager()
@@ -904,7 +951,7 @@ export class AgentManagerProvider implements Disposable {
const state = this.getStateManager()!
state.addSession(session.id, wt.worktree.id)
this.registerWorktreeSession(session.id, wt.result.path)
this.notifyWorktreeReady(session.id, wt.result)
this.notifyWorktreeReady(session.id, wt.result, wt.worktree.id)
// Set the per-version model immediately so the UI selector reflects
// the correct model as soon as the worktree appears, before Phase 2.
@@ -1382,6 +1429,7 @@ export class AgentManagerProvider implements Disposable {
status: "error",
message: `Setup script failed: ${msg}`,
branch,
worktreeId,
})
}
}
@@ -1410,6 +1458,10 @@ export class AgentManagerProvider implements Disposable {
if (!this.panel) return
this.panel.sessions.setSessionDirectory(sessionId, directory)
this.panel.sessions.trackSession(sessionId)
// Recover any permission/question prompts that arrived before the session
// was tracked. The CLI backend may have emitted permission.asked between
// session.create() returning and this registration completing.
this.panel.sessions.recoverPendingPrompts()
}
private onWorktreePresence(result: WorktreePresenceResult): void {
@@ -1428,12 +1480,20 @@ export class AgentManagerProvider implements Disposable {
const entries = result.worktrees.filter((item) => ids.has(item.worktreeId))
if (entries.length === 0) return
// Sync branches from git worktree list (no extra git calls)
let branchChanged = false
for (const entry of entries) {
if (entry.branch && state.updateWorktreeBranch(entry.worktreeId, entry.branch)) {
branchChanged = true
}
}
const next = new Set(entries.filter((entry) => entry.missing).map((entry) => entry.worktreeId))
const changed =
const staleChanged =
next.size !== this.staleWorktreeIds.size || [...next].some((worktreeId) => !this.staleWorktreeIds.has(worktreeId))
this.staleWorktreeIds = next
if (changed) {
if (staleChanged || branchChanged) {
this.pushState()
}
}
@@ -1464,6 +1524,7 @@ export class AgentManagerProvider implements Disposable {
type: "agentManager.state",
worktrees,
sessions: state.getSessions(),
sections: state.getSections(),
staleWorktreeIds,
tabOrder: state.getTabOrder(),
worktreeOrder: state.getWorktreeOrder(),
@@ -1474,6 +1535,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. */
@@ -1613,6 +1675,66 @@ export class AgentManagerProvider implements Disposable {
}
}
/** Revert a single file in a worktree back to the merge-base state. */
private async onRevertWorktreeFile(sessionId: string, file: string): Promise<void> {
if (!file) return
if (this.stateReady) {
await this.stateReady.catch((err) => this.log("stateReady rejected, continuing revert resolve:", err))
}
const target =
this.cachedDiffTarget?.sessionId === sessionId ? this.cachedDiffTarget : await this.resolveDiffTarget(sessionId)
if (!target) {
this.postToWebview({
type: "agentManager.revertWorktreeFileResult",
sessionId,
file,
status: "error",
message: "Could not resolve diff target",
})
return
}
// Look up the file status from the cached diffs so we know if it's added/modified/deleted
let status: "added" | "deleted" | "modified" | undefined
try {
const client = this.connectionService.getClient()
const { data } = await client.worktree.diffFile(
{ directory: target.directory, base: target.baseBranch, file },
{ throwOnError: true },
)
status = data?.status
} catch (err) {
this.log("Failed to look up file status for revert:", err)
}
try {
const result = await this.gitOps.revertFile(target.directory, target.baseBranch, file, status)
this.postToWebview({
type: "agentManager.revertWorktreeFileResult",
sessionId,
file,
status: result.ok ? "success" : "error",
message: result.message,
})
// After successful revert, trigger a diff refresh so the UI updates
if (result.ok) {
void this.onRequestWorktreeDiff(sessionId)
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
this.log("Failed to revert worktree file:", message)
this.postToWebview({
type: "agentManager.revertWorktreeFileResult",
sessionId,
file,
status: "error",
message,
})
}
}
// ---------------------------------------------------------------------------
// Diff polling
// ---------------------------------------------------------------------------
@@ -1886,13 +2008,21 @@ export class AgentManagerProvider implements Disposable {
)
}
private handleSection(m: AgentManagerInMessage): boolean {
return handleSection(this.state, m, () => this.pushState())
}
public postMessage(message: unknown): void {
this.panel?.postMessage(message)
}
public dispose(): void {
this.connectionService.unregisterFocused("agent-manager")
this.connectionService.registerOpen("agent-manager", [])
this.stopDiffPolling()
this.statsPoller.stop()
this.gitOps.dispose()
this.prBridge.poller.stop()
this.terminalManager.dispose()
this.panel?.dispose()
this.outputChannel.dispose()
+119 -31
View File
@@ -4,11 +4,14 @@ import * as fs from "fs/promises"
import { spawn } from "../util/process"
import simpleGit from "simple-git"
import { parseWorktreeList, normalizePath } from "./git-import"
import type { Semaphore } from "./semaphore"
interface GitOpsOptions {
log: (...args: unknown[]) => void
/** Override git command execution for testing. */
runGit?: (args: string[], cwd: string) => Promise<string>
/** Shared concurrency gate for child process spawning. */
semaphore?: Semaphore
}
export interface ApplyConflict {
@@ -62,19 +65,49 @@ export function nonInteractiveEnv(): NodeJS.ProcessEnv {
export class GitOps {
private readonly log: (...args: unknown[]) => void
private readonly runGit: (args: string[], cwd: string) => Promise<string>
private readonly controller = new AbortController()
private readonly semaphore: Semaphore | undefined
get disposed(): boolean {
return this.controller.signal.aborted
}
constructor(options: GitOpsOptions) {
this.log = options.log
this.semaphore = options.semaphore
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"))
const invoke = () =>
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 this.semaphore ? this.semaphore.run(invoke) : invoke()
}
/** Return the name of the currently checked-out branch, or `"HEAD"` if detached. */
@@ -130,14 +163,14 @@ export class GitOps {
}
/** 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
}
/**
@@ -260,6 +293,55 @@ export class GitOps {
}
}
/**
* Revert a single file in a worktree back to the merge-base state.
* For modified/deleted files: restores the file from the merge-base commit.
* For added (new) files: removes the file from the worktree.
*/
async revertFile(
cwd: string,
baseBranch: string,
file: string,
status?: "added" | "deleted" | "modified",
): Promise<{ ok: boolean; message: string }> {
// Validate path: no absolute paths, no ".." traversal
if (nodePath.isAbsolute(file) || file.split(/[\\/]/).includes("..")) {
return { ok: false, message: "Invalid file path" }
}
const base = (await this.raw(["merge-base", "HEAD", baseBranch], cwd).catch(() => "")).trim()
if (!base) {
return { ok: false, message: "Could not resolve merge-base" }
}
if (status === "added") {
// New file — remove it from disk and unstage
const full = nodePath.resolve(cwd, file)
const root = await fs.realpath(cwd)
const resolved = await fs.realpath(full).catch(() => full)
if (resolved !== root && !resolved.startsWith(root + nodePath.sep)) {
return { ok: false, message: "File path outside worktree" }
}
await fs.rm(full, { force: true })
// Also remove from git index in case it was staged
await this.raw(["rm", "--cached", "--force", "--ignore-unmatch", "--", file], cwd).catch(() => "")
return { ok: true, message: "Removed added file" }
}
// Modified or deleted file — restore from merge-base
const result = await this.exec(["checkout", base, "--", file], cwd)
if (result.code !== 0) {
return { ok: false, message: result.stderr.trim() || "Failed to revert file" }
}
// Only unstage for modified files. For deleted files the checkout already
// restored the file into the index correctly — resetting to HEAD would drop
// it from the index and make it appear as a new untracked file.
if (status === "modified") {
await this.raw(["reset", "HEAD", "--", file], cwd).catch(() => "")
}
return { ok: true, message: "Reverted file to base" }
}
async checkApplyPatch(targetPath: string, patch: string): Promise<ApplyCheckResult> {
if (!patch.trim()) {
return { ok: true, conflicts: [], message: "No changes to apply" }
@@ -335,36 +417,42 @@ export class GitOps {
}
private exec(args: string[], cwd: string, options?: ExecOptions): Promise<ExecResult> {
return new Promise((resolve) => {
const child = spawn("git", args, {
cwd,
env: options?.env,
stdio: ["pipe", "pipe", "pipe"],
})
if (this.controller.signal.aborted) {
return Promise.resolve({ code: 1, stdout: "", stderr: "GitOps disposed" })
}
const invoke = () =>
new Promise<ExecResult>((resolve) => {
const child = spawn("git", args, {
cwd,
env: options?.env,
signal: this.controller.signal,
stdio: ["pipe", "pipe", "pipe"],
})
if (options?.stdin !== undefined) {
if (!child.stdin) {
resolve({ code: 1, stdout: "", stderr: "stdin not available for git process" })
return
if (options?.stdin !== undefined) {
if (!child.stdin) {
resolve({ code: 1, stdout: "", stderr: "stdin not available for git process" })
return
}
child.stdin.end(options.stdin)
}
child.stdin.end(options.stdin)
}
const out: Buffer[] = []
const err: Buffer[] = []
child.stdout?.on("data", (chunk: Buffer) => out.push(chunk))
child.stderr?.on("data", (chunk: Buffer) => err.push(chunk))
const out: Buffer[] = []
const err: Buffer[] = []
child.stdout?.on("data", (chunk: Buffer) => out.push(chunk))
child.stderr?.on("data", (chunk: Buffer) => err.push(chunk))
child.on("error", (error) => {
resolve({ code: 1, stdout: "", stderr: error.message })
})
child.on("close", (code) => {
resolve({
code: code ?? 1,
stdout: Buffer.concat(out).toString("utf8"),
stderr: Buffer.concat(err).toString("utf8"),
child.on("error", (error) => {
resolve({ code: 1, stdout: "", stderr: error.message })
})
child.on("close", (code) => {
resolve({
code: code ?? 1,
stdout: Buffer.concat(out).toString("utf8"),
stderr: Buffer.concat(err).toString("utf8"),
})
})
})
})
return this.semaphore ? this.semaphore.run(invoke) : invoke()
}
}
@@ -3,6 +3,7 @@ import * as path from "path"
import type { KiloClient, FileDiff } from "@kilocode/sdk/v2/client"
import { remoteRef, type Worktree } from "./WorktreeStateManager"
import type { GitOps } from "./GitOps"
import type { Semaphore } from "./semaphore"
import { normalizePath } from "./git-import"
export interface WorktreeStats {
@@ -26,6 +27,8 @@ export interface LocalStats {
export interface WorktreePresence {
worktreeId: string
missing: boolean
/** Current branch from `git worktree list`, if available. */
branch?: string
}
export interface WorktreePresenceResult {
@@ -43,6 +46,8 @@ interface GitStatsPollerOptions {
onWorktreePresence?: (result: WorktreePresenceResult) => void
log: (...args: unknown[]) => void
intervalMs?: number
/** Shared concurrency gate for child process spawning. */
semaphore?: Semaphore
}
export class GitStatsPoller {
@@ -152,15 +157,20 @@ export class GitStatsPoller {
return
}
// Gate the HTTP diffSummary call through the semaphore but NOT the
// aheadBehind call — that goes through GitOps.raw() which already
// acquires the same semaphore. Wrapping both would deadlock.
const gate = this.options.semaphore
const diff = (dir: string, base: string) => {
const invoke = () => client.worktree.diffSummary({ directory: dir, base }, { throwOnError: true })
return gate ? gate.run(invoke) : invoke()
}
const stats = (
await Promise.all(
active.map(async (wt) => {
try {
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),
])
const [{ data: diffs }, ab] = await Promise.all([diff(wt.path, base), this.git.aheadBehind(wt.path, base)])
const files = diffs.length
const additions = diffs.reduce((sum: number, diff: FileDiff) => sum + diff.additions, 0)
const deletions = diffs.reduce((sum: number, diff: FileDiff) => sum + diff.deletions, 0)
@@ -231,7 +241,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 }
}),
)
@@ -257,8 +268,10 @@ export class GitStatsPoller {
try {
if (base && client) {
this.options.log(`Local stats: using HTTP client with base=${base}`)
const gate = this.options.semaphore
const invoke = () => client.worktree.diffSummary({ directory: root, base }, { throwOnError: true })
const [{ data: diffs }, ab] = await Promise.all([
client.worktree.diffSummary({ directory: root, base }, { throwOnError: true }),
gate ? gate.run(invoke) : invoke(),
this.git.aheadBehind(root, base),
])
files = diffs.length
@@ -0,0 +1,568 @@
import type { ExecFileOptionsWithStringEncoding } from "child_process"
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"
import type { Semaphore } from "./semaphore"
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
/** Shared concurrency gate for child process spawning. */
semaphore?: Semaphore
}
const GH_PROBE_TTL = 300_000 // 5 minutes — gh installation state rarely changes at runtime
const MAX_BACKOFF = 120_000 // 2 minutes — cap for exponential backoff on repeated errors
const BACKOFF_MULTIPLIER = 2
const PR_LOOKUP_TTL = 10_000 // 10 seconds — short TTL; only the active worktree polls so this stays cheap
const FULL_SYNC_INTERVAL = 120_000 // 2 minutes — periodic sync of ALL worktrees (badges stay fresh)
const FULL_SYNC_CONCURRENCY = 3 // max parallel gh processes during a full sync (caps the burst)
export class PRStatusPoller {
private timer: ReturnType<typeof setTimeout> | undefined
private active = false
private visible = true
private busy = false
private lastHash = new Map<string, string>()
private lastError: string | undefined // tracks global error state for de-duplication
private failures = 0 // consecutive failure count for backoff
private ghAvailable: boolean | undefined
private ghProbeTime = 0
private activeWorktreeId: string | undefined
private cachedRepo: { owner: string; name: string; cwd: string } | undefined
private prCache = new Map<string, { result: PRResult | null; expires: number }>()
private lastFullSync = 0 // timestamp of last full (all-worktree) sync
private readonly intervalMs: number
private readonly semaphore: Semaphore | undefined
constructor(private readonly options: PRStatusPollerOptions) {
this.intervalMs = options.intervalMs ?? 15_000
this.semaphore = options.semaphore
}
/** Run a command through the shared concurrency gate (when configured). */
private shell(
cmd: string,
args: string[],
options?: Omit<ExecFileOptionsWithStringEncoding, "encoding">,
): Promise<{ stdout: string; stderr: string }> {
const invoke = () => execWithShellEnv(cmd, args, options)
return this.semaphore ? this.semaphore.run(invoke) : invoke()
}
setEnabled(enabled: boolean): void {
if (enabled) {
if (this.active) return
this.start()
return
}
this.stop()
}
/** Pause/resume polling based on panel visibility. */
setVisible(visible: boolean): void {
if (this.visible === visible) return
this.visible = visible
if (!this.active) return
if (visible) {
// Resume — expire all PR caches and fetch all worktrees once to catch up,
// then resume the normal active-only poll cycle.
if (this.timer) clearTimeout(this.timer)
this.timer = undefined
this.prCache.clear()
this.lastHash.clear()
this.lastFullSync = 0
void this.poll()
return
}
// Pause — cancel pending timer
if (this.timer) {
clearTimeout(this.timer)
this.timer = undefined
}
}
stop(): void {
this.active = false
if (this.timer) {
clearTimeout(this.timer)
this.timer = undefined
}
this.busy = false
this.lastHash.clear()
this.lastError = undefined
this.failures = 0
this.ghAvailable = undefined
this.ghProbeTime = 0
this.cachedRepo = undefined
this.prCache.clear()
this.lastFullSync = 0
}
/** Force-refresh a specific worktree immediately, bypassing the PR cache. */
refresh(worktreeId: string): void {
if (!this.active) return
const wt = this.options.getWorktrees().find((w) => w.id === worktreeId)
if (wt) this.prCache.delete(wt.branch)
void this.fetchOne(worktreeId)
}
setActiveWorktreeId(id: string | undefined): void {
const prev = this.activeWorktreeId
this.activeWorktreeId = id
// When switching to a different worktree, fetch it immediately so the
// badge updates without waiting for the next poll cycle.
if (id && id !== prev && this.active) void this.fetchOne(id)
}
private start(): void {
this.stop()
this.active = true
// Don't override this.visible — it may already be set to false by
// setVisible() before setEnabled(true) is called.
void this.poll()
}
private nextDelay(): number {
if (this.failures === 0) return this.intervalMs
return Math.min(this.intervalMs * Math.pow(BACKOFF_MULTIPLIER, this.failures), MAX_BACKOFF)
}
private schedule(): void {
if (!this.active || !this.visible) return
const delay = this.nextDelay()
this.timer = setTimeout(() => {
void this.poll()
}, delay)
}
private poll(): Promise<void> {
if (!this.active || !this.visible) return Promise.resolve()
if (this.busy) return Promise.resolve()
this.busy = true
return this.fetchAll().finally(() => {
this.busy = false
this.schedule()
})
}
private async probeGh(): Promise<boolean> {
const now = Date.now()
if (this.ghAvailable !== undefined && now - this.ghProbeTime < GH_PROBE_TTL) {
return this.ghAvailable
}
try {
await this.shell("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
// Most ticks only poll the active worktree for fast feedback. Every
// FULL_SYNC_INTERVAL we poll ALL worktrees so badges stay current even
// for sessions that aren't selected (e.g. CI results changing).
// The very first poll (lastHash empty) also fetches everything.
const worktrees = this.options.getWorktrees()
const now = Date.now()
const initial = this.lastHash.size === 0
const full = initial || now - this.lastFullSync >= FULL_SYNC_INTERVAL
const targets = full ? worktrees : worktrees.filter((wt) => wt.id === this.activeWorktreeId)
if (full) this.lastFullSync = now
if (targets.length === 0) {
this.failures = 0
return
}
const thunks = targets.map((wt) => () => this.fetchOne(wt.id))
const results = full
? await settled(thunks, FULL_SYNC_CONCURRENCY)
: await Promise.allSettled(thunks.map((fn) => fn()))
const ok = results.every((r) => r.status === "fulfilled")
if (ok) {
this.failures = 0
return
}
this.failures++
}
private async fetchOne(worktreeId: string): Promise<void> {
const worktrees = this.options.getWorktrees()
const wt = worktrees.find((w) => w.id === worktreeId)
if (!wt) return
if (!this.options.getWorkspaceRoot()) return
try {
const pr = await this.cachedFetchPR(wt.branch, wt.path)
if (!pr) {
const hash = `${worktreeId}:none`
if (this.lastHash.get(worktreeId) === hash) return
this.lastHash.set(worktreeId, hash)
this.options.onStatus(worktreeId, null)
return
}
const [checks, comments] = await Promise.all([
this.fetchChecks(pr.number, wt.path),
this.activeWorktreeId === worktreeId ? this.fetchComments(pr.number, wt.path) : undefined,
])
const status: PRStatus = {
number: pr.number,
title: pr.title,
url: pr.url,
state: pr.state,
review: pr.review,
checks,
...(comments && { comments }),
additions: pr.additions,
deletions: pr.deletions,
files: pr.files,
}
const hash = `${worktreeId}:${pr.number}:${pr.state}:${pr.review}:${checks.status}:${checks.passed}/${checks.total}:${comments?.total ?? ""}:${comments?.unresolved ?? ""}`
if (this.lastHash.get(worktreeId) === hash) return
this.lastHash.set(worktreeId, hash)
this.options.onStatus(worktreeId, status)
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
const kind = classifyPRError(msg)
this.options.log(`PR fetch failed for ${wt.branch}:`, msg)
const errKey = kind === "gh_missing" ? "gh_missing" : kind === "gh_auth" ? "gh_auth" : "fetch_failed"
if (kind === "gh_missing") this.ghAvailable = false
// De-duplicate: only emit if the error state changed for this worktree
const hash = `${worktreeId}:error:${errKey}`
if (this.lastHash.get(worktreeId) !== hash) {
this.lastHash.set(worktreeId, hash)
this.options.onStatus(worktreeId, null, errKey)
}
throw err // propagate so fetchAll can track failures for backoff
}
}
private static readonly PR_JSON_FIELDS =
"number,title,url,state,isDraft,reviewDecision,additions,deletions,changedFiles,headRefName,headRefOid"
/** Return a cached PR lookup if still fresh, otherwise fetch and cache.
* Keyed by branch name so multiple worktrees on the same branch share
* the cache, and a branch switch in a worktree naturally misses. */
private async cachedFetchPR(branch: string, cwd: string): Promise<PRResult | null> {
const cached = this.prCache.get(branch)
if (cached && Date.now() < cached.expires) return cached.result
const result = await this.fetchPRForBranch(branch, cwd)
this.prCache.set(branch, { result, expires: Date.now() + PR_LOOKUP_TTL })
return result
}
private async fetchPRForBranch(branch: string, cwd: string): Promise<PRResult | null> {
// Strategy 1: bare `gh pr view` — resolves via the branch's tracking ref.
// Works for fork PRs checked out with `gh pr checkout` (tracking ref = refs/pull/N/head).
// Strategy 2: `gh pr view <branch>` — works for same-repo branches pushed to origin.
// Strategy 3: `gh pr list --search "<sha>"` — last resort, finds PRs by HEAD commit SHA.
return (await this.ghPRView(cwd)) ?? (await this.ghPRView(cwd, branch)) ?? (await this.ghPRListBySHA(cwd))
}
/** Run `gh pr view [branch] --json ...` and parse the result, or return null. */
private async ghPRView(cwd: string, branch?: string): Promise<PRResult | null> {
try {
const args = ["pr", "view"]
if (branch) args.push(branch)
args.push("--json", PRStatusPoller.PR_JSON_FIELDS)
const { stdout } = await this.shell("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 this.shell("git", ["rev-parse", "HEAD"], { cwd, timeout: 5_000 })
const head = sha.trim()
if (!head) return null
const { stdout } = await this.shell(
"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 this.shell(
"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 this.shell("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 this.shell(
"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`
}
/** Run async thunks with bounded concurrency, returning settled results. */
async function settled<T>(thunks: (() => Promise<T>)[], concurrency: number): Promise<PromiseSettledResult<T>[]> {
const results: PromiseSettledResult<T>[] = new Array(thunks.length)
let idx = 0
async function run(): Promise<void> {
while (idx < thunks.length) {
const i = idx++
const fn = thunks[i]!
try {
results[i] = { status: "fulfilled", value: await fn() }
} catch (reason) {
results[i] = { status: "rejected", reason }
}
}
}
await Promise.all(Array.from({ length: Math.min(concurrency, thunks.length) }, () => run()))
return results
}
@@ -858,6 +858,15 @@ export class WorktreeManager {
this.log(`defaultBranch: branchLocal failed: ${e}`)
}
// Check if this is an empty repo with no commits (unborn branch).
// rev-parse --verify HEAD exits non-zero only when HEAD has no target
// commit, which is the definitive test for an unborn branch.
try {
await this.git.raw(["rev-parse", "--verify", "HEAD"])
} catch {
throw new Error("This repository has no commits yet. Create an initial commit before using worktrees.")
}
throw new Error("Could not determine default branch")
}
@@ -26,6 +26,27 @@ export interface Worktree {
groupId?: string
/** User-provided display name for the worktree. */
label?: string
/** Cached PR number for instant badge display on reload. */
prNumber?: number
/** Cached PR URL for instant badge display on reload. */
prUrl?: string
/** Cached PR state for correct badge color on reload (open/merged/closed/draft). */
prState?: string
/** Original branch created with the worktree, used for cleanup on deletion.
* Set automatically when `branch` is updated via live sync. */
originalBranch?: string
/** Section this worktree belongs to, or undefined for ungrouped. */
sectionId?: string
}
export interface Section {
id: string
name: string
/** Color label (e.g. "Red", "Blue") mapped to VS Code theme CSS vars at render time, or null for default. */
color: string | null
/** Position among top-level sidebar children (interleaved with ungrouped worktrees). */
order: number
collapsed: boolean
}
/**
@@ -46,6 +67,7 @@ export interface ManagedSession {
interface StateFile {
worktrees: Record<string, Omit<Worktree, "id">>
sessions: Record<string, Omit<ManagedSession, "id">>
sections?: Record<string, Omit<Section, "id">>
tabOrder?: Record<string, string[]>
worktreeOrder?: string[]
sessionsCollapsed?: boolean
@@ -67,6 +89,7 @@ export class WorktreeStateManager {
private readonly file: string
private worktrees = new Map<string, Worktree>()
private sessions = new Map<string, ManagedSession>()
private sections = new Map<string, Section>()
private tabOrder: Record<string, string[]> = {}
private worktreeOrder: string[] = []
private collapsed = false
@@ -163,6 +186,16 @@ export class WorktreeStateManager {
return wt
}
updateWorktreeBranch(id: string, branch: string): boolean {
const wt = this.worktrees.get(id)
if (!wt || wt.branch === branch) return false
if (!wt.originalBranch) wt.originalBranch = wt.branch
this.log(`Updated worktree ${id} branch: ${wt.branch}${branch}`)
wt.branch = branch
void this.save()
return true
}
updateWorktreeLabel(id: string, label: string): void {
const wt = this.worktrees.get(id)
if (!wt) return
@@ -171,16 +204,26 @@ 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 []
// Dissociate all sessions from this worktree (set worktreeId to null)
// Collect and remove all sessions belonging to this worktree
const orphaned: ManagedSession[] = []
for (const s of this.sessions.values()) {
if (s.worktreeId === id) {
s.worktreeId = null
orphaned.push(s)
orphaned.push({ ...s })
this.sessions.delete(s.id)
}
}
@@ -191,7 +234,7 @@ export class WorktreeStateManager {
const idx = this.worktreeOrder.indexOf(id)
if (idx !== -1) this.worktreeOrder.splice(idx, 1)
this.log(`Removed worktree ${id}, orphaned ${orphaned.length} sessions`)
this.log(`Removed worktree ${id}, removed ${orphaned.length} sessions`)
void this.save()
return orphaned
}
@@ -204,12 +247,12 @@ export class WorktreeStateManager {
return session
}
/** Move an existing session to a worktree (promotion). */
moveSession(sessionId: string, worktreeId: string): void {
/** Move an existing session to a worktree (or back to local when null). */
moveSession(sessionId: string, worktreeId: string | null): void {
const session = this.sessions.get(sessionId)
if (!session) return
session.worktreeId = worktreeId
this.log(`Moved session ${sessionId} to worktree ${worktreeId}`)
this.log(`Moved session ${sessionId} to ${worktreeId ?? "local"}`)
void this.save()
}
@@ -255,7 +298,138 @@ export class WorktreeStateManager {
}
setWorktreeOrder(order: string[]): void {
this.worktreeOrder = order
const top = new Set<string>()
for (const sec of this.sections.values()) top.add(sec.id)
for (const wt of this.worktrees.values()) {
if (!wt.sectionId) top.add(wt.id)
}
this.worktreeOrder = order.filter((id) => top.has(id))
// Append any sections/ungrouped worktrees missing from the incoming order
const present = new Set(this.worktreeOrder)
for (const id of top) {
if (!present.has(id)) this.worktreeOrder.push(id)
}
void this.save()
}
// ---------------------------------------------------------------------------
// Sections
// ---------------------------------------------------------------------------
getSections(): Section[] {
return [...this.sections.values()]
}
getSection(id: string): Section | undefined {
return this.sections.get(id)
}
addSection(name: string, color: string | null, worktreeIds?: string[]): Section {
const id = generateId("sec")
const order = this.worktreeOrder.length
const sec: Section = { id, name, color, order, collapsed: false }
this.sections.set(id, sec)
this.worktreeOrder.push(id)
if (worktreeIds) {
for (const wtId of worktreeIds) {
const wt = this.worktrees.get(wtId)
if (wt) {
wt.sectionId = id
// Remove from top-level worktreeOrder since it's now inside a section
const idx = this.worktreeOrder.indexOf(wtId)
if (idx !== -1) this.worktreeOrder.splice(idx, 1)
}
}
}
this.log(`Added section ${id}: "${name}"`)
void this.save()
return sec
}
renameSection(id: string, name: string): void {
const sec = this.sections.get(id)
if (!sec || !name) return
sec.name = name
this.log(`Renamed section ${id} to "${name}"`)
void this.save()
}
setSectionColor(id: string, color: string | null): void {
const sec = this.sections.get(id)
if (!sec) return
sec.color = color
void this.save()
}
toggleSection(id: string): void {
const sec = this.sections.get(id)
if (!sec) return
sec.collapsed = !sec.collapsed
void this.save()
}
deleteSection(id: string): void {
if (!this.sections.delete(id)) return
// Ungroup all worktrees in this section — do NOT delete them
for (const wt of this.worktrees.values()) {
if (wt.sectionId === id) {
wt.sectionId = undefined
if (!this.worktreeOrder.includes(wt.id)) this.worktreeOrder.push(wt.id)
}
}
// Remove from sidebar order
const idx = this.worktreeOrder.indexOf(id)
if (idx !== -1) this.worktreeOrder.splice(idx, 1)
this.log(`Deleted section ${id}, ungrouped its worktrees`)
void this.save()
}
moveSection(id: string, dir: -1 | 1): void {
// Ensure the section is in worktreeOrder (it may be missing if drag-and-drop
// overwrote the order before this section was tracked)
if (this.sections.has(id) && !this.worktreeOrder.includes(id)) {
this.worktreeOrder.push(id)
}
const top = this.worktreeOrder.filter((item) => {
if (this.sections.has(item)) return true
const wt = this.worktrees.get(item)
return !!wt && !wt.sectionId
})
const idx = top.indexOf(id)
const next = idx + dir
if (idx === -1 || next < 0 || next >= top.length) return
const target = top[next]!
const result = [...this.worktreeOrder]
const fi = result.indexOf(id)
if (fi === -1 || result.indexOf(target) === -1) return
result.splice(fi, 1)
const insertAt = result.indexOf(target) + (dir === 1 ? 1 : 0)
result.splice(insertAt, 0, id)
this.worktreeOrder = result
void this.save()
}
moveToSection(worktreeIds: string[], sectionId: string | null): void {
// Expand to include all multi-version siblings (same groupId)
const expanded = new Set(worktreeIds)
for (const wtId of worktreeIds) {
const wt = this.worktrees.get(wtId)
if (!wt?.groupId) continue
for (const sibling of this.worktrees.values()) {
if (sibling.groupId === wt.groupId) expanded.add(sibling.id)
}
}
for (const wtId of expanded) {
const wt = this.worktrees.get(wtId)
if (!wt) continue
wt.sectionId = sectionId ?? undefined
if (sectionId) {
const idx = this.worktreeOrder.indexOf(wtId)
if (idx !== -1) this.worktreeOrder.splice(idx, 1)
} else {
if (!this.worktreeOrder.includes(wtId)) this.worktreeOrder.push(wtId)
}
}
void this.save()
}
@@ -314,6 +488,7 @@ export class WorktreeStateManager {
const data = JSON.parse(content) as StateFile
this.worktrees.clear()
this.sessions.clear()
this.sections.clear()
this.tabOrder = {}
this.worktreeOrder = []
this.reviewDiffStyle = "unified"
@@ -326,21 +501,42 @@ export class WorktreeStateManager {
}) ?? wt.path
this.worktrees.set(id, { id, ...wt, path: fixed })
}
let pruned = 0
for (const [id, s] of Object.entries(data.sessions ?? {})) {
// Skip orphaned sessions (null worktreeId or referencing a deleted worktree)
if (!s.worktreeId || !this.worktrees.has(s.worktreeId)) {
pruned++
continue
}
this.sessions.set(id, { id, ...s })
}
for (const [id, sec] of Object.entries(data.sections ?? {})) {
this.sections.set(id, { id, ...sec })
}
if (data.tabOrder) {
this.tabOrder = data.tabOrder
}
if (data.worktreeOrder) {
this.worktreeOrder = data.worktreeOrder
}
// Normalize: ensure all section IDs and ungrouped worktree IDs are in worktreeOrder
const present = new Set(this.worktreeOrder)
for (const id of this.sections.keys()) {
if (!present.has(id)) this.worktreeOrder.push(id)
}
for (const wt of this.worktrees.values()) {
if (!wt.sectionId && !present.has(wt.id)) this.worktreeOrder.push(wt.id)
}
this.collapsed = data.sessionsCollapsed ?? false
if (data.reviewDiffStyle === "split") {
this.reviewDiffStyle = "split"
}
this.defaultBase = data.defaultBaseBranch
this.log(`Loaded state: ${this.worktrees.size} worktrees, ${this.sessions.size} sessions`)
if (pruned > 0) {
this.log(`Pruned ${pruned} orphaned sessions`)
void this.save()
}
} catch (error) {
const code = (error as NodeJS.ErrnoException).code
if (code !== "ENOENT") {
@@ -350,7 +546,7 @@ export class WorktreeStateManager {
return migration
}
/** Remove worktrees whose directories no longer exist on disk. */
/** Remove worktrees whose directories no longer exist on disk and prune orphaned sessions. */
async validate(root: string): Promise<void> {
let changed = false
for (const wt of [...this.worktrees.values()]) {
@@ -361,7 +557,17 @@ export class WorktreeStateManager {
changed = true
}
}
if (changed) await this.save()
// Prune orphaned sessions (worktreeId is null or references a deleted worktree)
for (const s of [...this.sessions.values()]) {
if (!s.worktreeId || !this.worktrees.has(s.worktreeId)) {
this.sessions.delete(s.id)
changed = true
}
}
if (changed) {
this.log(`Pruned orphaned sessions during validation`)
await this.save()
}
}
/** Wait for any in-flight save to complete without triggering a new one. */
@@ -404,6 +610,13 @@ export class WorktreeStateManager {
const { id: _, ...rest } = s
data.sessions[id] = rest
}
if (this.sections.size > 0) {
data.sections = {}
for (const [id, sec] of this.sections) {
const { id: _, ...rest } = sec
data.sections[id] = rest
}
}
if (Object.keys(this.tabOrder).length > 0) {
data.tabOrder = this.tabOrder
}
@@ -29,7 +29,7 @@ interface WorktreeEntry {
type PRErrorKind = "not_found" | "gh_missing" | "gh_auth" | "unknown"
export type WorktreeSetupErrorCode = "git_not_found" | "not_git_repo" | "lfs_missing"
export type WorktreeSetupErrorCode = "git_not_found" | "not_git_repo" | "lfs_missing" | "no_commits"
export function parsePRUrl(url: string): PRUrlParts | null {
let normalized = url.trim()
@@ -158,5 +158,6 @@ export function classifyWorktreeError(msg: string): WorktreeSetupErrorCode | und
if (msg.includes("ENOENT") || msg.includes("not found in PATH")) return "git_not_found"
if (msg.includes("not a git repository")) return "not_git_repo"
if (msg.includes("Git LFS") && msg.includes("not found")) return "lfs_missing"
if (msg.includes("no commits yet")) return "no_commits"
return undefined
}
@@ -38,6 +38,8 @@ export interface SessionProvider {
trackSession(id: string): void
refreshSessions(): void
registerSession(session: Session): void
/** Recover any pending permission/question prompts for tracked sessions. */
recoverPendingPrompts(): void
dispose(): void
}
@@ -56,9 +58,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 +115,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,114 @@
/**
* 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 type { Semaphore } from "./semaphore"
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
semaphore?: Semaphore
}
/** 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
semaphore?: Semaphore
}): 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(),
semaphore: host.semaphore,
onStatus: (id: string, pr: PRStatus | null, err?: "gh_missing" | "gh_auth" | "fetch_failed") => {
if (err) {
// Don't forward errors to the webview when we have prior PR data
// (in-memory cache or persisted prNumber) — that would overwrite
// the live badge with pr:null. Only forward when there's truly no
// prior data (first poll failed, nothing persisted).
if (!bridge["cache"].has(id) && !host.hasPersistedPR(id))
host.postToWebview({
type: "agentManager.prStatus",
worktreeId: id,
pr: null,
error: err,
} as AgentManagerOutMessage)
return
}
const msg = { type: "agentManager.prStatus", worktreeId: id, pr, error: err } as AgentManagerOutMessage
bridge["cache"].set(id, msg)
host.postToWebview(msg)
host.updateWorktreePR(id, pr?.number, pr?.url, pr?.state)
},
log: (...args: unknown[]) => host.log(...args),
}
}
@@ -0,0 +1,21 @@
import type { WorktreeStateManager } from "./WorktreeStateManager"
import type { AgentManagerInMessage } from "./types"
/** Handle section CRUD messages. Returns true if handled. */
export function handleSection(
state: WorktreeStateManager | undefined,
m: AgentManagerInMessage,
push: () => void,
): boolean {
if (!state) return false
if (m.type === "agentManager.createSection") state.addSection(m.name, m.color ?? null, m.worktreeIds)
else if (m.type === "agentManager.renameSection") state.renameSection(m.sectionId, m.name)
else if (m.type === "agentManager.deleteSection") state.deleteSection(m.sectionId)
else if (m.type === "agentManager.setSectionColor") state.setSectionColor(m.sectionId, m.color)
else if (m.type === "agentManager.toggleSectionCollapsed") state.toggleSection(m.sectionId)
else if (m.type === "agentManager.moveToSection") state.moveToSection(m.worktreeIds, m.sectionId)
else if (m.type === "agentManager.moveSection") state.moveSection(m.sectionId, m.dir)
else return false
push()
return true
}
@@ -0,0 +1,41 @@
/**
* Bounded-concurrency gate for git/gh child processes.
*
* Shared across GitOps and PRStatusPoller so that all polling loops
* (GitStatsPoller, PRStatusPoller, diff watcher) compete for the same
* slots. Prevents process storms when many worktrees are active.
*/
export class Semaphore {
private running = 0
private readonly pending: (() => void)[] = []
constructor(private readonly limit: number) {}
async run<T>(fn: () => Promise<T>): Promise<T> {
await this.acquire()
try {
return await fn()
} finally {
this.release()
}
}
private acquire(): Promise<void> {
if (this.running < this.limit) {
this.running++
return Promise.resolve()
}
return new Promise<void>((resolve) => {
this.pending.push(() => {
this.running++
resolve()
})
})
}
private release(): void {
this.running--
const next = this.pending.shift()
if (next) next()
}
}
@@ -28,7 +28,16 @@ export async function executeVscodeTask(config: SetupTaskConfig): Promise<number
showReuseMessage: false,
}
const execution = await vscode.tasks.executeTask(task)
let execution: vscode.TaskExecution
try {
execution = await vscode.tasks.executeTask(task)
} catch {
// Task type may not be registered in certain VS Code environments
// (e.g. remote, codespaces, or if package.json contribution is not loaded yet).
// Return undefined so SetupScriptRunner treats it as a non-fatal skip
// rather than VS Code surfacing its own error notification.
return undefined
}
return new Promise((resolve, reject) => {
let done = false
+158 -1
View File
@@ -8,7 +8,7 @@
*/
import type { FileDiff } from "@kilocode/sdk/v2/client"
import type { Worktree, ManagedSession } from "./WorktreeStateManager"
import type { Worktree, ManagedSession, Section } from "./WorktreeStateManager"
import type { WorktreeStats, LocalStats } from "./GitStatsPoller"
import type { ApplyConflict } from "./GitOps"
import type { BranchListItem, WorktreeSetupErrorCode } from "./git-import"
@@ -29,6 +29,58 @@ export type WorktreeDiffEntry = FileDiff & {
stamp?: string
}
// ---------------------------------------------------------------------------
// PR status types
// ---------------------------------------------------------------------------
export type PRState = "open" | "draft" | "merged" | "closed"
export type ReviewDecision = "approved" | "changes_requested" | "pending"
export type CheckStatus = "success" | "failure" | "pending" | "skipped" | "cancelled"
export type AggregateCheckStatus = "success" | "failure" | "pending" | "none"
export interface PRCheck {
name: string
status: CheckStatus
url?: string
duration?: string
}
export interface PRComment {
id: string
author: string
avatar?: string
body: string
file?: string
line?: number
url?: string
resolved: boolean
createdAt?: number
}
export interface PRStatus {
number: number
title: string
url: string
state: PRState
review: ReviewDecision | null
checks: {
status: AggregateCheckStatus
total: number
passed: number
failed: number
pending: number
items: PRCheck[]
}
comments?: {
total: number
unresolved: number
items: PRComment[]
}
additions: number
deletions: number
files: number
}
// ---------------------------------------------------------------------------
// Extension → Webview messages (postToWebview)
// ---------------------------------------------------------------------------
@@ -66,6 +118,7 @@ interface StateMessage {
type: "agentManager.state"
worktrees: Worktree[]
sessions: ManagedSession[]
sections?: Section[]
staleWorktreeIds?: string[]
tabOrder?: Record<string, string[]>
worktreeOrder?: string[]
@@ -175,6 +228,21 @@ interface WorktreeDiffFileMessage {
diff: WorktreeDiffEntry | null
}
interface RevertWorktreeFileResultMessage {
type: "agentManager.revertWorktreeFileResult"
sessionId: string
file: string
status: "success" | "error"
message: string
}
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 +270,8 @@ export type AgentManagerOutMessage =
| WorktreeDiffLoadingMessage
| WorktreeDiffMessage
| WorktreeDiffFileMessage
| RevertWorktreeFileResultMessage
| PRStatusOutMessage
| ActionOutMessage
// ---------------------------------------------------------------------------
@@ -244,6 +314,18 @@ interface CloseSessionIn {
sessionId: string
}
/** Persist a non-worktree session to agent-manager.json (worktreeId = null). */
interface PersistSessionIn {
type: "agentManager.persistSession"
sessionId: string
}
/** Remove a non-worktree session from agent-manager.json. */
interface ForgetSessionIn {
type: "agentManager.forgetSession"
sessionId: string
}
interface ConfigureSetupScriptIn {
type: "agentManager.configureSetupScript"
}
@@ -379,6 +461,27 @@ interface StopDiffWatchIn {
type: "agentManager.stopDiffWatch"
}
interface RevertWorktreeFileIn {
type: "agentManager.revertWorktreeFile"
sessionId: string
file: string
}
interface RefreshPRIn {
type: "agentManager.refreshPR"
worktreeId: string
}
interface OpenPRIn {
type: "agentManager.openPR"
worktreeId: string
}
interface OpenSessionsIn {
type: "agentManager.openSessions"
sessionIDs: string[]
}
interface OpenFileIn {
type: "agentManager.openFile"
sessionId: string
@@ -453,6 +556,47 @@ interface ContinueInWorktreeIn {
sessionId: string
}
interface CreateSectionIn {
type: "agentManager.createSection"
name: string
color?: string
worktreeIds?: string[]
}
interface RenameSectionIn {
type: "agentManager.renameSection"
sectionId: string
name: string
}
interface DeleteSectionIn {
type: "agentManager.deleteSection"
sectionId: string
}
interface SetSectionColorIn {
type: "agentManager.setSectionColor"
sectionId: string
color: string | null
}
interface ToggleSectionCollapsedIn {
type: "agentManager.toggleSectionCollapsed"
sectionId: string
}
interface MoveToSectionIn {
type: "agentManager.moveToSection"
worktreeIds: string[]
sectionId: string | null
}
interface MoveSectionIn {
type: "agentManager.moveSection"
sectionId: string
dir: -1 | 1
}
/** All messages the Agent Manager expects from the webview (onMessage input). */
export type AgentManagerInMessage =
| CreateWorktreeIn
@@ -462,6 +606,8 @@ export type AgentManagerInMessage =
| OpenLocallyIn
| AddSessionToWorktreeIn
| CloseSessionIn
| PersistSessionIn
| ForgetSessionIn
| ForkSessionIn
| ConfigureSetupScriptIn
| ShowTerminalIn
@@ -489,6 +635,10 @@ export type AgentManagerInMessage =
| ApplyWorktreeDiffIn
| StartDiffWatchIn
| StopDiffWatchIn
| RevertWorktreeFileIn
| RefreshPRIn
| OpenPRIn
| OpenSessionsIn
| OpenFileIn
| GenericOpenFileIn
| PreviewImageIn
@@ -498,3 +648,10 @@ export type AgentManagerInMessage =
| ClearSessionIn
| AbortIn
| ContinueInWorktreeIn
| CreateSectionIn
| RenameSectionIn
| DeleteSectionIn
| SetSectionColorIn
| ToggleSectionCollapsedIn
| MoveToSectionIn
| MoveSectionIn
@@ -9,17 +9,24 @@ import * as vscode from "vscode"
import type { Host, PanelContext, OutputHandle, SessionProvider, Disposable } from "./host"
import type { KiloConnectionService } from "../services/cli-backend"
import { KiloProvider } from "../KiloProvider"
import { DiffVirtualProvider } from "../DiffVirtualProvider"
import { buildWebviewHtml } from "../utils"
import { openFileInEditor, getWorkspaceRoot } from "../review-utils"
import { TelemetryProxy, type TelemetryEventName } from "../services/telemetry"
export class VscodeHost implements Host {
private diffVirtual: DiffVirtualProvider | undefined
constructor(
private readonly extensionUri: vscode.Uri,
private readonly connectionService: KiloConnectionService,
private readonly context: vscode.ExtensionContext,
) {}
setDiffVirtualProvider(provider: DiffVirtualProvider): void {
this.diffVirtual = provider
}
openPanel(opts: {
onBeforeMessage: (msg: Record<string, unknown>) => Promise<Record<string, unknown> | null>
}): PanelContext {
@@ -74,6 +81,9 @@ export class VscodeHost implements Host {
const provider = new KiloProvider(this.extensionUri, this.connectionService, this.context, {
slimEditMetadata: true,
})
if (this.diffVirtual) {
provider.setDiffVirtualProvider(this.diffVirtual)
}
provider.attachToWebview(panel.webview, {
onBeforeMessage: opts.onBeforeMessage,
})
@@ -85,6 +95,7 @@ export class VscodeHost implements Host {
trackSession: (id) => provider.trackSession(id),
refreshSessions: () => provider.refreshSessions(),
registerSession: (s) => provider.registerSession(s),
recoverPendingPrompts: () => provider.recoverPendingPrompts(),
dispose: () => provider.dispose(),
}
@@ -92,6 +103,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 +113,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 +177,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")
}
+43 -2
View File
@@ -3,6 +3,7 @@ import { KiloProvider } from "./KiloProvider"
import { AgentManagerProvider } from "./agent-manager/AgentManagerProvider"
import { VscodeHost } from "./agent-manager/vscode-host"
import { DiffViewerProvider } from "./DiffViewerProvider"
import { DiffVirtualProvider } from "./DiffVirtualProvider"
import { SettingsEditorProvider } from "./SettingsEditorProvider"
import { SubAgentViewerProvider } from "./SubAgentViewerProvider"
import { EXTENSION_DISPLAY_NAME } from "./constants"
@@ -15,6 +16,7 @@ import { TelemetryProxy } from "./services/telemetry"
import { registerCommitMessageService } from "./services/commit-message"
import { registerCodeActions, registerTerminalActions, KiloCodeActionProvider } from "./services/code-actions"
import { registerToggleAutoApprove } from "./commands/toggle-auto-approve"
import { RemoteStatusService } from "./services/RemoteStatusService"
// Activated via "onStartupFinished" (package.json) so that commands, code actions, keybindings,
// autocomplete, commit-message generation, and URI deep links all work immediately — without
@@ -32,8 +34,13 @@ export function activate(context: vscode.ExtensionContext) {
const browserAutomationService = new BrowserAutomationService(connectionService)
browserAutomationService.syncWithSettings()
// Create remote status service (one status bar item for all webviews)
const remoteService = new RemoteStatusService()
context.subscriptions.push(remoteService)
connectionService.setRemoteService(remoteService)
// Re-register browser automation MCP server on CLI backend reconnect, configure telemetry,
// and reload autocomplete so it picks up the now-available backend connection.
// set remote service client, and reload autocomplete so it picks up the now-available backend connection.
const unsubscribeStateChange = connectionService.onStateChange((state) => {
if (state === "connected") {
browserAutomationService.reregisterIfEnabled()
@@ -41,7 +48,17 @@ export function activate(context: vscode.ExtensionContext) {
if (config) {
telemetry.configure(config.baseUrl, config.password)
}
try {
remoteService.setClient(connectionService.getClient())
console.log("[Kilo New] CLI connected, calling remoteService.refresh()")
remoteService.refresh().catch((err) => console.warn("[Kilo New] initial remote refresh failed:", err))
} catch {
remoteService.setClient(null)
}
AutocompleteServiceManager.getInstance()?.load()
} else {
remoteService.clearState()
remoteService.setClient(null)
}
})
@@ -59,6 +76,7 @@ export function activate(context: vscode.ExtensionContext) {
// Create the provider with shared service
const provider = new KiloProvider(context.extensionUri, connectionService, context)
provider.setRemoteService(remoteService)
// Register the webview view provider for the sidebar.
// retainContextWhenHidden keeps the webview alive when switching to other sidebar panels.
@@ -102,9 +120,11 @@ export function activate(context: vscode.ExtensionContext) {
vscode.window.registerWebviewPanelSerializer("kilo-code.new.TabPanel", {
deserializeWebviewPanel(panel: vscode.WebviewPanel) {
const tabProvider = new KiloProvider(context.extensionUri, connectionService, context)
tabProvider.setRemoteService(remoteService)
tabProvider.setContinueInWorktreeHandler((sessionId, progress) =>
agentManagerProvider.continueFromSidebar(sessionId, progress),
)
tabProvider.setDiffVirtualProvider(diffVirtualProvider)
tabProvider.resolveWebviewPanel(panel)
tabPanels.set(panel, tabProvider)
panel.onDidDispose(
@@ -128,8 +148,15 @@ export function activate(context: vscode.ExtensionContext) {
})
context.subscriptions.push(diffViewerProvider)
// Create diff virtual provider (lightweight single-file diff for permission approval)
const diffVirtualProvider = new DiffVirtualProvider(context.extensionUri)
provider.setDiffVirtualProvider(diffVirtualProvider)
agentManagerHost.setDiffVirtualProvider(diffVirtualProvider)
context.subscriptions.push(diffVirtualProvider)
// Create settings/profile editor provider (opens in editor area, not sidebar)
const settingsEditorProvider = new SettingsEditorProvider(context.extensionUri, connectionService, context)
settingsEditorProvider.setRemoteService(remoteService)
context.subscriptions.push(settingsEditorProvider)
// Create sub-agent viewer provider (read-only editor panel for sub-agent sessions)
@@ -220,8 +247,18 @@ export function activate(context: vscode.ExtensionContext) {
await provider.waitForReady()
provider.postMessage({ type: "triggerTask", text: `Generate a terminal command: ${input}` })
}),
vscode.commands.registerCommand("kilo-code.new.toggleRemote", () => {
remoteService.toggle().catch((err) => console.error("[Kilo New] toggleRemote command failed:", err))
}),
vscode.commands.registerCommand("kilo-code.new.openInTab", () => {
return openKiloInNewTab(context, connectionService, agentManagerProvider, tabPanels)
return openKiloInNewTab(
context,
connectionService,
agentManagerProvider,
tabPanels,
diffVirtualProvider,
remoteService,
)
}),
vscode.commands.registerCommand("kilo-code.new.showChanges", () => {
diffViewerProvider.openPanel()
@@ -353,6 +390,8 @@ async function openKiloInNewTab(
connectionService: KiloConnectionService,
agentManagerProvider: AgentManagerProvider,
tabPanels: Map<vscode.WebviewPanel, KiloProvider>,
diffVirtualProvider: DiffVirtualProvider,
remoteService: RemoteStatusService,
) {
const lastCol = Math.max(...vscode.window.visibleTextEditors.map((e) => e.viewColumn || 0), 0)
const hasVisibleEditors = vscode.window.visibleTextEditors.length > 0
@@ -375,9 +414,11 @@ async function openKiloInNewTab(
}
const tabProvider = new KiloProvider(context.extensionUri, connectionService, context)
tabProvider.setRemoteService(remoteService)
tabProvider.setContinueInWorktreeHandler((sessionId, progress) =>
agentManagerProvider.continueFromSidebar(sessionId, progress),
)
tabProvider.setDiffVirtualProvider(diffVirtualProvider)
tabProvider.resolveWebviewPanel(panel)
tabPanels.set(panel, tabProvider)
@@ -112,13 +112,29 @@ function slimMultiedit(state: Record<string, unknown>): Record<string, unknown>
return next
}
/** write: strip input.content (entire file). Keep filePath + diagnostics. */
/** write: strip input.content, raw diff text, and filediff.before/after. Keep filepath + exists + diagnostics. */
function slimWrite(state: Record<string, unknown>): Record<string, unknown> {
const next = { ...state }
const input = state.input
if (isObj(input) && typeof input.content === "string") {
next.input = { ...input, content: undefined }
}
const meta = state.metadata
if (isObj(meta)) {
const slim: Record<string, unknown> = {}
if (meta.filepath) slim.filepath = meta.filepath
if (meta.exists !== undefined) slim.exists = meta.exists
if (meta.diagnostics) slim.diagnostics = meta.diagnostics
const fd = meta.filediff
if (isObj(fd)) {
slim.filediff = {
...(typeof fd.file === "string" ? { file: fd.file } : {}),
additions: typeof fd.additions === "number" ? fd.additions : 0,
deletions: typeof fd.deletions === "number" ? fd.deletions : 0,
}
}
next.metadata = slim
}
return next
}
@@ -0,0 +1,17 @@
type Meta = { sessionId?: string }
type State = {
metadata?: Meta
}
type Part = {
type?: string
tool?: string
metadata?: Meta
state?: State
}
export function childID(part: Part): string | undefined {
if (part.type !== "tool" || part.tool !== "task") return undefined
return part.metadata?.sessionId ?? part.state?.metadata?.sessionId
}
@@ -0,0 +1,130 @@
import * as vscode from "vscode"
import type { KiloClient } from "@kilocode/sdk/v2/client"
import { t } from "./cli-backend/i18n"
export type RemoteState = { enabled: boolean; connected: boolean }
type Listener = (state: RemoteState) => void
/**
* Singleton service that owns all remote-control state and the VS Code status bar item.
* Replaces the per-webview polling in RemoteIndicator.tsx and ExperimentalTab.tsx
* with a push-based model: one status bar item, zero recurring cost for non-remote users.
*/
export class RemoteStatusService implements vscode.Disposable {
private state: RemoteState = { enabled: false, connected: false }
private bar: vscode.StatusBarItem
private listeners = new Set<Listener>()
private client: KiloClient | null = null
constructor() {
this.bar = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Right, 99)
this.bar.command = "kilo-code.new.toggleRemote"
this.sync()
}
setClient(c: KiloClient | null): void {
this.client = c
}
/** Get current state synchronously. */
getState(): RemoteState {
return this.state
}
updateFromEvent(state: RemoteState): void {
this.update(state)
}
/** Subscribe to state changes. Returns an unsubscribe function. */
onChange(cb: Listener): () => void {
this.listeners.add(cb)
return () => this.listeners.delete(cb)
}
clearState(): void {
this.update({ enabled: false, connected: false })
}
/** One-shot status fetch — broadcasts via onChange if state changed. */
async refresh(): Promise<void> {
if (!this.client) return
const res = await this.client.remote.status().catch((err: unknown) => {
console.warn("[Kilo] remote status refresh failed:", err)
return undefined
})
if (!res?.data) return
this.update({ enabled: res.data.enabled, connected: res.data.connected })
}
/** Toggle remote on/off based on current state. */
async toggle(): Promise<void> {
if (!this.client) return
const { data } = await this.client.remote.status({ throwOnError: true })
if (!data) return
await this.setEnabled(!data.enabled)
}
/** Enable or disable remote. State updates are pushed via events. */
async setEnabled(enabled: boolean): Promise<void> {
if (!this.client) return
if (enabled) {
await this.client.remote.enable({ throwOnError: true })
} else {
await this.client.remote.disable({ throwOnError: true })
}
this.update({ enabled, connected: false })
}
/**
* Handle a remote-related webview message.
* Returns a response message to post back to the webview, or null.
*/
async handleMessage(type: string, enabled?: boolean): Promise<RemoteState | null> {
switch (type) {
case "toggleRemote":
await this.toggle()
return null
case "setRemoteEnabled":
if (enabled === undefined) return null
await this.setEnabled(enabled)
return null
case "requestRemoteStatus":
void this.refresh()
return this.state
}
return null
}
dispose(): void {
this.listeners.clear()
this.bar.dispose()
}
// -- internal ---------------------------------------------------------------
private update(next: RemoteState): void {
if (this.state.enabled === next.enabled && this.state.connected === next.connected) return
this.state = next
this.sync()
for (const cb of this.listeners) cb(next)
}
/** Sync status bar appearance to current state. */
private sync(): void {
if (!this.state.enabled) {
this.bar.hide()
return
}
if (this.state.connected) {
this.bar.text = "$(radio-tower) Kilo Remote"
this.bar.tooltip = t("remote.connected")
this.bar.color = new vscode.ThemeColor("testing.iconPassed")
} else {
this.bar.text = "$(radio-tower) Kilo Remote \u2026"
this.bar.tooltip = t("remote.connecting")
this.bar.color = new vscode.ThemeColor("editorWarning.foreground")
}
this.bar.show()
}
}
@@ -1,15 +1,24 @@
import * as vscode from "vscode"
import { AutocompleteModel } from "../AutocompleteModel"
import { AutocompleteContext, VisibleCodeContext } from "../types"
import type { AutocompleteContext, VisibleCodeContext } from "../types"
import { removePrefixOverlap } from "../continuedev/core/autocomplete/postprocessing/removePrefixOverlap.js"
import { AutocompleteTelemetry } from "../classic-auto-complete/AutocompleteTelemetry"
import { postprocessAutocompleteSuggestion } from "../classic-auto-complete/uselessSuggestionFilter"
import { VisibleCodeTracker } from "../context/VisibleCodeTracker"
import { FileIgnoreController } from "../shims/FileIgnoreController"
import type { KiloConnectionService } from "../../cli-backend"
import type { ChatCompletionRequestMessage, ChatCompletionResponseSender } from "./handleChatCompletionRequest"
import { finalizeChatSuggestion, buildChatPrefix } from "./chat-autocomplete-utils"
interface ChatCompletionRequestMessage {
type: "requestChatCompletion"
text: string
requestId: string
}
interface ChatCompletionResponseSender {
postMessage(message: { type: "chatCompletionResult"; text: string; requestId: string }): void
}
/**
* Chat textarea autocomplete with cached per-request objects.
*
@@ -1,29 +0,0 @@
import { AutocompleteTelemetry } from "../classic-auto-complete/AutocompleteTelemetry"
export interface ChatCompletionAcceptedMessage {
type: "chatCompletionAccepted"
suggestionLength?: number
}
// Singleton telemetry instance for chat-textarea autocomplete
// This ensures we use the same instance across requests and acceptance events
let telemetryInstance: AutocompleteTelemetry | null = null
/**
* Get or create the telemetry instance for chat-textarea autocomplete
*/
export function getChatAutocompleteTelemetry(): AutocompleteTelemetry {
if (!telemetryInstance) {
telemetryInstance = new AutocompleteTelemetry("chat-textarea")
}
return telemetryInstance
}
/**
* Handles a chat completion accepted event from the webview.
* Captures telemetry when the user accepts a suggestion via Tab or ArrowRight.
*/
export function handleChatCompletionAccepted(message: ChatCompletionAcceptedMessage): void {
const telemetry = getChatAutocompleteTelemetry()
telemetry.captureAcceptSuggestion(message.suggestionLength)
}
@@ -1,43 +0,0 @@
import * as vscode from "vscode"
import { VisibleCodeTracker } from "../context/VisibleCodeTracker"
import { FileIgnoreController } from "../shims/FileIgnoreController"
import { ChatTextAreaAutocomplete } from "./ChatTextAreaAutocomplete"
import type { KiloConnectionService } from "../../cli-backend"
export interface ChatCompletionRequestMessage {
type: "requestChatCompletion"
text?: string
requestId?: string
}
export interface ChatCompletionResponseSender {
postMessage(message: { type: "chatCompletionResult"; text: string; requestId: string }): void
}
/**
* Handles a chat completion request from the webview.
* Captures visible code context and generates an autocomplete suggestion.
*/
export async function handleChatCompletionRequest(
message: ChatCompletionRequestMessage,
responseSender: ChatCompletionResponseSender,
connectionService: KiloConnectionService,
): Promise<void> {
const userText = message.text || ""
const requestId = message.requestId || ""
const workspacePath = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath ?? ""
const ignoreController = new FileIgnoreController(workspacePath)
await ignoreController.initialize()
const tracker = new VisibleCodeTracker(workspacePath, ignoreController)
const visibleContext = await tracker.captureVisibleCode()
const autocomplete = new ChatTextAreaAutocomplete(connectionService)
const { suggestion } = await autocomplete.getCompletion(userText, visibleContext)
responseSender.postMessage({ type: "chatCompletionResult", text: suggestion, requestId })
ignoreController.dispose()
}
@@ -1,807 +0,0 @@
# API Reference
Complete API documentation for the Autocomplete & NextEdit library.
## Table of Contents
- [CompletionProvider](#completionprovider)
- [NextEditProvider](#nexteditprovider)
- [MinimalConfigProvider](#minimalconfigprovider)
- [Core Interfaces](#core-interfaces)
- [LLM Adapters](#llm-adapters)
- [Types and Interfaces](#types-and-interfaces)
---
## CompletionProvider
The main class for providing AI-powered code autocompletion.
**Location**: [`core/autocomplete/CompletionProvider.ts`](core/autocomplete/CompletionProvider.ts)
### Constructor
```typescript
constructor(
configHandler: MinimalConfigProvider,
ide: IDE,
_injectedGetLlm: () => Promise<ILLM | undefined>,
_onError: (e: any) => void,
getDefinitionsFromLsp: GetLspDefinitionsFunction
)
```
**Parameters**:
- `configHandler`: Configuration provider for autocomplete options
- `ide`: IDE interface implementation for file I/O and editor operations
- `_injectedGetLlm`: Async function that returns the LLM to use for completions
- `_onError`: Error callback function for handling autocomplete errors
- `getDefinitionsFromLsp`: Function to retrieve LSP definitions for enhanced context
### Methods
#### `provideInlineCompletionItems()`
Generates an autocomplete completion for the given input.
```typescript
async provideInlineCompletionItems(
input: AutocompleteInput,
token: AbortSignal | undefined,
force?: boolean
): Promise<AutocompleteOutcome | undefined>
```
**Parameters**:
- `input`: Autocomplete context including file path, cursor position, recent edits
- `token`: AbortSignal to cancel the request
- `force`: If true, bypasses debouncing
**Returns**: `AutocompleteOutcome` containing the completion text and metadata, or `undefined` if no completion
**Example**:
```typescript
const outcome = await completionProvider.provideInlineCompletionItems(
{
filepath: "/path/to/file.ts",
pos: { line: 10, character: 5 },
completionId: "unique-completion-id",
recentlyEditedRanges: [],
recentlyEditedFiles: new Map(),
clipboardText: "",
},
abortController.signal,
)
```
#### `accept()`
Marks a completion as accepted by the user.
```typescript
accept(completionId: string): void
```
**Parameters**:
- `completionId`: Unique identifier for the accepted completion
**Side Effects**: Updates bracket matching service and completion cache
#### `markDisplayed()`
Marks a completion as having been displayed to the user.
```typescript
markDisplayed(completionId: string, outcome: AutocompleteOutcome): void
```
**Parameters**:
- `completionId`: Unique identifier for the completion
- `outcome`: The autocomplete outcome that was displayed
#### `cancel()`
Cancels any in-progress autocomplete requests.
```typescript
cancel(): void
```
### Configuration Options
See [`MinimalConfigProvider`](#minimalconfigprovider) for configuration options.
---
## NextEditProvider
The main class for providing predictive multi-location code edits.
**Location**: [`core/nextEdit/NextEditProvider.ts`](core/nextEdit/NextEditProvider.ts)
### Constructor
```typescript
constructor(
configHandler: MinimalConfigProvider,
ide: IDE,
_injectedGetLlm: () => Promise<ILLM | undefined>,
_onError: (e: any) => void
)
```
**Parameters**:
- `configHandler`: Configuration provider
- `ide`: IDE interface implementation
- `_injectedGetLlm`: Function returning the LLM for edit predictions
- `_onError`: Error callback
### Methods
#### `getNextEditPrediction()`
Generates predicted edits based on context and recent changes.
```typescript
async getNextEditPrediction(
context: ModelSpecificContext,
signal?: AbortSignal,
usingFullFileDiff?: boolean
): Promise<NextEditOutcome | undefined>
```
**Parameters**:
- `context`: Context including file contents, cursor position, recent edits
- `signal`: Optional AbortSignal to cancel the request
- `usingFullFileDiff`: If true, generates full-file diffs; if false, only edits within a region
**Returns**: `NextEditOutcome` containing predicted edits and final cursor position
**Example**:
```typescript
const outcome = await nextEditProvider.getNextEditPrediction(
{
filepath: "/path/to/file.ts",
pos: { line: 15, character: 0 },
fileContents: currentFileContents,
userEdits: recentDiff,
// ... other context
},
abortController.signal,
false, // Use partial file diff
)
if (outcome) {
console.log("Edit regions:", outcome.editableRegions)
console.log("Diff lines:", outcome.diffLines)
console.log("New cursor:", outcome.finalCursorPosition)
}
```
---
## MinimalConfigProvider
Simple configuration provider that replaces the complex Continue config system.
**Location**: [`core/autocomplete/MinimalConfig.ts`](core/autocomplete/MinimalConfig.ts)
### Constructor
```typescript
constructor(config?: Partial<MinimalConfig>)
```
**Parameters**:
- `config`: Optional partial configuration to override defaults
**Example**:
```typescript
const configProvider = new MinimalConfigProvider({
tabAutocompleteOptions: {
debounceDelay: 200,
maxPromptTokens: 2048,
prefixPercentage: 0.5,
suffixPercentage: 0.3,
useCache: true,
onlyMyCode: false,
},
experimental: {
enableStaticContextualization: true,
},
})
```
### Methods
#### `loadConfig()`
Returns the configuration object.
```typescript
async loadConfig(): Promise<{ config: MinimalConfig }>
```
**Returns**: Promise resolving to an object containing the config
#### `getAutocompleteOptions()`
Gets autocomplete-specific options.
```typescript
getAutocompleteOptions(): TabAutocompleteOptions
```
**Returns**: Autocomplete configuration options
#### `isStaticContextualizationEnabled()`
Checks if static contextualization is enabled.
```typescript
isStaticContextualizationEnabled(): boolean
```
**Returns**: True if enabled, false otherwise
### Configuration Interface
```typescript
interface MinimalConfig {
tabAutocompleteOptions?: TabAutocompleteOptions
experimental?: {
enableStaticContextualization?: boolean
}
modelsByRole?: {
autocomplete?: ILLM[]
}
selectedModelByRole?: {
autocomplete?: ILLM
}
}
```
### TabAutocompleteOptions
```typescript
interface TabAutocompleteOptions {
debounceDelay?: number // Debounce delay in ms (default: 150)
maxPromptTokens?: number // Max tokens for prompt (default: 1024)
prefixPercentage?: number // Percentage of tokens for prefix (default: 0.5)
suffixPercentage?: number // Percentage of tokens for suffix (default: 0.3)
maxSuffixPercentage?: number // Max suffix tokens percentage (default: 0.5)
useCache?: boolean // Enable completion caching (default: true)
onlyMyCode?: boolean // Only use workspace files for context (default: false)
template?: string // Custom prompt template
useFileSuffix?: boolean // Include file suffix in context (default: true)
multilineCompletions?: "always" | "never" | "auto" // Multiline behavior (default: 'auto')
slidingWindowPrefixPercentage?: number // Sliding window prefix % (default: 0.75)
slidingWindowSize?: number // Sliding window size (default: 500)
maxSnippetPercentage?: number // Max tokens for snippets (default: 0.6)
recentlyEditedSimilarityThreshold?: number // Similarity threshold (default: 0.3)
useOtherFiles?: boolean // Use other files for context (default: true)
disableInFiles?: string[] // Glob patterns to disable autocomplete
stopTokens?: string[] // Custom stop tokens
tokensPerCompletion?: number // Tokens per completion (default: 256)
transform?: boolean // Apply post-processing transforms (default: true)
}
```
---
## Core Interfaces
### IDE Interface
The IDE interface abstracts editor operations. Implement this to integrate with your editor.
**Location**: [`core/index.d.ts`](core/index.d.ts)
```typescript
interface IDE {
// File Operations
readFile(filepath: string): Promise<string>
writeFile(filepath: string, contents: string): Promise<void>
// Workspace
getWorkspaceDirs(): Promise<string[]>
listWorkspaceContents(directory?: string): Promise<string[]>
// Editor State
getCurrentFile(): Promise<FileWithContents | undefined>
getCursorPosition(): Promise<Position>
getVisibleFiles(): Promise<string[]>
// Code Navigation
getDefinition(filepath: string, position: Position): Promise<Location[]>
getReferences(filepath: string, position: Position): Promise<Location[]>
getSymbols(filepath: string): Promise<SymbolWithRange[]>
// File Information
readRangeInFile(filepath: string, range: Range): Promise<string>
getStats(filepath: string): Promise<FileStats>
// Edits
applyEdits(edits: FileEdit[]): Promise<void>
// Diff/SCM
getDiff(includeUnstaged: boolean): Promise<string>
getRepoName(dir: string): Promise<string | undefined>
getBranch(dir: string): Promise<string>
// UI
showMessage(message: string, severity?: "info" | "warning" | "error"): Promise<void>
showToast(type: "info" | "warning" | "error", message: string, ...actions: string[]): Promise<string | undefined>
// Terminal
runCommand(command: string, options?: TerminalOptions): Promise<string>
// Clipboard
getClipboardContent(): Promise<{ text: string; copiedAt: number } | undefined>
// Search
getSearchResults(query: string): Promise<string>
subprocess(command: string, cwd?: string): Promise<[string, string]>
// Other
getIdeInfo(): Promise<IdeInfo>
getIdeSettings(): Promise<IdeSettings>
isTelemetryEnabled(): Promise<boolean>
getUniqueId(): Promise<string>
}
```
**Key Methods to Implement**:
- `readFile()`, `writeFile()`: Essential for file I/O
- `getWorkspaceDirs()`: Returns workspace root directories
- `getCurrentFile()`, `getCursorPosition()`: Current editor state
- `applyEdits()`: Apply code changes
- `getDefinition()`: LSP-like functionality for context gathering
### ILLM Interface
The ILLM (Language Model) interface abstracts LLM providers.
**Location**: [`core/index.d.ts`](core/index.d.ts)
```typescript
interface ILLM {
// Required properties
uniqueId: string
model: string
contextLength: number
completionOptions: CompletionOptions
// Provider info
get providerName(): string
get underlyingProviderName(): string
// Optional
apiKey?: string
apiBase?: string
autocompleteOptions?: Partial<TabAutocompleteOptions>
promptTemplates?: PromptTemplates
// Completion methods
complete(prompt: string, signal: AbortSignal, options?: LLMFullCompletionOptions): Promise<string>
streamComplete(
prompt: string,
signal: AbortSignal,
options?: LLMFullCompletionOptions,
): AsyncGenerator<string, PromptLog>
streamFim(
prefix: string,
suffix: string,
signal: AbortSignal,
options?: LLMFullCompletionOptions,
): AsyncGenerator<string, PromptLog>
// Chat methods
chat(messages: ChatMessage[], signal: AbortSignal, options?: LLMFullCompletionOptions): Promise<ChatMessage>
streamChat(
messages: ChatMessage[],
signal: AbortSignal,
options?: LLMFullCompletionOptions,
): AsyncGenerator<ChatMessage, PromptLog>
// Utility methods
countTokens(text: string): number
supportsImages(): boolean
supportsCompletions(): boolean
supportsFim(): boolean
listModels(): Promise<string[]>
}
```
---
## LLM Adapters
### OpenAI
Pre-built adapter for OpenAI and OpenAI-compatible APIs.
**Location**: [`core/llm/llms/OpenAI.ts`](core/llm/llms/OpenAI.ts)
```typescript
import OpenAI from "@continuedev/core/llm/llms/OpenAI"
const llm = new OpenAI({
model: "gpt-4",
apiKey: process.env.OPENAI_API_KEY,
apiBase: "https://api.openai.com/v1", // Optional custom base URL
completionOptions: {
temperature: 0.1,
maxTokens: 1000,
},
})
```
**Constructor Options**:
```typescript
interface OpenAIOptions {
model: string
apiKey: string
apiBase?: string
completionOptions?: CompletionOptions
contextLength?: number
autocompleteOptions?: Partial<TabAutocompleteOptions>
}
```
### Creating Custom LLM Adapters
To create a custom LLM adapter, implement the `ILLM` interface:
```typescript
import { ILLM, CompletionOptions } from "@continuedev/core"
class CustomLLM implements ILLM {
uniqueId = "custom-llm"
model: string
contextLength: number
completionOptions: CompletionOptions
get providerName() {
return "custom"
}
get underlyingProviderName() {
return "custom"
}
constructor(options: { model: string }) {
this.model = options.model
this.contextLength = 4096
this.completionOptions = {
model: options.model,
temperature: 0.1,
}
}
async complete(prompt: string, signal: AbortSignal): Promise<string> {
// Call your LLM API
const response = await fetch("your-api-endpoint", {
method: "POST",
body: JSON.stringify({ prompt }),
signal,
})
return await response.text()
}
async *streamComplete(prompt: string, signal: AbortSignal) {
// Stream from your LLM API
for await (const chunk of streamFromAPI(prompt, signal)) {
yield chunk
}
return { modelTitle: this.model, prompt, completion: "" }
}
// Implement other required methods...
countTokens(text: string): number {
return text.length / 4 // Rough estimate
}
supportsImages() {
return false
}
supportsCompletions() {
return true
}
supportsFim() {
return false
}
// ... other methods
}
```
---
## Types and Interfaces
### AutocompleteInput
Input for autocomplete requests.
```typescript
interface AutocompleteInput {
filepath: string // Path to the file being edited
pos: Position // Cursor position
completionId: string // Unique ID for this completion request
recentlyEditedRanges: Range[] // Recently edited ranges in this file
recentlyEditedFiles: Map<string, [Range, number][]> // Recently edited files
clipboardText: string // Current clipboard content
manuallyPassFileContext?: RangeInFile[] // Manually provided context
}
```
### AutocompleteOutcome
Result of an autocomplete request.
```typescript
interface AutocompleteOutcome {
completion: string // The completion text
completionId: string // Unique ID for this completion
filepath: string // File path
prefix: string // Code prefix (before cursor)
suffix: string // Code suffix (after cursor)
prompt: string // Full prompt sent to LLM
modelTitle: string // Model used
modelProvider: string // Provider used
completionOptions: CompletionOptions // Options used
cacheHit: boolean // Whether cached
latency: number // Response latency in ms
}
```
### NextEditOutcome
Result of a NextEdit prediction.
```typescript
interface NextEditOutcome {
edits: string // The predicted edit text
diffLines: DiffLine[] // Diff representation
editableRegions: Range[] // Regions that were edited
finalCursorPosition: Position // Predicted cursor position after edits
filepath: string // File path
prompt: string // Prompt sent to LLM
modelTitle: string // Model used
latency: number // Response latency in ms
}
```
### Position
Position in a text document.
```typescript
interface Position {
line: number // 0-based line number
character: number // 0-based character offset
}
```
### Range
Range in a text document.
```typescript
interface Range {
start: Position // Start position (inclusive)
end: Position // End position (exclusive)
}
```
### RangeInFile
Range in a specific file.
```typescript
interface RangeInFile {
filepath: string // File path
range: Range // Range within the file
}
```
### DiffLine
A single line in a diff.
```typescript
interface DiffLine {
type: "same" | "new" | "old" // Type of change
line: string // Line content
lineNumber: number // Line number in file
}
```
### FileEdit
An edit to apply to a file.
```typescript
interface FileEdit {
filepath: string // File to edit
range: Range // Range to replace
replacement: string // New content
}
```
---
## Usage Examples
### Complete Example: Autocomplete with Custom IDE
```typescript
import { CompletionProvider, MinimalConfigProvider } from "@continuedev/core/autocomplete"
import { IDE, ILLM, Position } from "@continuedev/core"
import OpenAI from "@continuedev/core/llm/llms/OpenAI"
// 1. Implement IDE interface
class MyIDE implements IDE {
async readFile(filepath: string): Promise<string> {
return fs.readFileSync(filepath, "utf-8")
}
async getWorkspaceDirs(): Promise<string[]> {
return ["/path/to/workspace"]
}
async getCurrentFile() {
return {
filepath: this.currentFilePath,
contents: await this.readFile(this.currentFilePath),
}
}
async getCursorPosition(): Promise<Position> {
return this.cursorPosition
}
// ... implement other methods
}
// 2. Set up configuration
const config = new MinimalConfigProvider({
tabAutocompleteOptions: {
debounceDelay: 150,
maxPromptTokens: 1024,
useCache: true,
},
})
// 3. Set up LLM
const getLlm = async (): Promise<ILLM> => {
return new OpenAI({
apiKey: process.env.OPENAI_API_KEY,
model: "gpt-4",
})
}
// 4. Create completion provider
const ide = new MyIDE()
const provider = new CompletionProvider(
config,
ide,
getLlm,
(error) => console.error(error),
async () => [], // LSP definitions function
)
// 5. Request completion
const outcome = await provider.provideInlineCompletionItems(
{
filepath: "/path/to/file.ts",
pos: { line: 10, character: 5 },
completionId: "completion-1",
recentlyEditedRanges: [],
recentlyEditedFiles: new Map(),
clipboardText: "",
},
new AbortController().signal,
)
if (outcome) {
console.log("Completion:", outcome.completion)
provider.markDisplayed("completion-1", outcome)
}
```
---
## Error Handling
Both `CompletionProvider` and `NextEditProvider` accept an error callback:
```typescript
const onError = (error: any) => {
if (error instanceof Error) {
console.error("Error:", error.message)
// Show user notification
showNotification(error.message)
}
}
const provider = new CompletionProvider(
config,
ide,
getLlm,
onError, // Error callback
getLspDefinitions,
)
```
Common errors:
- **LLM API errors**: Network failures, invalid API keys, rate limits
- **File I/O errors**: Missing files, permission errors
- **Timeout errors**: Long-running requests that are aborted
---
## Performance Considerations
### Caching
Completions are automatically cached using an LRU cache. Configure caching:
```typescript
const config = new MinimalConfigProvider({
tabAutocompleteOptions: {
useCache: true, // Enable caching (default)
},
})
```
### Debouncing
Prevent excessive LLM calls during rapid typing:
```typescript
const config = new MinimalConfigProvider({
tabAutocompleteOptions: {
debounceDelay: 150, // Wait 150ms before requesting (default)
},
})
```
### Abort Signals
Always provide AbortSignals to cancel in-progress requests:
```typescript
const controller = new AbortController()
// Start request
const promise = provider.provideInlineCompletionItems(input, controller.signal)
// Cancel if needed
controller.abort()
```
---
## See Also
- [README.md](README.md) - Project overview and quick start
- [ARCHITECTURE.md](ARCHITECTURE.md) - Technical architecture details
- [EXAMPLES.md](EXAMPLES.md) - More usage examples
- [Core TypeScript Definitions](core/index.d.ts) - Full type definitions
@@ -1,272 +0,0 @@
import { MinimalConfigProvider } from "./MinimalConfig.js"
import { IDE, ILLM } from "../index.js"
import { DEFAULT_AUTOCOMPLETE_OPTS } from "../util/parameters.js"
import { shouldCompleteMultiline } from "./classification/shouldCompleteMultiline.js"
import { ContextRetrievalService } from "./context/ContextRetrievalService.js"
import { isSecurityConcern } from "../indexing/ignore.js"
import { BracketMatchingService } from "./filtering/BracketMatchingService.js"
import { CompletionStreamer } from "./generation/CompletionStreamer.js"
import { postprocessCompletion } from "./postprocessing/index.js"
import { shouldPrefilter } from "./prefiltering/index.js"
import { getAllSnippetsWithoutRace } from "./snippets/index.js"
import { renderPromptWithTokenLimit } from "./templating/index.js"
import { GetLspDefinitionsFunction } from "./types.js"
import { AutocompleteDebouncer } from "./util/AutocompleteDebouncer.js"
import { AutocompleteLoggingService } from "./util/AutocompleteLoggingService.js"
import { AutocompleteLruCacheInMem } from "./util/AutocompleteLruCacheInMem.js"
import { HelperVars } from "./util/HelperVars.js"
import { AutocompleteInput, AutocompleteOutcome } from "./util/types.js"
// Errors that can be expected on occasion even during normal functioning should not be shown.
// Not worth disrupting the user to tell them that a single autocomplete request didn't go through
const ERRORS_TO_IGNORE = [
// From Ollama
"unexpected server status",
"operation was aborted",
]
export class CompletionProvider {
private autocompleteCache = AutocompleteLruCacheInMem.get()
public errorsShown: Set<string> = new Set()
private bracketMatchingService = new BracketMatchingService()
private debouncer = new AutocompleteDebouncer()
private completionStreamer: CompletionStreamer
private loggingService = new AutocompleteLoggingService()
private contextRetrievalService: ContextRetrievalService
constructor(
private readonly configHandler: MinimalConfigProvider,
private readonly ide: IDE,
private readonly _injectedGetLlm: () => Promise<ILLM | undefined>,
private readonly _onError: (e: unknown) => void,
private readonly getDefinitionsFromLsp: GetLspDefinitionsFunction,
) {
this.completionStreamer = new CompletionStreamer(this.onError.bind(this))
this.contextRetrievalService = new ContextRetrievalService(this.ide)
}
private async _prepareLlm(): Promise<ILLM | undefined> {
const llm = await this._injectedGetLlm()
if (!llm) {
return undefined
}
// Temporary fix for JetBrains autocomplete bug as described in https://github.com/continuedev/continue/pull/3022
if (llm.model === undefined && llm.completionOptions?.model !== undefined) {
llm.model = llm.completionOptions.model
}
// Ignore empty API keys for Mistral since we currently write
// a template provider without one during onboarding
if (llm.providerName === "mistral" && llm.apiKey === "") {
return undefined
}
// Set temperature (but don't override)
if (llm.completionOptions.temperature === undefined) {
llm.completionOptions.temperature = 0.01
}
return llm
}
private onError(e: unknown) {
if (
ERRORS_TO_IGNORE.some((err) => (typeof e === "string" ? e.includes(err) : (e as Error)?.message?.includes(err)))
) {
return
}
console.warn("Error generating autocompletion: ", e)
const errorMessage = e instanceof Error ? e.message : String(e)
if (!this.errorsShown.has(errorMessage)) {
this.errorsShown.add(errorMessage)
this._onError(e)
}
}
public cancel() {
this.loggingService.cancel()
}
public accept(completionId: string) {
const outcome = this.loggingService.accept(completionId)
if (!outcome) {
return
}
this.bracketMatchingService.handleAcceptedCompletion(outcome.completion, outcome.filepath)
}
public markDisplayed(completionId: string, outcome: AutocompleteOutcome) {
this.loggingService.markDisplayed(completionId, outcome)
}
private async _getAutocompleteOptions(llm: ILLM) {
const { config } = await this.configHandler.loadConfig()
const options = {
...DEFAULT_AUTOCOMPLETE_OPTS,
...config?.tabAutocompleteOptions,
...llm.autocompleteOptions,
}
// Enable static contextualization if defined.
if (config?.experimental?.enableStaticContextualization) {
options.experimental_enableStaticContextualization = false
}
return options
}
public async provideInlineCompletionItems(
input: AutocompleteInput,
token: AbortSignal | undefined,
force?: boolean,
): Promise<AutocompleteOutcome | undefined> {
try {
// Create abort signal if not given
if (!token) {
const controller = this.loggingService.createAbortController(input.completionId)
token = controller.signal
}
const startTime = Date.now()
const llm = await this._prepareLlm()
if (!llm) {
return undefined
}
if (isSecurityConcern(input.filepath)) {
return undefined
}
const options = await this._getAutocompleteOptions(llm)
// Debounce
if (!force) {
if (await this.debouncer.delayAndShouldDebounce(options.debounceDelay)) {
return undefined
}
}
const helper = await HelperVars.create(input, options, llm.model, this.ide)
if (await shouldPrefilter(helper, await this.ide.getWorkspaceDirs())) {
return undefined
}
const [snippetPayload, workspaceDirs] = await Promise.all([
getAllSnippetsWithoutRace({
helper,
ide: this.ide,
getDefinitionsFromLsp: this.getDefinitionsFromLsp,
contextRetrievalService: this.contextRetrievalService,
}),
this.ide.getWorkspaceDirs(),
])
const { prompt, prefix, suffix, completionOptions } = renderPromptWithTokenLimit({
snippetPayload,
workspaceDirs,
helper,
llm,
})
// Completion
let completion: string | undefined = ""
const cache = await this.autocompleteCache
const cachedCompletion = helper.options.useCache ? await cache.get(helper.prunedPrefix) : undefined
let cacheHit = false
if (cachedCompletion) {
// Cache
cacheHit = true
completion = cachedCompletion
} else {
const multiline = !helper.options.transform || shouldCompleteMultiline(helper)
const completionStream = this.completionStreamer.streamCompletionWithFilters(
token,
llm,
prefix,
suffix,
prompt,
multiline,
completionOptions,
helper,
)
for await (const update of completionStream) {
completion += update
}
// Don't postprocess if aborted
if (token.aborted) {
return undefined
}
const processedCompletion = helper.options.transform
? postprocessCompletion({
completion,
prefix: helper.prunedPrefix,
suffix: helper.prunedSuffix,
llm,
})
: completion
completion = processedCompletion
}
if (!completion) {
return undefined
}
const outcome: AutocompleteOutcome = {
time: Date.now() - startTime,
completion,
prefix,
suffix,
prompt,
modelProvider: llm.underlyingProviderName,
modelName: llm.model,
completionOptions,
cacheHit,
filepath: helper.filepath,
numLines: completion.split("\n").length,
completionId: helper.input.completionId,
gitRepo: "fake-placeholder", //MINIMAL_REPO - came from git
uniqueId: await this.ide.getUniqueId(),
timestamp: new Date().toISOString(),
profileType: this.configHandler.currentProfile?.profileDescription.profileType,
...helper.options,
}
if (options.experimental_enableStaticContextualization) {
outcome.enabledStaticContextualization = true
}
//////////
// Save to cache
if (!outcome.cacheHit && helper.options.useCache) {
;(await this.autocompleteCache)
.put(outcome.prefix, outcome.completion)
.catch((e) => console.warn(`Failed to save to cache: ${e.message}`))
}
// When using the JetBrains extension, Mark as displayed
const ideType = (await this.ide.getIdeInfo()).ideType
if (ideType === "jetbrains") {
this.markDisplayed(input.completionId, outcome)
}
return outcome
} catch (e: unknown) {
this.onError(e)
return undefined
} finally {
this.loggingService.deleteAbortController(input.completionId)
}
}
}
export default CompletionProvider
@@ -1,126 +0,0 @@
/**
* Minimal configuration for autocomplete features.
* This replaces the complex ConfigHandler system with simple hardcoded defaults.
*
* Analysis of ConfigHandler usage:
* - CompletionProvider needs: config.tabAutocompleteOptions, config.experimental.enableStaticContextualization, currentProfile.profileType
*
* The profileType is only used for logging/telemetry, so we can set it to undefined for a minimal extraction.
*/
import { ILLM, TabAutocompleteOptions } from "../index.js"
import { DEFAULT_AUTOCOMPLETE_OPTS } from "../util/parameters.js"
interface MinimalConfig {
tabAutocompleteOptions?: TabAutocompleteOptions
experimental?: {
enableStaticContextualization?: boolean
}
// Minimal model selection support for NextEdit context fetching
modelsByRole?: {
autocomplete?: ILLM[]
}
selectedModelByRole?: {
autocomplete?: ILLM
edit?: ILLM
chat?: ILLM
rerank?: ILLM
}
rules?: unknown[]
}
interface MinimalProfile {
profileDescription: {
profileType?: "control-plane" | "local" | "platform"
}
}
/**
* Default configuration with hardcoded values suitable for autocomplete/NextEdit.
* Uses the same defaults from DEFAULT_AUTOCOMPLETE_OPTS.
*/
const DEFAULT_MINIMAL_CONFIG: MinimalConfig = {
tabAutocompleteOptions: {
...DEFAULT_AUTOCOMPLETE_OPTS,
},
experimental: {
enableStaticContextualization: false,
},
modelsByRole: {
autocomplete: [],
},
selectedModelByRole: {
autocomplete: undefined,
},
}
/**
* Simple config provider that replaces ConfigHandler for autocomplete/NextEdit.
* Returns hardcoded configuration without dependencies on control-plane.
*/
export class MinimalConfigProvider {
private config: MinimalConfig
public currentProfile: MinimalProfile | undefined
constructor(config?: Partial<MinimalConfig>) {
this.config = {
...DEFAULT_MINIMAL_CONFIG,
...config,
tabAutocompleteOptions: {
...DEFAULT_AUTOCOMPLETE_OPTS,
...config?.tabAutocompleteOptions,
} as TabAutocompleteOptions,
experimental: {
...DEFAULT_MINIMAL_CONFIG.experimental,
...config?.experimental,
},
}
// Set a minimal profile for logging purposes
// In a minimal extraction, we don't have a control-plane profile
this.currentProfile = undefined
}
/**
* Returns the config in the same shape as ConfigHandler.loadConfig()
* This maintains API compatibility with existing code.
*/
async loadConfig(): Promise<{ config: MinimalConfig }> {
return { config: this.config }
}
/**
* Get autocomplete options directly
*/
getAutocompleteOptions(): TabAutocompleteOptions {
return this.config.tabAutocompleteOptions || DEFAULT_AUTOCOMPLETE_OPTS
}
/**
* Check if static contextualization is enabled
*/
isStaticContextualizationEnabled(): boolean {
return this.config.experimental?.enableStaticContextualization ?? false
}
/**
* Reload config (stub for compatibility)
*/
async reloadConfig(..._args: unknown[]): Promise<void> {
// No-op for minimal config
}
/**
* Register config update handler (stub for compatibility)
*/
onConfigUpdate(_handler: (event: { config: MinimalConfig; configLoadInterrupted: boolean }) => void): void {
// No-op for minimal config
}
/**
* Register custom context provider (stub for compatibility)
*/
registerCustomContextProvider(_provider: unknown): void {
// No-op for minimal config
}
}
@@ -1,37 +0,0 @@
import { AutocompleteLanguageInfo } from "../constants/AutocompleteLanguageInfo"
import { HelperVars } from "../util/HelperVars"
function shouldCompleteMultilineBasedOnLanguage(language: AutocompleteLanguageInfo, prefix: string, suffix: string) {
return language.useMultiline?.({ prefix, suffix }) ?? true
}
export function shouldCompleteMultiline(helper: HelperVars) {
switch (helper.options.multilineCompletions) {
case "always":
return true
case "never":
return false
default:
break
}
// Always single-line if an intellisense option is selected
if (helper.input.selectedCompletionInfo) {
return true
}
// // Don't complete multi-line if you are mid-line
// if (isMidlineCompletion(helper.fullPrefix, helper.fullSuffix)) {
// return false;
// }
// Don't complete multi-line for single-line comments
if (
helper.lang.singleLineComment &&
helper.fullPrefix.split("\n").slice(-1)[0]?.trimStart().startsWith(helper.lang.singleLineComment)
) {
return false
}
return shouldCompleteMultilineBasedOnLanguage(helper.lang, helper.prunedPrefix, helper.prunedSuffix)
}
@@ -1,29 +0,0 @@
// @ts-nocheck
const getAddress = (person: Person): Address => {
// TODO
}
const logPerson = (person: Person) => {
// TODO
}
const getHardcodedAddress = (): Address => {
// TODO
}
const getAddresses = (people: Person[]): Address[] => {
// TODO
}
const logPersonWithAddres = (person: Person<Address>): Person<Address> => {
// TODO
}
const logPersonOrAddress = (person: Person | Address): Person | Address => {
// TODO
}
const logPersonAndAddress = (person: Person, address: Address) => {
// TODO
}
@@ -1,35 +0,0 @@
// @ts-nocheck
class Group {
getPersonAddress(person: Person): Address {
// TODO
}
getHardcodedAddress(): Address {
// TODO
}
addPerson(person: Person) {
// TODO
}
addPeople(people: Person[]) {
// TODO
}
getAddresses(people: Person[]): Address[] {
// TODO
}
logPersonWithAddress(person: Person<Address>): Person<Address> {
// TODO
}
logPersonOrAddress(person: Person | Address): Person | Address {
// TODO
}
logPersonAndAddress(person: Person, address: Address) {
// TODO
}
}
@@ -1,9 +0,0 @@
// @ts-nocheck
class Group extends BaseClass {}
class Group implements FirstInterface {}
class Group extends BaseClass implements FirstInterface, SecondInterface {}
class Group extends BaseClass<User> implements FirstInterface<User> {}
@@ -1,33 +0,0 @@
// @ts-nocheck
function getAddress(person: Person): Address {
// TODO
}
function getFirstAddress(people: Person[]): Address {
// TODO
}
function logPerson(person: Person) {
// TODO
}
function getHardcodedAddress(): Address {
// TODO
}
function getAddresses(people: Person[]): Address[] {
// TODO
}
function logPersonWithAddress(person: Person<Address>): Person<Address> {
// TODO
}
function logPersonOrAddress(person: Person | Address): Person | Address {
// TODO
}
function logPersonAndAddress(person: Person, address: Address) {
// TODO
}
@@ -1,33 +0,0 @@
// @ts-nocheck
function* getAddress(person: Person): Address {
// TODO
}
function* getFirstAddress(people: Person[]): Address {
// TODO
}
function* logPerson(person: Person) {
// TODO
}
function* getHardcodedAddress(): Address {
// TODO
}
function* getAddresses(people: Person[]): Address[] {
// TODO
}
function* logPersonWithAddress(person: Person<Address>): Person<Address> {
// TODO
}
function* logPersonOrAddress(person: Person | Address): Person | Address {
// TODO
}
function* logPersonAndAddress(person: Person, address: Address) {
// TODO
}
@@ -1,98 +1,3 @@
export const FUNCTIONS = [
{
nodeType: "function_definition with argument and return type",
fileName: "python/functions.py",
language: "Python",
cursorPosition: { line: 15, character: 8 },
definitionPositions: [
{ row: 14, column: 30 }, // Person
{ row: 14, column: 42 }, // Address
],
},
{
nodeType: "function_definition with generic argument and generic return type",
fileName: "python/functions.py",
language: "Python",
cursorPosition: { line: 18, character: 8 },
definitionPositions: [
{ row: 17, column: 35 }, // Group
{ row: 17, column: 42 }, // Person
{ row: 17, column: 53 }, // Group
{ row: 17, column: 61 }, // Address
],
},
{
nodeType: "function_definition with single argument and None return type",
fileName: "python/functions.py",
language: "Python",
cursorPosition: { line: 21, character: 8 },
definitionPositions: [
{ row: 20, column: 29 }, // Person
],
},
{
nodeType: "function_definition with no arguments and single return type",
fileName: "python/functions.py",
language: "Python",
cursorPosition: { line: 24, character: 8 },
definitionPositions: [
{ row: 23, column: 38 }, // Address
],
},
{
nodeType: "function_definition with Union arguments and Union return type",
fileName: "python/functions.py",
language: "Python",
cursorPosition: { line: 27, character: 8 },
definitionPositions: [
{ row: 26, column: 45 }, // Person
{ row: 26, column: 54 }, // Address
{ row: 26, column: 72 }, // Person
{ row: 26, column: 81 }, // Address
],
},
{
nodeType: "function_definition with multiple arguments and None return type",
fileName: "python/functions.py",
language: "Python",
cursorPosition: { line: 30, character: 8 },
definitionPositions: [
{ row: 29, column: 41 }, // Person
{ row: 29, column: 59 }, // Address
],
},
{
nodeType: "function_definition with one argument and Generator return type",
fileName: "python/functions.py",
language: "Python",
cursorPosition: { line: 33, character: 9 },
definitionPositions: [
{ row: 32, column: 40 }, // Person
{ row: 32, column: 62 }, // Address
],
},
{
nodeType: "function_definition inside a class",
fileName: "python/functions.py",
language: "Python",
cursorPosition: { line: 38, character: 12 },
definitionPositions: [
{ row: 37, column: 51 }, // Person
{ row: 37, column: 69 }, // Address
],
},
{
nodeType: "function_definition of an async function",
fileName: "python/functions.py",
language: "Python",
cursorPosition: { line: 41, character: 8 },
definitionPositions: [
{ row: 40, column: 37 }, // Address
{ row: 40, column: 48 }, // Person
],
},
]
export const CLASSES = [
{
nodeType: "class_definition with multiple superclasses",
@@ -1,82 +0,0 @@
import { streamLines } from "../../../diff/util"
import { HelperVars } from "../../util/HelperVars"
import { stopAtStartOf, stopAtStopTokens } from "./charStream"
import {
avoidEmptyComments,
avoidPathLine,
noDoubleNewLine,
showWhateverWeHaveAtXMs,
skipPrefixes,
stopAtLines,
stopAtLinesExact,
stopAtRepeatingLines,
stopAtSimilarLine,
streamWithNewLines,
} from "./lineStream"
const STOP_AT_PATTERNS = ["diff --git"]
export class StreamTransformPipeline {
async *transform(
generator: AsyncGenerator<string>,
prefix: string,
suffix: string,
multiline: boolean,
stopTokens: string[],
fullStop: () => void,
helper: HelperVars,
): AsyncGenerator<string> {
let charGenerator = generator
charGenerator = stopAtStopTokens(generator, [...stopTokens, ...STOP_AT_PATTERNS])
charGenerator = stopAtStartOf(charGenerator, suffix)
for (const charFilter of helper.lang.charFilters ?? []) {
charGenerator = charFilter({
chars: charGenerator,
prefix,
suffix,
filepath: helper.filepath,
multiline,
})
}
let lineGenerator = streamLines(charGenerator)
lineGenerator = stopAtLines(lineGenerator, fullStop)
const lineBelowCursor = this.getLineBelowCursor(helper)
if (lineBelowCursor.trim() !== "") {
lineGenerator = stopAtLinesExact(lineGenerator, fullStop, [lineBelowCursor])
}
lineGenerator = stopAtRepeatingLines(lineGenerator, fullStop)
lineGenerator = avoidEmptyComments(lineGenerator, helper.lang.singleLineComment)
lineGenerator = avoidPathLine(lineGenerator, helper.lang.singleLineComment)
lineGenerator = skipPrefixes(lineGenerator)
lineGenerator = noDoubleNewLine(lineGenerator)
for (const lineFilter of helper.lang.lineFilters ?? []) {
lineGenerator = lineFilter({ lines: lineGenerator, fullStop })
}
lineGenerator = stopAtSimilarLine(lineGenerator, this.getLineBelowCursor(helper), fullStop)
const timeoutValue = helper.options.modelTimeout
lineGenerator = showWhateverWeHaveAtXMs(lineGenerator, timeoutValue!)
const finalGenerator = streamWithNewLines(lineGenerator)
for await (const update of finalGenerator) {
yield update
}
}
private getLineBelowCursor(helper: HelperVars): string {
let lineBelowCursor = ""
let i = 1
while (lineBelowCursor.trim() === "" && helper.pos.line + i <= helper.fileLines.length - 1) {
lineBelowCursor = helper.fileLines[Math.min(helper.pos.line + i, helper.fileLines.length - 1)]
i++
}
return lineBelowCursor
}
}
@@ -1,192 +0,0 @@
import { describe, expect, it } from "vitest"
import { stopAtStartOf, stopAtStopTokens } from "./charStream"
async function* createMockStream(chunks: string[]): AsyncGenerator<string> {
for (const chunk of chunks) {
yield chunk
}
}
async function streamToString(stream: AsyncGenerator<string>): Promise<string> {
let result = ""
for await (const chunk of stream) {
result += chunk
}
return result
}
describe("stopAtStopTokens", () => {
it("should yield characters until a stop token is encountered", async () => {
const mockStream = createMockStream(["Hello", " world", "! Stop", "here"])
const stopTokens = ["Stop"]
const result = stopAtStopTokens(mockStream, stopTokens)
const output = []
for await (const char of result) {
output.push(char)
}
expect(output.join("")).toBe("Hello world! ")
})
it("should handle multiple stop tokens", async () => {
const mockStream = createMockStream(["This", " is a ", "test. END", " of stream"])
const stopTokens = ["END", "STOP", "HALT"]
const result = stopAtStopTokens(mockStream, stopTokens)
expect(await streamToString(result)).toBe("This is a test. ")
})
it("should handle stop tokens split across chunks", async () => {
const mockStream = createMockStream(["Hello", " wo", "r", "ld! ST", "OP now"])
const stopTokens = ["STOP"]
const result = stopAtStopTokens(mockStream, stopTokens)
const output = []
for await (const char of result) {
output.push(char)
}
expect(output.join("")).toBe("Hello world! ")
})
it("should yield all characters if no stop token is encountered", async () => {
const mockStream = createMockStream(["This", " is ", "a complete", " stream"])
const stopTokens = ["END"]
const result = stopAtStopTokens(mockStream, stopTokens)
expect(await streamToString(result)).toBe("This is a complete stream")
})
it("should handle empty chunks", async () => {
const mockStream = createMockStream(["Hello", "", " world", "", "! STOP"])
const stopTokens = ["STOP"]
const result = stopAtStopTokens(mockStream, stopTokens)
expect(await streamToString(result)).toBe("Hello world! ")
})
it("should handle stop token at the beginning of the stream", async () => {
const mockStream = createMockStream(["STOP", "Hello world"])
const stopTokens = ["STOP"]
const result = stopAtStopTokens(mockStream, stopTokens)
const output = []
for await (const char of result) {
output.push(char)
}
expect(output.join("")).toBe("")
})
it("should handle stop token at the end of the stream", async () => {
const mockStream = createMockStream(["Hello world", "STOP"])
const stopTokens = ["STOP"]
const result = stopAtStopTokens(mockStream, stopTokens)
expect(await streamToString(result)).toBe("Hello world")
})
it("should handle multiple stop tokens of different lengths", async () => {
const mockStream = createMockStream(["This is a ", "test with ", "multiple STOP", " tokens END"])
const stopTokens = ["STOP", "END", "HALT"]
const result = stopAtStopTokens(mockStream, stopTokens)
expect(await streamToString(result)).toBe("This is a test with multiple ")
})
it("should handle an empty stream", async () => {
const mockStream = createMockStream([])
const stopTokens = ["STOP"]
const result = stopAtStopTokens(mockStream, stopTokens)
expect(await streamToString(result)).toBe("")
})
it("should handle an empty stop tokens array", async () => {
const mockStream = createMockStream(["Hello", " world!"])
const stopTokens: string[] = []
const result = stopAtStopTokens(mockStream, stopTokens)
const output = []
for await (const char of result) {
output.push(char)
}
expect(output.join("")).toBe("Hello world!")
})
it("should handle stop token when remaining buffer is smaller than maximum stop token length", async () => {
const mockStream = createMockStream(["Hello world!STOP"])
const stopTokens: string[] = ["STOP", "STOP_TOKEN_THAT_IS_LARGER_THAN_BUFFER"]
const result = stopAtStopTokens(mockStream, stopTokens)
expect(await streamToString(result)).toBe("Hello world!")
})
})
describe("stopAtStartOf", () => {
const sampleCode = ` {
method: "GET",
headers: {
"Content-Type": "application/json",
Authorization: \`Bearer \${this.workOsAccessToken}\`,
},
},
);
const data = await response.json();
return data.items;
}
async getContextItems(
query: string,
extras: ContextProviderExtras,
): Promise<ContextItem[]> {
const response = await extras.fetch(
new URL(
\`/proxy/context/\${this.options.id}/retrieve\`,
controlPlaneEnv.CONTROL_PLANE_URL,
),
`
/* Some LLMs, such as Codestral, repeat the suffix of the query. To test our filtering, we cut the sample code at random positions, remove a part of the input
and construct a response, containing the removed part and the suffix. The goal of the stopAtStartOf() method is to detect the start of the suffix in the response */
it("should stop if the start of the suffix is reached", async () => {
const suffix = `
const data = await response.json();
return data.items;
}`
const mockStream = createMockStream(sampleCode.split(/(?! )/g))
const result = stopAtStartOf(mockStream, suffix)
const resultStr = await streamToString(result)
expect(resultStr).toBe(` {
method: "GET",
headers: {
"Content-Type": "application/json",
Authorization: \`Bearer \${this.workOsAccessToken}\`,
},
},
);
`)
})
it("should stop if the start of the suffix is reached, even if the suffix has a prefix", async () => {
const suffix = `
xxxconst data = await response.json();
return data.items;
}`
const mockStream = createMockStream(sampleCode.split(/(?! )/g))
const result = stopAtStartOf(mockStream, suffix)
const resultStr = await streamToString(result)
expect(resultStr).toBe(` {
method: "GET",
headers: {
"Content-Type": "application/json",
Authorization: \`Bearer \${this.workOsAccessToken}\`,
},
},
);
`)
})
})
@@ -1,96 +0,0 @@
/**
* Asynchronously yields characters from the input stream, stopping if a stop token is encountered.
*
* @param {AsyncGenerator<string>} stream - The input stream of characters.
* @param {string[]} stopTokens - Array of tokens that signal when to stop yielding.
* @yields {string} Characters from the input stream.
* @returns {AsyncGenerator<string>} An async generator that yields characters until a stop condition is met.
* @description
* 1. If no stop tokens are provided, yields all characters from the stream.
* 2. Otherwise, buffers incoming chunks and checks for stop tokens.
* 3. Yields characters one by one if no stop token is found at the start of the buffer.
* 4. Stops yielding and returns if a stop token is encountered.
* 5. After the stream ends, filters encountered stop tokens in remaining buffer.
* 6. Yields any remaining buffered characters.
*/
export async function* stopAtStopTokens(stream: AsyncGenerator<string>, stopTokens: string[]): AsyncGenerator<string> {
if (stopTokens.length === 0) {
for await (const char of stream) {
yield char
}
return
}
const maxStopTokenLength = Math.max(...stopTokens.map((token) => token.length))
let buffer = ""
for await (const chunk of stream) {
buffer += chunk
while (buffer.length >= maxStopTokenLength) {
let found = false
for (const stopToken of stopTokens) {
if (buffer.startsWith(stopToken)) {
found = true
return
}
}
if (!found) {
yield buffer[0]
buffer = buffer.slice(1)
}
}
}
// Filter out the possible stop tokens from remaining buffer
stopTokens.forEach((token) => {
buffer = buffer.replace(token, "")
})
// Yield any remaining characters in the buffer
for (const char of buffer) {
yield char
}
}
/**
* Asynchronously yields characters from the input stream.
* Stops if the beginning of the suffix is detected in the stream.
*/
export async function* stopAtStartOf(
stream: AsyncGenerator<string>,
suffix: string,
sequenceLength: number = 20,
): AsyncGenerator<string> {
if (suffix.length < sequenceLength) {
for await (const chunk of stream) {
yield chunk
}
return
}
// We use sequenceLength * 1.5 as a heuristic to make sure we don't miss the sequence if the
// stream is not perfectly aligned with the sequence (small whitespace differences etc).
const targetPart = suffix.trimStart().slice(0, Math.floor(sequenceLength * 1.5))
let buffer = ""
for await (const chunk of stream) {
buffer += chunk
// Check if the targetPart contains contains the buffer at any point
if (buffer.length >= sequenceLength && targetPart.includes(buffer)) {
return // Stop processing when the sequence is found
}
// Yield chunk by chunk, ensuring not to exceed sequenceLength in the buffer
while (buffer.length > sequenceLength) {
yield buffer[0]
buffer = buffer.slice(1)
}
}
// Yield the remaining buffer if it is not contained in the `targetPart`
if (buffer.length > 0) {
yield buffer
}
}
@@ -1,4 +1,4 @@
import { LineStream } from "../../../diff/util"
import type { LineStream } from "../../../diff/util"
import { lineIsRepeated } from "../../util/textSimilarity"
export { lineIsRepeated }
@@ -177,19 +177,6 @@ export async function* stopAtLines(
}
}
/**
* Yield until an exact stop line is encountered, then call fullStop.
*/
export async function* stopAtLinesExact(stream: LineStream, fullStop: () => void, linesToStopAt: string[]): LineStream {
for await (const line of stream) {
if (linesToStopAt.some((stopAt) => line === stopAt)) {
fullStop()
break
}
yield line
}
}
/**
* On the first line only, strip any configured prefix (e.g. "<COMPLETION>").
*/
@@ -230,41 +217,3 @@ export async function* stopAtRepeatingLines(lines: LineStream, fullStop: () => v
previousLine = line
}
}
/**
* Pass through lines, but if the stream takes longer than ms after we have at least one non-empty line, stop early.
*/
export async function* showWhateverWeHaveAtXMs(lines: LineStream, ms: number): LineStream {
const startTime = Date.now()
let firstNonWhitespaceLineYielded = false
for await (const line of lines) {
yield line
if (!firstNonWhitespaceLineYielded && line.trim() !== "") {
firstNonWhitespaceLineYielded = true
}
const isTakingTooLong = Date.now() - startTime > ms
if (isTakingTooLong && firstNonWhitespaceLineYielded) {
break
}
}
}
/**
* Yield lines until the first blank line after some content; then stop.
*/
export async function* noDoubleNewLine(lines: LineStream): LineStream {
let isFirstLine = true
for await (const line of lines) {
if (line.trim() === "" && !isFirstLine) {
return
}
isFirstLine = false
yield line
}
}
@@ -1,60 +0,0 @@
##### Prompt #####
{
"active": true,
"department": "Product Development",
"location": {
"country": "USA",
"state": "California",
"city": "San BERNARDINO",
"coordinates": {
<FIM>
}
},
"employees": [
{
"name": "John Doe",
"age": 30,
"position": "Developer",
"skills": ["JavaScript", "React", "Node.js"],
"remote": false,
"salary": {
"currency": "USD",
"amount": 95000
}
},
{
"name": "Jane Smith",
"age": 25,
"position": "Designer",
"skills": ["Photoshop", "Illustrator"],
"remote": true,
"salary": {
"currency": "USD",
"amount": 70000
}
},
{
"name": "Emily Johnson",
"age": 35,
"position": "Manager",
"teamSize": 8,
"remote": true,
"skills": ["Leadership", "Project Management"],==========================================================================
==========================================================================
Completion:
"latitude": 34.10834,
"longitude": -117.28977
}
},
"employeeCount": 2,
"averageAge": 30,
"remoteFriendly": true,
"salaryRange": {
"min": 70000,
"max": 95000,
"currency": "USD"
},
"skills": {
"required": ["JavaScript", "React", "Node.js", "Leadership", "Project Management"],
"optional": ["Photoshop", "Illustrator"]
@@ -1,128 +0,0 @@
##### Prompt #####
}`,
},
{
description: "Should autocomplete Vue computed property",
filename: "UserComponent.vue",
input: `<template>
<div>
<p>User Full Name: {{ fullName }}</p>
</div>
</template>
<script>
export default {
data() {
return {
firstName: 'John',
lastName: 'Doe',
};
},
computed: {
fullName<|fim|>
},
};
</script>
`,
llmOutput: `() {
return this.firstName + ' ' + this.lastName;
}`,
expectedCompletion: `() {
return this.firstName + ' ' + this.lastName;
}`,
},
{
description: "Should autocomplete Vue method using props",
filename: "TodoItem.vue",
input: `<template>
<li>
<p>{{ title }}</p>
<button @click="completeTodo">Complete</button>
</li>
</template>
<script>
export default {
props: {
title: String,
completed: Boolean,
},
methods: {
completeTodo() {
<|fim|> = true;
}
},
};
</script>
`,
llmOutput: `this.completed`,
expectedCompletion: `this.completed`,
},
{
description: "Should autocomplete Svelte reactive statement",
filename: "Counter.svelte",
input: `
<script>
let count = 0;
$: <|fim|>
function handleClick() {
count += 1;
}
</script>
<button on:click={handleClick}>
Clicked {count} times
</button>
`,
llmOutput: `doubledCount = count * 2`,
expectedCompletion: `doubledCount = count * 2`,
},
{
description: "Should autocomplete Svelte component inside HTML",
filename: "NestedComponent.svelte",
input: `
<script>
import ChildComponent from './ChildComponent.svelte';
</script>
<main>
<h1>Hello Svelte</h1>
<ChildComponent <|fim|> />
</main>
`,
llmOutput: `name="World"`,
expectedCompletion: `name="World"`,
},
{
description: "Should handle autocomplete in Svelte each block",
filename: "List.svelte",
input: `
<script>
let items = ["Apple", "Banana", "Cherry"];
</script>
<ul>
{#each items as item}
<li>{item}</li>
{/each<|fim|>
</ul>
`,
llmOutput: `}`,
expectedCompletion: `}`,
},
<FIM>
];
==========================================================================
==========================================================================
Completion:
export default {
components: {
ChildComponent,
},
@@ -1,20 +0,0 @@
##### Prompt #####
{
"employees": [
{ "name": "John Doe", "age": 30, "position": "Developer" },
{ "name": "Jane Smith", "age": 25, "position": "Designer" },
{ "name": "Emily Johnson", "age": 35, "position": "Manager" }<FIM>
],
"active": true
}
==========================================================================
==========================================================================
Completion:
}
{
"employees": [
{ "name": "John Doe", "age": 30 },
{ "name": "Jane Smith", "age": 25 }
@@ -1,29 +0,0 @@
##### Prompt #####
class Calculator:
def __init__(self):
self.result = 0
def add(self, number):
self.result += number
return self
def divid<FIM>
def subtract(self, number):
self.result -= number
return self
def reset(self):
self.result = 0
return self
def get_result(self):
return self.result
==========================================================================
==========================================================================
Completion:
self.result /= number
return self
@@ -1,41 +0,0 @@
##### Prompt #####
class Calculator
attr_accessor :result
def initialize
@result = 0
end
def add(number)
@result += number
self
end
def subtract(number)
@result -= number
self
end
def power(ne<FIM>
def get_result
@result
end
def reset
@result = 0
self
end
end
calc = Calculator.new
calc.add(5).subtract(3)
puts "Result: #{calc.get_result}"
==========================================================================
==========================================================================
Completion:
@result ** number
self
end
@@ -1,73 +0,0 @@
##### Prompt #####
use std::io;
fn main() {
println!("Welcome to the Calculator!");
loop {
println!("Please enter an operator (+, -, *, /) or 'q' to quit:");
let operator = read_input();
if operator == "q" {
break;
}
println!("Please enter the first number:");
let num1 = read_input().parse::<f64>().unwrap();
println!("Please enter the second number:");
let num2 = read_input().parse::<f64>().unwrap();
let result = match operator.as_str() {
"+" => num1 + num2,
"-" => num1 - num2,
"*" => num1 * num2,
"/" => num1 / num2,
<FIM>
_ => {
println!("Invalid operator. Please try again.");
continue;
}
};
println!("Result: {}", result);
}
}
fn read_input() -> String {
let mut input = String::new();
io::stdin()
.read_line(&mut input)
.expect("Failed to read input");
input.trim().to_string()
}
==========================================================================
==========================================================================
Completion:
=> {
println!("Invalid operator. Please try again.");
continue;
}
};
println!("Result: {}", result);
}
println!("Goodbye!");
}
fn main2() {
println!("Welcome to the Calculator!");
loop {
println!("Please enter an operator (+, -, *, /) or 'q' to quit:");
let operator = read_input();
if operator == "q" {
break;
}
match operator.as_str() {
"+" | "-" | "*" | "/" => (),
@@ -1,37 +0,0 @@
import { afterAll, beforeAll, describe, it } from "vitest"
import { addToTestDir, setUpTestDir, tearDownTestDir } from "../../../test/testDir"
import { TEST_CASES_WITH_DIFF, TEST_CASES_WITHOUT_DIFF } from "./testCases"
import { AutocompleteFileringTestInput, testAutocompleteFiltering } from "./util"
const filterTestCases = (tests: AutocompleteFileringTestInput[]) => {
if (tests.some((test) => test.options?.only)) {
return tests.filter((test) => test.options?.only)
}
return tests
}
describe("Autocomplete filtering tests", () => {
beforeAll(async () => {
tearDownTestDir()
setUpTestDir()
addToTestDir([".continueignore"])
})
afterAll(async () => {
tearDownTestDir()
})
describe("Should return unmodified LLM output", () => {
it.each(filterTestCases(TEST_CASES_WITHOUT_DIFF))("$description", async (testCase) => {
await testAutocompleteFiltering(testCase)
})
})
describe("Should return modified LLM output", () => {
it.each(filterTestCases(TEST_CASES_WITH_DIFF))("$description", async (testCase) => {
await testAutocompleteFiltering(testCase)
})
})
})
@@ -1,81 +0,0 @@
import { expect } from "vitest"
import { MockLLM } from "../../../llm/llms/Mock"
import { testMinimalConfigProvider, testIde } from "../../../test/fixtures"
import { joinPathsToUri } from "../../../util/uri"
import { CompletionProvider } from "../../CompletionProvider"
import { AutocompleteInput } from "../../util/types"
const FIM_DELIMITER = "<|fim|>"
function parseFimExample(text: string): { prefix: string; suffix: string } {
const [prefix, suffix] = text.split(FIM_DELIMITER)
return { prefix, suffix }
}
export interface AutocompleteFileringTestInput {
description: string
filename: string
input: string
llmOutput: string
expectedCompletion: string | null | undefined
options?: {
only?: boolean
}
}
export async function testAutocompleteFiltering(test: AutocompleteFileringTestInput) {
// Normalize line endings to LF for cross-platform compatibility (Windows Git may check out CRLF)
const normalizedInput = test.input.replace(/\r\n/g, "\n")
const normalizedLlmOutput = test.llmOutput.replace(/\r\n/g, "\n")
const { prefix } = parseFimExample(normalizedInput)
// Setup necessary objects
const llm = new MockLLM({
model: "mock",
})
llm.completion = normalizedLlmOutput
const ide = testIde
const configHandler = testMinimalConfigProvider
// Create a real file
const [workspaceDir] = await ide.getWorkspaceDirs()
const fileUri = joinPathsToUri(workspaceDir, test.filename)
await ide.writeFile(fileUri, normalizedInput.replace(FIM_DELIMITER, ""))
// Prepare completion input and provider
const completionProvider = new CompletionProvider(
configHandler,
ide,
async () => llm,
() => {},
async () => [],
)
const line = prefix.split("\n").length - 1
const character = prefix.split("\n")[line].length
const autocompleteInput: AutocompleteInput = {
isUntitledFile: false,
completionId: "test-completion-id",
filepath: fileUri,
pos: {
line,
character,
},
recentlyEditedRanges: [],
recentlyVisitedRanges: [],
}
// Generate a completion
const result = await completionProvider.provideInlineCompletionItems(
autocompleteInput,
undefined,
true, // force=true to skip debounce in tests
)
// Ensure that we return the text that is wanted to be displayed
// Normalize line endings for cross-platform compatibility
const normalizeLineEndings = (str: string | null | undefined) => str?.replace(/\r\n/g, "\n")
expect(normalizeLineEndings(result?.completion)).toEqual(normalizeLineEndings(test.expectedCompletion))
}
@@ -1,79 +0,0 @@
import { CompletionOptions, ILLM } from "../.."
import { StreamTransformPipeline } from "../filtering/streamTransforms/StreamTransformPipeline"
import { HelperVars } from "../util/HelperVars"
import { GeneratorReuseManager } from "./GeneratorReuseManager"
import { stopAfterMaxProcessingTime } from "./utils"
export class CompletionStreamer {
private streamTransformPipeline = new StreamTransformPipeline()
private generatorReuseManager: GeneratorReuseManager
constructor(onError: (err: unknown) => void) {
this.generatorReuseManager = new GeneratorReuseManager(onError)
}
async *streamCompletionWithFilters(
token: AbortSignal,
llm: ILLM,
prefix: string,
suffix: string,
prompt: string,
multiline: boolean,
completionOptions: Partial<CompletionOptions> | undefined,
helper: HelperVars,
) {
// Full stop means to stop the LLM's generation, instead of just truncating the displayed completion
const fullStop = () => this.generatorReuseManager.currentGenerator?.cancel()
// Try to reuse pending requests if what the user typed matches start of completion
const generator = this.generatorReuseManager.getGenerator(
prefix,
(abortSignal: AbortSignal) => {
const generator = llm.supportsFim()
? llm.streamFim(prefix, suffix, abortSignal, completionOptions)
: llm.streamComplete(prompt, abortSignal, {
...completionOptions,
raw: true,
})
/**
* This transformer applies even on reused generator. We are deliberately
* not using streamTransformPipeline because we want to capture and stop
* the request even if the generator is being reused.
*/
return helper.options.transform
? stopAfterMaxProcessingTime(generator, helper.options.modelTimeout * 2.5, fullStop)
: generator
},
multiline,
)
// LLM
const generatorWithCancellation = async function* () {
for await (const update of generator) {
if (token.aborted) {
return
}
yield update
}
}
const initialGenerator = generatorWithCancellation()
const transformedGenerator = helper.options.transform
? this.streamTransformPipeline.transform(
initialGenerator,
prefix,
suffix,
multiline,
completionOptions?.stop || [],
fullStop,
helper,
)
: initialGenerator
for await (const update of transformedGenerator) {
yield update
}
}
}
@@ -1,208 +0,0 @@
import { afterEach, beforeEach, describe, expect, Mock, test, vi } from "vitest"
import { GeneratorReuseManager } from "./GeneratorReuseManager"
function createMockGenerator(data: string[], delay: number = 0): (abortSignal: AbortSignal) => AsyncGenerator<string> {
const mockGenerator = async function* () {
for (const chunk of data) {
yield chunk
if (delay > 0) {
await new Promise((resolve) => setTimeout(resolve, delay))
}
}
}
const newGenerator = vi.fn<() => AsyncGenerator<string>>().mockReturnValue(mockGenerator())
return newGenerator
}
describe("GeneratorReuseManager", () => {
let reuseManager: GeneratorReuseManager
let onErrorMock: Mock
beforeEach(() => {
onErrorMock = vi.fn()
reuseManager = new GeneratorReuseManager(onErrorMock)
})
afterEach(() => {
vi.clearAllMocks()
})
test("creates new generator when there is no current generator", async () => {
const data = ["hello ", "world"]
const newGenerator = createMockGenerator(data)
const prefix = ""
const generator = reuseManager.getGenerator(prefix, newGenerator, true)
const output: string[] = []
for await (const chunk of generator) {
output.push(chunk)
}
expect(output).toEqual(data)
expect(newGenerator).toHaveBeenCalledTimes(1)
})
test("reuses generator when prefix matches pending completion", async () => {
const newGenerator = createMockGenerator(["llo ", "world"])
// First call with initial prefix
const prefix1 = "he"
const generator1 = reuseManager.getGenerator(prefix1, newGenerator, true)
const output1: string[] = []
for await (const chunk of generator1) {
output1.push(chunk)
}
expect(output1).toEqual(["llo ", "world"])
// Second call with extended prefix that matches pending completion
const prefix2 = "hello "
const generator2 = reuseManager.getGenerator(prefix2, newGenerator, true)
const output2: string[] = []
for await (const chunk of generator2) {
output2.push(chunk)
}
expect(output2).toEqual(["world"])
// Ensure generator was reused (newGenerator should be called only once)
expect(newGenerator).toHaveBeenCalledTimes(1)
})
test("creates new generator when prefix does not match pending completion", async () => {
const data = ["goodbye ", "world"]
const newGenerator = createMockGenerator(data)
// Initial generator with different prefix
reuseManager.pendingGeneratorPrefix = "hello "
reuseManager.pendingCompletion = "world"
const prefix = "good"
const generator = reuseManager.getGenerator(prefix, newGenerator, true)
const output: string[] = []
for await (const chunk of generator) {
output.push(chunk)
}
expect(output).toEqual(data)
// Ensure a new generator was created
expect(newGenerator).toHaveBeenCalledTimes(1)
})
test("handles multiline=false by stopping at newline", async () => {
const data = ["first line\n", "second line"]
const newGenerator = createMockGenerator(data)
const prefix = ""
const generator = reuseManager.getGenerator(prefix, newGenerator, false)
const output: string[] = []
for await (const chunk of generator) {
output.push(chunk)
}
expect(output).toEqual(["first line"])
// Ensure it stops after the first newline
})
test("handles multiline=true by not stopping at newline", async () => {
const data = ["first line\n", "second line"]
const newGenerator = createMockGenerator(data)
const prefix = ""
const generator = reuseManager.getGenerator(prefix, newGenerator, true)
const output: string[] = []
for await (const chunk of generator) {
output.push(chunk)
}
expect(output).toEqual(data)
})
test("cancels previous generator when creating a new one", async () => {
const data1 = ["data from generator 1", "not generated"]
const data2 = ["data from generator 2"]
const newGenerator1 = createMockGenerator(data1, 1000) // Delay so we have the chance to cancel it
const newGenerator2 = createMockGenerator(data2)
const prefix1 = "prefix1"
const prefix2 = "prefix2"
// First generator
const generator1 = reuseManager.getGenerator(prefix1, newGenerator1, true)
const output1: string[] = []
for await (const chunk of generator1) {
output1.push(chunk)
// Simulate the generator being canceled before completing
reuseManager.currentGenerator?.cancel()
}
expect(output1.length).toEqual(1)
expect(output1[0]).toEqual(data1[0])
// Second generator
const generator2 = reuseManager.getGenerator(prefix2, newGenerator2, true)
const output2: string[] = []
for await (const chunk of generator2) {
output2.push(chunk)
}
expect(output2).toEqual(data2)
})
test("calls onError when generator throws an error", async () => {
const error = new Error("Generator error")
const mockGenerator = async function* () {
throw error
}
const newGenerator = vi.fn<() => AsyncGenerator<string>>().mockReturnValue(mockGenerator())
const prefix = ""
const generator = reuseManager.getGenerator(prefix, newGenerator, true)
const output: string[] = []
await expect(async () => {
for await (const chunk of generator) {
output.push(chunk)
}
}).not.toThrow() // getGenerator handles errors internally
expect(onErrorMock).toHaveBeenCalledWith(error)
expect(output).toEqual([])
})
test("handles backspacing by creating new generator when prefix is shorter", async () => {
const data = ["hello world"]
const newGenerator1 = createMockGenerator(data)
const newGenerator2 = createMockGenerator(data)
// First prefix
const prefix1 = "hello world"
const generator1 = reuseManager.getGenerator(prefix1, newGenerator1, true)
const output1: string[] = []
for await (const chunk of generator1) {
output1.push(chunk)
}
// Simulate backspace (prefix is shorter)
const prefix2 = "hello worl"
const generator2 = reuseManager.getGenerator(prefix2, newGenerator2, true)
const output2: string[] = []
for await (const chunk of generator2) {
output2.push(chunk)
}
// Ensure a new generator was created
expect(newGenerator1).toHaveBeenCalledTimes(1)
expect(newGenerator2).toHaveBeenCalledTimes(1)
})
})
@@ -1,70 +0,0 @@
import { ListenableGenerator } from "./ListenableGenerator"
export class GeneratorReuseManager {
currentGenerator: ListenableGenerator<string> | undefined
pendingGeneratorPrefix: string | undefined
pendingCompletion = ""
constructor(private readonly onError: (err: unknown) => void) {}
private _createListenableGenerator(abortController: AbortController, gen: AsyncGenerator<string>, prefix: string) {
this.currentGenerator?.cancel()
const listenableGen = new ListenableGenerator(gen, this.onError, abortController)
listenableGen.listen((chunk) => (this.pendingCompletion += chunk ?? ""))
this.pendingGeneratorPrefix = prefix
this.pendingCompletion = ""
this.currentGenerator = listenableGen
}
private shouldReuseExistingGenerator(prefix: string): boolean {
return (
!!this.currentGenerator &&
!!this.pendingGeneratorPrefix &&
(this.pendingGeneratorPrefix + this.pendingCompletion).startsWith(prefix) &&
// for e.g. backspace
this.pendingGeneratorPrefix?.length <= prefix?.length
)
}
async *getGenerator(
prefix: string,
newGenerator: (abortSignal: AbortSignal) => AsyncGenerator<string>,
multiline: boolean,
): AsyncGenerator<string> {
// If we can't reuse, then create a new generator
if (!this.shouldReuseExistingGenerator(prefix)) {
// Create a wrapper over the current generator to fix the prompt
const abortController = new AbortController()
this._createListenableGenerator(abortController, newGenerator(abortController.signal), prefix)
}
// Already typed characters are those that are new in the prefix from the old generator
let typedSinceLastGenerator = prefix.slice(this.pendingGeneratorPrefix?.length) || ""
for await (let chunk of this.currentGenerator?.tee() ?? []) {
if (!chunk) {
continue
}
// Ignore already typed characters in the completion
while (chunk.length && typedSinceLastGenerator.length) {
if (chunk[0] === typedSinceLastGenerator[0]) {
typedSinceLastGenerator = typedSinceLastGenerator.slice(1)
chunk = chunk.slice(1)
} else {
break
}
}
// Break at newline unless we are in multiline mode
const newLineIndex = chunk.indexOf("\n")
if (newLineIndex >= 0 && !multiline) {
yield chunk.slice(0, newLineIndex)
break
} else if (chunk !== "") {
yield chunk
}
}
}
}
@@ -1,148 +0,0 @@
import { describe, expect, it, vi } from "vitest"
import { ListenableGenerator } from "./ListenableGenerator"
describe("ListenableGenerator", () => {
// Helper function to create an async generator
async function* asyncGenerator<T>(values: T[]) {
for (const value of values) {
// Yield on next event loop iteration to ensure async behavior
await new Promise(setImmediate)
yield value
}
}
it("should yield values from the source generator via tee()", async () => {
const values = [1, 2, 3]
const source = asyncGenerator(values)
const onError = vi.fn()
const lg = new ListenableGenerator<number>(source, onError, new AbortController())
const result: number[] = []
for await (const value of lg.tee()) {
result.push(value)
}
expect(result).toEqual(values)
expect(onError).not.toHaveBeenCalled()
})
it("should allow listeners to receive values", async () => {
const values = [1, 2, 3]
const source = asyncGenerator(values)
const onError = vi.fn()
const lg = new ListenableGenerator<number>(source, onError, new AbortController())
const listener = vi.fn()
// Add listener after yielding starts (next event loop iteration)
await new Promise(setImmediate)
lg.listen(listener)
// Wait for generator to actually finish
await lg.waitForCompletion()
expect(listener).toHaveBeenCalledWith(1)
expect(listener).toHaveBeenCalledWith(2)
expect(listener).toHaveBeenCalledWith(3)
// Listener should receive null at the end
expect(listener).toHaveBeenCalledWith(null)
})
it("should buffer values for listeners added after some values have been yielded", async () => {
const values = [1, 2, 3]
const source = asyncGenerator(values)
const onError = vi.fn()
const lg = new ListenableGenerator<number>(source, onError, new AbortController())
const initialListener = vi.fn()
lg.listen(initialListener)
// Wait for the first value to be yielded (next event loop iteration)
await new Promise(setImmediate)
// Add a second listener after first value has been yielded
const newListener = vi.fn()
lg.listen(newListener)
// Wait for generator to actually finish
await lg.waitForCompletion()
// Both listeners should have received all values
;[initialListener, newListener].forEach((listener) => {
expect(listener).toHaveBeenCalledWith(1)
expect(listener).toHaveBeenCalledWith(2)
expect(listener).toHaveBeenCalledWith(3)
expect(listener).toHaveBeenCalledWith(null)
})
})
it("should handle cancellation", async () => {
const values = [1, 2, 3, 4, 5]
const source = asyncGenerator(values)
const onError = vi.fn()
const lg = new ListenableGenerator<number>(source, onError, new AbortController())
const result: number[] = []
const teeIterator = lg.tee()
const consume = async () => {
for await (const value of teeIterator) {
result.push(value)
if (value === 3) {
lg.cancel()
}
}
}
await consume()
expect(result).toEqual([1, 2, 3])
expect(lg["_isEnded"]).toBe(true)
})
it("should call onError when the source generator throws an error", async () => {
async function* errorGenerator() {
yield 1
throw new Error("Test error")
}
const source = errorGenerator()
const onError = vi.fn()
const lg = new ListenableGenerator<number>(source, onError, new AbortController())
const result: number[] = []
for await (const value of lg.tee()) {
result.push(value)
}
expect(result).toEqual([1])
expect(onError).toHaveBeenCalledTimes(1)
expect(onError).toHaveBeenCalledWith(new Error("Test error"))
})
it("should notify listeners when the generator ends", async () => {
const values = [1, 2, 3]
const source = asyncGenerator(values)
const onError = vi.fn()
const lg = new ListenableGenerator<number>(source, onError, new AbortController())
const listener = vi.fn()
lg.listen(listener)
// Wait for the generator to actually finish
await lg.waitForCompletion()
expect(listener).toHaveBeenCalledWith(1)
expect(listener).toHaveBeenCalledWith(2)
expect(listener).toHaveBeenCalledWith(3)
expect(listener).toHaveBeenCalledWith(null)
})
})
@@ -1,84 +0,0 @@
export class ListenableGenerator<T> {
private _source: AsyncGenerator<T>
private _buffer: T[] = []
private _listeners: Set<(value: T) => void> = new Set()
private _isEnded = false
private _abortController: AbortController
private _completionPromise: Promise<void>
constructor(
source: AsyncGenerator<T>,
private readonly onError: (e: unknown) => void,
abortController: AbortController,
) {
this._source = source
this._abortController = abortController
this._completionPromise = this._start().catch((e) => console.log(`Listenable generator failed: ${e.message}`))
}
public cancel() {
this._abortController.abort()
this._isEnded = true
}
public waitForCompletion(): Promise<void> {
return this._completionPromise
}
private async _start() {
try {
for await (const value of this._source) {
if (this._isEnded) {
break
}
this._buffer.push(value)
for (const listener of this._listeners) {
listener(value)
}
}
} catch (e) {
this.onError(e)
} finally {
this._isEnded = true
for (const listener of this._listeners) {
listener(null as any)
}
}
}
listen(listener: (value: T) => void) {
this._listeners.add(listener)
for (const value of this._buffer) {
listener(value)
}
if (this._isEnded) {
listener(null as any)
}
}
async *tee(): AsyncGenerator<T> {
try {
let i = 0
while (i < this._buffer.length) {
yield this._buffer[i++]
}
while (!this._isEnded) {
let resolve: (value: T) => void
const promise = new Promise<T>((res) => {
resolve = res
this._listeners.add(resolve!)
})
await promise
this._listeners.delete(resolve!)
// Possible timing caused something to slip in between
// timers so we iterate over the buffer
while (i < this._buffer.length) {
yield this._buffer[i++]
}
}
} finally {
// this._listeners.delete(resolve!);
}
}
}
@@ -1,126 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
import { stopAfterMaxProcessingTime } from "./utils"
describe("stopAfterMaxProcessingTime", () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
async function* createMockStream(chunks: string[]): AsyncGenerator<string> {
for (const chunk of chunks) {
yield chunk
}
}
async function streamToString(stream: AsyncGenerator<string>): Promise<string> {
let result = ""
for await (const chunk of stream) {
result += chunk
}
return result
}
it("should yield all chunks when maxTimeMs is not reached", async () => {
const mockStream = createMockStream(["Hello", " world", "!"])
const fullStop = vi.fn()
const result = stopAfterMaxProcessingTime(mockStream, 1000, fullStop)
const output = await streamToString(result)
expect(output).toBe("Hello world!")
expect(fullStop).not.toHaveBeenCalled()
})
it("should stop processing after max time is reached", async () => {
// Mock implementation of Date.now
let currentTime = 0
const originalDateNow = Date.now
Date.now = vi.fn(() => currentTime)
// Create a generator that we can control
async function* controlledGenerator(): AsyncGenerator<string> {
for (let i = 0; i < 100; i++) {
// After yielding 10 chunks, simulate time passing beyond our limit
if (i === 10) {
currentTime = 1000 // This exceeds our 500ms limit
}
yield `chunk-${i}`
}
}
const fullStop = vi.fn()
const maxTimeMs = 500
const transformedGenerator = stopAfterMaxProcessingTime(controlledGenerator(), maxTimeMs, fullStop)
// Consume the generator and collect outputs
const outputs: string[] = []
for await (const chunk of transformedGenerator) {
outputs.push(chunk)
}
// We expect:
// 1. Not all chunks were processed (less than 100)
// 2. fullStop was called
// 3. We processed at least the chunks before time was exceeded
expect(outputs.length).toBeLessThan(100)
expect(outputs.length).toBeGreaterThanOrEqual(10) // We should get at least the first 10 chunks
expect(fullStop).toHaveBeenCalled()
// Restore Date.now
Date.now = originalDateNow
})
it("should check time only periodically based on checkInterval", async () => {
const chunks = Array(100).fill("x")
const mockStream = createMockStream(chunks)
const fullStop = vi.fn()
// Spy on Date.now to count how many times it's called
const dateSpy = vi.spyOn(Date, "now")
// Stream should complete normally (not hitting the timeout)
await streamToString(stopAfterMaxProcessingTime(mockStream, 10000, fullStop))
// The first call is to set startTime, then once every checkInterval (10) chunks
// So for 100 chunks, we expect startTime + ~10 checks = ~11 calls
// We use a range because implementation details might vary slightly
expect(dateSpy.mock.calls.length).toBeGreaterThanOrEqual(1)
expect(dateSpy.mock.calls.length).toBeLessThanOrEqual(15)
dateSpy.mockRestore()
})
it("should handle empty stream gracefully", async () => {
const mockStream = createMockStream([])
const fullStop = vi.fn()
const result = stopAfterMaxProcessingTime(mockStream, 1000, fullStop)
const output = await streamToString(result)
expect(output).toBe("")
expect(fullStop).not.toHaveBeenCalled()
})
it("should pass through all chunks if there's no timeout", async () => {
const chunks = Array(100).fill("test chunk")
const mockStream = createMockStream(chunks)
const fullStop = vi.fn()
// Use undefined as timeout to simulate no timeout
const result = stopAfterMaxProcessingTime(mockStream, undefined as any, fullStop)
// Process the stream
const processedChunks = []
for await (const chunk of result) {
processedChunks.push(chunk)
}
expect(processedChunks.length).toBe(chunks.length)
expect(fullStop).not.toHaveBeenCalled()
})
})
@@ -1,25 +0,0 @@
export async function* stopAfterMaxProcessingTime(
stream: AsyncGenerator<string>,
maxTimeMs: number,
fullStop: () => void,
): AsyncGenerator<string> {
const startTime = Date.now()
/**
* Check every 10 chunks to avoid performance overhead.
*/
const checkInterval = 10
let chunkCount = 0
for await (const chunk of stream) {
yield chunk
chunkCount++
if (chunkCount % checkInterval === 0) {
if (Date.now() - startTime > maxTimeMs) {
fullStop()
return
}
}
}
}
@@ -1,57 +0,0 @@
import ignore from "ignore"
import { getConfigJsonPath } from "../../util/paths"
import { findUriInDirs } from "../../util/uri"
import { HelperVars } from "../util/HelperVars"
async function isDisabledForFile(
currentFilepath: string,
disableInFiles: string[] | undefined,
workspaceDirs: string[],
) {
if (disableInFiles) {
// Relative path needed for `ignore`
const { relativePathOrBasename } = findUriInDirs(currentFilepath, workspaceDirs)
const pattern = ignore().add(disableInFiles)
if (pattern.ignores(relativePathOrBasename)) {
return true
}
}
return false
}
export async function shouldPrefilter(helper: HelperVars, workspaceDirs: string[]): Promise<boolean> {
// Allow disabling autocomplete from config.json
if (helper.options.disable) {
return true
}
// Check whether we're in the continue config.json file
if (helper.filepath === getConfigJsonPath()) {
return true
}
// Check whether autocomplete is disabled for this file
const disableInFiles = [
...(helper.options.disableInFiles ?? []),
"*.prompt",
// "some-example-ignored-file", //MINIMAL_REPO - was configurable
]
if (await isDisabledForFile(helper.filepath, disableInFiles, workspaceDirs)) {
return true
}
// Don't offer completions when we have no information (untitled file and no file contents)
if (helper.filepath.includes("Untitled") && helper.fileContents.trim() === "") {
return true
}
// if (
// helper.options.transform &&
// (await shouldLanguageSpecificPrefilter(helper))
// ) {
// return true;
// }
return false
}

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