Merge remote-tracking branch 'origin/main' into feat/suggest-code-review

# Conflicts:
#	packages/kilo-vscode/webview-ui/src/components/chat/ChatView.tsx
This commit is contained in:
Alex Alecu
2026-04-09 16:08:15 +03:00
328 changed files with 7551 additions and 14887 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
+18
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)
@@ -174,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:
@@ -217,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
+16 -17
View File
@@ -1,6 +1,5 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "@kilocode/kilo",
@@ -27,7 +26,7 @@
},
"packages/app": {
"name": "@opencode-ai/app",
"version": "7.2.0",
"version": "7.2.1",
"dependencies": {
"@kilocode/kilo-i18n": "workspace:*",
"@kilocode/kilo-ui": "workspace:*",
@@ -79,7 +78,7 @@
},
"packages/desktop": {
"name": "@opencode-ai/desktop",
"version": "7.2.0",
"version": "7.2.1",
"dependencies": {
"@opencode-ai/app": "workspace:*",
"@opencode-ai/ui": "workspace:*",
@@ -112,7 +111,7 @@
},
"packages/desktop-electron": {
"name": "@opencode-ai/desktop-electron",
"version": "7.2.0",
"version": "7.2.1",
"dependencies": {
"@opencode-ai/app": "workspace:*",
"@opencode-ai/ui": "workspace:*",
@@ -142,7 +141,7 @@
},
"packages/kilo-docs": {
"name": "@kilocode/kilo-docs",
"version": "7.2.0",
"version": "7.2.1",
"dependencies": {
"@docsearch/css": "^4",
"@docsearch/js": "^4",
@@ -171,7 +170,7 @@
},
"packages/kilo-gateway": {
"name": "@kilocode/kilo-gateway",
"version": "7.2.0",
"version": "7.2.1",
"dependencies": {
"@ai-sdk/anthropic": "2.0.65",
"@ai-sdk/openai": "2.0.101",
@@ -206,7 +205,7 @@
},
"packages/kilo-i18n": {
"name": "@kilocode/kilo-i18n",
"version": "7.2.0",
"version": "7.2.1",
"devDependencies": {
"@tsconfig/node22": "catalog:",
"@types/bun": "catalog:",
@@ -219,7 +218,7 @@
},
"packages/kilo-telemetry": {
"name": "@kilocode/kilo-telemetry",
"version": "7.2.0",
"version": "7.2.1",
"dependencies": {
"@kilocode/kilo-gateway": "workspace:*",
"@opentelemetry/api": "1.9.0",
@@ -239,7 +238,7 @@
},
"packages/kilo-ui": {
"name": "@kilocode/kilo-ui",
"version": "7.2.0",
"version": "7.2.1",
"dependencies": {
"@kobalte/core": "0.13.11",
"@opencode-ai/util": "workspace:*",
@@ -274,7 +273,7 @@
},
"packages/kilo-vscode": {
"name": "kilo-code",
"version": "7.2.0",
"version": "7.2.1",
"dependencies": {
"@anthropic-ai/sdk": "^0.39.0",
"@kilocode/kilo-i18n": "workspace:*",
@@ -327,7 +326,7 @@
},
"packages/opencode": {
"name": "@kilocode/cli",
"version": "7.2.0",
"version": "7.2.1",
"bin": {
"kilo": "./bin/kilo",
"kilocode": "./bin/kilo",
@@ -451,7 +450,7 @@
},
"packages/plugin": {
"name": "@kilocode/plugin",
"version": "7.2.0",
"version": "7.2.1",
"dependencies": {
"@kilocode/sdk": "workspace:*",
"zod": "catalog:",
@@ -465,14 +464,14 @@
},
"packages/script": {
"name": "@opencode-ai/script",
"version": "7.2.0",
"version": "7.2.1",
"devDependencies": {
"@types/bun": "catalog:",
},
},
"packages/sdk/js": {
"name": "@kilocode/sdk",
"version": "7.2.0",
"version": "7.2.1",
"devDependencies": {
"@hey-api/openapi-ts": "0.90.10",
"@tsconfig/node22": "catalog:",
@@ -483,7 +482,7 @@
},
"packages/storybook": {
"name": "@opencode-ai/storybook",
"version": "7.2.0",
"version": "7.2.1",
"devDependencies": {
"@opencode-ai/ui": "workspace:*",
"@solidjs/meta": "catalog:",
@@ -506,7 +505,7 @@
},
"packages/ui": {
"name": "@opencode-ai/ui",
"version": "7.2.0",
"version": "7.2.1",
"dependencies": {
"@kilocode/sdk": "workspace:*",
"@kobalte/core": "catalog:",
@@ -553,7 +552,7 @@
},
"packages/util": {
"name": "@opencode-ai/util",
"version": "7.2.0",
"version": "7.2.1",
"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.2.0",
"version": "7.2.1",
"peerDependencies": {}
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/app",
"version": "7.2.0",
"version": "7.2.1",
"description": "",
"type": "module",
"exports": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/desktop-electron",
"private": true,
"version": "7.2.0",
"version": "7.2.1",
"type": "module",
"license": "MIT",
"homepage": "https://opencode.ai",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/desktop",
"private": true,
"version": "7.2.0",
"version": "7.2.1",
"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.2.0"
version = "7.2.1"
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.2.0/opencode-darwin-arm64.zip"
archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.1/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.2.0/opencode-darwin-x64.zip"
archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.1/opencode-darwin-x64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-aarch64]
archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.0/opencode-linux-arm64.tar.gz"
archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.1/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.2.0/opencode-linux-x64.tar.gz"
archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.1/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.2.0/opencode-windows-x64.zip"
archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.1/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.2.0",
"version": "7.2.1",
"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.2.0",
"version": "7.2.1",
"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.2.0",
"version": "7.2.1",
"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.2.0",
"version": "7.2.1",
"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.2.0",
"version": "7.2.1",
"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 */
@@ -1155,6 +1155,7 @@ PART_MAPPING["compaction"] = function CompactionPartDisplay() {
PART_MAPPING["text"] = function TextPartDisplay(props) {
const data = useData()
const i18n = useI18n()
const part = () => props.part as TextPart
const displayText = () => (part().text ?? "").trim()
@@ -1166,6 +1167,21 @@ PART_MAPPING["text"] = function TextPartDisplay(props) {
return props.turnDiffSummary
})
const showCopy = createMemo(() => {
if (props.message.role !== "assistant") return false
if (props.showAssistantCopyPartID === null) return false
return props.showAssistantCopyPartID === part().id
})
const [copied, setCopied] = createSignal(false)
const handleCopy = async () => {
const content = displayText()
if (!content) return
await navigator.clipboard.writeText(content)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
const handleMarkdownClick = (e: MouseEvent) => {
if (!data.openFile) return
const target = e.target
@@ -1200,6 +1216,24 @@ PART_MAPPING["text"] = function TextPartDisplay(props) {
<div data-slot="text-part-body">
<Markdown text={throttledText()} cacheKey={part().id} onClick={handleMarkdownClick} />
</div>
<Show when={showCopy()}>
<div data-slot="assistant-copy-wrapper">
<Tooltip
value={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copyResponse")}
placement="right"
gutter={4}
>
<IconButton
icon={copied() ? "check" : "copy"}
size="normal"
variant="ghost"
onMouseDown={(e) => e.preventDefault()}
onClick={handleCopy}
aria-label={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copyResponse")}
/>
</Tooltip>
</div>
</Show>
<Show when={summary()}>
{(render) => (
<GrowBox animate={!!props.animate} fade gap={4} class="w-full min-w-0">
@@ -1220,6 +1254,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 +1286,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 +1311,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 +1344,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 +1353,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>
@@ -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", 73] },
},
{
files: ["src/agent-manager/AgentManagerProvider.ts"],
rules: { complexity: ["error", 63] },
},
{
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"]
}
+1 -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.2.0",
"version": "7.2.1",
"icon": "assets/icons/logo-outline-black.png",
"galleryBanner": {
"color": "#FFFFFF",
@@ -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()
}
}
+117 -62
View File
@@ -35,7 +35,7 @@ import {
import { GitOps } from "./agent-manager/GitOps"
import { GitStatsPoller, type LocalStats } from "./agent-manager/GitStatsPoller"
import { getWorkspaceRoot } from "./review-utils"
import { MarketplaceService } from "./services/marketplace"
import { MarketplaceService, type MarketplaceItem, type RemoveResult } from "./services/marketplace"
import { resolveProjectDirectory } from "./project-directory"
import { getBusySessionCount, seedSessionStatuses } from "./session-status"
import { retry } from "./services/cli-backend/retry"
@@ -96,12 +96,26 @@ 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"
@@ -189,6 +203,8 @@ 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
constructor(
private readonly extensionUri: vscode.Uri,
private readonly connectionService: KiloConnectionService,
@@ -207,6 +223,10 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
this.postMessage({ type: "workspaceDirectoryChanged", directory: directory ?? "" })
}
public setDiffVirtualProvider(provider: import("./DiffVirtualProvider").DiffVirtualProvider): void {
this.diffVirtualProvider = provider
}
getTelemetryProperties(): Record<string, unknown> {
return {
appName: "kilo-code",
@@ -619,6 +639,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) => {
@@ -982,12 +1007,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,
@@ -1171,6 +1192,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()
@@ -1640,15 +1662,8 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
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
@@ -1762,59 +1777,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) {
@@ -1861,23 +1904,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
}
/**
@@ -6,6 +6,7 @@ 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"
@@ -341,6 +342,7 @@ export class AgentManagerProvider implements Disposable {
this.state?.setSessionsCollapsed(m.collapsed)
return null
}
if (this.handleSection(m)) return null
if (m.type === "agentManager.setReviewDiffStyle") {
this.state?.setReviewDiffStyle(m.style)
return null
@@ -1488,6 +1490,7 @@ export class AgentManagerProvider implements Disposable {
type: "agentManager.state",
worktrees,
sessions: state.getSessions(),
sections: state.getSections(),
staleWorktreeIds,
tabOrder: state.getTabOrder(),
worktreeOrder: state.getWorktreeOrder(),
@@ -1911,6 +1914,10 @@ export class AgentManagerProvider implements Disposable {
)
}
private handleSection(m: AgentManagerInMessage): boolean {
return handleSection(this.state, m, () => this.pushState())
}
public postMessage(message: unknown): void {
this.panel?.postMessage(message)
}
@@ -14,6 +14,9 @@ interface PRStatusPollerOptions {
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
@@ -27,6 +30,8 @@ export class PRStatusPoller {
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
constructor(private readonly options: PRStatusPollerOptions) {
@@ -48,9 +53,13 @@ export class PRStatusPoller {
this.visible = visible
if (!this.active) return
if (visible) {
// Resume — poll immediately then schedule normally
// 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
}
@@ -74,16 +83,24 @@ export class PRStatusPoller {
this.ghAvailable = undefined
this.ghProbeTime = 0
this.cachedRepo = undefined
this.prCache.clear()
this.lastFullSync = 0
}
/** Force-refresh a specific worktree immediately. */
/** 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 {
@@ -146,8 +163,27 @@ export class PRStatusPoller {
}
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 results = await Promise.allSettled(worktrees.map((wt) => this.fetchOne(wt.id)))
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
@@ -164,7 +200,7 @@ export class PRStatusPoller {
if (!this.options.getWorkspaceRoot()) return
try {
const pr = await this.fetchPRForBranch(wt.branch, wt.path)
const pr = await this.cachedFetchPR(wt.branch, wt.path)
if (!pr) {
const hash = `${worktreeId}:none`
if (this.lastHash.get(worktreeId) === hash) return
@@ -217,6 +253,17 @@ export class PRStatusPoller {
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).
@@ -484,3 +531,22 @@ function formatCheckDuration(startedAt?: string, completedAt?: string): string |
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
}
@@ -35,6 +35,18 @@ export interface Worktree {
/** 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
}
/**
@@ -55,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
@@ -76,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
@@ -204,12 +218,12 @@ export class WorktreeStateManager {
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)
}
}
@@ -220,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
}
@@ -284,7 +298,128 @@ export class WorktreeStateManager {
}
setWorktreeOrder(order: string[]): void {
this.worktreeOrder = order
const top = new Set<string>()
for (const sec of this.sections.values()) top.add(sec.id)
for (const wt of this.worktrees.values()) {
if (!wt.sectionId) top.add(wt.id)
}
this.worktreeOrder = order.filter((id) => top.has(id))
void this.save()
}
// ---------------------------------------------------------------------------
// Sections
// ---------------------------------------------------------------------------
getSections(): Section[] {
return [...this.sections.values()]
}
getSection(id: string): Section | undefined {
return this.sections.get(id)
}
addSection(name: string, color: string | null, worktreeIds?: string[]): Section {
const id = generateId("sec")
const order = this.worktreeOrder.length
const sec: Section = { id, name, color, order, collapsed: false }
this.sections.set(id, sec)
this.worktreeOrder.push(id)
if (worktreeIds) {
for (const wtId of worktreeIds) {
const wt = this.worktrees.get(wtId)
if (wt) {
wt.sectionId = id
// Remove from top-level worktreeOrder since it's now inside a section
const idx = this.worktreeOrder.indexOf(wtId)
if (idx !== -1) this.worktreeOrder.splice(idx, 1)
}
}
}
this.log(`Added section ${id}: "${name}"`)
void this.save()
return sec
}
renameSection(id: string, name: string): void {
const sec = this.sections.get(id)
if (!sec || !name) return
sec.name = name
this.log(`Renamed section ${id} to "${name}"`)
void this.save()
}
setSectionColor(id: string, color: string | null): void {
const sec = this.sections.get(id)
if (!sec) return
sec.color = color
void this.save()
}
toggleSection(id: string): void {
const sec = this.sections.get(id)
if (!sec) return
sec.collapsed = !sec.collapsed
void this.save()
}
deleteSection(id: string): void {
if (!this.sections.delete(id)) return
// Ungroup all worktrees in this section — do NOT delete them
for (const wt of this.worktrees.values()) {
if (wt.sectionId === id) {
wt.sectionId = undefined
if (!this.worktreeOrder.includes(wt.id)) this.worktreeOrder.push(wt.id)
}
}
// Remove from sidebar order
const idx = this.worktreeOrder.indexOf(id)
if (idx !== -1) this.worktreeOrder.splice(idx, 1)
this.log(`Deleted section ${id}, ungrouped its worktrees`)
void this.save()
}
moveSection(id: string, dir: -1 | 1): void {
const top = this.worktreeOrder.filter((item) => {
if (this.sections.has(item)) return true
const wt = this.worktrees.get(item)
return !!wt && !wt.sectionId
})
const idx = top.indexOf(id)
const next = idx + dir
if (idx === -1 || next < 0 || next >= top.length) return
const target = top[next]!
const result = [...this.worktreeOrder]
const fi = result.indexOf(id)
if (fi === -1 || result.indexOf(target) === -1) return
result.splice(fi, 1)
const insertAt = result.indexOf(target) + (dir === 1 ? 1 : 0)
result.splice(insertAt, 0, id)
this.worktreeOrder = result
void this.save()
}
moveToSection(worktreeIds: string[], sectionId: string | null): void {
// Expand to include all multi-version siblings (same groupId)
const expanded = new Set(worktreeIds)
for (const wtId of worktreeIds) {
const wt = this.worktrees.get(wtId)
if (!wt?.groupId) continue
for (const sibling of this.worktrees.values()) {
if (sibling.groupId === wt.groupId) expanded.add(sibling.id)
}
}
for (const wtId of expanded) {
const wt = this.worktrees.get(wtId)
if (!wt) continue
wt.sectionId = sectionId ?? undefined
if (sectionId) {
const idx = this.worktreeOrder.indexOf(wtId)
if (idx !== -1) this.worktreeOrder.splice(idx, 1)
} else {
if (!this.worktreeOrder.includes(wtId)) this.worktreeOrder.push(wtId)
}
}
void this.save()
}
@@ -343,6 +478,7 @@ export class WorktreeStateManager {
const data = JSON.parse(content) as StateFile
this.worktrees.clear()
this.sessions.clear()
this.sections.clear()
this.tabOrder = {}
this.worktreeOrder = []
this.reviewDiffStyle = "unified"
@@ -355,21 +491,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") {
@@ -379,7 +536,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()]) {
@@ -390,7 +547,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. */
@@ -433,6 +600,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
}
@@ -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
}
@@ -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"
@@ -118,6 +118,7 @@ interface StateMessage {
type: "agentManager.state"
worktrees: Worktree[]
sessions: ManagedSession[]
sections?: Section[]
staleWorktreeIds?: string[]
tabOrder?: Record<string, string[]>
worktreeOrder?: string[]
@@ -523,6 +524,47 @@ interface ContinueInWorktreeIn {
sessionId: string
}
interface CreateSectionIn {
type: "agentManager.createSection"
name: string
color?: string
worktreeIds?: string[]
}
interface RenameSectionIn {
type: "agentManager.renameSection"
sectionId: string
name: string
}
interface DeleteSectionIn {
type: "agentManager.deleteSection"
sectionId: string
}
interface SetSectionColorIn {
type: "agentManager.setSectionColor"
sectionId: string
color: string | null
}
interface ToggleSectionCollapsedIn {
type: "agentManager.toggleSectionCollapsed"
sectionId: string
}
interface MoveToSectionIn {
type: "agentManager.moveToSection"
worktreeIds: string[]
sectionId: string | null
}
interface MoveSectionIn {
type: "agentManager.moveSection"
sectionId: string
dir: -1 | 1
}
/** All messages the Agent Manager expects from the webview (onMessage input). */
export type AgentManagerInMessage =
| CreateWorktreeIn
@@ -570,3 +612,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,
})
+11 -1
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"
@@ -105,6 +106,7 @@ export function activate(context: vscode.ExtensionContext) {
tabProvider.setContinueInWorktreeHandler((sessionId, progress) =>
agentManagerProvider.continueFromSidebar(sessionId, progress),
)
tabProvider.setDiffVirtualProvider(diffVirtualProvider)
tabProvider.resolveWebviewPanel(panel)
tabPanels.set(panel, tabProvider)
panel.onDidDispose(
@@ -128,6 +130,12 @@ 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)
context.subscriptions.push(settingsEditorProvider)
@@ -221,7 +229,7 @@ export function activate(context: vscode.ExtensionContext) {
provider.postMessage({ type: "triggerTask", text: `Generate a terminal command: ${input}` })
}),
vscode.commands.registerCommand("kilo-code.new.openInTab", () => {
return openKiloInNewTab(context, connectionService, agentManagerProvider, tabPanels)
return openKiloInNewTab(context, connectionService, agentManagerProvider, tabPanels, diffVirtualProvider)
}),
vscode.commands.registerCommand("kilo-code.new.showChanges", () => {
diffViewerProvider.openPanel()
@@ -353,6 +361,7 @@ async function openKiloInNewTab(
connectionService: KiloConnectionService,
agentManagerProvider: AgentManagerProvider,
tabPanels: Map<vscode.WebviewPanel, KiloProvider>,
diffVirtualProvider: DiffVirtualProvider,
) {
const lastCol = Math.max(...vscode.window.visibleTextEditors.map((e) => e.viewColumn || 0), 0)
const hasVisibleEditors = vscode.window.visibleTextEditors.length > 0
@@ -378,6 +387,7 @@ async function openKiloInNewTab(
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
}
@@ -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
}
@@ -1,262 +0,0 @@
import { afterEach, describe, expect, it, vi } from "vitest"
// ---------- Module mocks ----------
// Simple Handlebars mock that does naive placeholder substitution
vi.mock("handlebars", () => {
return {
default: {
compile: (template: string) => (ctx: Record<string, string>) => {
return template
.replace(/{{prefix}}/g, ctx.prefix)
.replace(/{{suffix}}/g, ctx.suffix)
.replace(/{{filename}}/g, ctx.filename ?? "")
.replace(/{{reponame}}/g, ctx.reponame ?? "")
.replace(/{{language}}/g, ctx.language ?? "")
},
},
}
})
// Token utilities – we map 1 char = 1 token for simplicity
vi.mock("../../../llm/countTokens", () => {
const countTokens = (str: string) => str.length
const pruneLinesFromTop = (str: string, allowed: number) => str.slice(Math.max(0, str.length - allowed))
const pruneLinesFromBottom = (str: string, allowed: number) => str.slice(0, allowed)
const getTokenCountingBufferSafety = () => 0
return {
countTokens,
pruneLinesFromTop,
pruneLinesFromBottom,
getTokenCountingBufferSafety,
}
})
// Snippet selection – configurable via constant return value
vi.mock("../filtering", () => ({
getSnippets: () => [],
}))
// Snippet formatting
const FORMATTED_SNIPPETS = "[FORMATTED_SNIPPETS]"
vi.mock("../formatting", () => ({
formatSnippets: () => FORMATTED_SNIPPETS,
}))
// Stop tokens helper – we expose a variable so each test can override it
let stopTokenReturn: string[] = ["<STOP>"]
vi.mock("../getStopTokens", () => ({
getStopTokens: () => stopTokenReturn,
}))
// AutocompleteTemplate – provide overridable template + compiler + completionOptions
let templateOverride: any = (prefix: string, suffix: string) => `${prefix}|${suffix}`
let compileFnOverride: ((...args: any[]) => [string, string]) | undefined
let completionOptionsOverride: Record<string, any> | undefined
vi.mock("../AutocompleteTemplate", () => ({
getTemplateForModel: () => ({
template: templateOverride,
compilePrefixSuffix: compileFnOverride,
completionOptions: completionOptionsOverride ?? {},
}),
}))
// ---------- Imports after mocks ----------
import { renderPrompt, renderPromptWithTokenLimit } from ".."
import { AutocompleteLanguageInfo } from "../../constants/AutocompleteLanguageInfo"
import { SnippetPayload } from "../../snippets"
import { HelperVars } from "../../util/HelperVars"
// ---------- Helper builders ----------
const tsLang: AutocompleteLanguageInfo = {
name: "TypeScript",
topLevelKeywords: [],
singleLineComment: "//",
endOfLine: [";"],
}
const emptySnippetPayload: SnippetPayload = {
rootPathSnippets: [],
importDefinitionSnippets: [],
ideSnippets: [],
recentlyEditedRangeSnippets: [],
recentlyVisitedRangesSnippets: [],
diffSnippets: [],
clipboardSnippets: [],
recentlyOpenedFileSnippets: [],
staticSnippet: [],
}
function makeHelper() {
return {
input: {
filepath: "file:///test.ts",
pos: { line: 0, character: 0 },
recentlyEditedRanges: [],
recentlyVisitedRanges: [],
},
prunedPrefix: "PRUNED_PREFIX",
prunedSuffix: "PRUNED_SUFFIX",
lang: tsLang,
modelName: "test-model",
filepath: "file:///test.ts",
workspaceUris: [],
options: {
maxPromptTokens: 2048,
prefixPercentage: 0.5,
maxSuffixPercentage: 0.5,
experimental_includeClipboard: false,
useRecentlyOpened: false,
experimental_includeRecentlyVisitedRanges: false,
experimental_includeRecentlyEditedRanges: false,
experimental_includeDiff: false,
onlyMyCode: false,
},
} as unknown as HelperVars
}
afterEach(() => {
// reset overridable mocks
templateOverride = (prefix: string, suffix: string) => `${prefix}|${suffix}`
compileFnOverride = undefined
completionOptionsOverride = undefined
stopTokenReturn = ["<STOP>"]
vi.restoreAllMocks()
})
// ---------- Test suite ----------
describe("renderPrompt prefix/suffix selection", () => {
it("uses manuallyPassPrefix when provided", () => {
const helper = makeHelper()
helper.input.manuallyPassPrefix = "MANUAL"
const { prefix, suffix } = renderPrompt({
snippetPayload: emptySnippetPayload,
workspaceDirs: ["file:///workspace"],
helper,
})
expect(suffix).toBe("\n")
expect(prefix.endsWith("MANUAL")).toBe(true)
})
it("falls back to prunedPrefix when no manual prefix", () => {
const helper = makeHelper()
const { prefix } = renderPrompt({
snippetPayload: emptySnippetPayload,
workspaceDirs: ["file:///workspace"],
helper,
})
expect(prefix.includes("PRUNED_PREFIX")).toBe(true)
})
})
describe("template rendering paths", () => {
it("handles function template", () => {
templateOverride = (p: string, s: string, _filepath: string, _reponame: string) => `FUNC:${p}|${s}`
const helper = makeHelper()
const { prompt } = renderPrompt({
snippetPayload: emptySnippetPayload,
workspaceDirs: ["file:///workspace"],
helper,
})
expect(prompt.startsWith("FUNC:")).toBe(true)
expect(prompt.includes("PRUNED_PREFIX")).toBe(true)
})
})
describe("compilePrefixSuffix vs snippet formatting", () => {
it("applies compilePrefixSuffix when provided", () => {
compileFnOverride = (p: string, s: string) => [`COMP_${p}`, `COMP_${s}`]
templateOverride = (prefix: string, suffix: string) => `${prefix}|${suffix}`
const helper = makeHelper()
const { prefix: compiledPrefix } = renderPrompt({
snippetPayload: emptySnippetPayload,
workspaceDirs: ["file:///workspace"],
helper,
})
expect(compiledPrefix.startsWith("COMP_PRUNED_PREFIX")).toBe(true)
})
it("prepends formatted snippets when no compiler present", () => {
const helper = makeHelper()
const { prefix: compiledPrefix } = renderPrompt({
snippetPayload: emptySnippetPayload,
workspaceDirs: ["file:///workspace"],
helper,
})
expect(compiledPrefix.startsWith(`${FORMATTED_SNIPPETS}\n`)).toBe(true)
})
})
describe("renderPromptWithTokenLimit parity & pruning", () => {
it("matches renderPrompt when llm is undefined", () => {
const helper = makeHelper()
const res1 = renderPrompt({
snippetPayload: emptySnippetPayload,
workspaceDirs: ["file:///workspace"],
helper,
})
const res2 = renderPromptWithTokenLimit({
snippetPayload: emptySnippetPayload,
workspaceDirs: ["file:///workspace"],
helper,
llm: undefined,
})
expect(res2).toEqual(res1)
})
it("prunes prefix/suffix to respect small context length", () => {
const longPrefix = "A".repeat(300)
const helper = makeHelper()
;(helper as any).prunedPrefix = longPrefix
const llmStub = {
contextLength: 120,
completionOptions: { maxTokens: 10 },
model: "test-model",
} as any
const { prefix: compiledPrefix } = renderPromptWithTokenLimit({
snippetPayload: emptySnippetPayload,
workspaceDirs: ["file:///workspace"],
helper,
llm: llmStub,
})
expect(compiledPrefix.length).toBeLessThan(120)
})
})
describe("stop-token merging", () => {
it("returns stop tokens from getStopTokens", () => {
stopTokenReturn = ["LANG_STOP", "TEMPLATE_STOP"]
completionOptionsOverride = { stop: ["TEMPLATE_STOP"] }
templateOverride = (prefix: string, suffix: string) => `${prefix}|${suffix}`
const helper = makeHelper()
const { completionOptions } = renderPrompt({
snippetPayload: emptySnippetPayload,
workspaceDirs: ["file:///workspace"],
helper,
})
expect(completionOptions?.stop).toEqual(stopTokenReturn)
})
})
@@ -1,90 +0,0 @@
import { getLastNUriRelativePathParts } from "../../util/uri"
import {
AutocompleteClipboardSnippet,
AutocompleteCodeSnippet,
AutocompleteDiffSnippet,
AutocompleteSnippet,
AutocompleteSnippetType,
AutocompleteStaticSnippet,
} from "../types"
import { HelperVars } from "../util/HelperVars"
const getCommentMark = (helper: HelperVars) => {
return helper.lang.singleLineComment
}
const addCommentMarks = (text: string, helper: HelperVars) => {
const commentMark = getCommentMark(helper)
return text
.trim()
.split("\n")
.map((line) => `${commentMark} ${line}`)
.join("\n")
}
const formatClipboardSnippet = (
snippet: AutocompleteClipboardSnippet,
workspaceDirs: string[],
): AutocompleteCodeSnippet => {
return formatCodeSnippet(
{
filepath: "file:///Untitled.txt",
content: snippet.content,
type: AutocompleteSnippetType.Code,
},
workspaceDirs,
)
}
const formatCodeSnippet = (snippet: AutocompleteCodeSnippet, workspaceDirs: string[]): AutocompleteCodeSnippet => {
return {
...snippet,
content: `Path: ${getLastNUriRelativePathParts(workspaceDirs, snippet.filepath, 2)}\n${snippet.content}`,
}
}
const formatDiffSnippet = (snippet: AutocompleteDiffSnippet): AutocompleteDiffSnippet => {
return snippet
}
const formatStaticSnippet = (snippet: AutocompleteStaticSnippet): AutocompleteStaticSnippet => {
return snippet
}
const commentifySnippet = (helper: HelperVars, snippet: AutocompleteSnippet): AutocompleteSnippet => {
return {
...snippet,
content: addCommentMarks(snippet.content, helper),
}
}
export const formatSnippets = (
helper: HelperVars,
snippets: AutocompleteSnippet[],
workspaceDirs: string[],
): string => {
const currentFilepathComment = addCommentMarks(
getLastNUriRelativePathParts(workspaceDirs, helper.filepath, 2),
helper,
)
return (
snippets
.map((snippet) => {
switch (snippet.type) {
case AutocompleteSnippetType.Code:
return formatCodeSnippet(snippet, workspaceDirs)
case AutocompleteSnippetType.Diff:
return formatDiffSnippet(snippet)
case AutocompleteSnippetType.Clipboard:
return formatClipboardSnippet(snippet, workspaceDirs)
case AutocompleteSnippetType.Static:
return formatStaticSnippet(snippet)
}
})
.map((item) => {
return commentifySnippet(helper, item).content
})
.join("\n") + `\n${currentFilepathComment}`
)
}
@@ -1,28 +0,0 @@
import { CompletionOptions } from "../.."
import { AutocompleteLanguageInfo } from "../constants/AutocompleteLanguageInfo"
// TODO: Do we want to stop completions when reaching a `/src/` string?
const SRC_DIRECTORY = "/src/"
// Starcoder2 tends to output artifacts starting with the letter "t"
const STARCODER2_T_ARTIFACTS = ["t.", "\nt", "<file_sep>"]
const PYTHON_ENCODING = "#- coding: utf-8"
const CODE_BLOCK_END = "```"
// const multilineStops: string[] = [DOUBLE_NEWLINE, WINDOWS_DOUBLE_NEWLINE];
const commonStops = [SRC_DIRECTORY, PYTHON_ENCODING, CODE_BLOCK_END]
export function getStopTokens(
completionOptions: Partial<CompletionOptions> | undefined,
_lang: AutocompleteLanguageInfo,
model: string,
): string[] {
const stopTokens = [
...(completionOptions?.stop || []),
// ...multilineStops,
...commonStops,
...(model.toLowerCase().includes("starcoder2") ? STARCODER2_T_ARTIFACTS : []),
// ...lang.topLevelKeywords.map((word) => `\n${word}`),
]
return stopTokens
}
@@ -1,205 +0,0 @@
import { CompletionOptions } from "../.."
import { HelperVars } from "../util/HelperVars"
import { ILLM } from "../../index.js"
import { DEFAULT_MAX_TOKENS } from "../../llm/constants.js"
import {
countTokens,
getTokenCountingBufferSafety,
pruneLinesFromBottom,
pruneLinesFromTop,
} from "../../llm/countTokens"
import { getUriPathBasename } from "../../util/uri"
import { SnippetPayload } from "../snippets"
import { AutocompleteSnippet } from "../types"
import { AutocompleteTemplate, getTemplateForModel } from "./AutocompleteTemplate"
import { getSnippets } from "./filtering"
import { formatSnippets } from "./formatting"
import { getStopTokens } from "./getStopTokens"
function getTemplate(helper: HelperVars): AutocompleteTemplate {
return getTemplateForModel(helper.modelName)
}
/** Consolidates shared setup between renderPrompt and renderPromptWithTokenLimit. */
function preparePromptContext({
snippetPayload,
workspaceDirs,
helper,
}: {
snippetPayload: SnippetPayload
workspaceDirs: string[]
helper: HelperVars
}): {
prefix: string
suffix: string
reponame: string
template: AutocompleteTemplate["template"]
compilePrefixSuffix: AutocompleteTemplate["compilePrefixSuffix"] | undefined
completionOptions: Partial<CompletionOptions> | undefined
snippets: AutocompleteSnippet[]
} {
// Determine base prefix/suffix, accounting for any manually supplied prefix.
const prefix = helper.input.manuallyPassPrefix || helper.prunedPrefix
let suffix = helper.input.manuallyPassPrefix ? "" : helper.prunedSuffix
if (suffix === "") {
suffix = "\n"
}
const reponame = getUriPathBasename(workspaceDirs[0] ?? "myproject")
const { template, compilePrefixSuffix, completionOptions } = getTemplate(helper)
const snippets = getSnippets(helper, snippetPayload)
return {
prefix,
suffix,
reponame,
template,
compilePrefixSuffix,
completionOptions,
snippets,
}
}
export function renderPrompt({
snippetPayload,
workspaceDirs,
helper,
}: {
snippetPayload: SnippetPayload
workspaceDirs: string[]
helper: HelperVars
}): {
prompt: string
prefix: string
suffix: string
completionOptions: Partial<CompletionOptions> | undefined
} {
const { prefix, suffix, reponame, template, compilePrefixSuffix, completionOptions, snippets } = preparePromptContext(
{ snippetPayload, workspaceDirs, helper },
)
// Delegate prompt construction to buildPrompt to avoid duplication.
const {
prompt,
prefix: compiledPrefix,
suffix: compiledSuffix,
} = buildPrompt(template, compilePrefixSuffix, prefix, suffix, helper, snippets, workspaceDirs, reponame)
const stopTokens = getStopTokens(completionOptions, helper.lang, helper.modelName)
return {
prompt,
prefix: compiledPrefix,
suffix: compiledSuffix,
completionOptions: {
...completionOptions,
stop: stopTokens,
},
}
}
/** Builds the final prompt by applying prefix/suffix compilation or snippet formatting, then rendering the template. */
function buildPrompt(
template: AutocompleteTemplate["template"],
compilePrefixSuffix: AutocompleteTemplate["compilePrefixSuffix"] | undefined,
prefix: string,
suffix: string,
helper: HelperVars,
snippets: AutocompleteSnippet[],
workspaceDirs: string[],
reponame: string,
): { prompt: string; prefix: string; suffix: string } {
if (compilePrefixSuffix) {
;[prefix, suffix] = compilePrefixSuffix(prefix, suffix, helper.filepath, reponame, snippets, helper.workspaceUris)
} else {
const formatted = formatSnippets(helper, snippets, workspaceDirs)
prefix = [formatted, prefix].join("\n")
}
const prompt = template(prefix, suffix, helper.filepath, reponame, helper.lang.name, snippets, helper.workspaceUris)
return { prompt, prefix, suffix }
}
function pruneLength(llm: ILLM, prompt: string): number {
const contextLength = llm.contextLength
const reservedTokens = llm.completionOptions.maxTokens ?? DEFAULT_MAX_TOKENS
const safetyBuffer = getTokenCountingBufferSafety(contextLength)
const maxAllowedPromptTokens = contextLength - reservedTokens - safetyBuffer
const promptTokenCount = countTokens(prompt, llm.model)
return promptTokenCount - maxAllowedPromptTokens
}
export function renderPromptWithTokenLimit({
snippetPayload,
workspaceDirs,
helper,
llm,
}: {
snippetPayload: SnippetPayload
workspaceDirs: string[]
helper: HelperVars
llm: ILLM | undefined
}): {
prompt: string
prefix: string
suffix: string
completionOptions: Partial<CompletionOptions> | undefined
} {
const {
prefix: initialPrefix,
suffix: initialSuffix,
reponame,
template,
compilePrefixSuffix,
completionOptions,
snippets,
} = preparePromptContext({ snippetPayload, workspaceDirs, helper })
// We'll mutate prefix/suffix during pruning, so copy them.
let prefix = initialPrefix
let suffix = initialSuffix
let {
prompt,
prefix: compiledPrefix,
suffix: compiledSuffix,
} = buildPrompt(template, compilePrefixSuffix, prefix, suffix, helper, snippets, workspaceDirs, reponame)
// Truncate prefix and suffix if prompt tokens exceed maxAllowedPromptTokens
if (llm) {
const prune = pruneLength(llm, prompt)
if (prune > 0) {
const tokensToDrop = prune
const prefixTokenCount = countTokens(prefix, helper.modelName)
const suffixTokenCount = countTokens(suffix, helper.modelName)
const totalContextTokens = prefixTokenCount + suffixTokenCount
if (totalContextTokens > 0) {
const dropPrefix = Math.ceil(tokensToDrop * (prefixTokenCount / totalContextTokens))
const dropSuffix = Math.ceil(tokensToDrop - dropPrefix)
const allowedPrefixTokens = Math.max(0, prefixTokenCount - dropPrefix)
const allowedSuffixTokens = Math.max(0, suffixTokenCount - dropSuffix)
prefix = pruneLinesFromTop(prefix, allowedPrefixTokens, helper.modelName)
suffix = pruneLinesFromBottom(suffix, allowedSuffixTokens, helper.modelName)
}
;({
prompt,
prefix: compiledPrefix,
suffix: compiledSuffix,
} = buildPrompt(template, compilePrefixSuffix, prefix, suffix, helper, snippets, workspaceDirs, reponame))
}
}
const stopTokens = getStopTokens(completionOptions, helper.lang, helper.modelName)
return {
prompt,
prefix: compiledPrefix,
suffix: compiledSuffix,
completionOptions: {
...completionOptions,
stop: stopTokens,
},
}
}
@@ -1,32 +0,0 @@
import { randomUUID } from "node:crypto"
export class AutocompleteDebouncer {
private debounceTimeout: NodeJS.Timeout | undefined = undefined
private currentRequestId: string | undefined = undefined
async delayAndShouldDebounce(debounceDelay: number): Promise<boolean> {
// Generate a unique ID for this request
const requestId = randomUUID()
this.currentRequestId = requestId
// Clear any existing timeout
if (this.debounceTimeout) {
clearTimeout(this.debounceTimeout)
}
// Create a new promise that resolves after the debounce delay
return new Promise<boolean>((resolve) => {
this.debounceTimeout = setTimeout(() => {
// When the timeout completes, check if this is still the most recent request
const shouldDebounce = this.currentRequestId !== requestId
// If this is the most recent request, it shouldn't be debounced
if (!shouldDebounce) {
this.currentRequestId = undefined
}
resolve(shouldDebounce)
}, debounceDelay)
})
}
}
@@ -1,93 +0,0 @@
import { COUNT_COMPLETION_REJECTED_AFTER } from "../../util/parameters"
import { AutocompleteOutcome } from "./types"
export class AutocompleteLoggingService {
// Key is completionId
private _abortControllers = new Map<string, AbortController>()
private _logRejectionTimeouts = new Map<string, NodeJS.Timeout>()
private _outcomes = new Map<string, AutocompleteOutcome>()
_lastDisplayedCompletion: { id: string; displayedAt: number } | undefined = undefined
public createAbortController(completionId: string): AbortController {
const abortController = new AbortController()
this._abortControllers.set(completionId, abortController)
return abortController
}
public deleteAbortController(completionId: string) {
this._abortControllers.delete(completionId)
}
public cancel() {
this._abortControllers.forEach((abortController) => {
abortController.abort()
})
this._abortControllers.clear()
}
public accept(completionId: string): AutocompleteOutcome | undefined {
if (this._logRejectionTimeouts.has(completionId)) {
clearTimeout(this._logRejectionTimeouts.get(completionId))
this._logRejectionTimeouts.delete(completionId)
}
if (this._outcomes.has(completionId)) {
const outcome = this._outcomes.get(completionId)!
outcome.accepted = true
this.logAutocompleteOutcome(outcome)
this._outcomes.delete(completionId)
return outcome
}
return undefined
}
public cancelRejectionTimeout(completionId: string) {
if (this._logRejectionTimeouts.has(completionId)) {
clearTimeout(this._logRejectionTimeouts.get(completionId)!)
this._logRejectionTimeouts.delete(completionId)
}
if (this._outcomes.has(completionId)) {
this._outcomes.delete(completionId)
}
}
public markDisplayed(completionId: string, outcome: AutocompleteOutcome) {
const logRejectionTimeout = setTimeout(() => {
// Wait 10 seconds, then assume it wasn't accepted
outcome.accepted = false
this.logAutocompleteOutcome(outcome)
this._logRejectionTimeouts.delete(completionId)
}, COUNT_COMPLETION_REJECTED_AFTER)
this._outcomes.set(completionId, outcome)
this._logRejectionTimeouts.set(completionId, logRejectionTimeout)
// If the previously displayed completion is still waiting for rejection,
// and this one is a continuation of that (the outcome.completion is the same modulo prefix)
// then we should cancel the rejection timeout
const previous = this._lastDisplayedCompletion
const now = Date.now()
if (previous && this._logRejectionTimeouts.has(previous.id)) {
const previousOutcome = this._outcomes.get(previous.id)
const c1 = previousOutcome?.completion.split("\n")[0] ?? ""
const c2 = outcome.completion.split("\n")[0]
if (previousOutcome && (c1.endsWith(c2) || c2.endsWith(c1) || c1.startsWith(c2) || c2.startsWith(c1))) {
this.cancelRejectionTimeout(previous.id)
} else if (now - previous.displayedAt < 500) {
// If a completion isn't shown for more than
this.cancelRejectionTimeout(previous.id)
}
}
this._lastDisplayedCompletion = {
id: completionId,
displayedAt: now,
}
}
private logAutocompleteOutcome(outcome: AutocompleteOutcome) {
if (!process.env.VITEST) {
console.log(outcome)
}
}
}
@@ -1,247 +0,0 @@
import { describe, it, expect, beforeEach } from "vitest"
import { AutocompleteLruCacheInMem } from "./AutocompleteLruCacheInMem"
describe("AutoCompleteLruCacheInMem", () => {
let cache: AutocompleteLruCacheInMem
beforeEach(async () => {
cache = await AutocompleteLruCacheInMem.get()
})
describe("basic operations", () => {
it("should store and retrieve a value", async () => {
await cache.put("hello", "world")
const result = await cache.get("hello")
expect(result).toBe("world")
})
it("should return undefined for non-existent key", async () => {
const result = await cache.get("nonexistent")
expect(result).toBeUndefined()
})
it("should update existing value", async () => {
await cache.put("key", "value1")
await cache.put("key", "value2")
const result = await cache.get("key")
expect(result).toBe("value2")
})
})
describe("exact key matching", () => {
it("should match exact key and return value", async () => {
await cache.put("hello", "world")
const result = await cache.get("hello")
expect(result).toBe("world")
})
it("should return undefined when key doesn't match exactly", async () => {
await cache.put("hello", "world")
const result = await cache.get("goodbye")
expect(result).toBeUndefined()
})
it("should return undefined for partial key match", async () => {
await cache.put("hello", "world")
const result = await cache.get("hel")
expect(result).toBeUndefined()
})
it("should be case sensitive", async () => {
await cache.put("Hello", "World")
const result1 = await cache.get("Hello")
const result2 = await cache.get("hello")
expect(result1).toBe("World")
expect(result2).toBeUndefined()
})
})
describe("fuzzy matching", () => {
it("should return completion when prefix extends a cached key", async () => {
// Cache "c" -> "ontinue"
await cache.put("c", "ontinue")
// Query "co" should return "ntinue" (completion minus what we already have)
const result = await cache.get("co")
expect(result).toBe("ntinue")
})
it("should prefer longest matching key", async () => {
// Cache multiple overlapping keys
await cache.put("h", "ello world")
await cache.put("he", "llo world")
await cache.put("hel", "lo world")
// Query "hello" should match "hel" (longest key)
// User typed "hello" = "hel" + "lo", cached completion is "lo world"
// So return " world" (the part not yet typed)
const result = await cache.get("hello")
expect(result).toBe(" world")
})
it("should validate cached completion starts correctly", async () => {
// Cache "c" -> "ontinue"
await cache.put("c", "ontinue")
// Query "cx" doesn't match the completion pattern, should return undefined
const result = await cache.get("cx")
expect(result).toBeUndefined()
})
it("should return exact match if available", async () => {
// Cache both exact and partial keys
await cache.put("co", "mplete")
await cache.put("c", "ontinue")
// Exact match should be preferred
const result = await cache.get("co")
expect(result).toBe("mplete")
})
it("should handle multiple partial matches correctly", async () => {
// Cache overlapping prefixes
await cache.put("fun", "ction")
await cache.put("f", "unction")
// Query "func" should match "fun" (longest) and return "ction"
const result = await cache.get("func")
expect(result).toBe("tion")
})
it("should return undefined when no fuzzy match exists", async () => {
await cache.put("hello", "world")
// "goodbye" doesn't start with "hello"
const result = await cache.get("goodbye")
expect(result).toBeUndefined()
})
it("should handle empty cache for fuzzy matching", async () => {
const result = await cache.get("anyprefix")
expect(result).toBeUndefined()
})
})
describe("LRU eviction", () => {
it("should evict oldest entry when capacity is reached", async () => {
// Create a fresh cache for this test
const testCache = await AutocompleteLruCacheInMem.get()
// Fill cache to capacity (100 entries)
for (let i = 0; i < 100; i++) {
await testCache.put(`key${i}`, `value${i}`)
}
// Add one more entry to trigger eviction
await testCache.put("newkey", "newvalue")
// First entry should be evicted (oldest timestamp)
const result = await testCache.get("key0")
expect(result).toBeUndefined()
// New entry should exist
const newResult = await testCache.get("newkey")
expect(newResult).toBe("newvalue")
})
it("should update timestamp on cache hit", async () => {
// Create a fresh cache for this test
const testCache = await AutocompleteLruCacheInMem.get()
// Fill to capacity
for (let i = 0; i < 100; i++) {
await testCache.put(`key${i}`, `value${i}`)
}
// Access an early entry to refresh its timestamp
const refreshedValue = await testCache.get("key5")
expect(refreshedValue).toBe("value5")
// Add new entries to trigger evictions
await testCache.put("new1", "newvalue1")
await testCache.put("new2", "newvalue2")
// key5 should still exist (refreshed timestamp)
const key5Result = await testCache.get("key5")
expect(key5Result).toBe("value5")
// key0 should be evicted (oldest timestamp, never accessed)
const key0Result = await testCache.get("key0")
expect(key0Result).toBeUndefined()
})
})
describe("edge cases", () => {
it("should handle empty strings", async () => {
const testCache = await AutocompleteLruCacheInMem.get()
await testCache.put("", "empty")
const result = await testCache.get("")
expect(result).toBe("empty")
})
it("should handle very long strings", async () => {
const testCache = await AutocompleteLruCacheInMem.get()
const longString = "a".repeat(10000)
await testCache.put(longString, "completion")
const result = await testCache.get(longString)
expect(result).toBe("completion")
})
it("should handle special characters", async () => {
await cache.put("const x = {", "foo: 'bar'}")
const result = await cache.get("const x = {")
expect(result).toBe("foo: 'bar'}")
})
it("should handle unicode characters", async () => {
await cache.put("emoji 🚀", "rocket")
const result = await cache.get("emoji 🚀")
expect(result).toBe("rocket")
})
})
describe("concurrent operations", () => {
it("should handle concurrent put operations", async () => {
const promises = []
for (let i = 0; i < 10; i++) {
promises.push(cache.put(`concurrent${i}`, `value${i}`))
}
await Promise.all(promises)
// All values should be stored
for (let i = 0; i < 10; i++) {
const result = await cache.get(`concurrent${i}`)
expect(result).toBe(`value${i}`)
}
})
it("should handle concurrent get operations", async () => {
await cache.put("shared", "value")
const promises = []
for (let i = 0; i < 10; i++) {
promises.push(cache.get("shared"))
}
const results = await Promise.all(promises)
// All gets should return the same value
results.forEach((result) => {
expect(result).toBe("value")
})
})
})
describe("multiple cache instances", () => {
it("should create separate cache instances", async () => {
const cache1 = await AutocompleteLruCacheInMem.get()
const cache2 = await AutocompleteLruCacheInMem.get()
await cache1.put("test", "value1")
await cache2.put("test", "value2")
const result1 = await cache1.get("test")
const result2 = await cache2.get("test")
// Each instance should have its own data
expect(result1).toBe("value1")
expect(result2).toBe("value2")
})
})
})
@@ -1,77 +0,0 @@
import { LRUCache } from "lru-cache"
const MAX_PREFIX_LENGTH = 50000
function truncatePrefix(input: string, safety: number = 100): string {
const maxBytes = MAX_PREFIX_LENGTH - safety
let bytes = 0
let startIndex = 0
// Count bytes from the end, keeping the most recent typing
for (let i = input.length - 1; i >= 0; i--) {
bytes += new TextEncoder().encode(input[i]).length
if (bytes > maxBytes) {
startIndex = i + 1
break
}
}
return input.substring(startIndex)
}
export class AutocompleteLruCacheInMem {
private static capacity = 100
private cache: LRUCache<string, string>
private constructor() {
this.cache = new LRUCache<string, string>({
max: AutocompleteLruCacheInMem.capacity,
})
}
static async get(): Promise<AutocompleteLruCacheInMem> {
return new AutocompleteLruCacheInMem()
}
async get(prefix: string): Promise<string | undefined> {
const truncated = truncatePrefix(prefix)
// First try exact match (faster)
const exactMatch = this.cache.get(truncated)
if (exactMatch !== undefined) {
return exactMatch
}
// Then try fuzzy matching - find keys where prefix starts with the key
// If the query is "co" and we have "c" -> "ontinue" in the cache,
// we should return "ntinue" as the completion.
// Have to make sure we take the key with longest length for best match
let bestMatch: { key: string; value: string } | null = null
let longestKeyLength = 0
for (const [key, value] of this.cache.entries()) {
// Check if truncated prefix starts with this key
if (truncated.startsWith(key) && key.length > longestKeyLength) {
bestMatch = { key, value }
longestKeyLength = key.length
}
}
if (bestMatch) {
// Validate that the cached completion is a valid completion for the prefix
if (bestMatch.value.startsWith(truncated.slice(bestMatch.key.length))) {
// Update LRU timestamp for the matched key by accessing it
this.cache.get(bestMatch.key)
// Return the portion of the value that extends beyond the current prefix
return bestMatch.value.slice(truncated.length - bestMatch.key.length)
}
}
return undefined
}
async put(prefix: string, completion: string) {
const truncated = truncatePrefix(prefix)
this.cache.set(truncated, completion)
}
}
@@ -1,63 +0,0 @@
import { describe, expect, it } from "vitest"
import { processTestCase, type CompletionTestCase } from "./completionTestUtils"
describe("processTestCase utility", () => {
it("processes simple console.log completion", () => {
const testCase: CompletionTestCase = {
original: "console.log(|cur||till|)",
completion: '"foo,", bar',
}
expect(processTestCase(testCase)).toEqual({
input: {
lastLineOfCompletionText: '"foo,", bar',
currentText: ")",
cursorPosition: "console.log(".length,
},
expectedResult: {
completionText: '"foo,", bar',
},
})
})
it("processes simple console.log completion with overwriting", () => {
const testCase: CompletionTestCase = {
original: "console.log(|cur|)|till|",
completion: '"foo,", bar);',
}
expect(processTestCase(testCase)).toEqual({
input: {
lastLineOfCompletionText: '"foo,", bar);',
currentText: ")",
cursorPosition: "console.log(".length,
},
expectedResult: {
completionText: '"foo,", bar);',
range: {
start: "console.log(".length,
end: "console.log()".length,
},
},
})
})
it("partially applying completion", () => {
const testCase: CompletionTestCase = {
original: '|cur||till|fetch("https://example.com");',
completion: 'await fetch("https://example.com");',
appliedCompletion: "await ",
}
expect(processTestCase(testCase)).toEqual({
input: {
lastLineOfCompletionText: 'await fetch("https://example.com");',
currentText: 'fetch("https://example.com");',
cursorPosition: 0,
},
expectedResult: {
completionText: "await ",
},
})
})
})
@@ -1,94 +0,0 @@
export interface CompletionTestCase {
original: string // Text with |cur| and |till| markers
completion: string // Text to insert/overwrite
appliedCompletion?: string | null
cursorMarker?: string
tillMarker?: string
}
interface ProcessedTestCase {
input: {
lastLineOfCompletionText: string
currentText: string
cursorPosition: number
}
expectedResult: {
completionText: string
range?: {
start: number
end: number
}
}
}
/**
* Transforms human-readable test case into input and expected results.
*
* - `original`: Your original text with |cur| marking where the cursor is before completion,
* and |till| marking where the cursor should be after accepting completion (and this is
* the end of the actual applied completion)
* - `completion`: LLM completion output
* - `appliedCompletion` (optional): part of the LLM completion output that is actually applied
* (written between |cur| and |till| in the original)
*
* For example, you have this line:
*
* console.log("<cursor here>");
*
* and expect it to be completed this way:
*
* console.log("foo: ", bar<cursor here>);
*
* with your completion coming from LLM being: `'foo: ", bar<cursor here>);'`
*
* Your input to this function should be:
* - original: `'console.log("|cur|"|till|);'`
* - completion: `'foo: ", bar);'`
* - appliedCompletion: `'foo: ", bar'`
*
* Output: input and expected output of {@link core/autocomplete/util/processSingleLineCompletion/processSingleLineCompletion|processSingleLineCompletion()}
*
*/
export function processTestCase({
original,
completion,
appliedCompletion = null,
cursorMarker = "|cur|",
tillMarker = "|till|",
}: CompletionTestCase): ProcessedTestCase {
// Validate cursor marker
if (!original.includes(cursorMarker)) {
throw new Error("Cursor marker not found in original text")
}
const cursorPos = original.indexOf(cursorMarker)
original = original.replace(cursorMarker, "")
let tillPos = original.indexOf(tillMarker)
if (tillPos < 0) {
tillPos = cursorPos
} else {
original = original.replace(tillMarker, "")
}
// Calculate currentText based on what's between cursor and till marker
const currentText = original.substring(cursorPos)
return {
input: {
lastLineOfCompletionText: completion,
currentText,
cursorPosition: cursorPos,
},
expectedResult: {
completionText: appliedCompletion || completion,
range:
cursorPos === tillPos
? undefined
: {
start: cursorPos,
end: tillPos,
},
},
}
}
@@ -1,96 +0,0 @@
import { describe, expect, it } from "vitest"
import { processTestCase } from "./completionTestUtils"
import { processSingleLineCompletion } from "./processSingleLineCompletion"
describe("processSingleLineCompletion", () => {
it("should handle simple end of line completion", () => {
const testCase = processTestCase({
original: "console.log(|cur|",
completion: '"Hello, world!")',
})
const result = processSingleLineCompletion(
testCase.input.lastLineOfCompletionText,
testCase.input.currentText,
testCase.input.cursorPosition,
)
expect(result).toEqual(testCase.expectedResult)
})
it("should handle midline insert repeating the end of line", () => {
const testCase = processTestCase({
original: "console.log(|cur|);|till|",
completion: '"Hello, world!");',
})
const result = processSingleLineCompletion(
testCase.input.lastLineOfCompletionText,
testCase.input.currentText,
testCase.input.cursorPosition,
)
expect(result).toEqual(testCase.expectedResult)
})
it("should handle midline insert repeating the end of line plus adding a semicolon", () => {
const testCase = processTestCase({
original: "console.log(|cur|)|till|",
completion: '"Hello, world!");',
})
const result = processSingleLineCompletion(
testCase.input.lastLineOfCompletionText,
testCase.input.currentText,
testCase.input.cursorPosition,
)
expect(result).toEqual(testCase.expectedResult)
})
it("should handle simple midline insert", () => {
const testCase = processTestCase({
original: "console.log(|cur|)",
completion: '"Hello, world!"',
})
const result = processSingleLineCompletion(
testCase.input.lastLineOfCompletionText,
testCase.input.currentText,
testCase.input.cursorPosition,
)
expect(result).toEqual(testCase.expectedResult)
})
it("should handle complex dif with addition in the beginning", () => {
const testCase = processTestCase({
original: 'console.log(|cur||till|, "param1", )', // TODO
completion: '"Hello world!", "param1", param1);',
appliedCompletion: '"Hello world!"',
})
const result = processSingleLineCompletion(
testCase.input.lastLineOfCompletionText,
testCase.input.currentText,
testCase.input.cursorPosition,
)
expect(result).toEqual(testCase.expectedResult)
})
it("should handle simple insertion even with random equality", () => {
const testCase = processTestCase({
original: 'print(f"Foobar length: |cur||till|")',
completion: "{len(foobar)}",
})
const result = processSingleLineCompletion(
testCase.input.lastLineOfCompletionText,
testCase.input.currentText,
testCase.input.cursorPosition,
)
expect(result).toEqual(testCase.expectedResult)
})
})
@@ -1,80 +0,0 @@
import * as Diff from "diff"
interface SingleLineCompletionResult {
completionText: string
range?: {
start: number
end: number
}
}
interface DiffType {
count?: number
added?: boolean
removed?: boolean
value: string
}
function diffPatternMatches(diffs: DiffType[], pattern: DiffPartType[]): boolean {
if (diffs.length !== pattern.length) {
return false
}
for (let i = 0; i < diffs.length; i++) {
const diff = diffs[i]
const diffPartType: DiffPartType = !diff.added && !diff.removed ? "=" : diff.added ? "+" : "-"
if (diffPartType !== pattern[i]) {
return false
}
}
return true
}
type DiffPartType = "+" | "-" | "="
export function processSingleLineCompletion(
lastLineOfCompletionText: string,
currentText: string,
cursorPosition: number,
): SingleLineCompletionResult | undefined {
const diffs: DiffType[] = Diff.diffWords(currentText, lastLineOfCompletionText)
if (diffPatternMatches(diffs, ["+"])) {
// Just insert, we're already at the end of the line
return {
completionText: lastLineOfCompletionText,
}
}
if (diffPatternMatches(diffs, ["+", "="]) || diffPatternMatches(diffs, ["+", "=", "+"])) {
// The model repeated the text after the cursor to the end of the line
return {
completionText: lastLineOfCompletionText,
range: {
start: cursorPosition,
end: currentText.length + cursorPosition,
},
}
}
if (diffPatternMatches(diffs, ["+", "-"]) || diffPatternMatches(diffs, ["-", "+"])) {
// We are midline and the model just inserted without repeating to the end of the line
return {
completionText: lastLineOfCompletionText,
}
}
// For any other diff pattern, just use the first added part if available
if (diffs[0]?.added) {
return {
completionText: diffs[0].value,
}
}
// Default case: treat as simple insertion
return {
completionText: lastLineOfCompletionText,
}
}
@@ -1,5 +1,5 @@
import { Position, Range, RangeInFile, TabAutocompleteOptions } from "../.."
import { AutocompleteCodeSnippet } from "../types"
import type { Position, Range, RangeInFile } from "../.."
import type { AutocompleteCodeSnippet } from "../types"
export type RecentlyEditedRange = RangeInFile & {
timestamp: number
@@ -24,24 +24,3 @@ export interface AutocompleteInput {
}
injectDetails?: string
}
export interface AutocompleteOutcome extends TabAutocompleteOptions {
accepted?: boolean
time: number
prefix: string
suffix: string
prompt: string
completion: string
modelProvider: string
modelName: string
completionOptions: any
cacheHit: boolean
numLines: number
filepath: string
gitRepo?: string
completionId: string
uniqueId: string
timestamp: string
enabledStaticContextualization?: boolean
profileType?: "local" | "platform" | "control-plane"
}
@@ -1,634 +0,0 @@
import { describe, expect, test } from "vitest"
import { dedent } from "../util"
import { myersCharDiff, myersDiff } from "./myers"
describe("Test myers diff function", () => {
test("should ...", () => {
const linesA = dedent`
A
B
C
D
E
`
const linesB = dedent`
A
B
C'
D'
E
`
const diffLines = myersDiff(linesA, linesB)
expect(diffLines).toEqual([
{ type: "same", line: "A" },
{ type: "same", line: "B" },
{ type: "old", line: "C" },
{ type: "old", line: "D" },
{ type: "new", line: "C'" },
{ type: "new", line: "D'" },
{ type: "same", line: "E" },
])
})
test("should ignore newline differences at end", () => {
const linesA = "A\nB\nC\n"
const linesB = "A\nB\nC"
const diffLines = myersDiff(linesA, linesB)
expect(diffLines).toEqual([
{ type: "same", line: "A" },
{ type: "same", line: "B" },
{ type: "same", line: "C" },
])
})
test("should ignore single-line whitespace-only differences", () => {
const linesA = "A\n B\nC\n"
const linesB = "A\nB\nC"
const diffLines = myersDiff(linesA, linesB)
expect(diffLines).toEqual([
{ type: "same", line: "A" },
{ type: "same", line: " B" },
{ type: "same", line: "C" },
])
})
})
describe("Test myersCharDiff function on the same line", () => {
test("should differentiate character changes", () => {
const oldContent = "hello world"
const newContent = "hello earth"
const diffChars = myersCharDiff(oldContent, newContent)
expect(diffChars).toEqual([
{
type: "same",
char: "hello ",
oldIndex: 0,
newIndex: 0,
oldCharIndexInLine: 0,
newCharIndexInLine: 0,
oldLineIndex: 0,
newLineIndex: 0,
},
{
type: "old",
char: "wo",
oldIndex: 6,
oldCharIndexInLine: 6,
oldLineIndex: 0,
},
{
type: "new",
char: "ea",
newIndex: 6,
newCharIndexInLine: 6,
newLineIndex: 0,
},
{
type: "same",
char: "r",
oldIndex: 8,
newIndex: 8,
oldCharIndexInLine: 8,
newCharIndexInLine: 8,
oldLineIndex: 0,
newLineIndex: 0,
},
{
type: "old",
char: "ld",
oldIndex: 9,
oldCharIndexInLine: 9,
oldLineIndex: 0,
},
{
type: "new",
char: "th",
newIndex: 9,
newCharIndexInLine: 9,
newLineIndex: 0,
},
])
})
test("should handle insertions", () => {
const oldContent = "abc"
const newContent = "abxyzc"
const diffChars = myersCharDiff(oldContent, newContent)
expect(diffChars).toEqual([
{
type: "same",
char: "ab",
oldIndex: 0,
newIndex: 0,
oldCharIndexInLine: 0,
newCharIndexInLine: 0,
oldLineIndex: 0,
newLineIndex: 0,
},
{
type: "new",
char: "xyz",
newIndex: 2,
newCharIndexInLine: 2,
newLineIndex: 0,
},
{
type: "same",
char: "c",
oldIndex: 2,
newIndex: 5,
oldCharIndexInLine: 2,
newCharIndexInLine: 5,
oldLineIndex: 0,
newLineIndex: 0,
},
])
})
test("should handle deletions", () => {
const oldContent = "abxyzc"
const newContent = "abc"
const diffChars = myersCharDiff(oldContent, newContent)
expect(diffChars).toEqual([
{
type: "same",
char: "ab",
oldIndex: 0,
newIndex: 0,
oldCharIndexInLine: 0,
newCharIndexInLine: 0,
oldLineIndex: 0,
newLineIndex: 0,
},
{
type: "old",
char: "xyz",
oldIndex: 2,
oldCharIndexInLine: 2,
oldLineIndex: 0,
},
{
type: "same",
char: "c",
oldIndex: 5,
newIndex: 2,
oldCharIndexInLine: 5,
newCharIndexInLine: 2,
oldLineIndex: 0,
newLineIndex: 0,
},
])
})
test("should handle empty strings", () => {
const oldContent = ""
const newContent = "abc"
const diffChars = myersCharDiff(oldContent, newContent)
expect(diffChars).toEqual([
{
type: "new",
char: "abc",
newIndex: 0,
newCharIndexInLine: 0,
newLineIndex: 0,
},
])
})
test("should handle identical strings", () => {
const content = "no changes here"
const diffChars = myersCharDiff(content, content)
expect(diffChars).toEqual([
{
type: "same",
char: "no changes here",
oldIndex: 0,
newIndex: 0,
oldCharIndexInLine: 0,
newCharIndexInLine: 0,
oldLineIndex: 0,
newLineIndex: 0,
},
])
})
test("should handle whitespace changes", () => {
const oldContent = "hello world"
const newContent = "hello world"
const diffChars = myersCharDiff(oldContent, newContent)
expect(diffChars).toEqual([
{
type: "same",
char: "hello ",
oldIndex: 0,
newIndex: 0,
oldCharIndexInLine: 0,
newCharIndexInLine: 0,
oldLineIndex: 0,
newLineIndex: 0,
},
{
type: "new",
char: " ",
newIndex: 6,
newCharIndexInLine: 6,
newLineIndex: 0,
},
{
type: "same",
char: "world",
oldIndex: 6,
newIndex: 7,
oldCharIndexInLine: 6,
newCharIndexInLine: 7,
oldLineIndex: 0,
newLineIndex: 0,
},
])
})
test("should handle complex changes", () => {
const oldContent = "The quick brown fox jumps over the lazy dog"
const newContent = "The fast brown fox leaps over the sleeping dog"
const diffChars = myersCharDiff(oldContent, newContent)
expect(diffChars).toEqual([
{
type: "same",
char: "The ",
oldIndex: 0,
newIndex: 0,
oldCharIndexInLine: 0,
newCharIndexInLine: 0,
oldLineIndex: 0,
newLineIndex: 0,
},
{
type: "old",
char: "quick",
oldIndex: 4,
oldCharIndexInLine: 4,
oldLineIndex: 0,
},
{
type: "new",
char: "fast",
newIndex: 4,
newCharIndexInLine: 4,
newLineIndex: 0,
},
{
type: "same",
char: " brown fox ",
oldIndex: 9,
newIndex: 8,
oldCharIndexInLine: 9,
newCharIndexInLine: 8,
oldLineIndex: 0,
newLineIndex: 0,
},
{
type: "old",
char: "jum",
oldIndex: 20,
oldCharIndexInLine: 20,
oldLineIndex: 0,
},
{
type: "new",
char: "lea",
newIndex: 19,
newCharIndexInLine: 19,
newLineIndex: 0,
},
{
type: "same",
char: "ps over the ",
oldIndex: 23,
newIndex: 22,
oldCharIndexInLine: 23,
newCharIndexInLine: 22,
oldLineIndex: 0,
newLineIndex: 0,
},
{
type: "new",
char: "s",
newIndex: 34,
newCharIndexInLine: 34,
newLineIndex: 0,
},
{
type: "same",
char: "l",
oldIndex: 35,
newIndex: 35,
oldCharIndexInLine: 35,
newCharIndexInLine: 35,
oldLineIndex: 0,
newLineIndex: 0,
},
{
type: "old",
char: "azy",
oldIndex: 36,
oldCharIndexInLine: 36,
oldLineIndex: 0,
},
{
type: "new",
char: "eeping",
newIndex: 36,
newCharIndexInLine: 36,
newLineIndex: 0,
},
{
type: "same",
char: " dog",
oldIndex: 39,
newIndex: 42,
oldCharIndexInLine: 39,
newCharIndexInLine: 42,
oldLineIndex: 0,
newLineIndex: 0,
},
])
})
})
describe("Test myersCharDiff function on different lines", () => {
test("should track line indices for multi-line changes", () => {
const oldContent = ["Line one", "Line two", "Line three"].join("\n")
const newContent = ["Line one", "Modified line", "Line three"].join("\n")
const diffChars = myersCharDiff(oldContent, newContent)
expect(diffChars).toEqual([
{
type: "same",
char: "Line one",
oldIndex: 0,
newIndex: 0,
oldCharIndexInLine: 0,
newCharIndexInLine: 0,
oldLineIndex: 0,
newLineIndex: 0,
},
{
type: "same",
char: "\n",
oldIndex: 8,
newIndex: 8,
oldCharIndexInLine: 8,
newCharIndexInLine: 8,
oldLineIndex: 0,
newLineIndex: 0,
},
{
type: "old",
char: "L",
oldIndex: 9,
oldCharIndexInLine: 0,
oldLineIndex: 1,
},
{
type: "new",
char: "Mod",
newIndex: 9,
newCharIndexInLine: 0,
newLineIndex: 1,
},
{
char: "i",
newCharIndexInLine: 3,
newIndex: 12,
newLineIndex: 1,
oldCharIndexInLine: 1,
oldIndex: 10,
oldLineIndex: 1,
type: "same",
},
{
char: "n",
oldCharIndexInLine: 2,
oldIndex: 11,
oldLineIndex: 1,
type: "old",
},
{
char: "fi",
newCharIndexInLine: 4,
newIndex: 13,
newLineIndex: 1,
type: "new",
},
{
char: "e",
newCharIndexInLine: 6,
newIndex: 15,
newLineIndex: 1,
oldCharIndexInLine: 3,
oldIndex: 12,
oldLineIndex: 1,
type: "same",
},
{
char: "d",
newCharIndexInLine: 7,
newIndex: 16,
newLineIndex: 1,
type: "new",
},
{
char: " ",
newCharIndexInLine: 8,
newIndex: 17,
newLineIndex: 1,
oldCharIndexInLine: 4,
oldIndex: 13,
oldLineIndex: 1,
type: "same",
},
{
type: "old",
char: "two",
oldCharIndexInLine: 5,
oldIndex: 14,
oldLineIndex: 1,
},
{
type: "new",
char: "line",
newCharIndexInLine: 9,
newIndex: 18,
newLineIndex: 1,
},
{
type: "same",
char: "\n",
oldIndex: 17,
oldCharIndexInLine: 8,
oldLineIndex: 1,
newIndex: 22,
newCharIndexInLine: 13,
newLineIndex: 1,
},
{
type: "same",
char: "Line three",
oldIndex: 18,
newIndex: 23,
oldCharIndexInLine: 0,
newCharIndexInLine: 0,
oldLineIndex: 2,
newLineIndex: 2,
},
])
})
test("should track line indices when adding new lines", () => {
const oldContent = ["First line", "Last line"].join("\n")
const newContent = ["First line", "Middle line", "Another middle", "Last line"].join("\n")
const diffChars = myersCharDiff(oldContent, newContent)
expect(diffChars).toEqual([
{
type: "same",
char: "First line",
oldIndex: 0,
newIndex: 0,
oldCharIndexInLine: 0,
newCharIndexInLine: 0,
oldLineIndex: 0,
newLineIndex: 0,
},
{
type: "same",
char: "\n",
oldIndex: 10,
newIndex: 10,
oldCharIndexInLine: 10,
newCharIndexInLine: 10,
oldLineIndex: 0,
newLineIndex: 0,
},
{
type: "new",
char: "Middle line",
newIndex: 11,
newCharIndexInLine: 0,
newLineIndex: 1,
},
{
type: "new",
char: "\n",
newIndex: 22,
newCharIndexInLine: 11,
newLineIndex: 1,
},
{
type: "new",
char: "Another middle",
newCharIndexInLine: 0,
newIndex: 23,
newLineIndex: 2,
},
{
type: "new",
char: "\n",
newCharIndexInLine: 14,
newIndex: 37,
newLineIndex: 2,
},
{
type: "same",
char: "Last line",
oldIndex: 11,
oldCharIndexInLine: 0,
oldLineIndex: 1,
newIndex: 38,
newCharIndexInLine: 0,
newLineIndex: 3,
},
])
})
test("should track line indices when removing lines", () => {
const oldContent = ["Start", "Line to remove", "Another to remove", "End"].join("\n")
const newContent = ["Start", "End"].join("\n")
const diffChars = myersCharDiff(oldContent, newContent)
expect(diffChars).toEqual([
{
type: "same",
char: "Start",
oldIndex: 0,
newIndex: 0,
oldCharIndexInLine: 0,
newCharIndexInLine: 0,
oldLineIndex: 0,
newLineIndex: 0,
},
{
type: "same",
char: "\n",
oldIndex: 5,
newIndex: 5,
oldCharIndexInLine: 5,
newCharIndexInLine: 5,
oldLineIndex: 0,
newLineIndex: 0,
},
{
type: "old",
char: "Line to remove",
oldIndex: 6,
oldCharIndexInLine: 0,
oldLineIndex: 1,
},
{
type: "old",
char: "\n",
oldCharIndexInLine: 14,
oldIndex: 20,
oldLineIndex: 1,
},
{
type: "old",
char: "Another to remove",
oldCharIndexInLine: 0,
oldIndex: 21,
oldLineIndex: 2,
},
{
type: "old",
char: "\n",
oldCharIndexInLine: 17,
oldIndex: 38,
oldLineIndex: 2,
},
{
type: "same",
char: "End",
oldIndex: 39,
newIndex: 6,
oldCharIndexInLine: 0,
newCharIndexInLine: 0,
oldLineIndex: 3,
newLineIndex: 1,
},
])
})
})
@@ -1,204 +0,0 @@
import { diffChars, diffLines, type Change } from "diff"
import { DiffChar, DiffLine } from ".."
function convertMyersChangeToDiffLines(change: Change): DiffLine[] {
const type: DiffLine["type"] = change.added ? "new" : change.removed ? "old" : "same"
const lines = change.value.split("\n")
// Ignore the \n at the end of the final line, if there is one
if (lines[lines.length - 1] === "") {
lines.pop()
}
return lines.map((line) => ({ type, line }))
}
// The interpretation of lines in oldContent and newContent is the same as jsdiff
// Lines are separated by \n, with the exception that a trailing \n does *not*
// represent an empty line.
//
// The default for jsdiff is that "foo" and "foo\n" are *different* single-line
// contents, but we can't represent that: to avoid a diff
// [ { type: "old", line: "foo" }, { type: "new", line: "foo" } ], we
// pass ignoreNewlineAtEof: true.
export function myersDiff(oldContent: string, newContent: string): DiffLine[] {
const theirFormat = diffLines(oldContent, newContent, {
ignoreNewlineAtEof: true,
})
const ourFormat = theirFormat.flatMap(convertMyersChangeToDiffLines)
// Combine consecutive old/new pairs that are identical after trimming
for (let i = 0; i < ourFormat.length - 1; i++) {
if (
ourFormat[i]?.type === "old" &&
ourFormat[i + 1]?.type === "new" &&
ourFormat[i].line.trim() === ourFormat[i + 1].line.trim()
) {
ourFormat[i] = { type: "same", line: ourFormat[i].line }
ourFormat.splice(i + 1, 1)
}
}
// Remove trailing empty old lines
while (
ourFormat.length > 0 &&
ourFormat[ourFormat.length - 1].type === "old" &&
ourFormat[ourFormat.length - 1].line === ""
) {
ourFormat.pop()
}
return ourFormat
}
export function myersCharDiff(oldContent: string, newContent: string): DiffChar[] {
// Process the content character by character.
// We will handle newlines separately,
// because diffChars does not have an option to ignore eol newlines.
const theirFormat = diffChars(oldContent, newContent)
// Track indices as we process the diff.
let oldIndex = 0
let newIndex = 0
let oldLineIndex = 0
let newLineIndex = 0
let oldCharIndexInLine = 0
let newCharIndexInLine = 0
const result: DiffChar[] = []
for (const change of theirFormat) {
// Split the change value by newlines to handle them separately.
if (change.value.includes("\n")) {
const parts = change.value.split(/(\n)/g) // This keeps the newlines as separate entries.
for (let i = 0; i < parts.length; i++) {
const part = parts[i]
if (part === "") continue
if (part === "\n") {
// Handle newline.
if (change.added) {
result.push({
type: "new",
char: part,
newIndex: newIndex,
newLineIndex: newLineIndex,
newCharIndexInLine: newCharIndexInLine,
})
newIndex += part.length
newLineIndex++
newCharIndexInLine = 0 // Reset when moving to a new line.
} else if (change.removed) {
result.push({
type: "old",
char: part,
oldIndex: oldIndex,
oldLineIndex: oldLineIndex,
oldCharIndexInLine: oldCharIndexInLine,
})
oldIndex += part.length
oldLineIndex++
oldCharIndexInLine = 0 // Reset when moving to a new line.
} else {
result.push({
type: "same",
char: part,
oldIndex: oldIndex,
newIndex: newIndex,
oldLineIndex: oldLineIndex,
newLineIndex: newLineIndex,
oldCharIndexInLine: oldCharIndexInLine,
newCharIndexInLine: newCharIndexInLine,
})
oldIndex += part.length
newIndex += part.length
oldLineIndex++
newLineIndex++
oldCharIndexInLine = 0
newCharIndexInLine = 0
}
} else {
// Handle regular text.
if (change.added) {
result.push({
type: "new",
char: part,
newIndex: newIndex,
newLineIndex: newLineIndex,
newCharIndexInLine: newCharIndexInLine,
})
newIndex += part.length
newCharIndexInLine += part.length
} else if (change.removed) {
result.push({
type: "old",
char: part,
oldIndex: oldIndex,
oldLineIndex: oldLineIndex,
oldCharIndexInLine: oldCharIndexInLine,
})
oldIndex += part.length
oldCharIndexInLine += part.length
} else {
result.push({
type: "same",
char: part,
oldIndex: oldIndex,
newIndex: newIndex,
oldLineIndex: oldLineIndex,
newLineIndex: newLineIndex,
oldCharIndexInLine: oldCharIndexInLine,
newCharIndexInLine: newCharIndexInLine,
})
oldIndex += part.length
newIndex += part.length
oldCharIndexInLine += part.length
newCharIndexInLine += part.length
}
}
}
} else {
// No newlines, handle as a simple change.
if (change.added) {
result.push({
type: "new",
char: change.value,
newIndex: newIndex,
newLineIndex: newLineIndex,
newCharIndexInLine: newCharIndexInLine,
})
newIndex += change.value.length
newCharIndexInLine += change.value.length
} else if (change.removed) {
result.push({
type: "old",
char: change.value,
oldIndex: oldIndex,
oldLineIndex: oldLineIndex,
oldCharIndexInLine: oldCharIndexInLine,
})
oldIndex += change.value.length
oldCharIndexInLine += change.value.length
} else {
result.push({
type: "same",
char: change.value,
oldIndex: oldIndex,
newIndex: newIndex,
oldLineIndex: oldLineIndex,
newLineIndex: newLineIndex,
oldCharIndexInLine: oldCharIndexInLine,
newCharIndexInLine: newCharIndexInLine,
})
oldIndex += change.value.length
newIndex += change.value.length
oldCharIndexInLine += change.value.length
newCharIndexInLine += change.value.length
}
}
}
return result
}
@@ -1,317 +0,0 @@
import fs from "node:fs"
import path from "node:path"
import { describe, expect, test } from "vitest"
// @ts-expect-error no typings available
import { changed, diff as myersDiff } from "myers-diff"
import { streamDiff } from "../diff/streamDiff.js"
import { DiffLine, DiffType } from "../index.js"
import { generateLines } from "./util.js"
// "modification" is an extra type used to represent an "old" + "new" diff line
type MyersDiffTypes = Extract<DiffType, "new" | "old"> | "modification"
const UNIFIED_DIFF_SYMBOLS = {
same: "",
new: "+",
old: "-",
}
async function collectDiffs(
oldLines: string[],
newLines: string[],
): Promise<{ streamDiffs: DiffLine[]; myersDiffs: any }> {
const streamDiffs: DiffLine[] = []
for await (const diffLine of streamDiff(oldLines, generateLines(newLines))) {
streamDiffs.push(diffLine)
}
const myersDiffs = myersDiff(oldLines.join("\n"), newLines.join("\n"))
return { streamDiffs, myersDiffs }
}
function getMyersDiffType(diff: any): MyersDiffTypes | undefined {
if (changed(diff.rhs) && !changed(diff.lhs)) {
return "new"
}
if (!changed(diff.rhs) && changed(diff.lhs)) {
return "old"
}
if (changed(diff.rhs) && changed(diff.lhs)) {
return "modification"
}
return undefined
}
function displayDiff(diff: DiffLine[]) {
return diff.map(({ type, line }) => `${UNIFIED_DIFF_SYMBOLS[type]} ${line}`).join("\n")
}
async function expectDiff(file: string) {
const testFilePath = path.join(__dirname, "test-examples", file + ".diff")
const testFileContents = fs.readFileSync(testFilePath, "utf-8")
const normalized = testFileContents.replace(/\r\n/g, "\n")
const [oldText, newText, expectedDiff] = normalized.split("\n---\n").map((s) => s.replace(/^\n+/, "").trimEnd())
const oldLines = oldText.split("\n")
const newLines = newText.split("\n")
const { streamDiffs } = await collectDiffs(oldLines, newLines)
const displayedDiff = displayDiff(streamDiffs)
if (!expectedDiff || expectedDiff.trim() === "") {
console.log("Expected diff was empty. Writing computed diff to the test file")
// Persist with LF to keep fixtures stable cross-platform
fs.writeFileSync(testFilePath, `${oldText}\n\n---\n\n${newText}\n\n---\n\n${displayedDiff}`)
throw new Error("Expected diff is empty")
}
expect(displayedDiff).toEqual(expectedDiff)
}
// We use a longer `)` string here to not get
// caught by the fuzzy matcher
describe("streamDiff(", () => {
test("no changes", async () => {
const oldLines = ["first item", "second arg", "third param"]
const newLines = ["first item", "second arg", "third param"]
const { streamDiffs, myersDiffs } = await collectDiffs(oldLines, newLines)
expect(streamDiffs).toEqual([
{ type: "same", line: "first item" },
{ type: "same", line: "second arg" },
{ type: "same", line: "third param" },
])
expect(myersDiffs).toEqual([])
})
test("add new line", async () => {
const oldLines = ["first item", "second arg"]
const newLines = ["first item", "second arg", "third param"]
const { streamDiffs, myersDiffs } = await collectDiffs(oldLines, newLines)
expect(streamDiffs).toEqual([
{ type: "same", line: "first item" },
{ type: "same", line: "second arg" },
{ type: "new", line: "third param" },
])
expect(myersDiffs.length).toEqual(1)
expect(getMyersDiffType(myersDiffs[0])).toBe("new")
})
test("remove line", async () => {
const oldLines = ["first item", "second arg", "third param"]
const newLines = ["first item", "third param"]
const { streamDiffs, myersDiffs } = await collectDiffs(oldLines, newLines)
expect(streamDiffs).toEqual([
{ type: "same", line: "first item" },
{ type: "old", line: "second arg" },
{ type: "same", line: "third param" },
])
expect(myersDiffs.length).toEqual(1)
expect(getMyersDiffType(myersDiffs[0])).toBe("old")
})
test("modify line", async () => {
const oldLines = ["first item", "second arg", "third param"]
const newLines = ["first item", "modified second arg", "third param"]
const { streamDiffs, myersDiffs } = await collectDiffs(oldLines, newLines)
expect(streamDiffs).toEqual([
{ type: "same", line: "first item" },
{ type: "old", line: "second arg" },
{ type: "new", line: "modified second arg" },
{ type: "same", line: "third param" },
])
expect(myersDiffs.length).toEqual(1)
expect(getMyersDiffType(myersDiffs[0])).toBe("modification")
})
test("add multiple lines", async () => {
const oldLines = ["first item", "fourth val"]
const newLines = ["first item", "second arg", "third param", "fourth val"]
const { streamDiffs, myersDiffs } = await collectDiffs(oldLines, newLines)
expect(streamDiffs).toEqual([
{ type: "same", line: "first item" },
{ type: "new", line: "second arg" },
{ type: "new", line: "third param" },
{ type: "same", line: "fourth val" },
])
// Multi-line addition
expect(myersDiffs[0].rhs.add).toEqual(2)
expect(getMyersDiffType(myersDiffs[0])).toBe("new")
})
test("remove multiple lines", async () => {
const oldLines = ["first item", "second arg", "third param", "fourth val"]
const newLines = ["first item", "fourth val"]
const { streamDiffs, myersDiffs } = await collectDiffs(oldLines, newLines)
expect(streamDiffs).toEqual([
{ type: "same", line: "first item" },
{ type: "old", line: "second arg" },
{ type: "old", line: "third param" },
{ type: "same", line: "fourth val" },
])
// Multi-line deletion
expect(myersDiffs[0].lhs.del).toEqual(2)
expect(getMyersDiffType(myersDiffs[0])).toBe("old")
})
test("empty old lines", async () => {
const oldLines: string[] = []
const newLines = ["first item", "second arg"]
const { streamDiffs, myersDiffs } = await collectDiffs(oldLines, newLines)
expect(streamDiffs).toEqual([
{ type: "new", line: "first item" },
{ type: "new", line: "second arg" },
])
// Multi-line addition
expect(myersDiffs[0].rhs.add).toEqual(2)
expect(getMyersDiffType(myersDiffs[0])).toBe("new")
})
test("empty new lines", async () => {
const oldLines = ["first item", "second arg"]
const newLines: string[] = []
const { streamDiffs, myersDiffs } = await collectDiffs(oldLines, newLines)
expect(streamDiffs).toEqual([
{ type: "old", line: "first item" },
{ type: "old", line: "second arg" },
])
// Multi-line deletion
expect(myersDiffs[0].lhs.del).toEqual(2)
expect(getMyersDiffType(myersDiffs[0])).toBe("old")
})
test("tabs vs. spaces differences are ignored", async () => {
await expectDiff("fastapi-tabs-vs-spaces.py")
})
test("trailing whitespaces should match ", async () => {
const oldLines = ["first item ", "second arg ", "third param "]
const newLines = ["first item", "second arg", "third param "]
const { streamDiffs } = await collectDiffs(oldLines, newLines)
expect(streamDiffs).toEqual([
{ type: "same", line: "first item " },
{ type: "same", line: "second arg " },
{ type: "same", line: "third param " },
])
})
//indentation and whitespace handling
test.each([false, true])(
"ignores indentation changes for sufficiently long lines (trailingWhitespace: %s)",
async (trailingWhitespace) => {
let oldLines = [" short", " middle", " a long enough line", " short2", "indented line", "final line"]
const newLines = ["short", "middle", "a long enough line", "short2", " indented line", "final line"]
if (trailingWhitespace) {
oldLines = oldLines.map((line) => line + " ")
}
const { streamDiffs } = await collectDiffs(oldLines, newLines)
const expected = trailingWhitespace
? [
{ type: "old", line: " short " },
{ type: "new", line: "short" },
{ type: "old", line: " middle " },
{ type: "new", line: "middle" },
{ type: "same", line: " a long enough line " },
{ type: "same", line: " short2 " },
{ type: "same", line: "indented line " },
{ type: "same", line: "final line " },
]
: [
{ type: "old", line: " short" },
{ type: "new", line: "short" },
{ type: "old", line: " middle" },
{ type: "new", line: "middle" },
{ type: "same", line: " a long enough line" },
{ type: "same", line: " short2" },
{ type: "same", line: "indented line" },
{ type: "same", line: "final line" },
]
expect(streamDiffs).toEqual(expected)
},
)
test("preserves original lines for minor reindentation in simple block", async () => {
const oldLines = ["if (checkValueOf(x)) {", " doSomethingWith(x);", "}"]
const newLines = ["if (checkValueOf(x)) {", " doSomethingWith(x);", "}"]
const { streamDiffs } = await collectDiffs(oldLines, newLines)
expect(streamDiffs).toEqual([
{ type: "same", line: "if (checkValueOf(x)) {" },
{ type: "same", line: " doSomethingWith(x);" },
{ type: "same", line: "}" },
])
})
test("uses new lines for nested reindentation changes", async () => {
const oldLines = ["if (checkValueOf(x)) {", " doSomethingWith(x);", "}"]
const newLines = [
"if (checkValueOf(x)) {",
" if (reallyCheckValueOf(x)) {",
" doSomethingElseWith(x);",
" }",
"}",
]
const { streamDiffs } = await collectDiffs(oldLines, newLines)
expect(streamDiffs).toEqual([
{ type: "same", line: "if (checkValueOf(x)) {" },
{ type: "new", line: " if (reallyCheckValueOf(x)) {" },
{ type: "old", line: " doSomethingWith(x);" },
{ type: "new", line: " doSomethingElseWith(x);" },
{ type: "old", line: "}" },
{ type: "new", line: " }" },
{ type: "new", line: "}" },
])
})
test("FastAPI example", async () => {
await expectDiff("fastapi.py")
})
test("FastAPI comments", async () => {
await expectDiff("add-comments.py")
})
test("Mock LLM example", async () => {
await expectDiff("mock-llm.ts")
})
})

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