Compare commits

..

9 Commits

Author SHA1 Message Date
celestial-vault 64b557bdac syntax error 2025-06-20 20:10:41 -07:00
celestial-vault 03158ac198 merge conflicts 2025-06-20 20:10:10 -07:00
celestial-vault c821079626 merge conflicts 2025-06-11 12:45:20 -07:00
celestial-vault e0a08ddc53 change proto fields to snake case 2025-06-11 12:42:26 -07:00
celestial-vault 21033eb502 linter error 2025-06-09 13:31:04 -07:00
celestial-vault 85875d5c4d merge conflicts 2025-06-09 13:30:45 -07:00
celestial-vault ea0d3c14d2 remove parseInt 2025-06-09 13:24:19 -07:00
Elephant Lumps 139ad91004 changeset 2025-05-24 10:33:13 -07:00
Elephant Lumps 3972686972 migrate fetchUserCreditsData 2025-05-24 10:32:44 -07:00
144 changed files with 3742 additions and 10117 deletions
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
Fix ENAMETOOLONG when calling Claude Code
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": minor
---
Migrate fetchUserCreditsData to protobus
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": minor
---
Migrate authStateChanged to protobus
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
Removed deleteNonFavoritedTasks, moved popup to extension, cleaned up deletion logic
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
host bridge migration - clipboard
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
Refactor task class, moving auto approve
-89
View File
@@ -1,89 +0,0 @@
# Cline Protobuf Development Guide
This guide outlines how to add new gRPC endpoints for communication between the webview (frontend) and the extension host (backend).
## Overview
Cline uses [Protobuf](https://protobuf.dev/) to define a strongly-typed API, ensuring efficient and type-safe communication. All definitions are in the `/proto` directory. The compiler and plugins are included as project dependencies, so no manual installation is needed.
## Key Concepts & Best Practices
- **File Structure**: Each feature domain should have its own `.proto` file (e.g., `account.proto`, `task.proto`).
- **Message Design**:
- For simple, single-value data, use the shared types in `proto/common.proto` (e.g., `StringRequest`, `Empty`, `Int64Request`). This promotes consistency.
- For complex data structures, define custom messages within the feature's `.proto` file (see `task.proto` for examples like `NewTaskRequest`).
- **Naming Conventions**:
- Services: `PascalCaseService` (e.g., `AccountService`).
- RPCs: `camelCase` (e.g., `accountEmailIdentified`).
- Messages: `PascalCase` (e.g., `StringRequest`).
- **Streaming**: For server-to-client streaming, use the `stream` keyword on the response type. See `subscribeToAuthCallback` in `account.proto` for an example.
---
## 4-Step Development Workflow
Heres how to add a new RPC, using `scrollToSettings` as an example.
### 1. Define the RPC in a `.proto` File
Add your service method to the appropriate file in the `proto/` directory.
**File: `proto/ui.proto`**
```proto
service UiService {
// ... other RPCs
// Scrolls to a specific settings section in the settings view
rpc scrollToSettings(StringRequest) returns (KeyValuePair);
}
```
Here, we use the common `StringRequest` and `KeyValuePair` types.
### 2. Compile Definitions
After editing a `.proto` file, regenerate the TypeScript code. From the project root, run:
```bash
npm run protos
```
This command compiles all `.proto` files and outputs the generated code to `src/generated/` and `src/shared/`. Do not edit these generated files manually.
### 3. Implement the Backend Handler
Create the RPC implementation in the backend. Handlers are located in `src/core/controller/[service-name]/`.
**File: `src/core/controller/ui/scrollToSettings.ts`**
```typescript
import { Controller } from ".."
import { StringRequest, KeyValuePair } from "../../../shared/proto/common"
/**
* Executes a scroll to settings action
* @param controller The controller instance
* @param request The request containing the ID of the settings section to scroll to
* @returns KeyValuePair with action and value fields for the UI to process
*/
export async function scrollToSettings(controller: Controller, request: StringRequest): Promise<KeyValuePair> {
return KeyValuePair.create({
key: "scrollToSettings",
value: request.value || "",
})
}
```
### 4. Call the RPC from the Webview
Call the new RPC from a React component in `webview-ui/`. The generated client makes this simple.
**File: `webview-ui/src/components/browser/BrowserSettingsMenu.tsx`** (Example)
```tsx
import { UiServiceClient } from "../../../services/grpc"
import { StringRequest } from "../../../../shared/proto/common"
// ... inside a React component
const handleMenuClick = async () => {
try {
await UiServiceClient.scrollToSettings(StringRequest.create({ value: "browser" }))
} catch (error) {
console.error("Error scrolling to browser settings:", error)
}
}
```
@@ -1,116 +0,0 @@
name: 💡 Feature Proposal & Contribution
description: Propose a new feature or improvement, and optionally offer to implement feature as a contributor
labels: ["proposal"]
body:
- type: markdown
attributes:
value: |
**Feature Proposal & Contribution for Cline**
Thank you for proposing a feature or improvement for Cline! This template helps us understand the problem, evaluate the solution, and coordinate implementation.
**For detailed proposals:** Please provide comprehensive information to enable fast prioritization and discussion.
**For contribution offers:** You can indicate your willingness to implement the feature yourself.
Before submitting:
- Search existing [Issues](https://github.com/cline/cline/issues) and [Discussions](https://github.com/cline/cline/discussions) to avoid duplicates
- Read the [Contributing Guide](https://github.com/cline/cline/blob/main/CONTRIBUTING.md) if you plan to contribute
- Don't start implementation until the proposal is reviewed and approved
- type: textarea
id: problem-description
attributes:
label: What problem does this solve?
description: |
Describe the problem clearly from a user's point of view. Focus on why this matters, who it affects, and when it occurs.
✅ Good examples:
- "LLM provider returns 400 error when nearing the context window instead of truncating"
- "Submit button is invisible in dark mode"
- "Users can't easily share their Cline configurations with team members"
❌ Avoid vague descriptions:
- "Performance is bad"
- "UI needs work"
Your description should include:
- Who is affected?
- When does it happen?
- What's the current vs expected behavior?
- What is the impact?
placeholder: Be specific about the problem, who it affects, and the impact.
validations:
required: true
- type: textarea
id: proposed-solution
attributes:
label: What's the proposed solution?
description: |
Describe how the problem should be solved. Be specific about UX, system behavior, and any flows that would change.
✅ Good examples:
- "Add error handling immediately after attempting to create the llm stream and retry after manually truncating"
- "Update button styling to ensure contrast in all themes"
- "Add export/import functionality in settings with JSON format"
❌ Avoid vague solutions:
- "Improve performance"
- "Fix the bug"
Your solution should include:
- What exactly will change?
- How will users interact with it?
- What's the expected outcome?
placeholder: Describe the proposed changes and how they solve the problem.
validations:
required: false
- type: dropdown
id: contribution-intent
attributes:
label: Are you interested in implementing this?
description: Let us know if you'd like to contribute to this feature
options:
- "No, just proposing the idea"
- "Yes, I'd like to implement this myself"
- "Yes, I'd like to collaborate with others"
- "Maybe, depending on complexity and guidance"
validations:
required: false
- type: textarea
id: implementation-approach
attributes:
label: Implementation approach (if contributing)
description: |
**Only fill this out if you selected "Yes" above.**
How do you plan to implement this? Include:
- High-level technical approach
- Files/components that would be affected
- Any new dependencies required
- Potential challenges or considerations you've identified
This helps us provide better guidance and ensures alignment before you start coding.
placeholder: "My implementation approach would be..."
- type: checkboxes
id: checklist
attributes:
label: Proposal checklist
options:
- label: I've checked for existing issues or related proposals
required: true
- label: I understand this needs review before implementation can start
required: true
- type: checkboxes
id: contribution-checklist
attributes:
label: Contribution checklist (if contributing)
description: Only check these if you plan to contribute
options:
- label: I've read the [Contributing Guide](https://github.com/cline/cline/blob/main/CONTRIBUTING.md)
- label: I'm willing to make changes based on feedback
- label: I understand the code review process and requirements
+130
View File
@@ -0,0 +1,130 @@
name: 📝 Detailed Feature Proposal
description: Propose a new feature or improvement
labels: ["proposal"]
body:
- type: markdown
attributes:
value: |
**Feature Proposal for Cline**
Thank you for creating a feature proposal for Cline! This template is for clear, actionable proposals that define a specific problem and a high-confidence solution. Please provide enough detail to enable fast prioritization, discussion, and execution.
Detailed proposals will be prioritized, while vague proposals may be closed or require extensive back and forth communication.
Before submitting:
- Search existing [Issues](https://github.com/cline/cline/issues) and [Discussions](https://github.com/cline/cline/discussions) to avoid duplicates
- Dont start implementation until the proposal is reviewed and approved
- type: textarea
id: problem-description
attributes:
label: What problem does this solve?
description: |
Describe the problem clearly from a user's point of view. Focus on why this matters, who it affects, and when it occurs.
✅ Solid:
- "LLM provider returns 400 error when nearing the context window instead of truncating"
- "Submit button is invisible in dark mode"
❌ Avoid:
- "Performance is bad"
- "UI needs work"
Your description should include:
- Who is affected?
- When does it happen?
- What's the current vs expected behavior?
- What is the impact?
placeholder: Be specific about the problem, who it affects, and the impact.
validations:
required: true
- type: textarea
id: proposed-solution
attributes:
label: Whats the proposed solution?
description: |
Describe how the problem should be solved. Be specific about UX, system behavior, and any flows that would change.
✅ Solid:
- "Add error handling immediately after attempting to create the llm stream and retry after manually truncating"
- "Update button styling to ensure contrast in all themes"
❌ Avoid:
- "Improve performance"
- "Fix the bug"
Your solution should include:
- What exactly will change?
- How will users interact with it?
- Whats the expected outcome?
placeholder: Describe the proposed changes and how they solve the problem.
validations:
required: true
- type: textarea
id: acceptance-criteria
attributes:
label: How will we know it works? (Acceptance Criteria)
description: Define clear, testable success criteria.
placeholder: Provide specific and testable conditions for success.
validations:
required: true
- type: textarea
id: estimated-effort
attributes:
label: Estimated effort and complexity
description: |
Help us understand scope and risks. Include:
- Size estimate (XS/S/M/L/XL or hours/days)
- Why this size? Whats technically involved?
- Any tricky parts, refactors, or risks?
- Performance or compatibility concerns?
- Any dependencies on systems, teams, or libraries?
placeholder: Size, reasoning, risks, and dependencies.
validations:
required: true
- type: textarea
id: technical-considerations
attributes:
label: Technical considerations, tradeoffs, and/or risks (optional)
description: |
Include any technical context that helps us evaluate or implement the proposal more effectively.
You may include:
- Architectural changes or required refactors
- Performance implications or system-level impacts
- Known limitations, risks, or tricky edge cases
- Compatibility concerns or migration steps
- Alternative approaches you considered and why they were not chosen
- Dependencies on other systems, teams, or libraries
- Were other approaches considered? Why is this one preferred?
placeholder: Technical considerations, tradeoffs, and/or risks.
- type: textarea
id: additional-context
attributes:
label: Additional context (optional)
description: Diagrams, mockups, logs, links, or anything else that helps explain or justify the proposal.
placeholder: Diagrams, mockups, logs, links, or anything else that helps explain or justify the proposal.
- type: checkboxes
id: checklist
attributes:
label: Proposal checklist
options:
- label: Ive checked for existing issues or related proposals
required: true
- label: I understand this needs review before implementation can start
required: true
- type: checkboxes
id: willingness-to-contribute
attributes:
label: Interested in implementing this?
description: Optional
options:
- label: Yes, Id like to help implement this feature
+1 -1
View File
@@ -12,7 +12,7 @@ jobs:
issues: write
pull-requests: write
steps:
- uses: actions/stale@v9
- uses: actions/stale@28ca103
with:
days-before-issue-stale: 60
days-before-issue-close: 14
-14
View File
@@ -1,19 +1,5 @@
# Changelog
## [3.18.0]
- Optimized Cline to work with the Claude 4 family of models, resulting in improved performance, reliability, and new capabilities
- Added a new Gemini CLI provider that allows you to use your local Gemini CLI authentication to access Gemini models for free (Thanks @google-gemini!)
- Optimized Cline to work with the Gemini 2.5 family of models
- Updated the default and recommended model to Claude 4 Sonnet for the best performance
- Fix race condition in Plan/Act mode switching
- Improve robustness of search and replace parsing
## [3.17.16]
- Fix Claude Code provider error handling for incomplete messages during long-running tasks (Thanks @BarreiroT!)
- Add taskId as metadata to LiteLLM API requests for better request tracing (Thanks @jorgegarciarey!)
## [3.17.15]
- Fix LiteLLM provider to properly respect selected model IDs when switching between Plan and Act modes (Thanks @sammcj!)
+12 -59
View File
@@ -10,6 +10,13 @@ Bug reports help make Cline better for everyone! Before creating a new issue, pl
🔐 <b>Important:</b> If you discover a security vulnerability, please use the <a href="https://github.com/cline/cline/security/advisories/new">Github security tool to report it privately</a>.
</blockquote>
## Deciding What to Work On
Looking for a good first contribution? Check out issues labeled ["good first issue"](https://github.com/cline/cline/labels/good%20first%20issue) or ["help wanted"](https://github.com/cline/cline/labels/help%20wanted). These are specifically curated for new contributors and areas where we'd love some help!
We also welcome contributions to our [documentation](https://github.com/cline/cline/tree/main/docs)! Whether it's fixing typos, improving existing guides, or creating new educational content - we'd love to build a community-driven repository of resources that helps everyone get the most out of Cline. You can start by diving into `/docs` and looking for areas that need improvement.
If you're planning to work on a bigger feature, please create a [feature request](https://github.com/cline/cline/discussions/categories/feature-requests?discussions_q=is%3Aopen+category%3A%22Feature+Requests%22+sort%3Atop) first so we can discuss whether it aligns with Cline's vision.
## Before Contributing
@@ -17,70 +24,14 @@ All contributions must begin with a GitHub Issue, unless the change is for small
- **Check existing issues**: Search [GitHub Issues](https://github.com/cline/cline/issues).
- **Create an issue**: Use appropriate templates:
- **Contributions:** Use the "Contribution Request" template to propose what you'd like to work on.
- **Bugs:** "Bug Report" template for reporting issues.
- **Features:** "Detailed Feature Proposal" template for suggesting new features.
- **Wait for approval**: A core Cline contributor must approve your contribution request before you start implementation.
- **Claim issues**: Once approved, the issue will be assigned to you.
- **Bugs:** "Bug Report" template.
- **Features:** "Detailed Feature Proposal" template. Approval from a core Cline contributor required before starting.
- **Claim issues**: Comment your interest.
**PRs without approved issues may be closed.**
## Deciding What to Work On
Looking for a good first contribution? Check out issues labeled ["good first issue"](https://github.com/cline/cline/labels/good%20first%20issue) or ["help wanted"](https://github.com/cline/cline/labels/help%20wanted). These are specifically curated for new contributors and areas where we'd love some help!
We also welcome contributions to our [documentation](https://github.com/cline/cline/tree/main/docs)! Whether it's fixing typos, improving existing guides, or creating new educational content - we'd love to build a community-driven repository of resources that helps everyone get the most out of Cline. You can start by diving into `/docs` and looking for areas that need improvement.
## Development Setup
### Local Development Instructions
1. Clone the repository _(Requires [git-lfs](https://git-lfs.com/))_:
```bash
git clone https://github.com/cline/cline.git
```
2. Open the project in VSCode:
```bash
code cline
```
3. Install the necessary dependencies for the extension and webview-gui:
```bash
npm run install:all
```
4. Launch by pressing `F5` (or `Run`->`Start Debugging`) to open a new VSCode window with the extension loaded. (You may need to install the [esbuild problem matchers extension](https://marketplace.visualstudio.com/items?itemName=connor4312.esbuild-problem-matchers) if you run into issues building the project.)
### Creating a Pull Request
1. Before creating a PR, generate a changeset entry:
```bash
npm run changeset
```
This will prompt you for:
- Type of change (major, minor, patch)
- `major` → breaking changes (1.0.0 → 2.0.0)
- `minor` → new features (1.0.0 → 1.1.0)
- `patch` → bug fixes (1.0.0 → 1.0.1)
- Description of your changes
2. Commit your changes and the generated `.changeset` file
3. Push your branch and create a PR on GitHub. Our CI will:
- Run tests and checks
- Changesetbot will create a comment showing the version impact
- When merged to main, changesetbot will create a Version Packages PR
- When the Version Packages PR is merged, a new release will be published
4. Testing
- Run `npm run test` to run tests locally.
- Before submitting PR, run `npm run format:fix` to format your code
- Run `npm run test:ci` to run tests locally
### Extension
1. **VS Code Extensions**
- When opening the project, VS Code will prompt you to install recommended extensions
@@ -135,6 +86,8 @@ We also welcome contributions to our [documentation](https://github.com/cline/cl
xvfb
```
- Run `npm run test:ci` to run tests locally
## Writing and Submitting Code
Anyone can contribute code to Cline, but we ask that you follow these guidelines to ensure your contributions can be smoothly integrated:
+45 -1
View File
@@ -30,7 +30,7 @@ English | <a href="https://github.com/cline/cline/blob/main/locales/es/README.md
</table>
</div>
Meet Cline (pronounced /klaɪn/, like "Klein"), an AI assistant that can use your **CLI** a**N**d **E**ditor.
Meet Cline, an AI assistant that can use your **CLI** a**N**d **E**ditor.
Thanks to [Claude 3.7 Sonnet's agentic coding capabilities](https://www.anthropic.com/claude/sonnet), Cline can handle complex software development tasks step-by-step. With tools that let him create & edit files, explore large projects, use the browser, and execute terminal commands (after you grant permission), he can assist you in ways that go beyond code completion or tech support. Cline can even use the Model Context Protocol (MCP) to create new tools and extend his own capabilities. While autonomous AI scripts traditionally run in sandboxed environments, this extension provides a human-in-the-loop GUI to approve every file change and terminal command, providing a safe and accessible way to explore the potential of agentic AI.
@@ -141,6 +141,50 @@ For example, when working with a local web server, you can use 'Restore Workspac
To contribute to the project, start with our [Contributing Guide](CONTRIBUTING.md) to learn the basics. You can also join our [Discord](https://discord.gg/cline) to chat with other contributors in the `#contributors` channel. If you're looking for full-time work, check out our open positions on our [careers page](https://cline.bot/join-us)!
<details>
<summary>Local Development Instructions</summary>
1. Clone the repository _(Requires [git-lfs](https://git-lfs.com/))_:
```bash
git clone https://github.com/cline/cline.git
```
2. Open the project in VSCode:
```bash
code cline
```
3. Install the necessary dependencies for the extension and webview-gui:
```bash
npm run install:all
```
4. Launch by pressing `F5` (or `Run`->`Start Debugging`) to open a new VSCode window with the extension loaded. (You may need to install the [esbuild problem matchers extension](https://marketplace.visualstudio.com/items?itemName=connor4312.esbuild-problem-matchers) if you run into issues building the project.)
</details>
<details>
<summary>Creating a Pull Request</summary>
1. Before creating a PR, generate a changeset entry:
```bash
npm run changeset
```
This will prompt you for:
- Type of change (major, minor, patch)
- `major` → breaking changes (1.0.0 → 2.0.0)
- `minor` → new features (1.0.0 → 1.1.0)
- `patch` → bug fixes (1.0.0 → 1.0.1)
- Description of your changes
2. Commit your changes and the generated `.changeset` file
3. Push your branch and create a PR on GitHub. Our CI will:
- Run tests and checks
- Changesetbot will create a comment showing the version impact
- When merged to main, changesetbot will create a Version Packages PR
- When the Version Packages PR is merged, a new release will be published
</details>
## License
[Apache 2.0 © 2025 Cline Bot Inc.](./LICENSE)
-1
View File
@@ -146,7 +146,6 @@
"group": "Provider Configuration",
"pages": [
"provider-config/anthropic",
"provider-config/claude-code",
"provider-config/aws-bedrock-with-credentials-authentication",
"provider-config/aws-bedrock-with-profile-authentication",
"provider-config/gcp-vertex-ai",
@@ -120,7 +120,7 @@ Example of context window usage over 50% with a 200K context window:
# Context Window Usage
105,000 / 200,000 tokens (53%)
Model: anthropic/claude-sonnet-4 (200K context window)
Model: anthropic/claude-3.7-sonnet (200K context window)
\`\`\`
**IMPORTANT**: When you see context window usage at or above 50%, you MUST:
+2 -2
View File
@@ -6,7 +6,7 @@ title: "Telemetry"
To help make Cline better for everyone, we collect anonymous usage data that helps us understand how developers are using our open-source AI coding agent. This feedback loop is crucial for improving Cline's capabilities and user experience.
We use PostHog, an open-source analytics platform, for data collection and analysis. Our telemetry implementation is fully transparent - you can review the [source code](https://github.com/cline/cline/blob/main/src/services/posthog/telemetry/TelemetryService.ts) to see exactly what we track.
We use PostHog, an open-source analytics platform, for data collection and analysis. Our telemetry implementation is fully transparent - you can review the [source code](https://github.com/cline/cline/blob/main/src/services/telemetry/TelemetryService.ts) to see exactly what we track.
### Tracking Policy
@@ -22,7 +22,7 @@ We collect basic anonymous usage data including:
**System Context:** OS type and VS Code environment details\
**UI Activity:** Navigation patterns and feature usage
For complete transparency, you can inspect our [telemetry implementation](https://github.com/cline/cline/blob/main/src/services/posthog/telemetry/TelemetryService.ts) to see the exact events we track.
For complete transparency, you can inspect our [telemetry implementation](https://github.com/cline/cline/blob/main/src/services/telemetry/TelemetryService.ts) to see the exact events we track.
### How to Opt Out
-86
View File
@@ -1,86 +0,0 @@
---
title: "Claude Code"
description: "Use your Claude Max subscription with Cline instead of paying per token. Learn how to set up and configure the Claude Code provider."
---
**Website:** [https://docs.anthropic.com/en/docs/claude-code/setup](https://docs.anthropic.com/en/docs/claude-code/setup)
The Claude Code provider lets you use your existing Claude subscription with Cline. If you have Claude Max, this means you can use Claude in Cline without paying extra API costs.
<Frame>
<img
src="https://storage.googleapis.com/cline_public_images/docs/assets/claude-code-use-opus.gif"
alt="Using the Claude Code provider in Cline with Opus model"
/>
</Frame>
## Setup
First, you'll need to install and authenticate Claude Code on your system:
1. **Install Claude Code**: Follow Anthropic's [official setup guide](https://docs.anthropic.com/en/docs/claude-code/setup) to install and authenticate the Claude CLI.
2. **Configure in Cline**:
- Open Cline settings (⚙️ icon)
- Select **Claude Code** from the **API Provider** dropdown
- Set the path to your Claude CLI executable (usually just `claude` if it's in your PATH)
<Frame>
<img
src="https://storage.googleapis.com/cline_public_images/docs/assets/claude-code-setup.gif"
alt="Setting up the Claude Code provider in Cline"
/>
</Frame>
### Finding your Claude Code path
If you're not sure where Claude Code is installed:
- **macOS / Linux**: Run `which claude` in your terminal
- **Windows (Command Prompt)**: Run `where claude`
- **Windows (PowerShell)**: Run `Get-Command claude`
## Supported Models
The Claude Code provider supports these models:
- `claude-sonnet-4-20250514` (Recommended)
- `claude-opus-4-20250514`
- `claude-3-7-sonnet-20250219`
- `claude-3-5-sonnet-20241022`
- `claude-3-5-haiku-20241022`
## How it works
When you use Claude Code with Cline, here's what happens behind the scenes:
Cline wraps the Claude Code CLI to handle your requests. Each time you send a message, Cline starts a new `claude` process, sends your conversation, and streams the response back. The AI reasoning comes from Claude Code, but all the actual file editing, terminal commands, and other tools are handled by Cline.
The main difference you'll notice is that responses don't stream character-by-character like other providers. Instead, Claude Code processes your full request before sending back the complete response.
## Limitations
There are a few things to keep in mind with Claude Code:
- Images in your messages get converted to text placeholders since Claude Code doesn't support image uploads through the CLI
- Prompt caching isn't available with this provider
- Responses don't stream in real-time like other providers
## Troubleshooting
If you run into issues:
**Authentication problems**: Make sure you're logged into Claude Code with your subscription account. Run `claude auth status` to check.
**Path issues**: Double-check that the Claude CLI path in Cline's settings is correct. Try running `claude --version` in your terminal to verify it's working.
**Still having trouble?** We're actively improving this integration. Report issues on our [GitHub](https://github.com/cline/cline/issues) or ask for help in our [Discord](https://discord.gg/cline).
## Usage with subscriptions
If you have a Claude Max subscription, your usage in Cline shows up as $0.00 in the billing interface since you're not paying additional API costs. Your usage still counts against your subscription limits, but you won't see per-token charges.
For more details about using Claude Code with your subscription, check out Anthropic's documentation:
- [Claude Code Setup Guide](https://docs.anthropic.com/en/docs/claude-code/setup)
- [Using Claude Code with Pro/Max Plans](https://support.anthropic.com/en/articles/11145838-using-claude-code-with-your-pro-or-max-plan)
@@ -1,74 +0,0 @@
const { RuleTester: VscodeRuleTester } = require("eslint")
const vscodePostmessageRule = require("../no-vscode-postmessage")
const vscodeRuleTester = new VscodeRuleTester({
parser: require.resolve("@typescript-eslint/parser"),
parserOptions: {
ecmaVersion: 2020,
sourceType: "module",
ecmaFeatures: {
jsx: true,
},
},
})
vscodeRuleTester.run("no-vscode-postmessage", vscodePostmessageRule, {
valid: [
// Should allow vscode.postMessage in grpc-client-base.ts
{
code: `vscode.postMessage({ type: "grpc_request", data: {} })`,
filename: "grpc-client-base.ts",
},
{
code: `vscode.postMessage({ type: "grpc_request_cancel" })`,
filename: "/path/to/grpc-client-base.ts",
},
// Should allow other vscode API calls
{
code: `vscode.window.showInformationMessage("Hello")`,
filename: "test.ts",
},
// Should allow postMessage calls on other objects
{
code: `window.postMessage({ type: "test" }, "*")`,
filename: "test.ts",
},
// Should allow variables named vscode but not calling postMessage
{
code: `const vscode = { other: "method" }; vscode.other()`,
filename: "test.ts",
},
],
invalid: [
// Should ban vscode.postMessage in regular files
{
code: `vscode.postMessage({ type: "test", data: {} })`,
filename: "test.ts",
errors: [
{
messageId: "useGrpcClient",
},
],
},
// Should ban vscode.postMessage in components
{
code: `vscode.postMessage({ type: "apiConfiguration", apiConfiguration })`,
filename: "ApiOptions.tsx",
errors: [
{
messageId: "useGrpcClient",
},
],
},
// Should ban vscode.postMessage in test files
{
code: `vscode.postMessage({ type: "newTask", text: message.text })`,
filename: "test.test.ts",
errors: [
{
messageId: "useGrpcClient",
},
],
},
],
})
-3
View File
@@ -1,13 +1,11 @@
// eslint-rules/index.js
const noProtobufObjectLiterals = require("./no-protobuf-object-literals")
const noGrpcClientObjectLiterals = require("./no-grpc-client-object-literals")
const noVscodePostmessage = require("./no-vscode-postmessage")
module.exports = {
rules: {
"no-protobuf-object-literals": noProtobufObjectLiterals,
"no-grpc-client-object-literals": noGrpcClientObjectLiterals,
"no-vscode-postmessage": noVscodePostmessage,
},
configs: {
recommended: {
@@ -15,7 +13,6 @@ module.exports = {
rules: {
"local/no-protobuf-object-literals": "error",
"local/no-grpc-client-object-literals": "error",
"local/no-vscode-postmessage": "error",
},
},
},
-61
View File
@@ -1,61 +0,0 @@
const { ESLintUtils } = require("@typescript-eslint/utils")
const path = require("path")
const createRule = ESLintUtils.RuleCreator((name) => `https://cline.bot/eslint-rules/${name}`)
module.exports = createRule({
name: "no-vscode-postmessage",
meta: {
type: "problem",
docs: {
description: "Ban vscode.postMessage() calls in favor of gRPC service clients, except in grpc-client-base.ts",
recommended: "error",
},
messages: {
useGrpcClient:
"Use gRPC service clients instead of vscode.postMessage().\n" +
"Example: AccountServiceClient.methodName(RequestType.create({...})) instead of vscode.postMessage({type: '...'}).\n" +
"Found: {{code}}",
},
schema: [],
},
defaultOptions: [],
create(context) {
// Check if current file is grpc-client-base.ts (exception case)
const filename = context.filename
const isGrpcClientBase = path.basename(filename) === "grpc-client-base.ts"
return {
// Detect vscode.postMessage calls
"CallExpression[callee.type='MemberExpression']"(node) {
// Skip if this is grpc-client-base.ts
if (isGrpcClientBase) {
return
}
const callee = node.callee
// Check for vscode.postMessage pattern
if (
callee.object &&
callee.object.type === "Identifier" &&
callee.object.name === "vscode" &&
callee.property &&
callee.property.name === "postMessage"
) {
const sourceCode = context.sourceCode
const callText = sourceCode.getText(node).trim()
context.report({
node,
messageId: "useGrpcClient",
data: {
code: callText,
},
})
}
},
}
},
})
+1 -3
View File
@@ -19,6 +19,4 @@ diff_editing/test_outputs/
.cache
# Python bytecode cache
*__pycache__/
diff-edits/cases.zip
*__pycache__/
-10
View File
@@ -14,8 +14,6 @@ interface RunDiffEvalOptions {
testPath: string
outputPath: string
replay: boolean
replayRunId?: string
diffApplyFile?: string
maxCases?: number
}
@@ -58,14 +56,6 @@ export async function runDiffEvalHandler(options: RunDiffEvalOptions) {
args.push("--replay")
}
if (options.replayRunId) {
args.push("--replay-run-id", options.replayRunId)
}
if (options.diffApplyFile) {
args.push("--diff-apply-file", options.diffApplyFile)
}
if (options.verbose) {
args.push("--verbose")
}
-2
View File
@@ -93,8 +93,6 @@ program
.option("--thinking-budget <tokens>", "Set the thinking tokens budget", "0")
.option("--parallel", "Run tests in parallel", false)
.option("--replay", "Run evaluation from a pre-recorded LLM output, skipping the API call", false)
.option("--replay-run-id <run_id>", "The ID of the run to replay from the database")
.option("--diff-apply-file <filename>", "The name of the diff apply file to use for the replay")
.option("-v, --verbose", "Enable verbose logging", false)
.action(async (options) => {
try {
+6 -8
View File
@@ -8,9 +8,8 @@ import {
parseAssistantMessageV3,
AssistantMessageContent,
} from "./parsing/parse-assistant-message-06-06-25" // "../../src/core/assistant-message"
import { constructNewFileContent as constructNewFileContent_06_06_25 } from "./diff-apply/diff-06-06-25"
import { constructNewFileContent as constructNewFileContent_06_23_25 } from "./diff-apply/diff-06-23-25"
import { constructNewFileContent as constructNewFileContent_06_25_25 } from "./diff-apply/diff-06-25-25"
import { constructNewFileContent as constructNewFileContentV1, constructNewFileContentV2 } from "./diff-apply/diff-06-06-25"
import { constructNewFileContent as constructNewFileContentV3 } from "../../src/core/assistant-message/diff" // this defaults to the new v1 when called
type ParseAssistantMessageFn = (message: string) => AssistantMessageContent[]
type ConstructNewFileContentFn = (diff: string, original: string, strict: boolean) => Promise<string>
@@ -22,9 +21,9 @@ const parsingFunctions: Record<string, ParseAssistantMessageFn> = {
}
const diffEditingFunctions: Record<string, ConstructNewFileContentFn> = {
"diff-06-06-25": constructNewFileContent_06_06_25,
"diff-06-23-25": constructNewFileContent_06_23_25,
"diff-06-25-25": constructNewFileContent_06_25_25,
constructNewFileContentV1: constructNewFileContentV1,
constructNewFileContentV2: constructNewFileContentV2,
constructNewFileContentV3: constructNewFileContentV3, // position invariant diff
}
import { TestInput, TestResult, ExtractedToolCall } from "./types"
@@ -152,7 +151,6 @@ export async function runSingleEvaluation(input: TestInput): Promise<TestResult>
diffEditFunction,
thinkingBudgetTokens,
originalDiffEditToolCallMessage,
diffApplyFile,
} = input
const requiredParams = {
@@ -178,7 +176,7 @@ export async function runSingleEvaluation(input: TestInput): Promise<TestResult>
}
const parseAssistantMessage = parsingFunctions[parsingFunction]
const constructNewFileContent = diffEditingFunctions[diffApplyFile || diffEditFunction]
const constructNewFileContent = diffEditingFunctions[diffEditFunction]
if (!parseAssistantMessage || !constructNewFileContent) {
return {
+4 -17
View File
@@ -63,22 +63,9 @@ For example, if we ask for 5 valid attempts per test case, the system will keep
This way, if we're comparing two models and one gets a 10% success rate on its valid diff edit attempts, and another gets 90%, we have a much clearer picture of their actual diff-generating capabilities. It avoids muddying the waters with attempts where the model didn't even try to perform the specific action we're evaluating. This approach helps us isolate and measure the diff-editing skill more directly, despite the non-deterministic nature of these models.
## Replays
## Some known edge cases
You can also use the replay argument to replay a previous benchmark run. This is super useful for iterating on our diffing algorithms without having to re-run expensive and time-consuming LLM calls.
I noticed that some of the current conversation jsons in the `./cases` folder are a little big bogus. Here's a running list of these areas:
When you run an evaluation, every detail is stored in the database—including the raw, unmodified output from the model. The replay feature takes advantage of this by pulling that raw output and feeding it into a *different* diffing algorithm. This lets you isolate the performance of the diffing logic itself. We can see if a new algorithm is better at applying the exact same set of diffs that a model generated in a previous run.
This process is blazingly fast and free, as it completely bypasses the need to make new API calls. It ensures a true apples-to-apples comparison between diffing strategies, since the model's output—the "ground truth" for the evaluation—remains identical.
Heres an example of how you would replay a previous run with a new diffing algorithm:
```shell
cd evals && npm run diff-eval -- --replay-run-id 9902189e-63a8-4210-a4fc-fe59e2eaf2c2 --diff-apply-file diff-06-23-25 --verbose
```
In this command:
- `--replay-run-id` specifies the original run we want to use as our ground truth.
- `--diff-apply-file` tells the script to use the new diffing logic from the `diff-06-23-25.ts` file.
The script will then create a new run in the database that mirrors the original, but with the results of applying the new diffing algorithm. This allows for a direct comparison in the dashboard, helping us quickly see which of our diffing strategies is the most robust.
- ~~What if the conversation json was using a model with a massive context window, like Google Gemini's 1M context window, and we're now re-rolling that case on a smaller context window model like claude 4 (200k) or grok-3 (128k)? It's def gonna fail, and we shouldn't just keep trying. We should have a smart system for selecting which cases we can use given the arguments passed in. For example, if we pass in claude and grok with `max cases = 2`, we shouldn't just pick the first two jsons in the folder. We should go through, use a tokenizer, and make sure it would fit with some padding like 20k tokens. Use tiktoken. 20k padding will be sufficient even though different models tokenize differently.~~
- There are some weird jsons, where something weird happen, where essentially there's a fluke. Maybe the user was using an extremely dumb model that just hallucinated a fake filepath. Now when we try to reroll that case, we never get a valid case. This can easily be handled by making sure that the file is present before selecting that eval for testing. By "file is present" I mean, that file_contents is present in the eval. Additionally, we should in the streamlit dashboard show cases where getting a valid attempt is a challenge, so we can review those cases more easily and throw them out if they're bogus. Maybe a special tab/page in the dashboard for this purpose. Across all runs / cases, what the most consistently problematic cases are. Pop one open to see the case formatted json with all the user/assistant turns.
-232
View File
@@ -1,9 +1,4 @@
import { runSingleEvaluation, TestInput, TestResult } from "./ClineWrapper"
import { parseAssistantMessageV2, AssistantMessageContent } from "./parsing/parse-assistant-message-06-06-25"
import { constructNewFileContent as constructNewFileContent_06_06_25 } from "./diff-apply/diff-06-06-25"
import { constructNewFileContent as constructNewFileContent_06_23_25 } from "./diff-apply/diff-06-23-25"
import { constructNewFileContent as constructNewFileContent_06_25_25 } from "./diff-apply/diff-06-25-25"
import { constructNewFileContent as constructNewFileContentV3 } from "../../src/core/assistant-message/diff"
import { basicSystemPrompt } from "./prompts/basicSystemPrompt-06-06-25"
import { claude4SystemPrompt } from "./prompts/claude4SystemPrompt-06-06-25"
import { formatResponse } from "./helpers"
@@ -23,10 +18,6 @@ import {
insertResult,
DatabaseClient,
CreateResultInput,
getResultsByRun,
getCaseById,
getFileByHash,
getBenchmarkRun,
} from "./database"
// Load environment variables from .env file
@@ -193,78 +184,6 @@ class NodeTestRunner {
log(isVerbose, `✓ Created ${this.caseIdMap.size} database case records`);
}
/**
* Store replay result in database, copying original data but with new diffing results
*/
async storeReplayResultInDatabase(replayResult: TestResult, originalResult: any, testId: string, newCaseId: string): Promise<void> {
if (!this.currentRunId || !this.processingFunctionsHash) {
return; // Skip if database not initialized
}
try {
// Map error string to error enum (simple mapping)
const errorEnum = this.mapErrorToEnum(replayResult.error);
// Store diff edit content if available
let fileEditedHash: string | undefined;
if (replayResult.diffEdit) {
fileEditedHash = await upsertFile({
filepath: `diff-edit-${testId}`,
content: replayResult.diffEdit
});
}
// Calculate basic metrics from diff edit if available
let numEdits = 0;
let numLinesAdded = 0;
let numLinesDeleted = 0;
if (replayResult.diffEdit) {
// Simple parsing to count edits - count SEARCH/REPLACE blocks
const searchBlocks = (replayResult.diffEdit.match(/------- SEARCH/g) || []).length;
numEdits = searchBlocks;
// Count added/deleted lines (rough approximation)
const lines = replayResult.diffEdit.split('\n');
for (const line of lines) {
if (line.startsWith('+') && !line.startsWith('+++')) {
numLinesAdded++;
} else if (line.startsWith('-') && !line.startsWith('---')) {
numLinesDeleted++;
}
}
}
// Copy original result data but update replay-specific fields
const resultInput: CreateResultInput = {
run_id: this.currentRunId, // New run ID
case_id: newCaseId, // New case ID
model_id: originalResult.model_id, // Copy from original
processing_functions_hash: this.processingFunctionsHash, // New processing functions
succeeded: replayResult.success && (replayResult.diffEditSuccess ?? false), // New result
error_enum: errorEnum, // New error if any
num_edits: numEdits || originalResult.num_edits, // New or original
num_lines_deleted: numLinesDeleted || originalResult.num_lines_deleted, // New or original
num_lines_added: numLinesAdded || originalResult.num_lines_added, // New or original
// Copy timing and cost data from original (since we didn't make API calls)
time_to_first_token_ms: originalResult.time_to_first_token_ms,
time_to_first_edit_ms: originalResult.time_to_first_edit_ms,
time_round_trip_ms: originalResult.time_round_trip_ms,
cost_usd: originalResult.cost_usd,
completion_tokens: originalResult.completion_tokens,
// Use original model output (since we're replaying)
raw_model_output: originalResult.raw_model_output,
file_edited_hash: fileEditedHash || originalResult.file_edited_hash,
parsed_tool_call_json: replayResult.toolCalls ? JSON.stringify(replayResult.toolCalls) : originalResult.parsed_tool_call_json
};
await insertResult(resultInput);
} catch (error) {
console.error(`Failed to store replay result in database for ${testId}:`, error);
// Continue execution - don't fail the test run
}
}
/**
* Store test result in database
*/
@@ -475,143 +394,6 @@ class NodeTestRunner {
}
}
async runDatabaseReplay(replayRunId: string, diffApplyFile: string, isVerbose: boolean) {
log(isVerbose, `Starting database replay for run_id: ${replayRunId}`)
log(isVerbose, `Using diff apply file: ${diffApplyFile}`)
// 1. Get the correct diffing function
const diffEditingFunctions: Record<string, any> = {
"diff-06-06-25": constructNewFileContent_06_06_25,
"diff-06-23-25": constructNewFileContent_06_23_25,
"diff-06-25-25": constructNewFileContent_06_25_25,
constructNewFileContentV3: constructNewFileContentV3,
}
const constructNewFileContent = diffEditingFunctions[diffApplyFile]
if (!constructNewFileContent) {
throw new Error(`Could not find diff apply function for: ${diffApplyFile}`)
}
log(isVerbose, `Successfully loaded diff apply function: ${diffApplyFile}`)
// 2. Fetch original run data
const originalResults = await getResultsByRun(replayRunId)
if (originalResults.length === 0) {
throw new Error(`No results found for run_id: ${replayRunId}`)
}
log(isVerbose, `Found ${originalResults.length} results to replay.`)
const originalRun = await getBenchmarkRun(replayRunId)
if (!originalRun) {
throw new Error(`Could not find original run with id ${replayRunId}`)
}
// 3. Create a new benchmark run for the replay
const replayRunDescription = `Replay of run ${replayRunId} using ${diffApplyFile}`
this.currentRunId = await createBenchmarkRun({
description: replayRunDescription,
system_prompt_hash: originalRun.system_prompt_hash,
})
log(isVerbose, `Created new run for replay: ${this.currentRunId}`)
// 4. Set up processing functions for the new run
this.processingFunctionsHash = await upsertProcessingFunctions({
name: `replay-${diffApplyFile}`,
parsing_function: "parseAssistantMessageV2",
diff_edit_function: diffApplyFile,
})
// 5. Process each result from the original run
let replayedCount = 0
const caseIdMirror: Map<string, string> = new Map()
for (const originalResult of originalResults) {
// 5a. Basic validation to ensure we can even process this
if (!originalResult.case_id) {
log(isVerbose, `Skipping result ${originalResult.result_id} due to missing case_id.`)
continue
}
// 5b. Mirror the case for the new run, reusing if already created
let newCaseId = caseIdMirror.get(originalResult.case_id)
if (!newCaseId) {
const originalCase = await getCaseById(originalResult.case_id)
if (!originalCase) {
log(isVerbose, `Skipping result ${originalResult.result_id} because original case could not be found.`)
continue
}
newCaseId = await createCase({
run_id: this.currentRunId,
description: `Replay of case ${originalCase.case_id} from run ${replayRunId}`,
system_prompt_hash: originalCase.system_prompt_hash,
task_id: originalCase.task_id,
tokens_in_context: originalCase.tokens_in_context,
file_hash: originalCase.file_hash,
})
caseIdMirror.set(originalResult.case_id, newCaseId)
}
// 5c. Determine if the original attempt was a "valid attempt"
const isValidOriginalAttempt = originalResult.error_enum === null || originalResult.error_enum === 3 // 3 is diff_edit_error
const newResultInput: CreateResultInput = {
...(originalResult as any),
run_id: this.currentRunId,
case_id: newCaseId,
processing_functions_hash: this.processingFunctionsHash,
}
delete (newResultInput as any).result_id
if (isValidOriginalAttempt) {
// This was a valid attempt. Re-run the diff algorithm.
const originalCase = await getCaseById(originalResult.case_id)
if (!originalCase) {
log(isVerbose, ` [WARN] Replay for result ${originalResult.result_id}: Could not find original case. Copying original result.`)
newResultInput.succeeded = originalResult.succeeded
newResultInput.error_enum = originalResult.error_enum
} else {
const originalFile = originalCase.file_hash ? await getFileByHash(originalCase.file_hash) : null
const parsedToolCall = originalResult.parsed_tool_call_json ? JSON.parse(originalResult.parsed_tool_call_json)[0] : null
const diffContent = parsedToolCall?.input?.diff
if (originalFile && diffContent) {
let diffSuccess = false
try {
await constructNewFileContent(diffContent, originalFile.content, true)
diffSuccess = true
log(isVerbose, ` [OK] Replay for task ${originalCase.task_id}: Diff applied successfully.`)
} catch (e) {
diffSuccess = false
log(isVerbose, ` [FAIL] Replay for task ${originalCase.task_id}: New diff algorithm failed.`)
}
newResultInput.succeeded = diffSuccess
newResultInput.error_enum = diffSuccess ? undefined : 3 // 3 = diff_edit_error
} else {
// Something is wrong with the ground truth data, just copy it.
log(
isVerbose,
` [WARN] Replay for task ${originalCase.task_id}: Valid original attempt but missing file or diff content. Copying original result.`,
)
newResultInput.succeeded = originalResult.succeeded
newResultInput.error_enum = originalResult.error_enum
}
}
} else {
// This was not a valid attempt. Just copy the original result's outcome.
log(isVerbose, ` [SKIP] Replay for task ${originalResult.case_id}: Invalid original attempt. Copying original result.`)
newResultInput.succeeded = originalResult.succeeded
newResultInput.error_enum = originalResult.error_enum
}
await insertResult(newResultInput)
replayedCount++
}
log(isVerbose, `\n✓ Database replay completed successfully.`)
log(isVerbose, ` Total original results: ${originalResults.length}`)
log(isVerbose, ` Total replayed results: ${replayedCount}`)
log(isVerbose, ` New run ID: ${this.currentRunId}`)
}
/**
* Run a single test example
*/
@@ -638,7 +420,6 @@ class NodeTestRunner {
diffEditFunction: testConfig.diff_edit_function,
thinkingBudgetTokens: testConfig.thinking_tokens_budget,
originalDiffEditToolCallMessage: testConfig.replay ? testCase.original_diff_edit_tool_call_message : undefined,
diffApplyFile: testConfig.diff_apply_file,
}
if (isVerbose) {
@@ -931,8 +712,6 @@ async function main() {
.option("--thinking-budget <tokens>", "Set the thinking tokens budget", "0")
.option("--parallel", "Run tests in parallel", false)
.option("--replay", "Run evaluation from a pre-recorded LLM output, skipping the API call", false)
.option("--replay-run-id <run_id>", "The ID of the run to replay from the database")
.option("--diff-apply-file <filename>", "The name of the diff apply file to use for the replay")
.option("-v, --verbose", "Enable verbose logging", false)
.option("--max-concurrency <number>", "Maximum number of parallel requests", "80")
@@ -953,17 +732,6 @@ async function main() {
}
const validAttemptsPerCase = parseInt(options.validAttemptsPerCase, 10);
const runner = new NodeTestRunner(options.replay || !!options.replayRunId)
if (options.replayRunId) {
if (!options.diffApplyFile) {
console.error("Error: --diff-apply-file is required when using --replay-run-id")
process.exit(1)
}
await runner.runDatabaseReplay(options.replayRunId, options.diffApplyFile, isVerbose)
return
}
try {
const startTime = Date.now()
+3 -90
View File
@@ -420,33 +420,21 @@ def render_model_comparison_cards(model_performance):
metric_col1, metric_col2, metric_col3, metric_col4 = st.columns(4)
with metric_col1:
if pd.notna(model['avg_round_trip_ms']):
st.metric("Avg Latency", f"{model['avg_round_trip_ms']:.0f}ms")
else:
st.metric("Avg Latency", "N/A")
st.metric("Avg Latency", f"{model['avg_round_trip_ms']:.0f}ms")
with metric_col2:
if pd.notna(model['avg_cost']):
st.metric("Avg Cost", f"${model['avg_cost']:.4f}")
else:
st.metric("Avg Cost", "N/A")
st.metric("Avg Cost", f"${model['avg_cost']:.4f}")
with metric_col3:
st.metric("Valid Results", f"{model['total_results']}")
with metric_col4:
if pd.notna(model['avg_first_token_ms']):
st.metric("First Token", f"{model['avg_first_token_ms']:.0f}ms")
else:
st.metric("First Token", "N/A")
st.metric("First Token", f"{model['avg_first_token_ms']:.0f}ms")
with col2:
st.write("") # Add some spacing
if st.button(f"Drill Down", key=f"drill_{model['model_id']}", use_container_width=True):
st.session_state.drill_down_model = model['model_id']
# Update URL with model_id for drill down
st.query_params["model_id"] = model['model_id']
st.rerun()
st.divider() # Add a divider between models
@@ -847,11 +835,6 @@ def main():
if 'selected_run_id' not in st.session_state:
st.session_state.selected_run_id = None
# Handle URL parameters for direct linking
query_params = st.query_params
url_run_id = query_params.get("run_id")
url_model_id = query_params.get("model_id")
# Load all runs for sidebar
all_runs = load_all_runs()
@@ -859,18 +842,6 @@ def main():
st.error("No evaluation runs found in the database.")
st.stop()
# Set initial run selection from URL or default to latest
if url_run_id and url_run_id in all_runs['run_id'].values:
if st.session_state.selected_run_id != url_run_id:
st.session_state.selected_run_id = url_run_id
st.session_state.drill_down_model = None # Reset drill down when changing runs via URL
elif st.session_state.selected_run_id is None:
st.session_state.selected_run_id = all_runs.iloc[0]['run_id'] # Default to latest
# Set drill down model from URL
if url_model_id and st.session_state.selected_run_id == url_run_id:
st.session_state.drill_down_model = url_model_id
# Sidebar for run selection
with st.sidebar:
st.markdown("## 📊 Evaluation Runs")
@@ -916,10 +887,6 @@ def main():
if run_ids[selected_run_idx] != st.session_state.selected_run_id:
st.session_state.selected_run_id = run_ids[selected_run_idx]
st.session_state.drill_down_model = None # Reset drill down when changing runs
# Update URL with new run_id
st.query_params["run_id"] = st.session_state.selected_run_id
if "model_id" in st.query_params:
del st.query_params["model_id"] # Clear model_id when changing runs
st.rerun()
# Show run details in sidebar
@@ -930,57 +897,6 @@ def main():
st.markdown(f"**Created:** {selected_run['created_at']}")
if selected_run['description']:
st.markdown(f"**Description:** {selected_run['description']}")
# Show shareable URL
st.markdown("---")
st.markdown("### 🔗 Share This View")
# Build current URL
# Dynamically derive the base URL
server_address = st.server.server_address if hasattr(st.server, 'server_address') else "localhost"
server_port = st.server.server_port if hasattr(st.server, 'server_port') else "8501"
base_url = f"http://{server_address}:{server_port}"
current_url = f"{base_url}/?run_id={st.session_state.selected_run_id}"
if st.session_state.drill_down_model:
current_url += f"&model_id={st.session_state.drill_down_model}"
st.markdown("**Current URL:**")
st.code(current_url, language=None)
# Copy button using HTML/JS
copy_button_html = f"""
<button onclick="copyToClipboard('{current_url}')" style="
padding: 8px 16px;
border-radius: 5px;
border: 1px solid #ccc;
background: #f0f2f6;
cursor: pointer;
font-size: 14px;
margin-top: 5px;
">📋 Copy Link</button>
<script>
function copyToClipboard(text) {{
navigator.clipboard.writeText(text).then(function() {{
// Success feedback
event.target.innerText = '✅ Copied!';
event.target.style.backgroundColor = '#d4edda';
setTimeout(() => {{
event.target.innerText = '📋 Copy Link';
event.target.style.backgroundColor = '#f0f2f6';
}}, 2000);
}}, function(err) {{
// Error feedback
event.target.innerText = '❌ Failed';
event.target.style.backgroundColor = '#f8d7da';
setTimeout(() => {{
event.target.innerText = '📋 Copy Link';
event.target.style.backgroundColor = '#f0f2f6';
}}, 2000);
}});
}}
</script>
"""
st.components.v1.html(copy_button_html, height=50)
# Load data for selected run
current_run, model_performance = load_run_comparison(st.session_state.selected_run_id)
@@ -998,9 +914,6 @@ def main():
with col1:
if st.button("Back to Overview", use_container_width=True):
st.session_state.drill_down_model = None
# Clear model_id from URL when going back to overview
if "model_id" in st.query_params:
del st.query_params["model_id"]
st.rerun()
render_detailed_analysis(current_run['run_id'], st.session_state.drill_down_model)
+1 -13
View File
@@ -33,7 +33,7 @@ This is the most granular and important table in the database. A `result` repres
- **Purpose**: To store the detailed outcome of every single model attempt, providing the raw data for all quantitative and qualitative analysis.
- **Key Columns**:
- `result_id`: The primary key for the result.
- `result_id`: A unique identifier for the individual attempt.
- `run_id`, `case_id`, `model_id`, `processing_functions_hash`: A set of foreign keys that precisely situate this result within the context of a specific run, case, model, and set of helper functions.
- `succeeded`: A boolean indicating if the generated diff was applied successfully.
- `error_enum`: A numeric code representing the specific type of error if the attempt failed (e.g., `1` for `no_tool_calls`, `7` for `wrong_file_edited`).
@@ -82,15 +82,3 @@ This relational schema provides a powerful foundation for sophisticated analysis
- "Which of our internal diffing algorithms is the most robust against a wide range of model-generated edits?"
Ultimately, this data model enables us to move from simply *measuring* performance to truly *understanding* it, providing the insights needed to build more capable and reliable AI engineering systems.
---
## Viewing the Full Schema
To see the most up-to-date and detailed schema for the database, you can use the `sqlite3` command-line tool. From the `evals/diff-edits` directory, run the following command:
```bash
sqlite3 evals.db .schema
```
This will print the complete `CREATE TABLE` statements for all tables in the database, providing a definitive reference for the database structure.
@@ -1,827 +0,0 @@
const SEARCH_BLOCK_START = "------- SEARCH"
const SEARCH_BLOCK_END = "======="
const REPLACE_BLOCK_END = "+++++++ REPLACE"
const SEARCH_BLOCK_CHAR = "-"
const REPLACE_BLOCK_CHAR = "+"
const LEGACY_SEARCH_BLOCK_CHAR = "<"
const LEGACY_REPLACE_BLOCK_CHAR = ">"
// Replace the exact string constants with flexible regex patterns
const SEARCH_BLOCK_START_REGEX = /^[-]{3,} SEARCH$/
const SEARCH_BLOCK_END_REGEX = /^[=]{3,}$/
const REPLACE_BLOCK_END_REGEX = /^[+]{3,} REPLACE$/
const LEGACY_SEARCH_BLOCK_START_REGEX = /^[<]{3,} SEARCH$/
const LEGACY_REPLACE_BLOCK_END_REGEX = /^[>]{3,} REPLACE$/
// Helper functions to check if a line matches the flexible patterns
function isSearchBlockStart(line: string): boolean {
return SEARCH_BLOCK_START_REGEX.test(line) || LEGACY_SEARCH_BLOCK_START_REGEX.test(line)
}
function isSearchBlockEnd(line: string): boolean {
return SEARCH_BLOCK_END_REGEX.test(line)
}
function isReplaceBlockEnd(line: string): boolean {
return REPLACE_BLOCK_END_REGEX.test(line) || LEGACY_REPLACE_BLOCK_END_REGEX.test(line)
}
/**
* Attempts a line-trimmed fallback match for the given search content in the original content.
* It tries to match `searchContent` lines against a block of lines in `originalContent` starting
* from `lastProcessedIndex`. Lines are matched by trimming leading/trailing whitespace and ensuring
* they are identical afterwards.
*
* Returns [matchIndexStart, matchIndexEnd] if found, or false if not found.
*/
function lineTrimmedFallbackMatch(originalContent: string, searchContent: string, startIndex: number): [number, number] | false {
// Split both contents into lines
const originalLines = originalContent.split("\n")
const searchLines = searchContent.split("\n")
// Trim trailing empty line if exists (from the trailing \n in searchContent)
if (searchLines[searchLines.length - 1] === "") {
searchLines.pop()
}
// Find the line number where startIndex falls
let startLineNum = 0
let currentIndex = 0
while (currentIndex < startIndex && startLineNum < originalLines.length) {
currentIndex += originalLines[startLineNum].length + 1 // +1 for \n
startLineNum++
}
// For each possible starting position in original content
for (let i = startLineNum; i <= originalLines.length - searchLines.length; i++) {
let matches = true
// Try to match all search lines from this position
for (let j = 0; j < searchLines.length; j++) {
const originalTrimmed = originalLines[i + j].trim()
const searchTrimmed = searchLines[j].trim()
if (originalTrimmed !== searchTrimmed) {
matches = false
break
}
}
// If we found a match, calculate the exact character positions
if (matches) {
// Find start character index
let matchStartIndex = 0
for (let k = 0; k < i; k++) {
matchStartIndex += originalLines[k].length + 1 // +1 for \n
}
// Find end character index
let matchEndIndex = matchStartIndex
for (let k = 0; k < searchLines.length; k++) {
matchEndIndex += originalLines[i + k].length + 1 // +1 for \n
}
return [matchStartIndex, matchEndIndex]
}
}
return false
}
/**
* Attempts to match blocks of code by using the first and last lines as anchors.
* This is a third-tier fallback strategy that helps match blocks where we can identify
* the correct location by matching the beginning and end, even if the exact content
* differs slightly.
*
* The matching strategy:
* 1. Only attempts to match blocks of 3 or more lines to avoid false positives
* 2. Extracts from the search content:
* - First line as the "start anchor"
* - Last line as the "end anchor"
* 3. For each position in the original content:
* - Checks if the next line matches the start anchor
* - If it does, jumps ahead by the search block size
* - Checks if that line matches the end anchor
* - All comparisons are done after trimming whitespace
*
* This approach is particularly useful for matching blocks of code where:
* - The exact content might have minor differences
* - The beginning and end of the block are distinctive enough to serve as anchors
* - The overall structure (number of lines) remains the same
*
* @param originalContent - The full content of the original file
* @param searchContent - The content we're trying to find in the original file
* @param startIndex - The character index in originalContent where to start searching
* @returns A tuple of [startIndex, endIndex] if a match is found, false otherwise
*/
function blockAnchorFallbackMatch(originalContent: string, searchContent: string, startIndex: number): [number, number] | false {
const originalLines = originalContent.split("\n")
const searchLines = searchContent.split("\n")
// Only use this approach for blocks of 3+ lines
if (searchLines.length < 3) {
return false
}
// Trim trailing empty line if exists
if (searchLines[searchLines.length - 1] === "") {
searchLines.pop()
}
const firstLineSearch = searchLines[0].trim()
const lastLineSearch = searchLines[searchLines.length - 1].trim()
const searchBlockSize = searchLines.length
// Find the line number where startIndex falls
let startLineNum = 0
let currentIndex = 0
while (currentIndex < startIndex && startLineNum < originalLines.length) {
currentIndex += originalLines[startLineNum].length + 1
startLineNum++
}
// Look for matching start and end anchors
for (let i = startLineNum; i <= originalLines.length - searchBlockSize; i++) {
// Check if first line matches
if (originalLines[i].trim() !== firstLineSearch) {
continue
}
// Check if last line matches at the expected position
if (originalLines[i + searchBlockSize - 1].trim() !== lastLineSearch) {
continue
}
// Calculate exact character positions
let matchStartIndex = 0
for (let k = 0; k < i; k++) {
matchStartIndex += originalLines[k].length + 1
}
let matchEndIndex = matchStartIndex
for (let k = 0; k < searchBlockSize; k++) {
matchEndIndex += originalLines[i + k].length + 1
}
return [matchStartIndex, matchEndIndex]
}
return false
}
/**
* This function reconstructs the file content by applying a streamed diff (in a
* specialized SEARCH/REPLACE block format) to the original file content. It is designed
* to handle both incremental updates and the final resulting file after all chunks have
* been processed.
*
* The diff format is a custom structure that uses three markers to define changes:
*
* ------- SEARCH
* [Exact content to find in the original file]
* =======
* [Content to replace with]
* +++++++ REPLACE
*
* Behavior and Assumptions:
* 1. The file is processed chunk-by-chunk. Each chunk of `diffContent` may contain
* partial or complete SEARCH/REPLACE blocks. By calling this function with each
* incremental chunk (with `isFinal` indicating the last chunk), the final reconstructed
* file content is produced.
*
* 2. Matching Strategy (in order of attempt):
* a. Exact Match: First attempts to find the exact SEARCH block text in the original file
* b. Line-Trimmed Match: Falls back to line-by-line comparison ignoring leading/trailing whitespace
* c. Block Anchor Match: For blocks of 3+ lines, tries to match using first/last lines as anchors
* If all matching strategies fail, an error is thrown.
*
* 3. Empty SEARCH Section:
* - If SEARCH is empty and the original file is empty, this indicates creating a new file
* (pure insertion).
* - If SEARCH is empty and the original file is not empty, this indicates a complete
* file replacement (the entire original content is considered matched and replaced).
*
* 4. Applying Changes:
* - Before encountering the "=======" marker, lines are accumulated as search content.
* - After "=======" and before ">>>>>>> REPLACE", lines are accumulated as replacement content.
* - Once the block is complete (">>>>>>> REPLACE"), the matched section in the original
* file is replaced with the accumulated replacement lines, and the position in the original
* file is advanced.
*
* 5. Incremental Output:
* - As soon as the match location is found and we are in the REPLACE section, each new
* replacement line is appended to the result so that partial updates can be viewed
* incrementally.
*
* 6. Partial Markers:
* - If the final line of the chunk looks like it might be part of a marker but is not one
* of the known markers, it is removed. This prevents incomplete or partial markers
* from corrupting the output.
*
* 7. Finalization:
* - Once all chunks have been processed (when `isFinal` is true), any remaining original
* content after the last replaced section is appended to the result.
* - Trailing newlines are not forcibly added. The code tries to output exactly what is specified.
*
* Errors:
* - If the search block cannot be matched using any of the available matching strategies,
* an error is thrown.
*/
export async function constructNewFileContent(
diffContent: string,
originalContent: string,
isFinal: boolean,
version: "v1" | "v2" = "v1",
): Promise<string> {
const constructor = constructNewFileContentVersionMapping[version]
if (!constructor) {
throw new Error(`Invalid version '${version}' for file content constructor`)
}
return constructor(diffContent, originalContent, isFinal)
}
const constructNewFileContentVersionMapping: Record<
string,
(diffContent: string, originalContent: string, isFinal: boolean) => Promise<string>
> = {
v1: constructNewFileContentV1,
v2: constructNewFileContentV2,
} as const
async function constructNewFileContentV1(diffContent: string, originalContent: string, isFinal: boolean): Promise<string> {
let result = ""
let lastProcessedIndex = 0
let currentSearchContent = ""
let currentReplaceContent = ""
let inSearch = false
let inReplace = false
let searchMatchIndex = -1
let searchEndIndex = -1
// Track all replacements to handle out-of-order edits
let replacements: Array<{ start: number; end: number; content: string }> = []
let pendingOutOfOrderReplacement = false
let lines = diffContent.split("\n")
// If the last line looks like a partial marker but isn't recognized,
// remove it because it might be incomplete.
const lastLine = lines[lines.length - 1]
if (
lines.length > 0 &&
(lastLine.startsWith(SEARCH_BLOCK_CHAR) ||
lastLine.startsWith(LEGACY_SEARCH_BLOCK_CHAR) ||
lastLine.startsWith("=") ||
lastLine.startsWith(REPLACE_BLOCK_CHAR) ||
lastLine.startsWith(LEGACY_REPLACE_BLOCK_CHAR)) &&
!isSearchBlockStart(lastLine) &&
!isSearchBlockEnd(lastLine) &&
!isReplaceBlockEnd(lastLine)
) {
lines.pop()
}
for (const line of lines) {
if (isSearchBlockStart(line)) {
inSearch = true
currentSearchContent = ""
currentReplaceContent = ""
continue
}
if (isSearchBlockEnd(line)) {
inSearch = false
inReplace = true
// Remove trailing linebreak for adding the === marker
// if (currentSearchContent.endsWith("\r\n")) {
// currentSearchContent = currentSearchContent.slice(0, -2)
// } else if (currentSearchContent.endsWith("\n")) {
// currentSearchContent = currentSearchContent.slice(0, -1)
// }
if (!currentSearchContent) {
// Empty search block
if (originalContent.length === 0) {
// New file scenario: nothing to match, just start inserting
searchMatchIndex = 0
searchEndIndex = 0
} else {
// ERROR: Empty search block with non-empty file indicates malformed SEARCH marker
throw new Error(
"Empty SEARCH block detected with non-empty file. This usually indicates a malformed SEARCH marker.\n" +
"Please ensure your SEARCH marker follows the correct format:\n" +
"- Use '------- SEARCH' (7+ dashes + space + SEARCH)\n",
)
}
} else {
// Add check for inefficient full-file search
// if (currentSearchContent.trim() === originalContent.trim()) {
// throw new Error(
// "The SEARCH block contains the entire file content. Please either:\n" +
// "1. Use an empty SEARCH block to replace the entire file, or\n" +
// "2. Make focused changes to specific parts of the file that need modification.",
// )
// }
// Exact search match scenario
const exactIndex = originalContent.indexOf(currentSearchContent, lastProcessedIndex)
if (exactIndex !== -1) {
searchMatchIndex = exactIndex
searchEndIndex = exactIndex + currentSearchContent.length
} else {
// Attempt fallback line-trimmed matching
const lineMatch = lineTrimmedFallbackMatch(originalContent, currentSearchContent, lastProcessedIndex)
if (lineMatch) {
;[searchMatchIndex, searchEndIndex] = lineMatch
} else {
// Try block anchor fallback for larger blocks
const blockMatch = blockAnchorFallbackMatch(originalContent, currentSearchContent, lastProcessedIndex)
if (blockMatch) {
;[searchMatchIndex, searchEndIndex] = blockMatch
} else {
// Last resort: search the entire file from the beginning
const fullFileIndex = originalContent.indexOf(currentSearchContent, 0)
if (fullFileIndex !== -1) {
// Found in the file - could be out of order
searchMatchIndex = fullFileIndex
searchEndIndex = fullFileIndex + currentSearchContent.length
if (searchMatchIndex < lastProcessedIndex) {
pendingOutOfOrderReplacement = true
}
} else {
throw new Error(
`The SEARCH block:\n${currentSearchContent.trimEnd()}\n...does not match anything in the file.`,
)
}
}
}
}
}
// Check if this is an out-of-order replacement
if (searchMatchIndex < lastProcessedIndex) {
pendingOutOfOrderReplacement = true
}
// For in-order replacements, output everything up to the match location
if (!pendingOutOfOrderReplacement) {
result += originalContent.slice(lastProcessedIndex, searchMatchIndex)
}
continue
}
if (isReplaceBlockEnd(line)) {
// Finished one replace block
// Store this replacement
replacements.push({
start: searchMatchIndex,
end: searchEndIndex,
content: currentReplaceContent,
})
// If this was an in-order replacement, advance lastProcessedIndex
if (!pendingOutOfOrderReplacement) {
lastProcessedIndex = searchEndIndex
}
// Reset for next block
inSearch = false
inReplace = false
currentSearchContent = ""
currentReplaceContent = ""
searchMatchIndex = -1
searchEndIndex = -1
pendingOutOfOrderReplacement = false
continue
}
// Accumulate content for search or replace
// (currentReplaceContent is not being used for anything right now since we directly append to result.)
// (We artificially add a linebreak since we split on \n at the beginning. In order to not include a trailing linebreak in the final search/result blocks we need to remove it before using them. This allows for partial line matches to be correctly identified.)
// NOTE: search/replace blocks must be arranged in the order they appear in the file due to how we build the content using lastProcessedIndex. We also cannot strip the trailing newline since for non-partial lines it would remove the linebreak from the original content. (If we remove end linebreak from search, then we'd also have to remove it from replace but we can't know if it's a partial line or not since the model may be using the line break to indicate the end of the block rather than as part of the search content.) We require the model to output full lines in order for our fallbacks to work as well.
if (inSearch) {
currentSearchContent += line + "\n"
} else if (inReplace) {
currentReplaceContent += line + "\n"
// Only output replacement lines immediately for in-order replacements
if (searchMatchIndex !== -1 && !pendingOutOfOrderReplacement) {
result += line + "\n"
}
}
}
// If this is the final chunk, we need to apply all replacements and build the final result
if (isFinal) {
// Handle the case where we're still in replace mode when processing ends
// and this is the final chunk - treat it as if we encountered the REPLACE marker
if (inReplace && searchMatchIndex !== -1) {
// Store this replacement
replacements.push({
start: searchMatchIndex,
end: searchEndIndex,
content: currentReplaceContent,
})
// If this was an in-order replacement, advance lastProcessedIndex
if (!pendingOutOfOrderReplacement) {
lastProcessedIndex = searchEndIndex
}
// Reset state
inSearch = false
inReplace = false
currentSearchContent = ""
currentReplaceContent = ""
searchMatchIndex = -1
searchEndIndex = -1
pendingOutOfOrderReplacement = false
}
// end of handling missing replace marker
// Sort replacements by start position
replacements.sort((a, b) => a.start - b.start)
// Rebuild the entire result by applying all replacements
result = ""
let currentPos = 0
for (const replacement of replacements) {
// Add original content up to this replacement
result += originalContent.slice(currentPos, replacement.start)
// Add the replacement content
result += replacement.content
// Move position to after the replaced section
currentPos = replacement.end
}
// Add any remaining original content
result += originalContent.slice(currentPos)
}
return result
}
enum ProcessingState {
Idle = 0,
StateSearch = 1 << 0,
StateReplace = 1 << 1,
}
class NewFileContentConstructor {
private originalContent: string
private isFinal: boolean
private state: number
private pendingNonStandardLines: string[]
private result: string
private lastProcessedIndex: number
private currentSearchContent: string
private currentReplaceContent: string
private searchMatchIndex: number
private searchEndIndex: number
constructor(originalContent: string, isFinal: boolean) {
this.originalContent = originalContent
this.isFinal = isFinal
this.pendingNonStandardLines = []
this.result = ""
this.lastProcessedIndex = 0
this.state = ProcessingState.Idle
this.currentSearchContent = ""
this.currentReplaceContent = ""
this.searchMatchIndex = -1
this.searchEndIndex = -1
}
private resetForNextBlock() {
// Reset for next block
this.state = ProcessingState.Idle
this.currentSearchContent = ""
this.currentReplaceContent = ""
this.searchMatchIndex = -1
this.searchEndIndex = -1
}
private findLastMatchingLineIndex(regx: RegExp, lineLimit: number) {
for (let i = lineLimit; i > 0; ) {
i--
if (this.pendingNonStandardLines[i].match(regx)) {
return i
}
}
return -1
}
private updateProcessingState(newState: ProcessingState) {
const isValidTransition =
(this.state === ProcessingState.Idle && newState === ProcessingState.StateSearch) ||
(this.state === ProcessingState.StateSearch && newState === ProcessingState.StateReplace)
if (!isValidTransition) {
throw new Error(
`Invalid state transition.\n` +
"Valid transitions are:\n" +
"- Idle → StateSearch\n" +
"- StateSearch → StateReplace",
)
}
this.state |= newState
}
private isStateActive(state: ProcessingState): boolean {
return (this.state & state) === state
}
private activateReplaceState() {
this.updateProcessingState(ProcessingState.StateReplace)
}
private activateSearchState() {
this.updateProcessingState(ProcessingState.StateSearch)
this.currentSearchContent = ""
this.currentReplaceContent = ""
}
private isSearchingActive(): boolean {
return this.isStateActive(ProcessingState.StateSearch)
}
private isReplacingActive(): boolean {
return this.isStateActive(ProcessingState.StateReplace)
}
private hasPendingNonStandardLines(pendingNonStandardLineLimit: number): boolean {
return this.pendingNonStandardLines.length - pendingNonStandardLineLimit < this.pendingNonStandardLines.length
}
public processLine(line: string) {
this.internalProcessLine(line, true, this.pendingNonStandardLines.length)
}
public getResult() {
// If this is the final chunk, append any remaining original content
if (this.isFinal && this.lastProcessedIndex < this.originalContent.length) {
this.result += this.originalContent.slice(this.lastProcessedIndex)
}
if (this.isFinal && this.state !== ProcessingState.Idle) {
throw new Error("File processing incomplete - SEARCH/REPLACE operations still active during finalization")
}
return this.result
}
private internalProcessLine(
line: string,
canWritependingNonStandardLines: boolean,
pendingNonStandardLineLimit: number,
): number {
let removeLineCount = 0
if (isSearchBlockStart(line)) {
removeLineCount = this.trimPendingNonStandardTrailingEmptyLines(pendingNonStandardLineLimit)
if (removeLineCount > 0) {
pendingNonStandardLineLimit = pendingNonStandardLineLimit - removeLineCount
}
if (this.hasPendingNonStandardLines(pendingNonStandardLineLimit)) {
this.tryFixSearchReplaceBlock(pendingNonStandardLineLimit)
canWritependingNonStandardLines && (this.pendingNonStandardLines.length = 0)
}
this.activateSearchState()
} else if (isSearchBlockEnd(line)) {
// 校验非标内容
if (!this.isSearchingActive()) {
this.tryFixSearchBlock(pendingNonStandardLineLimit)
canWritependingNonStandardLines && (this.pendingNonStandardLines.length = 0)
}
this.activateReplaceState()
this.beforeReplace()
} else if (isReplaceBlockEnd(line)) {
if (!this.isReplacingActive()) {
this.tryFixReplaceBlock(pendingNonStandardLineLimit)
canWritependingNonStandardLines && (this.pendingNonStandardLines.length = 0)
}
this.lastProcessedIndex = this.searchEndIndex
this.resetForNextBlock()
} else {
// Accumulate content for search or replace
// (currentReplaceContent is not being used for anything right now since we directly append to result.)
// (We artificially add a linebreak since we split on \n at the beginning. In order to not include a trailing linebreak in the final search/result blocks we need to remove it before using them. This allows for partial line matches to be correctly identified.)
// NOTE: search/replace blocks must be arranged in the order they appear in the file due to how we build the content using lastProcessedIndex. We also cannot strip the trailing newline since for non-partial lines it would remove the linebreak from the original content. (If we remove end linebreak from search, then we'd also have to remove it from replace but we can't know if it's a partial line or not since the model may be using the line break to indicate the end of the block rather than as part of the search content.) We require the model to output full lines in order for our fallbacks to work as well.
if (this.isReplacingActive()) {
this.currentReplaceContent += line + "\n"
// Output replacement lines immediately if we know the insertion point
if (this.searchMatchIndex !== -1) {
this.result += line + "\n"
}
} else if (this.isSearchingActive()) {
this.currentSearchContent += line + "\n"
} else {
let appendToPendingNonStandardLines = canWritependingNonStandardLines
if (appendToPendingNonStandardLines) {
// 处理非标内容
this.pendingNonStandardLines.push(line)
}
}
}
return removeLineCount
}
private beforeReplace() {
// Remove trailing linebreak for adding the === marker
// if (currentSearchContent.endsWith("\r\n")) {
// currentSearchContent = currentSearchContent.slice(0, -2)
// } else if (currentSearchContent.endsWith("\n")) {
// currentSearchContent = currentSearchContent.slice(0, -1)
// }
if (!this.currentSearchContent) {
// Empty search block
if (this.originalContent.length === 0) {
// New file scenario: nothing to match, just start inserting
this.searchMatchIndex = 0
this.searchEndIndex = 0
} else {
// Complete file replacement scenario: treat the entire file as matched
this.searchMatchIndex = 0
this.searchEndIndex = this.originalContent.length
}
} else {
// Add check for inefficient full-file search
// if (currentSearchContent.trim() === originalContent.trim()) {
// throw new Error(
// "The SEARCH block contains the entire file content. Please either:\n" +
// "1. Use an empty SEARCH block to replace the entire file, or\n" +
// "2. Make focused changes to specific parts of the file that need modification.",
// )
// }
// Exact search match scenario
const exactIndex = this.originalContent.indexOf(this.currentSearchContent, this.lastProcessedIndex)
if (exactIndex !== -1) {
this.searchMatchIndex = exactIndex
this.searchEndIndex = exactIndex + this.currentSearchContent.length
} else {
// Attempt fallback line-trimmed matching
const lineMatch = lineTrimmedFallbackMatch(
this.originalContent,
this.currentSearchContent,
this.lastProcessedIndex,
)
if (lineMatch) {
;[this.searchMatchIndex, this.searchEndIndex] = lineMatch
} else {
// Try block anchor fallback for larger blocks
const blockMatch = blockAnchorFallbackMatch(
this.originalContent,
this.currentSearchContent,
this.lastProcessedIndex,
)
if (blockMatch) {
;[this.searchMatchIndex, this.searchEndIndex] = blockMatch
} else {
throw new Error(
`The SEARCH block:\n${this.currentSearchContent.trimEnd()}\n...does not match anything in the file.`,
)
}
}
}
}
if (this.searchMatchIndex < this.lastProcessedIndex) {
throw new Error(
`The SEARCH block:\n${this.currentSearchContent.trimEnd()}\n...matched an incorrect content in the file.`,
)
}
// Output everything up to the match location
this.result += this.originalContent.slice(this.lastProcessedIndex, this.searchMatchIndex)
}
private tryFixSearchBlock(lineLimit: number): number {
let removeLineCount = 0
if (lineLimit < 0) {
lineLimit = this.pendingNonStandardLines.length
}
if (!lineLimit) {
throw new Error("Invalid SEARCH/REPLACE block structure - no lines available to process")
}
let searchTagRegexp = /^([-]{3,}|[<]{3,}) SEARCH$/
const searchTagIndex = this.findLastMatchingLineIndex(searchTagRegexp, lineLimit)
if (searchTagIndex !== -1) {
let fixLines = this.pendingNonStandardLines.slice(searchTagIndex, lineLimit)
fixLines[0] = SEARCH_BLOCK_START
for (const line of fixLines) {
removeLineCount += this.internalProcessLine(line, false, searchTagIndex)
}
} else {
throw new Error(
`Invalid REPLACE marker detected - could not find matching SEARCH block starting from line ${searchTagIndex + 1}`,
)
}
return removeLineCount
}
private tryFixReplaceBlock(lineLimit: number): number {
let removeLineCount = 0
if (lineLimit < 0) {
lineLimit = this.pendingNonStandardLines.length
}
if (!lineLimit) {
throw new Error()
}
let replaceBeginTagRegexp = /^[=]{3,}$/
const replaceBeginTagIndex = this.findLastMatchingLineIndex(replaceBeginTagRegexp, lineLimit)
if (replaceBeginTagIndex !== -1) {
// // 校验非标内容
// if (!this.isSearchingActive()) {
// removeLineCount += this.tryFixSearchBlock(replaceBeginTagIndex)
// }
let fixLines = this.pendingNonStandardLines.slice(replaceBeginTagIndex - removeLineCount, lineLimit - removeLineCount)
fixLines[0] = SEARCH_BLOCK_END
for (const line of fixLines) {
removeLineCount += this.internalProcessLine(line, false, replaceBeginTagIndex - removeLineCount)
}
} else {
throw new Error(`Malformed REPLACE block - missing valid separator after line ${replaceBeginTagIndex + 1}`)
}
return removeLineCount
}
private tryFixSearchReplaceBlock(lineLimit: number): number {
let removeLineCount = 0
if (lineLimit < 0) {
lineLimit = this.pendingNonStandardLines.length
}
if (!lineLimit) {
throw new Error()
}
let replaceEndTagRegexp = /^([+]{3,}|[>]{3,}) REPLACE$/
const replaceEndTagIndex = this.findLastMatchingLineIndex(replaceEndTagRegexp, lineLimit)
const likeReplaceEndTag = replaceEndTagIndex === lineLimit - 1
if (likeReplaceEndTag) {
// // 校验非标内容
// if (!this.isReplacingActive()) {
// removeLineCount += this.tryFixReplaceBlock(replaceEndTagIndex)
// }
let fixLines = this.pendingNonStandardLines.slice(replaceEndTagIndex - removeLineCount, lineLimit - removeLineCount)
fixLines[fixLines.length - 1] = REPLACE_BLOCK_END
for (const line of fixLines) {
removeLineCount += this.internalProcessLine(line, false, replaceEndTagIndex - removeLineCount)
}
} else {
throw new Error("Malformed SEARCH/REPLACE block structure: Missing valid closing REPLACE marker")
}
return removeLineCount
}
/**
* Removes trailing empty lines from the pendingNonStandardLines array
* @param lineLimit - The index to start checking from (exclusive).
* Removes empty lines from lineLimit-1 backwards.
* @returns The number of empty lines removed
*/
private trimPendingNonStandardTrailingEmptyLines(lineLimit: number): number {
let removedCount = 0
let i = Math.min(lineLimit, this.pendingNonStandardLines.length) - 1
while (i >= 0 && this.pendingNonStandardLines[i].trim() === "") {
this.pendingNonStandardLines.pop()
removedCount++
i--
}
return removedCount
}
}
export async function constructNewFileContentV2(diffContent: string, originalContent: string, isFinal: boolean): Promise<string> {
let newFileContentConstructor = new NewFileContentConstructor(originalContent, isFinal)
let lines = diffContent.split("\n")
// If the last line looks like a partial marker but isn't recognized,
// remove it because it might be incomplete.
const lastLine = lines[lines.length - 1]
if (
lines.length > 0 &&
(lastLine.startsWith(SEARCH_BLOCK_CHAR) ||
lastLine.startsWith(LEGACY_SEARCH_BLOCK_CHAR) ||
lastLine.startsWith("=") ||
lastLine.startsWith(REPLACE_BLOCK_CHAR) ||
lastLine.startsWith(LEGACY_REPLACE_BLOCK_CHAR)) &&
lastLine !== SEARCH_BLOCK_START &&
lastLine !== SEARCH_BLOCK_END &&
lastLine !== REPLACE_BLOCK_END
) {
lines.pop()
}
for (const line of lines) {
newFileContentConstructor.processLine(line)
}
let result = newFileContentConstructor.getResult()
return result
}
@@ -1,829 +0,0 @@
const SEARCH_BLOCK_START = "------- SEARCH"
const SEARCH_BLOCK_END = "======="
const REPLACE_BLOCK_END = "+++++++ REPLACE"
const SEARCH_BLOCK_CHAR = "-"
const REPLACE_BLOCK_CHAR = "+"
const LEGACY_SEARCH_BLOCK_CHAR = "<"
const LEGACY_REPLACE_BLOCK_CHAR = ">"
// Replace the exact string constants with flexible regex patterns
const SEARCH_BLOCK_START_REGEX = /^[-]{3,} SEARCH>?$/
const LEGACY_SEARCH_BLOCK_START_REGEX = /^[<]{3,} SEARCH>?$/
const SEARCH_BLOCK_END_REGEX = /^[=]{3,}$/
const REPLACE_BLOCK_END_REGEX = /^[+]{3,} REPLACE>?$/
const LEGACY_REPLACE_BLOCK_END_REGEX = /^[>]{3,} REPLACE>?$/
// Helper functions to check if a line matches the flexible patterns
function isSearchBlockStart(line: string): boolean {
return SEARCH_BLOCK_START_REGEX.test(line) || LEGACY_SEARCH_BLOCK_START_REGEX.test(line)
}
function isSearchBlockEnd(line: string): boolean {
return SEARCH_BLOCK_END_REGEX.test(line)
}
function isReplaceBlockEnd(line: string): boolean {
return REPLACE_BLOCK_END_REGEX.test(line) || LEGACY_REPLACE_BLOCK_END_REGEX.test(line)
}
/**
* Attempts a line-trimmed fallback match for the given search content in the original content.
* It tries to match `searchContent` lines against a block of lines in `originalContent` starting
* from `lastProcessedIndex`. Lines are matched by trimming leading/trailing whitespace and ensuring
* they are identical afterwards.
*
* Returns [matchIndexStart, matchIndexEnd] if found, or false if not found.
*/
function lineTrimmedFallbackMatch(originalContent: string, searchContent: string, startIndex: number): [number, number] | false {
// Split both contents into lines
const originalLines = originalContent.split("\n")
const searchLines = searchContent.split("\n")
// Trim trailing empty line if exists (from the trailing \n in searchContent)
if (searchLines[searchLines.length - 1] === "") {
searchLines.pop()
}
// Find the line number where startIndex falls
let startLineNum = 0
let currentIndex = 0
while (currentIndex < startIndex && startLineNum < originalLines.length) {
currentIndex += originalLines[startLineNum].length + 1 // +1 for \n
startLineNum++
}
// For each possible starting position in original content
for (let i = startLineNum; i <= originalLines.length - searchLines.length; i++) {
let matches = true
// Try to match all search lines from this position
for (let j = 0; j < searchLines.length; j++) {
const originalTrimmed = originalLines[i + j].trim()
const searchTrimmed = searchLines[j].trim()
if (originalTrimmed !== searchTrimmed) {
matches = false
break
}
}
// If we found a match, calculate the exact character positions
if (matches) {
// Find start character index
let matchStartIndex = 0
for (let k = 0; k < i; k++) {
matchStartIndex += originalLines[k].length + 1 // +1 for \n
}
// Find end character index
let matchEndIndex = matchStartIndex
for (let k = 0; k < searchLines.length; k++) {
matchEndIndex += originalLines[i + k].length + 1 // +1 for \n
}
return [matchStartIndex, matchEndIndex]
}
}
return false
}
/**
* Attempts to match blocks of code by using the first and last lines as anchors.
* This is a third-tier fallback strategy that helps match blocks where we can identify
* the correct location by matching the beginning and end, even if the exact content
* differs slightly.
*
* The matching strategy:
* 1. Only attempts to match blocks of 3 or more lines to avoid false positives
* 2. Extracts from the search content:
* - First line as the "start anchor"
* - Last line as the "end anchor"
* 3. For each position in the original content:
* - Checks if the next line matches the start anchor
* - If it does, jumps ahead by the search block size
* - Checks if that line matches the end anchor
* - All comparisons are done after trimming whitespace
*
* This approach is particularly useful for matching blocks of code where:
* - The exact content might have minor differences
* - The beginning and end of the block are distinctive enough to serve as anchors
* - The overall structure (number of lines) remains the same
*
* @param originalContent - The full content of the original file
* @param searchContent - The content we're trying to find in the original file
* @param startIndex - The character index in originalContent where to start searching
* @returns A tuple of [startIndex, endIndex] if a match is found, false otherwise
*/
function blockAnchorFallbackMatch(originalContent: string, searchContent: string, startIndex: number): [number, number] | false {
const originalLines = originalContent.split("\n")
const searchLines = searchContent.split("\n")
// Only use this approach for blocks of 3+ lines
if (searchLines.length < 3) {
return false
}
// Trim trailing empty line if exists
if (searchLines[searchLines.length - 1] === "") {
searchLines.pop()
}
const firstLineSearch = searchLines[0].trim()
const lastLineSearch = searchLines[searchLines.length - 1].trim()
const searchBlockSize = searchLines.length
// Find the line number where startIndex falls
let startLineNum = 0
let currentIndex = 0
while (currentIndex < startIndex && startLineNum < originalLines.length) {
currentIndex += originalLines[startLineNum].length + 1
startLineNum++
}
// Look for matching start and end anchors
for (let i = startLineNum; i <= originalLines.length - searchBlockSize; i++) {
// Check if first line matches
if (originalLines[i].trim() !== firstLineSearch) {
continue
}
// Check if last line matches at the expected position
if (originalLines[i + searchBlockSize - 1].trim() !== lastLineSearch) {
continue
}
// Calculate exact character positions
let matchStartIndex = 0
for (let k = 0; k < i; k++) {
matchStartIndex += originalLines[k].length + 1
}
let matchEndIndex = matchStartIndex
for (let k = 0; k < searchBlockSize; k++) {
matchEndIndex += originalLines[i + k].length + 1
}
return [matchStartIndex, matchEndIndex]
}
return false
}
/**
* This function reconstructs the file content by applying a streamed diff (in a
* specialized SEARCH/REPLACE block format) to the original file content. It is designed
* to handle both incremental updates and the final resulting file after all chunks have
* been processed.
*
* The diff format is a custom structure that uses three markers to define changes:
*
* ------- SEARCH
* [Exact content to find in the original file]
* =======
* [Content to replace with]
* +++++++ REPLACE
*
* Behavior and Assumptions:
* 1. The file is processed chunk-by-chunk. Each chunk of `diffContent` may contain
* partial or complete SEARCH/REPLACE blocks. By calling this function with each
* incremental chunk (with `isFinal` indicating the last chunk), the final reconstructed
* file content is produced.
*
* 2. Matching Strategy (in order of attempt):
* a. Exact Match: First attempts to find the exact SEARCH block text in the original file
* b. Line-Trimmed Match: Falls back to line-by-line comparison ignoring leading/trailing whitespace
* c. Block Anchor Match: For blocks of 3+ lines, tries to match using first/last lines as anchors
* If all matching strategies fail, an error is thrown.
*
* 3. Empty SEARCH Section:
* - If SEARCH is empty and the original file is empty, this indicates creating a new file
* (pure insertion).
* - If SEARCH is empty and the original file is not empty, this indicates a complete
* file replacement (the entire original content is considered matched and replaced).
*
* 4. Applying Changes:
* - Before encountering the "=======" marker, lines are accumulated as search content.
* - After "=======" and before ">>>>>>> REPLACE", lines are accumulated as replacement content.
* - Once the block is complete (">>>>>>> REPLACE"), the matched section in the original
* file is replaced with the accumulated replacement lines, and the position in the original
* file is advanced.
*
* 5. Incremental Output:
* - As soon as the match location is found and we are in the REPLACE section, each new
* replacement line is appended to the result so that partial updates can be viewed
* incrementally.
*
* 6. Partial Markers:
* - If the final line of the chunk looks like it might be part of a marker but is not one
* of the known markers, it is removed. This prevents incomplete or partial markers
* from corrupting the output.
*
* 7. Finalization:
* - Once all chunks have been processed (when `isFinal` is true), any remaining original
* content after the last replaced section is appended to the result.
* - Trailing newlines are not forcibly added. The code tries to output exactly what is specified.
*
* Errors:
* - If the search block cannot be matched using any of the available matching strategies,
* an error is thrown.
*/
export async function constructNewFileContent(
diffContent: string,
originalContent: string,
isFinal: boolean,
version: "v1" | "v2" = "v1",
): Promise<string> {
const constructor = constructNewFileContentVersionMapping[version]
if (!constructor) {
throw new Error(`Invalid version '${version}' for file content constructor`)
}
return constructor(diffContent, originalContent, isFinal)
}
const constructNewFileContentVersionMapping: Record<
string,
(diffContent: string, originalContent: string, isFinal: boolean) => Promise<string>
> = {
v1: constructNewFileContentV1,
v2: constructNewFileContentV2,
} as const
async function constructNewFileContentV1(diffContent: string, originalContent: string, isFinal: boolean): Promise<string> {
let result = ""
let lastProcessedIndex = 0
let currentSearchContent = ""
let currentReplaceContent = ""
let inSearch = false
let inReplace = false
let searchMatchIndex = -1
let searchEndIndex = -1
// Track all replacements to handle out-of-order edits
let replacements: Array<{ start: number; end: number; content: string }> = []
let pendingOutOfOrderReplacement = false
let lines = diffContent.split("\n")
// If the last line looks like a partial marker but isn't recognized,
// remove it because it might be incomplete.
const lastLine = lines[lines.length - 1]
if (
lines.length > 0 &&
(lastLine.startsWith(SEARCH_BLOCK_CHAR) ||
lastLine.startsWith(LEGACY_SEARCH_BLOCK_CHAR) ||
lastLine.startsWith("=") ||
lastLine.startsWith(REPLACE_BLOCK_CHAR) ||
lastLine.startsWith(LEGACY_REPLACE_BLOCK_CHAR)) &&
!isSearchBlockStart(lastLine) &&
!isSearchBlockEnd(lastLine) &&
!isReplaceBlockEnd(lastLine)
) {
lines.pop()
}
for (const line of lines) {
if (isSearchBlockStart(line)) {
inSearch = true
currentSearchContent = ""
currentReplaceContent = ""
continue
}
if (isSearchBlockEnd(line)) {
inSearch = false
inReplace = true
// Remove trailing linebreak for adding the === marker
// if (currentSearchContent.endsWith("\r\n")) {
// currentSearchContent = currentSearchContent.slice(0, -2)
// } else if (currentSearchContent.endsWith("\n")) {
// currentSearchContent = currentSearchContent.slice(0, -1)
// }
if (!currentSearchContent) {
// Empty search block
if (originalContent.length === 0) {
// New file scenario: nothing to match, just start inserting
searchMatchIndex = 0
searchEndIndex = 0
} else {
// ERROR: Empty search block with non-empty file indicates malformed SEARCH marker
throw new Error(
"Empty SEARCH block detected with non-empty file. This usually indicates a malformed SEARCH marker.\n" +
"Please ensure your SEARCH marker follows the correct format:\n" +
"- Use '------- SEARCH' (7+ dashes + space + SEARCH)\n",
)
}
} else {
// Add check for inefficient full-file search
// if (currentSearchContent.trim() === originalContent.trim()) {
// throw new Error(
// "The SEARCH block contains the entire file content. Please either:\n" +
// "1. Use an empty SEARCH block to replace the entire file, or\n" +
// "2. Make focused changes to specific parts of the file that need modification.",
// )
// }
// Exact search match scenario
const exactIndex = originalContent.indexOf(currentSearchContent, lastProcessedIndex)
if (exactIndex !== -1) {
searchMatchIndex = exactIndex
searchEndIndex = exactIndex + currentSearchContent.length
} else {
// Attempt fallback line-trimmed matching
const lineMatch = lineTrimmedFallbackMatch(originalContent, currentSearchContent, lastProcessedIndex)
if (lineMatch) {
;[searchMatchIndex, searchEndIndex] = lineMatch
} else {
// Try block anchor fallback for larger blocks
const blockMatch = blockAnchorFallbackMatch(originalContent, currentSearchContent, lastProcessedIndex)
if (blockMatch) {
;[searchMatchIndex, searchEndIndex] = blockMatch
} else {
// Last resort: search the entire file from the beginning
const fullFileIndex = originalContent.indexOf(currentSearchContent, 0)
if (fullFileIndex !== -1) {
// Found in the file - could be out of order
searchMatchIndex = fullFileIndex
searchEndIndex = fullFileIndex + currentSearchContent.length
if (searchMatchIndex < lastProcessedIndex) {
pendingOutOfOrderReplacement = true
}
} else {
throw new Error(
`The SEARCH block:\n${currentSearchContent.trimEnd()}\n...does not match anything in the file.`,
)
}
}
}
}
}
// Check if this is an out-of-order replacement
if (searchMatchIndex < lastProcessedIndex) {
pendingOutOfOrderReplacement = true
}
// For in-order replacements, output everything up to the match location
if (!pendingOutOfOrderReplacement) {
result += originalContent.slice(lastProcessedIndex, searchMatchIndex)
}
continue
}
if (isReplaceBlockEnd(line)) {
// Finished one replace block
// Store this replacement
replacements.push({
start: searchMatchIndex,
end: searchEndIndex,
content: currentReplaceContent,
})
// If this was an in-order replacement, advance lastProcessedIndex
if (!pendingOutOfOrderReplacement) {
lastProcessedIndex = searchEndIndex
}
// Reset for next block
inSearch = false
inReplace = false
currentSearchContent = ""
currentReplaceContent = ""
searchMatchIndex = -1
searchEndIndex = -1
pendingOutOfOrderReplacement = false
continue
}
// Accumulate content for search or replace
// (currentReplaceContent is not being used for anything right now since we directly append to result.)
// (We artificially add a linebreak since we split on \n at the beginning. In order to not include a trailing linebreak in the final search/result blocks we need to remove it before using them. This allows for partial line matches to be correctly identified.)
// NOTE: search/replace blocks must be arranged in the order they appear in the file due to how we build the content using lastProcessedIndex. We also cannot strip the trailing newline since for non-partial lines it would remove the linebreak from the original content. (If we remove end linebreak from search, then we'd also have to remove it from replace but we can't know if it's a partial line or not since the model may be using the line break to indicate the end of the block rather than as part of the search content.) We require the model to output full lines in order for our fallbacks to work as well.
if (inSearch) {
currentSearchContent += line + "\n"
} else if (inReplace) {
currentReplaceContent += line + "\n"
// Only output replacement lines immediately for in-order replacements
if (searchMatchIndex !== -1 && !pendingOutOfOrderReplacement) {
result += line + "\n"
}
}
}
// If this is the final chunk, we need to apply all replacements and build the final result
if (isFinal) {
// Handle the case where we're still in replace mode when processing ends
// and this is the final chunk - treat it as if we encountered the REPLACE marker
if (inReplace && searchMatchIndex !== -1) {
// Store this replacement
replacements.push({
start: searchMatchIndex,
end: searchEndIndex,
content: currentReplaceContent,
})
// If this was an in-order replacement, advance lastProcessedIndex
if (!pendingOutOfOrderReplacement) {
lastProcessedIndex = searchEndIndex
}
// Reset state
inSearch = false
inReplace = false
currentSearchContent = ""
currentReplaceContent = ""
searchMatchIndex = -1
searchEndIndex = -1
pendingOutOfOrderReplacement = false
}
// end of handling missing replace marker
// Sort replacements by start position
replacements.sort((a, b) => a.start - b.start)
// Rebuild the entire result by applying all replacements
result = ""
let currentPos = 0
for (const replacement of replacements) {
// Add original content up to this replacement
result += originalContent.slice(currentPos, replacement.start)
// Add the replacement content
result += replacement.content
// Move position to after the replaced section
currentPos = replacement.end
}
// Add any remaining original content
result += originalContent.slice(currentPos)
}
return result
}
enum ProcessingState {
Idle = 0,
StateSearch = 1 << 0,
StateReplace = 1 << 1,
}
class NewFileContentConstructor {
private originalContent: string
private isFinal: boolean
private state: number
private pendingNonStandardLines: string[]
private result: string
private lastProcessedIndex: number
private currentSearchContent: string
private currentReplaceContent: string
private searchMatchIndex: number
private searchEndIndex: number
constructor(originalContent: string, isFinal: boolean) {
this.originalContent = originalContent
this.isFinal = isFinal
this.pendingNonStandardLines = []
this.result = ""
this.lastProcessedIndex = 0
this.state = ProcessingState.Idle
this.currentSearchContent = ""
this.currentReplaceContent = ""
this.searchMatchIndex = -1
this.searchEndIndex = -1
}
private resetForNextBlock() {
// Reset for next block
this.state = ProcessingState.Idle
this.currentSearchContent = ""
this.currentReplaceContent = ""
this.searchMatchIndex = -1
this.searchEndIndex = -1
}
private findLastMatchingLineIndex(regx: RegExp, lineLimit: number) {
for (let i = lineLimit; i > 0; ) {
i--
if (this.pendingNonStandardLines[i].match(regx)) {
return i
}
}
return -1
}
private updateProcessingState(newState: ProcessingState) {
const isValidTransition =
(this.state === ProcessingState.Idle && newState === ProcessingState.StateSearch) ||
(this.state === ProcessingState.StateSearch && newState === ProcessingState.StateReplace)
if (!isValidTransition) {
throw new Error(
`Invalid state transition.\n` +
"Valid transitions are:\n" +
"- Idle → StateSearch\n" +
"- StateSearch → StateReplace",
)
}
this.state |= newState
}
private isStateActive(state: ProcessingState): boolean {
return (this.state & state) === state
}
private activateReplaceState() {
this.updateProcessingState(ProcessingState.StateReplace)
}
private activateSearchState() {
this.updateProcessingState(ProcessingState.StateSearch)
this.currentSearchContent = ""
this.currentReplaceContent = ""
}
private isSearchingActive(): boolean {
return this.isStateActive(ProcessingState.StateSearch)
}
private isReplacingActive(): boolean {
return this.isStateActive(ProcessingState.StateReplace)
}
private hasPendingNonStandardLines(pendingNonStandardLineLimit: number): boolean {
return this.pendingNonStandardLines.length - pendingNonStandardLineLimit < this.pendingNonStandardLines.length
}
public processLine(line: string) {
this.internalProcessLine(line, true, this.pendingNonStandardLines.length)
}
public getResult() {
// If this is the final chunk, append any remaining original content
if (this.isFinal && this.lastProcessedIndex < this.originalContent.length) {
this.result += this.originalContent.slice(this.lastProcessedIndex)
}
if (this.isFinal && this.state !== ProcessingState.Idle) {
throw new Error("File processing incomplete - SEARCH/REPLACE operations still active during finalization")
}
return this.result
}
private internalProcessLine(
line: string,
canWritependingNonStandardLines: boolean,
pendingNonStandardLineLimit: number,
): number {
let removeLineCount = 0
if (isSearchBlockStart(line)) {
removeLineCount = this.trimPendingNonStandardTrailingEmptyLines(pendingNonStandardLineLimit)
if (removeLineCount > 0) {
pendingNonStandardLineLimit = pendingNonStandardLineLimit - removeLineCount
}
if (this.hasPendingNonStandardLines(pendingNonStandardLineLimit)) {
this.tryFixSearchReplaceBlock(pendingNonStandardLineLimit)
canWritependingNonStandardLines && (this.pendingNonStandardLines.length = 0)
}
this.activateSearchState()
} else if (isSearchBlockEnd(line)) {
// 校验非标内容
if (!this.isSearchingActive()) {
this.tryFixSearchBlock(pendingNonStandardLineLimit)
canWritependingNonStandardLines && (this.pendingNonStandardLines.length = 0)
}
this.activateReplaceState()
this.beforeReplace()
} else if (isReplaceBlockEnd(line)) {
if (!this.isReplacingActive()) {
this.tryFixReplaceBlock(pendingNonStandardLineLimit)
canWritependingNonStandardLines && (this.pendingNonStandardLines.length = 0)
}
this.lastProcessedIndex = this.searchEndIndex
this.resetForNextBlock()
} else {
// Accumulate content for search or replace
// (currentReplaceContent is not being used for anything right now since we directly append to result.)
// (We artificially add a linebreak since we split on \n at the beginning. In order to not include a trailing linebreak in the final search/result blocks we need to remove it before using them. This allows for partial line matches to be correctly identified.)
// NOTE: search/replace blocks must be arranged in the order they appear in the file due to how we build the content using lastProcessedIndex. We also cannot strip the trailing newline since for non-partial lines it would remove the linebreak from the original content. (If we remove end linebreak from search, then we'd also have to remove it from replace but we can't know if it's a partial line or not since the model may be using the line break to indicate the end of the block rather than as part of the search content.) We require the model to output full lines in order for our fallbacks to work as well.
if (this.isReplacingActive()) {
this.currentReplaceContent += line + "\n"
// Output replacement lines immediately if we know the insertion point
if (this.searchMatchIndex !== -1) {
this.result += line + "\n"
}
} else if (this.isSearchingActive()) {
this.currentSearchContent += line + "\n"
} else {
let appendToPendingNonStandardLines = canWritependingNonStandardLines
if (appendToPendingNonStandardLines) {
// 处理非标内容
this.pendingNonStandardLines.push(line)
}
}
}
return removeLineCount
}
private beforeReplace() {
// Remove trailing linebreak for adding the === marker
// if (currentSearchContent.endsWith("\r\n")) {
// currentSearchContent = currentSearchContent.slice(0, -2)
// } else if (currentSearchContent.endsWith("\n")) {
// currentSearchContent = currentSearchContent.slice(0, -1)
// }
if (!this.currentSearchContent) {
// Empty search block
if (this.originalContent.length === 0) {
// New file scenario: nothing to match, just start inserting
this.searchMatchIndex = 0
this.searchEndIndex = 0
} else {
// Complete file replacement scenario: treat the entire file as matched
this.searchMatchIndex = 0
this.searchEndIndex = this.originalContent.length
}
} else {
// Add check for inefficient full-file search
// if (currentSearchContent.trim() === originalContent.trim()) {
// throw new Error(
// "The SEARCH block contains the entire file content. Please either:\n" +
// "1. Use an empty SEARCH block to replace the entire file, or\n" +
// "2. Make focused changes to specific parts of the file that need modification.",
// )
// }
// Exact search match scenario
const exactIndex = this.originalContent.indexOf(this.currentSearchContent, this.lastProcessedIndex)
if (exactIndex !== -1) {
this.searchMatchIndex = exactIndex
this.searchEndIndex = exactIndex + this.currentSearchContent.length
} else {
// Attempt fallback line-trimmed matching
const lineMatch = lineTrimmedFallbackMatch(
this.originalContent,
this.currentSearchContent,
this.lastProcessedIndex,
)
if (lineMatch) {
;[this.searchMatchIndex, this.searchEndIndex] = lineMatch
} else {
// Try block anchor fallback for larger blocks
const blockMatch = blockAnchorFallbackMatch(
this.originalContent,
this.currentSearchContent,
this.lastProcessedIndex,
)
if (blockMatch) {
;[this.searchMatchIndex, this.searchEndIndex] = blockMatch
} else {
throw new Error(
`The SEARCH block:\n${this.currentSearchContent.trimEnd()}\n...does not match anything in the file.`,
)
}
}
}
}
if (this.searchMatchIndex < this.lastProcessedIndex) {
throw new Error(
`The SEARCH block:\n${this.currentSearchContent.trimEnd()}\n...matched an incorrect content in the file.`,
)
}
// Output everything up to the match location
this.result += this.originalContent.slice(this.lastProcessedIndex, this.searchMatchIndex)
}
private tryFixSearchBlock(lineLimit: number): number {
let removeLineCount = 0
if (lineLimit < 0) {
lineLimit = this.pendingNonStandardLines.length
}
if (!lineLimit) {
throw new Error("Invalid SEARCH/REPLACE block structure - no lines available to process")
}
let searchTagRegexp = /^([-]{3,}|[<]{3,}) SEARCH$/
const searchTagIndex = this.findLastMatchingLineIndex(searchTagRegexp, lineLimit)
if (searchTagIndex !== -1) {
let fixLines = this.pendingNonStandardLines.slice(searchTagIndex, lineLimit)
fixLines[0] = SEARCH_BLOCK_START
for (const line of fixLines) {
removeLineCount += this.internalProcessLine(line, false, searchTagIndex)
}
} else {
throw new Error(
`Invalid REPLACE marker detected - could not find matching SEARCH block starting from line ${searchTagIndex + 1}`,
)
}
return removeLineCount
}
private tryFixReplaceBlock(lineLimit: number): number {
let removeLineCount = 0
if (lineLimit < 0) {
lineLimit = this.pendingNonStandardLines.length
}
if (!lineLimit) {
throw new Error()
}
let replaceBeginTagRegexp = /^[=]{3,}$/
const replaceBeginTagIndex = this.findLastMatchingLineIndex(replaceBeginTagRegexp, lineLimit)
if (replaceBeginTagIndex !== -1) {
// // 校验非标内容
// if (!this.isSearchingActive()) {
// removeLineCount += this.tryFixSearchBlock(replaceBeginTagIndex)
// }
let fixLines = this.pendingNonStandardLines.slice(replaceBeginTagIndex - removeLineCount, lineLimit - removeLineCount)
fixLines[0] = SEARCH_BLOCK_END
for (const line of fixLines) {
removeLineCount += this.internalProcessLine(line, false, replaceBeginTagIndex - removeLineCount)
}
} else {
throw new Error(`Malformed REPLACE block - missing valid separator after line ${replaceBeginTagIndex + 1}`)
}
return removeLineCount
}
private tryFixSearchReplaceBlock(lineLimit: number): number {
let removeLineCount = 0
if (lineLimit < 0) {
lineLimit = this.pendingNonStandardLines.length
}
if (!lineLimit) {
throw new Error()
}
let replaceEndTagRegexp = /^([+]{3,}|[>]{3,}) REPLACE$/
const replaceEndTagIndex = this.findLastMatchingLineIndex(replaceEndTagRegexp, lineLimit)
const likeReplaceEndTag = replaceEndTagIndex === lineLimit - 1
if (likeReplaceEndTag) {
// // 校验非标内容
// if (!this.isReplacingActive()) {
// removeLineCount += this.tryFixReplaceBlock(replaceEndTagIndex)
// }
let fixLines = this.pendingNonStandardLines.slice(replaceEndTagIndex - removeLineCount, lineLimit - removeLineCount)
fixLines[fixLines.length - 1] = REPLACE_BLOCK_END
for (const line of fixLines) {
removeLineCount += this.internalProcessLine(line, false, replaceEndTagIndex - removeLineCount)
}
} else {
throw new Error("Malformed SEARCH/REPLACE block structure: Missing valid closing REPLACE marker")
}
return removeLineCount
}
/**
* Removes trailing empty lines from the pendingNonStandardLines array
* @param lineLimit - The index to start checking from (exclusive).
* Removes empty lines from lineLimit-1 backwards.
* @returns The number of empty lines removed
*/
private trimPendingNonStandardTrailingEmptyLines(lineLimit: number): number {
let removedCount = 0
let i = Math.min(lineLimit, this.pendingNonStandardLines.length) - 1
while (i >= 0 && this.pendingNonStandardLines[i].trim() === "") {
this.pendingNonStandardLines.pop()
removedCount++
i--
}
return removedCount
}
}
export async function constructNewFileContentV2(diffContent: string, originalContent: string, isFinal: boolean): Promise<string> {
let newFileContentConstructor = new NewFileContentConstructor(originalContent, isFinal)
let lines = diffContent.split("\n")
// If the last line looks like a partial marker but isn't recognized,
// remove it because it might be incomplete.
const lastLine = lines[lines.length - 1]
if (
lines.length > 0 &&
(lastLine.startsWith(SEARCH_BLOCK_CHAR) ||
lastLine.startsWith(LEGACY_SEARCH_BLOCK_CHAR) ||
lastLine.startsWith("=") ||
lastLine.startsWith(REPLACE_BLOCK_CHAR) ||
lastLine.startsWith(LEGACY_REPLACE_BLOCK_CHAR)) &&
lastLine !== SEARCH_BLOCK_START &&
lastLine !== SEARCH_BLOCK_END &&
lastLine !== REPLACE_BLOCK_END
) {
lines.pop()
}
for (const line of lines) {
newFileContentConstructor.processLine(line)
}
let result = newFileContentConstructor.getResult()
return result
}
-2
View File
@@ -33,7 +33,6 @@ export interface TestConfig {
diff_edit_function: string
thinking_tokens_budget: number
replay: boolean
diff_apply_file?: string
}
export interface SystemPromptDetails {
@@ -101,5 +100,4 @@ export interface TestInput {
diffEditFunction: string
thinkingBudgetTokens: number
originalDiffEditToolCallMessage?: string
diffApplyFile?: string
}
+128 -927
View File
File diff suppressed because it is too large Load Diff
+1 -4
View File
@@ -2,7 +2,7 @@
"name": "claude-dev",
"displayName": "Cline",
"description": "Autonomous coding agent right in your IDE, capable of creating/editing files, running commands, using the browser, and more with your permission every step of the way.",
"version": "3.18.0",
"version": "3.17.15",
"icon": "assets/icons/icon.png",
"engines": {
"vscode": "^1.84.0"
@@ -332,7 +332,6 @@
"package": "npm run check-types && npm run build:webview && npm run lint && node esbuild.js --production",
"protos": "node proto/build-proto.js && node scripts/generate-server-setup.mjs && node scripts/generate-host-bridge-client.mjs",
"postprotos": "prettier src/shared/proto src/core/controller src/hosts/ webview-ui/src/services src/generated --write --log-level warn",
"clean": "rimraf dist dist-standalone webview-ui/build src/generated",
"compile-tests": "node ./scripts/build-tests.js",
"watch-tests": "tsc -p . -w --outDir out",
"pretest": "npm run compile-tests && npm run compile && npm run compile-standalone && npm run lint",
@@ -397,7 +396,6 @@
"prettier": "^3.3.3",
"protoc-gen-ts": "^0.8.7",
"proxyquire": "^2.1.3",
"rimraf": "^6.0.1",
"should": "^13.2.3",
"sinon": "^19.0.2",
"ts-node": "^10.9.2",
@@ -442,7 +440,6 @@
"fzf": "^0.5.2",
"get-folder-size": "^5.0.0",
"globby": "^14.0.2",
"google-auth-library": "^10.1.0",
"grpc-health-check": "^2.0.2",
"iconv-lite": "^0.6.3",
"ignore": "^7.0.3",
-30
View File
@@ -1,30 +0,0 @@
// Configuration file for protocol buffer build scripts
// Contains service name mappings used by both build-proto.js and build-go-proto.js
// List of gRPC services
// To add a new service, simply add it to this map and run the build scripts
// The service handler will be automatically discovered and used by grpc-handler.ts
export const serviceNameMap = {
account: "cline.AccountService",
browser: "cline.BrowserService",
checkpoints: "cline.CheckpointsService",
file: "cline.FileService",
mcp: "cline.McpService",
state: "cline.StateService",
task: "cline.TaskService",
web: "cline.WebService",
models: "cline.ModelsService",
slash: "cline.SlashService",
ui: "cline.UiService",
// Add new services here - no other code changes needed!
}
// List of host gRPC services (IDE API bridge)
// These services are implemented in the IDE extension and called by the standalone Cline Core
export const hostServiceNameMap = {
uri: "host.UriService",
watch: "host.WatchService",
workspace: "host.WorkspaceService",
env: "host.EnvService",
// Add new host services here
}
+44 -18
View File
@@ -9,18 +9,16 @@ import chalk from "chalk"
import os from "os"
import { createRequire } from "module"
import { serviceNameMap, hostServiceNameMap } from "./build-proto-config.js"
const require = createRequire(import.meta.url)
const PROTOC = path.join(require.resolve("grpc-tools"), "../bin/protoc")
const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url))
const ROOT_DIR = path.resolve(SCRIPT_DIR, "..")
const TS_OUT_DIR = path.join(ROOT_DIR, "src", "shared", "proto")
const GRPC_JS_OUT_DIR = path.join(ROOT_DIR, "src", "generated", "grpc-js")
const NICE_JS_OUT_DIR = path.join(ROOT_DIR, "src", "generated", "nice-grpc")
const DESCRIPTOR_OUT_DIR = path.join(ROOT_DIR, "dist-standalone", "proto")
const TS_OUT_DIR = path.join(ROOT_DIR, "src/shared/proto")
const GRPC_JS_OUT_DIR = path.join(ROOT_DIR, "src/generated/grpc-js")
const NICE_JS_OUT_DIR = path.join(ROOT_DIR, "src/generated/nice-grpc")
const DESCRIPTOR_OUT_DIR = path.join(ROOT_DIR, "dist-standalone/proto")
const isWindows = process.platform === "win32"
const TS_PROTO_PLUGIN = isWindows
@@ -36,13 +34,33 @@ const TS_PROTO_OPTIONS = [
"useDate=false", // Timestamp fields will not be automatically converted to Date.
]
// Service directories derived from imported serviceNameMap
const serviceDirs = Object.keys(serviceNameMap).map((serviceKey) => path.join(ROOT_DIR, "src", "core", "controller", serviceKey))
// List of gRPC services
// To add a new service, simply add it to this map and run this script
// The service handler will be automatically discovered and used by grpc-handler.ts
const serviceNameMap = {
account: "cline.AccountService",
browser: "cline.BrowserService",
checkpoints: "cline.CheckpointsService",
file: "cline.FileService",
mcp: "cline.McpService",
state: "cline.StateService",
task: "cline.TaskService",
web: "cline.WebService",
models: "cline.ModelsService",
slash: "cline.SlashService",
ui: "cline.UiService",
// Add new services here - no other code changes needed!
}
const serviceDirs = Object.keys(serviceNameMap).map((serviceKey) => path.join(ROOT_DIR, "src/core/controller", serviceKey))
// Host service directories derived from imported hostServiceNameMap
const hostServiceDirs = Object.keys(hostServiceNameMap).map((serviceKey) =>
path.join(ROOT_DIR, "src", "hosts", "vscode", serviceKey),
)
// List of host gRPC services (IDE API bridge)
// These services are implemented in the IDE extension and called by the standalone Cline Core
const hostServiceNameMap = {
uri: "host.UriService",
watch: "host.WatchService",
// Add new host services here
}
const hostServiceDirs = Object.keys(hostServiceNameMap).map((serviceKey) => path.join(ROOT_DIR, "src/hosts/vscode", serviceKey))
async function main() {
console.log(chalk.bold.blue("Starting Protocol Buffer code generation..."))
@@ -158,7 +176,7 @@ export {
${serviceExports.join(",\n\t")}
}`
const filePath = path.join(ROOT_DIR, "webview-ui", "src", "services", "grpc-client.ts")
const filePath = path.join(ROOT_DIR, "webview-ui/src/services/grpc-client.ts")
await writeFileWithMkdirs(filePath, content)
log_verbose(chalk.green(`Generated gRPC client at ${filePath}`))
}
@@ -367,7 +385,7 @@ export interface ServiceHandlerConfig {
export const serviceHandlers: Record<string, ServiceHandlerConfig> = {${serviceConfigs.join(",")}
};`
const configPath = path.join(ROOT_DIR, "src", "core", "controller", "grpc-service-config.ts")
const configPath = path.join(ROOT_DIR, "src/core/controller/grpc-service-config.ts")
await writeFileWithMkdirs(configPath, content)
log_verbose(chalk.green(`Generated service configuration at ${configPath}`))
}
@@ -583,12 +601,13 @@ async function cleanup() {
for (const file of existingFiles) {
await fs.unlink(path.join(TS_OUT_DIR, file))
}
await rmdir(path.join(ROOT_DIR, "src", "generated"))
await rmdir(path.join(ROOT_DIR, "src/generated"))
// Clean up generated files that were moved.
await fs.rm(path.join(ROOT_DIR, "src", "standalone", "services", "host-grpc-client.ts"), { force: true })
await rmdir(path.join(ROOT_DIR, "src", "standalone", "services"))
await fs.rm(path.join(ROOT_DIR, "hosts", "vscode"), { force: true, recursive: true })
await fs.rm(path.join(ROOT_DIR, "src/standalone/services/host-grpc-client.ts"), { force: true })
await rmdir(path.join(ROOT_DIR, "src/standalone/services"))
await fs.rm(path.join(ROOT_DIR, "hosts/vscode"), { force: true, recursive: true })
await rmdir(path.join(ROOT_DIR, "hosts"))
await fs.rm(path.join(ROOT_DIR, "src/standalone/server-setup.ts"), { force: true })
@@ -616,6 +635,13 @@ async function rmdir(path) {
}
}
function serviceNameWithoutPackage(fullServiceName) {
return fullServiceName.replace(/.*\./, "")
}
function lowercaseFirstChar(str) {
return str.charAt(0).toLowerCase() + str.slice(1)
}
// Check for Apple Silicon compatibility
function checkAppleSiliconCompatibility() {
// Only run check on macOS
+3
View File
@@ -29,6 +29,9 @@ service FileService {
// Search git commits in the workspace
rpc searchCommits(StringRequest) returns (GitCommits);
// Select images from the file system and return as data URLs
rpc selectImages(EmptyRequest) returns (StringArray);
// Select images and other files from the file system and returns as data URLs & paths respectively
rpc selectFiles(BooleanRequest) returns (StringArrays);
-16
View File
@@ -1,16 +0,0 @@
syntax = "proto3";
package host;
option java_package = "bot.cline.host.proto";
option java_multiple_files = true;
import "common.proto";
// Provides methods for working with the user's environment.
service EnvService {
// Writes text to the system clipboard.
rpc clipboardWriteText(cline.StringRequest) returns (cline.Empty);
// Reads text from the system clipboard.
rpc clipboardReadText(cline.EmptyRequest) returns (cline.String);
}
-24
View File
@@ -1,24 +0,0 @@
syntax = "proto3";
package host;
option java_package = "bot.cline.host.proto";
option java_multiple_files = true;
// Provides methods for working with workspaces/projects.
service WorkspaceService {
// Returns a list of the top level directories of the workspace.
rpc getWorkspacePaths(GetWorkspacePathsRequest) returns (GetWorkspacePathsResponse);
}
message GetWorkspacePathsRequest {
// The unique ID for the workspace/project.
// This is currently optional in vscode. It is required in other environments where cline is running at
// the application level, and the user can open multiple projects.
optional string id = 1;
}
message GetWorkspacePathsResponse {
// The unique ID for the workspace/project.
optional string id = 1;
repeated string paths = 2;
}
+19 -22
View File
@@ -105,25 +105,24 @@ enum ApiProvider {
OLLAMA = 5;
LMSTUDIO = 6;
GEMINI = 7;
GEMINI_CLI = 8;
OPENAI_NATIVE = 9;
REQUESTY = 10;
TOGETHER = 11;
DEEPSEEK = 12;
QWEN = 13;
DOUBAO = 14;
MISTRAL = 15;
VSCODE_LM = 16;
CLINE = 17;
LITELLM = 18;
NEBIUS = 19;
FIREWORKS = 20;
ASKSAGE = 21;
XAI = 22;
SAMBANOVA = 23;
CEREBRAS = 24;
SAPAICORE = 25;
CLAUDE_CODE = 26;
OPENAI_NATIVE = 8;
REQUESTY = 9;
TOGETHER = 10;
DEEPSEEK = 11;
QWEN = 12;
DOUBAO = 13;
MISTRAL = 14;
VSCODE_LM = 15;
CLINE = 16;
LITELLM = 17;
NEBIUS = 18;
FIREWORKS = 19;
ASKSAGE = 20;
XAI = 21;
SAMBANOVA = 22;
CEREBRAS = 23;
SAPAICORE = 24;
CLAUDE_CODE = 25;
}
// Model info for OpenAI-compatible models
@@ -237,6 +236,4 @@ message ModelsApiConfiguration {
optional string sap_ai_core_token_url = 71;
optional string sap_ai_core_base_url = 72;
optional string claude_code_path = 73;
optional string gemini_cli_oauth_path = 74;
optional string gemini_cli_project_id = 75;
}
}
-12
View File
@@ -17,7 +17,6 @@ service StateService {
rpc togglePlanActMode(TogglePlanActModeRequest) returns (Boolean);
rpc updateAutoApprovalSettings(AutoApprovalSettingsRequest) returns (Empty);
rpc updateSettings(UpdateSettingsRequest) returns (Empty);
rpc updateTelemetrySetting(TelemetrySettingRequest) returns (Empty);
}
message State {
@@ -89,17 +88,6 @@ message AutoApprovalSettingsRequest {
repeated string favorites = 7;
}
enum TelemetrySettingEnum {
UNSET = 0;
ENABLED = 1;
DISABLED = 2;
}
message TelemetrySettingRequest {
Metadata metadata = 1;
TelemetrySettingEnum setting = 2;
}
// Message for updating settings
message UpdateSettingsRequest {
Metadata metadata = 1;
+8 -7
View File
@@ -23,6 +23,8 @@ service TaskService {
rpc exportTaskWithId(StringRequest) returns (Empty);
// Toggles the favorite status of a task
rpc toggleTaskFavorite(TaskFavoriteRequest) returns (Empty);
// Deletes all non-favorited tasks
rpc deleteNonFavoritedTasks(EmptyRequest) returns (DeleteNonFavoritedTasksResults);
// Gets filtered task history
rpc getTaskHistory(GetTaskHistoryRequest) returns (TaskHistoryArray);
// Sends a response to a previous ask operation
@@ -33,8 +35,6 @@ service TaskService {
rpc taskCompletionViewChanges(Int64Request) returns (Empty);
// Executes a quick win task with command and title
rpc executeQuickWin(ExecuteQuickWinRequest) returns (Empty);
// Deletes all task history
rpc deleteAllTaskHistory(EmptyRequest) returns (DeleteAllTaskHistoryCount);
}
// Request message for creating a new task
@@ -66,6 +66,12 @@ message TaskResponse {
int32 cache_reads = 10;
}
// Results returned when deleting non-favorited tasks
message DeleteNonFavoritedTasksResults {
int32 tasks_preserved = 1;
int32 tasks_deleted = 2;
}
// Request for getting task history with filtering
message GetTaskHistoryRequest {
Metadata metadata = 1;
@@ -110,8 +116,3 @@ message ExecuteQuickWinRequest {
string command = 2;
string title = 3;
}
// Results returned when deleting all task history
message DeleteAllTaskHistoryCount {
int32 tasks_deleted = 1;
}
-3
View File
@@ -265,7 +265,4 @@ service UiService {
// Returns the HTML for the webview index page. This is only used by external clients, not by the vscode webview.
rpc getWebviewHtml(EmptyRequest) returns (String);
// Opens a URL in the default browser
rpc openUrl(StringRequest) returns (Empty);
}
+1 -2
View File
@@ -13,8 +13,7 @@ grep -Ev '//.*vscode' | # remove commented out code
sed 's|.*vscode\.|vscode.|'| # remove everything before vscode.
sed 's/[^a-zA-Z0-9_.].*$//' | # remove everything after last identifier
grep -E '\.[a-z][^.]+$' | # remove types (last part of identifier should be lowercase)
sort | uniq -c | sort -n | # Count occurrences
cat > $SDK_DEST
sort | uniq > $SDK_DEST
}
echo Wrote uses of the vscode SDK to $(realpath $SDK_DEST)
+2 -28
View File
@@ -8,7 +8,6 @@ import { OpenAiHandler } from "./providers/openai"
import { OllamaHandler } from "./providers/ollama"
import { LmStudioHandler } from "./providers/lmstudio"
import { GeminiHandler } from "./providers/gemini"
import { GeminiCliHandler } from "./providers/gemini-cli"
import { OpenAiNativeHandler } from "./providers/openai-native"
import { ApiStream, ApiStreamUsageChunk } from "./transform/stream"
import { DeepSeekHandler } from "./providers/deepseek"
@@ -39,7 +38,8 @@ export interface SingleCompletionHandler {
completePrompt(prompt: string): Promise<string>
}
function createHandlerForProvider(apiProvider: string | undefined, options: any): ApiHandler {
export function buildApiHandler(configuration: ApiConfiguration): ApiHandler {
const { apiProvider, ...options } = configuration
switch (apiProvider) {
case "anthropic":
return new AnthropicHandler(options)
@@ -57,8 +57,6 @@ function createHandlerForProvider(apiProvider: string | undefined, options: any)
return new LmStudioHandler(options)
case "gemini":
return new GeminiHandler(options)
case "gemini-cli":
return new GeminiCliHandler(options)
case "openai-native":
return new OpenAiNativeHandler(options)
case "deepseek":
@@ -99,27 +97,3 @@ function createHandlerForProvider(apiProvider: string | undefined, options: any)
return new AnthropicHandler(options)
}
}
export function buildApiHandler(configuration: ApiConfiguration): ApiHandler {
const { apiProvider, ...options } = configuration
// Validate thinking budget tokens against model's maxTokens to prevent API errors
// wrapped in a try-catch for safety, but this should never throw
try {
if (options.thinkingBudgetTokens && options.thinkingBudgetTokens > 0) {
const handler = createHandlerForProvider(apiProvider, options)
const modelInfo = handler.getModel().info
if (modelInfo.maxTokens && options.thinkingBudgetTokens > modelInfo.maxTokens) {
const clippedValue = modelInfo.maxTokens - 1
options.thinkingBudgetTokens = clippedValue
} else {
return handler // don't rebuild unless its necessary
}
}
} catch (error) {
console.error("buildApiHandler error:", error)
}
return createHandlerForProvider(apiProvider, options)
}
+77 -62
View File
@@ -4,7 +4,7 @@ import { type ApiHandler } from ".."
import { ApiStreamUsageChunk, type ApiStream } from "../transform/stream"
import { withRetry } from "../retry"
import { runClaudeCode } from "@/integrations/claude-code/run"
import { filterMessagesForClaudeCode } from "@/integrations/claude-code/message-filter"
import { ClaudeCodeMessage } from "@/integrations/claude-code/types"
export class ClaudeCodeHandler implements ApiHandler {
private options: ApiHandlerOptions
@@ -19,16 +19,39 @@ export class ClaudeCodeHandler implements ApiHandler {
maxDelay: 15000,
})
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
// Filter out image blocks since Claude Code doesn't support them
const filteredMessages = filterMessagesForClaudeCode(messages)
const claudeProcess = runClaudeCode({
systemPrompt,
messages: filteredMessages,
messages,
path: this.options.claudeCodePath,
modelId: this.getModel().id,
})
const dataQueue: string[] = []
let processError = null
let errorOutput = ""
let exitCode: number | null = null
claudeProcess.stdout.on("data", (data) => {
const output = data.toString()
const lines = output.split("\n").filter((line: string) => line.trim() !== "")
for (const line of lines) {
dataQueue.push(line)
}
})
claudeProcess.stderr.on("data", (data) => {
errorOutput += data.toString()
})
claudeProcess.on("close", (code) => {
exitCode = code
})
claudeProcess.on("error", (error) => {
processError = error
})
// Usage is included with assistant messages,
// but cost is included in the result chunk
let usage: ApiStreamUsageChunk = {
@@ -39,75 +62,61 @@ export class ClaudeCodeHandler implements ApiHandler {
cacheWriteTokens: 0,
}
let isPaidUsage = true
while (exitCode !== 0 || dataQueue.length > 0) {
if (dataQueue.length === 0) {
await new Promise((resolve) => setImmediate(resolve))
}
for await (const chunk of claudeProcess) {
if (typeof chunk === "string") {
if (exitCode !== null && exitCode !== 0) {
throw new Error(
`Claude Code process exited with code ${exitCode}.${errorOutput ? ` Error output: ${errorOutput.trim()}` : ""}`,
)
}
const data = dataQueue.shift()
if (!data) {
continue
}
const chunk = this.attemptParseChunk(data)
if (!chunk) {
yield {
type: "text",
text: chunk,
text: data || "",
}
continue
}
if (chunk.type === "system" && chunk.subtype === "init") {
// Based on my tests, subscription usage sets the `apiKeySource` to "none"
isPaidUsage = chunk.apiKeySource !== "none"
continue
}
if (chunk.type === "assistant" && "message" in chunk) {
const message = chunk.message
if (message.stop_reason !== null) {
const content = "text" in message.content[0] ? message.content[0] : undefined
if (message.stop_reason !== null && message.stop_reason !== "tool_use") {
const errorMessage = message.content[0]?.text || `Claude Code stopped with reason: ${message.stop_reason}`
const isError = content && content.text.startsWith(`API Error`)
if (isError) {
// Error messages are formatted as: `API Error: <<status code>> <<json>>`
const errorMessageStart = content.text.indexOf("{")
const errorMessage = content.text.slice(errorMessageStart)
const error = this.attemptParse(errorMessage)
if (!error) {
throw new Error(content.text)
}
if (error.error.message.includes("Invalid model name")) {
throw new Error(
content.text +
`\n\nAPI keys and subscription plans allow different models. Make sure the selected model is included in your plan.`,
)
}
throw new Error(errorMessage)
if (errorMessage.includes("Invalid model name")) {
throw new Error(
errorMessage +
`\n\nAPI keys and subscription plans allow different models. Make sure the selected model is included in your plan.`,
)
}
throw new Error(errorMessage)
}
for (const content of message.content) {
switch (content.type) {
case "text":
yield {
type: "text",
text: content.text,
}
break
case "thinking":
yield {
type: "reasoning",
reasoning: content.thinking || "",
}
break
case "redacted_thinking":
yield {
type: "reasoning",
reasoning: "[Redacted thinking block]",
}
break
case "tool_use":
console.error(`tool_use is not supported yet. Received: ${JSON.stringify(content)}`)
break
if (content.type === "text") {
yield {
type: "text",
text: content.text,
}
} else {
console.warn("Unsupported content type:", content.type)
}
}
@@ -120,18 +129,14 @@ export class ClaudeCodeHandler implements ApiHandler {
}
if (chunk.type === "result" && "result" in chunk) {
usage.totalCost = isPaidUsage ? chunk.total_cost_usd : 0
usage.totalCost = chunk.cost_usd || 0
yield usage
}
}
}
private attemptParse(str: string) {
try {
return JSON.parse(str)
} catch (err) {
return null
if (processError) {
throw processError
}
}
}
@@ -147,4 +152,14 @@ export class ClaudeCodeHandler implements ApiHandler {
info: claudeCodeModels[claudeCodeDefaultModelId],
}
}
// TOOD: Validate instead of parsing
private attemptParseChunk(data: string): ClaudeCodeMessage | null {
try {
return JSON.parse(data)
} catch (error) {
console.error("Error parsing chunk:", error)
return null
}
}
}
+17 -57
View File
@@ -56,21 +56,7 @@ export class ClineHandler implements ApiHandler {
this.lastGenerationId = chunk.id
}
// Check for mid-stream error via finish_reason
const choice = chunk.choices?.[0]
// OpenRouter may return finish_reason = "error" with error details
if ((choice?.finish_reason as string) === "error") {
const choiceWithError = choice as any
if (choiceWithError.error) {
const error = choiceWithError.error
console.error(`Cline Mid-Stream Error: ${error.code || error.type || "Unknown"} - ${error.message}`)
throw new Error(`Cline Mid-Stream Error: ${error.code || error.type || "Unknown"} - ${error.message}`)
} else {
throw new Error("Cline Mid-Stream Error: Stream terminated with error status but no error details provided")
}
}
const delta = choice?.delta
const delta = chunk.choices[0]?.delta
if (delta?.content) {
yield {
type: "text",
@@ -89,7 +75,7 @@ export class ClineHandler implements ApiHandler {
if (!didOutputUsage && chunk.usage) {
// @ts-ignore-next-line
let totalCost = (chunk.usage.cost || 0) + (chunk.usage.cost_details?.upstream_inference_cost || 0)
let totalCost = chunk.usage.cost || 0
const modelId = this.getModel().id
const provider = modelId.split("/")[0]
@@ -98,26 +84,14 @@ export class ClineHandler implements ApiHandler {
totalCost = 0
}
if (modelId.includes("gemini")) {
yield {
type: "usage",
cacheWriteTokens: 0,
cacheReadTokens: chunk.usage.prompt_tokens_details?.cached_tokens || 0,
inputTokens: (chunk.usage.prompt_tokens || 0) - (chunk.usage.prompt_tokens_details?.cached_tokens || 0),
outputTokens: chunk.usage.completion_tokens || 0,
// @ts-ignore-next-line
totalCost,
}
} else {
yield {
type: "usage",
cacheWriteTokens: 0,
cacheReadTokens: chunk.usage.prompt_tokens_details?.cached_tokens || 0,
inputTokens: chunk.usage.prompt_tokens || 0,
outputTokens: chunk.usage.completion_tokens || 0,
// @ts-ignore-next-line
totalCost,
}
yield {
type: "usage",
cacheWriteTokens: 0,
cacheReadTokens: chunk.usage.prompt_tokens_details?.cached_tokens || 0,
inputTokens: chunk.usage.prompt_tokens || 0,
outputTokens: chunk.usage.completion_tokens || 0,
// @ts-ignore-next-line
totalCost,
}
didOutputUsage = true
}
@@ -143,27 +117,13 @@ export class ClineHandler implements ApiHandler {
})
const generation = response.data
let modelId = this.options.openRouterModelId
if (modelId && modelId.includes("gemini")) {
return {
type: "usage",
cacheWriteTokens: 0,
cacheReadTokens: generation?.native_tokens_cached || 0,
// openrouter generation endpoint fails often
inputTokens: (generation?.native_tokens_prompt || 0) - (generation?.native_tokens_cached || 0),
outputTokens: generation?.native_tokens_completion || 0,
totalCost: generation?.total_cost || 0,
}
} else {
return {
type: "usage",
cacheWriteTokens: 0,
cacheReadTokens: generation?.native_tokens_cached || 0,
// openrouter generation endpoint fails often
inputTokens: generation?.native_tokens_prompt || 0,
outputTokens: generation?.native_tokens_completion || 0,
totalCost: generation?.total_cost || 0,
}
return {
type: "usage",
cacheWriteTokens: 0,
cacheReadTokens: generation?.native_tokens_cached || 0,
inputTokens: generation?.native_tokens_prompt || 0,
outputTokens: generation?.native_tokens_completion || 0,
totalCost: generation?.total_cost || 0,
}
} catch (error) {
// ignore if fails
-418
View File
@@ -1,418 +0,0 @@
/**
* Gemini CLI Provider - OAuth-based API Handler
*
* This implementation provides access to Google's Gemini models through OAuth authentication,
* leveraging the same authentication mechanism as the official Gemini CLI tool.
*
* Attribution: This implementation is inspired by and uses concepts from the Google Gemini CLI,
* which is licensed under the Apache License 2.0.
* Original project: https://github.com/google-gemini/gemini-cli
*
* Copyright 2025 Google LLC
* Licensed under the Apache License, Version 2.0
*
* Key features:
* - OAuth2 authentication (no API keys required)
* - Auto-discovery of Google Cloud project IDs
* - Real-time streaming via Server-Sent Events
* - Free tier access through Google's Code Assist API
* - Compatible with personal Google accounts only
*/
import type { Anthropic } from "@anthropic-ai/sdk"
import { OAuth2Client } from "google-auth-library"
import fs from "fs/promises"
import path from "path"
import os from "os"
import * as readline from "readline"
import { Readable } from "stream"
import { ApiHandler } from "../"
import { ApiHandlerOptions, GeminiCliModelId, geminiCliModels, ModelInfo, geminiCliDefaultModelId } from "@shared/api"
import { convertAnthropicMessageToGemini } from "../transform/gemini-format"
import { ApiStream } from "../transform/stream"
import { withRetry } from "../retry"
const CODE_ASSIST_ENDPOINT = "https://cloudcode-pa.googleapis.com"
const CODE_ASSIST_API_VERSION = "v1internal"
// OAuth configuration
const OAUTH_CLIENT_ID = "681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com"
// Change this line in setup.js:
const OAUTH_CLIENT_SECRET = "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"
const OAUTH_REDIRECT_URI = "http://localhost:45289"
interface OAuthCredentials {
access_token: string
refresh_token: string
scope: string
token_type: string
expiry_date: number
}
interface GeminiCliHandlerOptions extends ApiHandlerOptions {
geminiCliOAuthPath?: string
geminiCliProjectId?: string
}
/**
* Handler for Google's Gemini API via OAuth (Gemini CLI style).
*
* This provider uses OAuth authentication instead of API keys, making it suitable
* for users who have already authenticated with the Gemini CLI tool.
* It automatically discovers project IDs and works with the free tier.
*/
export class GeminiCliHandler implements ApiHandler {
private options: GeminiCliHandlerOptions
private authClient: OAuth2Client
private projectId: string | null = null
private authInitialized: boolean = false
constructor(options: GeminiCliHandlerOptions) {
this.options = options
this.authClient = new OAuth2Client(OAUTH_CLIENT_ID, OAUTH_CLIENT_SECRET, OAUTH_REDIRECT_URI)
}
/**
* Load OAuth credentials from the file system
*/
private async loadOAuthCredentials(): Promise<OAuthCredentials> {
const credPath = this.options.geminiCliOAuthPath || path.join(os.homedir(), ".gemini", "oauth_creds.json")
try {
const data = await fs.readFile(credPath, "utf8")
return JSON.parse(data)
} catch (err) {
throw new Error(`Failed to load OAuth credentials from ${credPath}. Please authenticate with 'gemini auth' first.`)
}
}
/**
* Call a Code Assist API endpoint
*/
private async callEndpoint(method: string, body: any, retryAuth: boolean = true): Promise<any> {
console.log(`[GeminiCLI] Calling endpoint: ${method}`)
console.log(`[GeminiCLI] Request body:`, JSON.stringify(body, null, 2))
try {
const res = await this.authClient.request({
url: `${CODE_ASSIST_ENDPOINT}/${CODE_ASSIST_API_VERSION}:${method}`,
method: "POST",
headers: {
"Content-Type": "application/json",
},
responseType: "json",
body: JSON.stringify(body),
})
console.log(`[GeminiCLI] Response status:`, res.status)
console.log(`[GeminiCLI] Response data:`, JSON.stringify(res.data, null, 2))
return res.data
} catch (error: any) {
console.error(`[GeminiCLI] Error calling ${method}:`, error)
console.error(`[GeminiCLI] Error response:`, error.response?.data)
console.error(`[GeminiCLI] Error status:`, error.response?.status)
console.error(`[GeminiCLI] Error message:`, error.message)
// If we get a 401 and haven't retried yet, try refreshing auth
if (error.response?.status === 401 && retryAuth) {
console.log(`[GeminiCLI] Got 401, attempting to refresh authentication...`)
await this.initializeAuth(true) // Force refresh
return this.callEndpoint(method, body, false) // Retry without further auth retries
}
throw error
}
}
/**
* Discover or retrieve the project ID
*/
private async discoverProjectId(): Promise<string> {
// If we already have a project ID, use it
if (this.options.geminiCliProjectId) {
return this.options.geminiCliProjectId
}
// If we've already discovered it, return it
if (this.projectId) {
return this.projectId
}
// Start with a default project ID (can be anything for personal OAuth)
const initialProjectId = "default"
// Prepare client metadata
const clientMetadata = {
ideType: "IDE_UNSPECIFIED",
platform: "PLATFORM_UNSPECIFIED",
pluginType: "GEMINI",
duetProject: initialProjectId,
}
try {
// Call loadCodeAssist to discover the actual project ID
const loadRequest = {
cloudaicompanionProject: initialProjectId,
metadata: clientMetadata,
}
const loadResponse = await this.callEndpoint("loadCodeAssist", loadRequest)
// Check if we already have a project ID from the response
if (loadResponse.cloudaicompanionProject) {
this.projectId = loadResponse.cloudaicompanionProject
return this.projectId as string
}
// If no existing project, we need to onboard
const defaultTier = loadResponse.allowedTiers?.find((tier: any) => tier.isDefault)
const tierId = defaultTier?.id || "free-tier"
const onboardRequest = {
tierId: tierId,
cloudaicompanionProject: initialProjectId,
metadata: clientMetadata,
}
let lroResponse = await this.callEndpoint("onboardUser", onboardRequest)
// Poll until operation is complete
while (!lroResponse.done) {
await new Promise((resolve) => setTimeout(resolve, 2000))
lroResponse = await this.callEndpoint("onboardUser", onboardRequest)
}
const discoveredProjectId = lroResponse.response?.cloudaicompanionProject?.id || initialProjectId
this.projectId = discoveredProjectId
return this.projectId as string
} catch (error: any) {
console.error("Failed to discover project ID:", error.response?.data || error.message)
throw new Error("Could not discover project ID. Make sure you're authenticated with 'gemini auth'.")
}
}
/**
* Initialize the OAuth client with credentials
*/
private async initializeAuth(forceRefresh: boolean = false): Promise<void> {
// Check if we need to initialize or refresh
if (this.authInitialized && !forceRefresh) {
// Check if token is still valid
const credentials = this.authClient.credentials
if (credentials && credentials.expiry_date && Date.now() < credentials.expiry_date) {
console.log(`[GeminiCLI] Auth already initialized and token still valid`)
return
}
}
console.log(`[GeminiCLI] Initializing OAuth authentication...`)
const credentials = await this.loadOAuthCredentials()
const isExpired = credentials.expiry_date ? Date.now() > credentials.expiry_date : false
console.log(`[GeminiCLI] Loaded credentials:`, {
hasAccessToken: !!credentials.access_token,
hasRefreshToken: !!credentials.refresh_token,
tokenType: credentials.token_type,
expiryDate: credentials.expiry_date,
isExpired: isExpired,
})
this.authClient.setCredentials(credentials)
// If token is expired and we have a refresh token, try to refresh
if (isExpired && credentials.refresh_token) {
console.log(`[GeminiCLI] Token expired, attempting to refresh...`)
try {
const { credentials: newCredentials } = await this.authClient.refreshAccessToken()
console.log(`[GeminiCLI] Token refreshed successfully`)
// Note: In a real implementation, you'd want to save the new credentials back to the file
// For now, we'll just use them in memory
} catch (error) {
console.error(`[GeminiCLI] Failed to refresh token:`, error)
// Continue with the expired token - the API might still accept it
}
}
this.authInitialized = true
console.log(`[GeminiCLI] OAuth client configured`)
}
/**
* Parse Server-Sent Events from a stream
*/
private async *parseSSEStream(stream: Readable): AsyncGenerator<any> {
const rl = readline.createInterface({
input: stream,
crlfDelay: Infinity,
})
let bufferedLines: string[] = []
for await (const line of rl) {
// Blank lines separate JSON objects in the stream
if (line === "") {
if (bufferedLines.length === 0) {
continue
}
try {
const jsonData = JSON.parse(bufferedLines.join("\n"))
yield jsonData
} catch (parseError) {
console.error("Error parsing JSON chunk:", parseError)
}
bufferedLines = []
} else if (line.startsWith("data: ")) {
bufferedLines.push(line.slice(6).trim())
}
}
// Process any remaining buffered content
if (bufferedLines.length > 0) {
try {
const jsonData = JSON.parse(bufferedLines.join("\n"))
yield jsonData
} catch (parseError) {
console.error("Error parsing final buffered content:", parseError)
}
}
}
/**
* Create a message using the Gemini CLI OAuth API
*/
@withRetry({
maxRetries: 2,
baseDelay: 2000,
maxDelay: 10000,
})
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
// Initialize auth if not already done
await this.initializeAuth()
// Discover project ID if needed
const projectId = await this.discoverProjectId()
// Convert messages to Gemini format
const contents = messages.map(convertAnthropicMessageToGemini)
// Get the selected model
const { id: modelId, info: modelInfo } = this.getModel()
// Build the request
const streamRequest = {
model: modelId,
project: projectId,
request: {
contents: [
{
role: "user",
parts: [{ text: systemPrompt }],
},
...contents,
],
generationConfig: {
temperature: 0.7,
maxOutputTokens: modelInfo.maxTokens || 8192,
},
},
}
let totalContent = ""
let promptTokens = 0
let outputTokens = 0
let lastUsageMetadata: any = null
try {
// Make the streaming request
const response = await this.authClient.request({
url: `${CODE_ASSIST_ENDPOINT}/${CODE_ASSIST_API_VERSION}:streamGenerateContent`,
method: "POST",
params: { alt: "sse" },
headers: {
"Content-Type": "application/json",
},
responseType: "stream",
body: JSON.stringify(streamRequest),
})
// Process the SSE stream
for await (const jsonData of this.parseSSEStream(response.data as Readable)) {
// Extract content from the response
const candidate = jsonData.response?.candidates?.[0]
if (candidate?.content?.parts?.[0]?.text) {
const content = candidate.content.parts[0].text
totalContent += content
// Yield text chunk
yield {
type: "text",
text: content,
}
}
// Store usage metadata for final reporting
if (jsonData.response?.usageMetadata) {
lastUsageMetadata = jsonData.response.usageMetadata
promptTokens = lastUsageMetadata.promptTokenCount || promptTokens
outputTokens = lastUsageMetadata.candidatesTokenCount || outputTokens
}
// Check if this is the final chunk
if (candidate?.finishReason) {
break
}
}
// Yield usage information
if (lastUsageMetadata) {
yield {
type: "usage",
inputTokens: promptTokens,
outputTokens: outputTokens,
totalCost: 0, // Free tier
}
}
} catch (error) {
// Handle rate limit errors similar to the Gemini provider
if (error instanceof Error) {
// Check for rate limit patterns in the error message
const rateLimitPatterns = [
/got status: 429/i,
/429 Too Many Requests/i,
/rate limit exceeded/i,
/too many requests/i,
/quota exceeded/i,
/resource exhausted/i,
/code 429/i,
]
const isRateLimit = rateLimitPatterns.some((pattern) => pattern.test(error.message))
if (isRateLimit) {
const rateLimitError = Object.assign(new Error(error.message), {
...error,
status: 429,
})
throw rateLimitError
}
}
// Re-throw the original error if it's not a rate limit error
throw error
}
}
/**
* Get the model ID and info
*/
getModel(): { id: GeminiCliModelId; info: ModelInfo } {
const modelId = this.options.apiModelId as GeminiCliModelId
if (modelId && modelId in geminiCliModels) {
return { id: modelId, info: geminiCliModels[modelId] }
}
return {
id: geminiCliDefaultModelId,
info: geminiCliModels[geminiCliDefaultModelId],
}
}
}
+2 -9
View File
@@ -101,21 +101,14 @@ export class LiteLlmHandler implements ApiHandler {
return message
})
const requestPayload: OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming & {
metadata?: { cline_task_id: string }
} = {
const stream = await this.client.chat.completions.create({
model: this.options.liteLlmModelId || liteLlmDefaultModelId,
messages: [enhancedSystemMessage, ...enhancedMessages],
temperature,
stream: true,
stream_options: { include_usage: true },
...(thinkingConfig && { thinking: thinkingConfig }), // Add thinking configuration when applicable
...(this.options.taskId && {
metadata: { cline_task_id: this.options.taskId },
}),
}
const stream = await this.client.chat.completions.create(requestPayload)
})
const inputCost = (await this.calculateCost(1e6, 0)) || 0
const outputCost = (await this.calculateCost(0, 1e6)) || 0
+16 -66
View File
@@ -44,7 +44,6 @@ export class OpenRouterHandler implements ApiHandler {
for await (const chunk of stream) {
// openrouter returns an error object instead of the openai sdk throwing an error
// Check for error field directly on chunk
if ("error" in chunk) {
const error = chunk.error as OpenRouterErrorResponse["error"]
console.error(`OpenRouter API Error: ${error?.code} - ${error?.message}`)
@@ -53,29 +52,6 @@ export class OpenRouterHandler implements ApiHandler {
throw new Error(`OpenRouter API Error ${error.code}: ${error.message}${metadataStr}`)
}
// Check for error in choices[0].finish_reason
// OpenRouter may return errors in a non-standard way within choices
const choice = chunk.choices?.[0]
// Use type assertion since OpenRouter uses non-standard "error" finish_reason
if ((choice?.finish_reason as string) === "error") {
// Use type assertion since OpenRouter adds non-standard error property
const choiceWithError = choice as any
if (choiceWithError.error) {
const error = choiceWithError.error
console.error(
`OpenRouter Mid-Stream Error: ${error?.code || "Unknown"} - ${error?.message || "Unknown error"}`,
)
// Format error details
const errorDetails = typeof error === "object" ? JSON.stringify(error, null, 2) : String(error)
throw new Error(`OpenRouter Mid-Stream Error: ${errorDetails}`)
} else {
// Fallback if error details are not available
throw new Error(
`OpenRouter Mid-Stream Error: Stream terminated with error status but no error details provided`,
)
}
}
if (!this.lastGenerationId && chunk.id) {
this.lastGenerationId = chunk.id
}
@@ -98,27 +74,14 @@ export class OpenRouterHandler implements ApiHandler {
}
if (!didOutputUsage && chunk.usage) {
let modelId = this.options.openRouterModelId
if (modelId && modelId.includes("gemini")) {
yield {
type: "usage",
cacheWriteTokens: 0,
cacheReadTokens: chunk.usage.prompt_tokens_details?.cached_tokens || 0,
inputTokens: (chunk.usage.prompt_tokens || 0) - (chunk.usage.prompt_tokens_details?.cached_tokens || 0),
outputTokens: chunk.usage.completion_tokens || 0,
// @ts-ignore-next-line
totalCost: (chunk.usage.cost || 0) + (chunk.usage.cost_details?.upstream_inference_cost || 0),
}
} else {
yield {
type: "usage",
cacheWriteTokens: 0,
cacheReadTokens: chunk.usage.prompt_tokens_details?.cached_tokens || 0,
inputTokens: chunk.usage.prompt_tokens || 0,
outputTokens: chunk.usage.completion_tokens || 0,
// @ts-ignore-next-line
totalCost: (chunk.usage.cost || 0) + (chunk.usage.cost_details?.upstream_inference_cost || 0),
}
yield {
type: "usage",
cacheWriteTokens: 0,
cacheReadTokens: chunk.usage.prompt_tokens_details?.cached_tokens || 0,
inputTokens: chunk.usage.prompt_tokens || 0,
outputTokens: chunk.usage.completion_tokens || 0,
// @ts-ignore-next-line
totalCost: chunk.usage.cost || 0,
}
didOutputUsage = true
}
@@ -140,27 +103,14 @@ export class OpenRouterHandler implements ApiHandler {
const generationIterator = this.fetchGenerationDetails(this.lastGenerationId)
const generation = (await generationIterator.next()).value
// console.log("OpenRouter generation details:", generation)
let modelId = this.options.openRouterModelId
if (modelId && modelId.includes("gemini")) {
return {
type: "usage",
cacheWriteTokens: 0,
cacheReadTokens: generation?.native_tokens_cached || 0,
// openrouter generation endpoint fails often
inputTokens: (generation?.native_tokens_prompt || 0) - (generation?.native_tokens_cached || 0),
outputTokens: generation?.native_tokens_completion || 0,
totalCost: generation?.total_cost || 0,
}
} else {
return {
type: "usage",
cacheWriteTokens: 0,
cacheReadTokens: generation?.native_tokens_cached || 0,
// openrouter generation endpoint fails often
inputTokens: generation?.native_tokens_prompt || 0,
outputTokens: generation?.native_tokens_completion || 0,
totalCost: generation?.total_cost || 0,
}
return {
type: "usage",
cacheWriteTokens: 0,
cacheReadTokens: generation?.native_tokens_cached || 0,
// openrouter generation endpoint fails often
inputTokens: generation?.native_tokens_prompt || 0,
outputTokens: generation?.native_tokens_completion || 0,
totalCost: generation?.total_cost || 0,
}
} catch (error) {
// ignore if fails
+4 -6
View File
@@ -8,13 +8,11 @@ const LEGACY_SEARCH_BLOCK_CHAR = "<"
const LEGACY_REPLACE_BLOCK_CHAR = ">"
// Replace the exact string constants with flexible regex patterns
const SEARCH_BLOCK_START_REGEX = /^[-]{3,} SEARCH>?$/
const LEGACY_SEARCH_BLOCK_START_REGEX = /^[<]{3,} SEARCH>?$/
const SEARCH_BLOCK_START_REGEX = /^[-]{3,} SEARCH$/
const SEARCH_BLOCK_END_REGEX = /^[=]{3,}$/
const REPLACE_BLOCK_END_REGEX = /^[+]{3,} REPLACE>?$/
const LEGACY_REPLACE_BLOCK_END_REGEX = /^[>]{3,} REPLACE>?$/
const REPLACE_BLOCK_END_REGEX = /^[+]{3,} REPLACE$/
const LEGACY_SEARCH_BLOCK_START_REGEX = /^[<]{3,} SEARCH$/
const LEGACY_REPLACE_BLOCK_END_REGEX = /^[>]{3,} REPLACE$/
// Helper functions to check if a line matches the flexible patterns
function isSearchBlockStart(line: string): boolean {
+1 -2
View File
@@ -1,7 +1,6 @@
import * as vscode from "vscode"
import { Controller } from ".."
import { Empty, StringRequest } from "../../../shared/proto/common"
import { writeTextToClipboard } from "@/utils/env"
/**
* Copies text to the system clipboard
@@ -12,7 +11,7 @@ import { writeTextToClipboard } from "@/utils/env"
export async function copyToClipboard(controller: Controller, request: StringRequest): Promise<Empty> {
try {
if (request.value) {
await writeTextToClipboard(request.value)
await vscode.env.clipboard.writeText(request.value)
}
} catch (error) {
console.error("Error copying to clipboard:", error)
+21
View File
@@ -0,0 +1,21 @@
import { Controller } from ".."
import { EmptyRequest, StringArray } from "@shared/proto/common"
import { selectImages as selectImagesIntegration } from "@integrations/misc/process-images"
import { FileMethodHandler } from "./index"
/**
* Prompts the user to select images from the file system and returns them as data URLs
* @param controller The controller instance
* @param request Empty request, no parameters needed
* @returns Array of image data URLs
*/
export const selectImages: FileMethodHandler = async (controller: Controller, request: EmptyRequest): Promise<StringArray> => {
try {
const images = await selectImagesIntegration()
return StringArray.create({ values: images })
} catch (error) {
console.error("Error selecting images:", error)
// Return empty array on error
return StringArray.create({ values: [] })
}
}
+139 -26
View File
@@ -16,7 +16,7 @@ import { McpHub } from "@services/mcp/McpHub"
import { telemetryService } from "@/services/posthog/telemetry/TelemetryService"
import { ApiProvider, ModelInfo } from "@shared/api"
import { ChatContent } from "@shared/ChatContent"
import { ChatSettings, StoredChatSettings } from "@shared/ChatSettings"
import { ChatSettings } from "@shared/ChatSettings"
import { ExtensionMessage, ExtensionState, Platform } from "@shared/ExtensionMessage"
import { HistoryItem } from "@shared/HistoryItem"
import { McpMarketplaceCatalog } from "@shared/mcp"
@@ -44,8 +44,6 @@ import { sendAddToInputEvent } from "./ui/subscribeToAddToInput"
import { sendAuthCallbackEvent } from "./account/subscribeToAuthCallback"
import { sendMcpMarketplaceCatalogEvent } from "./mcp/subscribeToMcpMarketplaceCatalog"
import { sendRelinquishControlEvent } from "./ui/subscribeToRelinquishControl"
import { handleTaskServiceRequest } from "./task"
import { BooleanRequest } from "@shared/proto/common"
/*
https://github.com/microsoft/vscode-webview-ui-toolkit-samples/blob/main/default/weather-webview/src/providers/WeatherViewProvider.ts
@@ -58,12 +56,11 @@ export class Controller {
private postMessage: (message: ExtensionMessage) => Thenable<boolean> | undefined
private disposables: vscode.Disposable[] = []
private mode: "plan" | "act" = "plan" // In-memory plan/act mode state
task?: Task
workspaceTracker: WorkspaceTracker
mcpHub: McpHub
accountService: ClineAccountService
latestAnnouncementId = "june-25-2025_16:11:00" // update to some unique identifier when we add a new announcement
latestAnnouncementId = "may-22-2025_16:11:00" // update to some unique identifier when we add a new announcement
constructor(
readonly context: vscode.ExtensionContext,
@@ -138,7 +135,7 @@ export class Controller {
apiConfiguration,
autoApprovalSettings,
browserSettings,
chatSettings: storedChatSettings,
chatSettings,
shellIntegrationTimeout,
terminalReuseEnabled,
terminalOutputLineLimit,
@@ -148,12 +145,6 @@ export class Controller {
taskHistory,
} = await getAllExtensionState(this.context)
// Reconstruct ChatSettings with in-memory mode and stored preferences
const chatSettings: ChatSettings = {
...storedChatSettings, // Spread stored preferences (preferredLanguage, openAIReasoningEffort)
mode: this.mode, // Use in-memory mode (override any stored mode)
}
const NEW_USER_TASK_COUNT_THRESHOLD = 10
// Check if the user has completed enough tasks to no longer be considered a "new user"
@@ -218,6 +209,35 @@ export class Controller {
await this.fetchMcpMarketplace(message.bool)
break
}
// telemetry
case "telemetrySetting": {
if (message.telemetrySetting) {
await this.updateTelemetrySetting(message.telemetrySetting)
}
await this.postStateToWebview()
break
}
case "clearAllTaskHistory": {
const answer = await vscode.window.showWarningMessage(
"What would you like to delete?",
{ modal: true },
"Delete All Except Favorites",
"Delete Everything",
"Cancel",
)
if (answer === "Delete All Except Favorites") {
await this.deleteNonFavoriteTaskHistory()
await this.postStateToWebview()
} else if (answer === "Delete Everything") {
await this.deleteAllTaskHistory()
await this.postStateToWebview()
}
sendRelinquishControlEvent()
break
}
case "grpc_request": {
if (message.grpc_request) {
await handleGrpcRequest(this, message.grpc_request)
@@ -245,9 +265,6 @@ export class Controller {
async togglePlanActModeWithChatSettings(chatSettings: ChatSettings, chatContent?: ChatContent): Promise<boolean> {
const didSwitchToActMode = chatSettings.mode === "act"
// Store mode in-memory only
this.mode = chatSettings.mode
// Capture mode switch telemetry | Capture regardless of if we know the taskId
telemetryService.captureModeSwitch(this.task?.taskId ?? "0", chatSettings.mode)
@@ -417,9 +434,7 @@ export class Controller {
}
}
// Save only non-mode properties to workspace storage
const { mode, ...persistentChatSettings }: { mode: string } & StoredChatSettings = chatSettings
await updateWorkspaceState(this.context, "chatSettings", persistentChatSettings)
await updateWorkspaceState(this.context, "chatSettings", chatSettings)
await this.postStateToWebview()
if (this.task) {
@@ -815,6 +830,110 @@ export class Controller {
await downloadTask(historyItem.ts, apiConversationHistory)
}
async deleteAllTaskHistory() {
await this.clearTask()
await updateGlobalState(this.context, "taskHistory", undefined)
try {
// Remove all contents of tasks directory
const taskDirPath = path.join(this.context.globalStorageUri.fsPath, "tasks")
if (await fileExistsAtPath(taskDirPath)) {
await fs.rm(taskDirPath, { recursive: true, force: true })
}
// Remove checkpoints directory contents
const checkpointsDirPath = path.join(this.context.globalStorageUri.fsPath, "checkpoints")
if (await fileExistsAtPath(checkpointsDirPath)) {
await fs.rm(checkpointsDirPath, { recursive: true, force: true })
}
} catch (error) {
vscode.window.showErrorMessage(
`Encountered error while deleting task history, there may be some files left behind. Error: ${error instanceof Error ? error.message : String(error)}`,
)
}
// await this.postStateToWebview()
}
async deleteNonFavoriteTaskHistory() {
await this.clearTask()
const taskHistory = ((await getGlobalState(this.context, "taskHistory")) as HistoryItem[]) || []
const favoritedTasks = taskHistory.filter((task) => task.isFavorited === true)
// If user has no favorited tasks, show a warning message
if (favoritedTasks.length === 0) {
vscode.window.showWarningMessage("No favorited tasks found. Please favorite tasks before using this option.")
await this.postStateToWebview()
return
}
await updateGlobalState(this.context, "taskHistory", favoritedTasks)
// Delete non-favorited task directories
try {
const preserveTaskIds = favoritedTasks.map((task) => task.id)
const taskDirPath = path.join(this.context.globalStorageUri.fsPath, "tasks")
if (await fileExistsAtPath(taskDirPath)) {
const taskDirs = await fs.readdir(taskDirPath)
for (const taskDir of taskDirs) {
if (!preserveTaskIds.includes(taskDir)) {
await fs.rm(path.join(taskDirPath, taskDir), { recursive: true, force: true })
}
}
}
} catch (error) {
vscode.window.showErrorMessage(
`Error deleting task history: ${error instanceof Error ? error.message : String(error)}`,
)
}
await this.postStateToWebview()
}
async deleteTaskWithId(id: string) {
console.info("deleteTaskWithId: ", id)
try {
if (id === this.task?.taskId) {
await this.clearTask()
console.debug("cleared task")
}
const {
taskDirPath,
apiConversationHistoryFilePath,
uiMessagesFilePath,
contextHistoryFilePath,
taskMetadataFilePath,
} = await this.getTaskWithId(id)
const legacyMessagesFilePath = path.join(taskDirPath, "claude_messages.json")
const updatedTaskHistory = await this.deleteTaskFromState(id)
// Delete the task files
for (const filePath of [
apiConversationHistoryFilePath,
uiMessagesFilePath,
contextHistoryFilePath,
taskMetadataFilePath,
legacyMessagesFilePath,
]) {
const fileExists = await fileExistsAtPath(filePath)
if (fileExists) {
await fs.unlink(filePath)
}
}
await fs.rmdir(taskDirPath) // succeeds if the dir is empty
if (updatedTaskHistory.length === 0) {
await this.deleteAllTaskHistory()
}
} catch (error) {
console.debug(`Error deleting task:`, error)
}
await this.postStateToWebview()
}
async deleteTaskFromState(id: string) {
// Remove the task from history
const taskHistory = ((await getGlobalState(this.context, "taskHistory")) as HistoryItem[] | undefined) || []
@@ -829,7 +948,7 @@ export class Controller {
async postStateToWebview() {
const state = await this.getStateToPostToWebview()
await sendStateUpdate(this.id, state)
await sendStateUpdate(state)
}
async getStateToPostToWebview(): Promise<ExtensionState> {
@@ -839,7 +958,7 @@ export class Controller {
taskHistory,
autoApprovalSettings,
browserSettings,
chatSettings: storedChatSettings,
chatSettings,
userInfo,
mcpMarketplaceEnabled,
mcpRichDisplayEnabled,
@@ -856,12 +975,6 @@ export class Controller {
terminalOutputLineLimit,
} = await getAllExtensionState(this.context)
// Reconstruct ChatSettings with in-memory mode and stored preferences
const chatSettings: ChatSettings = {
...storedChatSettings, // Spread stored preferences (preferredLanguage, openAIReasoningEffort)
mode: this.mode, // Use in-memory mode (override any stored mode)
}
const localClineRulesToggles =
((await getWorkspaceState(this.context, "localClineRulesToggles")) as ClineRulesToggles) || {}
+29 -32
View File
@@ -3,8 +3,8 @@ import { Controller } from "../index"
import { EmptyRequest } from "../../../shared/proto/common"
import { StreamingResponseHandler, getRequestRegistry } from "../grpc-handler"
// Keep track of active state subscriptions by controller ID
const activeStateSubscriptions = new Map<string, StreamingResponseHandler>()
// Keep track of active state subscriptions
const activeStateSubscriptions = new Set<StreamingResponseHandler>()
/**
* Subscribe to state updates
@@ -19,25 +19,23 @@ export async function subscribeToState(
responseStream: StreamingResponseHandler,
requestId?: string,
): Promise<void> {
const controllerId = controller.id
// Send the initial state
const initialState = await controller.getStateToPostToWebview()
const initialStateJson = JSON.stringify(initialState)
console.log(`[DEBUG] set up state subscription for controller ${controllerId}`)
console.log("[DEBUG] set up state subscription")
await responseStream({
stateJson: initialStateJson,
})
// Add this subscription to the active subscriptions with the controller ID
activeStateSubscriptions.set(controllerId, responseStream)
// Add this subscription to the active subscriptions
activeStateSubscriptions.add(responseStream)
// Register cleanup when the connection is closed
const cleanup = () => {
activeStateSubscriptions.delete(controllerId)
console.log(`[DEBUG] Cleaned up state subscription for controller ${controllerId}`)
activeStateSubscriptions.delete(responseStream)
console.log("[DEBUG] Cleaned up state subscription")
}
// Register the cleanup function with the request registry if we have a requestId
@@ -47,31 +45,30 @@ export async function subscribeToState(
}
/**
* Send a state update to a specific controller's subscription
* @param controllerId The ID of the controller to send the state to
* Send a state update to all active subscribers
* @param state The state to send
*/
export async function sendStateUpdate(controllerId: string, state: any): Promise<void> {
// Get the subscription for this specific controller
const responseStream = activeStateSubscriptions.get(controllerId)
export async function sendStateUpdate(state: any): Promise<void> {
const stateJson = JSON.stringify(state)
if (!responseStream) {
console.log(`[DEBUG] No active state subscription for controller ${controllerId}`)
return
}
// Send the update to all active subscribers
const promises = Array.from(activeStateSubscriptions).map(async (responseStream) => {
try {
// The issue might be that we're not properly formatting the response
// Let's ensure we're sending a properly formatted State message
await responseStream(
{
stateJson,
},
false, // Not the last message
)
console.log("[DEBUG] sending followup state", stateJson.length, "chars")
} catch (error) {
console.error("Error sending state update:", error)
// Remove the subscription if there was an error
activeStateSubscriptions.delete(responseStream)
}
})
try {
const stateJson = JSON.stringify(state)
await responseStream(
{
stateJson,
},
false, // Not the last message
)
console.log(`[DEBUG] sending followup state to controller ${controllerId}`, stateJson.length, "chars")
} catch (error) {
console.error(`Error sending state update to controller ${controllerId}:`, error)
// Remove the subscription if there was an error
activeStateSubscriptions.delete(controllerId)
}
await Promise.all(promises)
}
@@ -22,7 +22,7 @@ export async function updateAutoApprovalSettings(controller: Controller, request
await updateGlobalState(controller.context, "autoApprovalSettings", settings)
if (controller.task) {
controller.task.updateAutoApprovalSettings(settings)
controller.task.autoApprovalSettings = settings
}
await controller.postStateToWebview()
@@ -1,16 +0,0 @@
import { Controller } from ".."
import { Empty } from "../../../shared/proto/common"
import { TelemetrySettingRequest } from "../../../shared/proto/state"
import { convertProtoTelemetrySettingToDomain } from "../../../shared/proto-conversions/state/telemetry-setting-conversion"
/**
* Updates the telemetry setting
* @param controller The controller instance
* @param request The telemetry setting request
* @returns Empty response
*/
export async function updateTelemetrySetting(controller: Controller, request: TelemetrySettingRequest): Promise<Empty> {
const telemetrySetting = convertProtoTelemetrySettingToDomain(request.setting)
await controller.updateTelemetrySetting(telemetrySetting)
return Empty.create()
}
@@ -1,138 +0,0 @@
import path from "path"
import fs from "fs/promises"
import { Controller } from ".."
import { DeleteAllTaskHistoryCount } from "../../../shared/proto/task"
import { getGlobalState, updateGlobalState } from "../../storage/state"
import { fileExistsAtPath } from "../../../utils/fs"
import vscode from "vscode"
/**
* Deletes all task history, with an option to preserve favorites
* @param controller The controller instance
* @param request Request with option to preserve favorites
* @returns Results with count of deleted tasks
*/
export async function deleteAllTaskHistory(controller: Controller): Promise<DeleteAllTaskHistoryCount> {
try {
// Clear current task first
await controller.clearTask()
// Get existing task history
const taskHistory = ((await getGlobalState(controller.context, "taskHistory")) as any[]) || []
const totalTasks = taskHistory.length
const userChoice = await vscode.window.showWarningMessage(
"What would you like to delete?",
{ modal: true },
"Delete All Except Favorites",
"Delete Everything",
)
// Default VS Code Cancel button returns `undefined` - don't delete anything
if (userChoice === undefined) {
return DeleteAllTaskHistoryCount.create({
tasksDeleted: 0,
})
}
// If preserving favorites, filter out non-favorites
if (userChoice === "Delete All Except Favorites") {
const favoritedTasks = taskHistory.filter((task) => task.isFavorited === true)
// If there are favorited tasks, update state
if (favoritedTasks.length > 0) {
await updateGlobalState(controller.context, "taskHistory", favoritedTasks)
// Delete non-favorited task directories
const preserveTaskIds = favoritedTasks.map((task) => task.id)
await cleanupTaskFiles(controller, preserveTaskIds)
// Update webview
try {
await controller.postStateToWebview()
} catch (webviewErr) {
console.error("Error posting to webview:", webviewErr)
}
return DeleteAllTaskHistoryCount.create({
tasksDeleted: totalTasks - favoritedTasks.length,
})
} else {
// No favorited tasks found - show warning and ask user what to do
const answer = await vscode.window.showWarningMessage(
"No favorited tasks found. Would you like to delete all tasks anyway?",
{ modal: true },
"Delete All Tasks",
)
// User cancelled - don't delete anything
if (answer === undefined) {
return DeleteAllTaskHistoryCount.create({
tasksDeleted: 0,
})
}
// If user chose "Delete All Tasks", fall through to the `delete everything` section below
}
}
// Delete everything (not preserving favorites)
await updateGlobalState(controller.context, "taskHistory", undefined)
try {
// Remove all contents of tasks directory
const taskDirPath = path.join(controller.context.globalStorageUri.fsPath, "tasks")
if (await fileExistsAtPath(taskDirPath)) {
await fs.rm(taskDirPath, { recursive: true, force: true })
}
// Remove checkpoints directory contents
const checkpointsDirPath = path.join(controller.context.globalStorageUri.fsPath, "checkpoints")
if (await fileExistsAtPath(checkpointsDirPath)) {
await fs.rm(checkpointsDirPath, { recursive: true, force: true })
}
} catch (error) {
vscode.window.showErrorMessage(
`Encountered error while deleting task history, there may be some files left behind. Error: ${error instanceof Error ? error.message : String(error)}`,
)
}
// Update webview
try {
await controller.postStateToWebview()
} catch (webviewErr) {
console.error("Error posting to webview:", webviewErr)
}
return DeleteAllTaskHistoryCount.create({
tasksDeleted: totalTasks,
})
} catch (error) {
console.error("Error in deleteAllTaskHistory:", error)
throw error
}
}
/**
* Helper function to cleanup task files while preserving specified tasks
*/
async function cleanupTaskFiles(controller: Controller, preserveTaskIds: string[]) {
const taskDirPath = path.join(controller.context.globalStorageUri.fsPath, "tasks")
try {
if (await fileExistsAtPath(taskDirPath)) {
const taskDirs = await fs.readdir(taskDirPath)
console.debug(`[cleanupTaskFiles] Found ${taskDirs.length} task directories`)
// Delete only non-preserved task directories
for (const dir of taskDirs) {
if (!preserveTaskIds.includes(dir)) {
await fs.rm(path.join(taskDirPath, dir), { recursive: true, force: true })
}
}
}
} catch (error) {
console.error("Error cleaning up task files:", error)
}
return true
}
@@ -0,0 +1,88 @@
import path from "path"
import fs from "fs/promises"
import { Controller } from ".."
import { EmptyRequest } from "../../../shared/proto/common"
import { DeleteNonFavoritedTasksResults } from "../../../shared/proto/task"
import { getGlobalState, updateGlobalState } from "../../storage/state"
import { fileExistsAtPath } from "../../../utils/fs"
/**
* Deletes all non-favorited tasks, preserving only favorited ones
* @param controller The controller instance
* @param request Empty request
* @returns DeleteNonFavoritedTasksResults with counts of preserved and deleted tasks
*/
export async function deleteNonFavoritedTasks(
controller: Controller,
_request: EmptyRequest,
): Promise<DeleteNonFavoritedTasksResults> {
try {
// Clear current task first
await controller.clearTask()
// Get existing task history
const taskHistory = ((await getGlobalState(controller.context, "taskHistory")) as any[]) || []
// Filter out non-favorited tasks
const favoritedTasks = taskHistory.filter((task) => task.isFavorited === true)
const deletedCount = taskHistory.length - favoritedTasks.length
console.log(`[deleteNonFavoritedTasks] Found ${favoritedTasks.length} favorited tasks to preserve`)
// Update global state
if (favoritedTasks.length > 0) {
await updateGlobalState(controller.context, "taskHistory", favoritedTasks)
} else {
await updateGlobalState(controller.context, "taskHistory", undefined)
}
// Handle file system cleanup for deleted tasks
const preserveTaskIds = favoritedTasks.map((task) => task.id)
await cleanupTaskFiles(controller, preserveTaskIds)
// Update webview
try {
await controller.postStateToWebview()
} catch (webviewErr) {
console.error("Error posting to webview:", webviewErr)
}
return DeleteNonFavoritedTasksResults.create({
tasksPreserved: favoritedTasks.length,
tasksDeleted: deletedCount,
})
} catch (error) {
console.error("Error in deleteNonFavoritedTasks:", error)
throw error
}
}
/**
* Helper function to cleanup task files while preserving specified tasks
*/
async function cleanupTaskFiles(controller: Controller, preserveTaskIds: string[]) {
const taskDirPath = path.join(controller.context.globalStorageUri.fsPath, "tasks")
try {
if (await fileExistsAtPath(taskDirPath)) {
if (preserveTaskIds.length > 0) {
const taskDirs = await fs.readdir(taskDirPath)
console.debug(`[cleanupTaskFiles] Found ${taskDirs.length} task directories`)
// Delete only non-preserved task directories
for (const dir of taskDirs) {
if (!preserveTaskIds.includes(dir)) {
await fs.rm(path.join(taskDirPath, dir), { recursive: true, force: true })
}
}
} else {
// No tasks to preserve, delete everything
await fs.rm(taskDirPath, { recursive: true, force: true })
}
}
} catch (error) {
console.error("Error cleaning up task files:", error)
}
return true
}
+2 -70
View File
@@ -1,9 +1,6 @@
import path from "path"
import fs from "fs/promises"
import { Controller } from ".."
import { Empty, StringArrayRequest, BooleanRequest } from "../../../shared/proto/common"
import { Empty, StringArrayRequest } from "../../../shared/proto/common"
import { TaskMethodHandler } from "./index"
import { fileExistsAtPath } from "../../../utils/fs"
/**
* Deletes tasks with the specified IDs
@@ -20,72 +17,7 @@ export const deleteTasksWithIds: TaskMethodHandler = async (
throw new Error("Missing task IDs")
}
for (const id of request.value) {
await deleteTaskWithId(controller, id)
}
await Promise.all(request.value.map((value) => controller.deleteTaskWithId(value)))
return Empty.create()
}
/**
* Deletes a single task with the specified ID
* @param controller The controller instance
* @param id The task ID to delete
*/
async function deleteTaskWithId(controller: Controller, id: string): Promise<void> {
console.info("deleteTaskWithId: ", id)
try {
// Clear current task if it matches the ID being deleted
if (id === controller.task?.taskId) {
await controller.clearTask()
console.debug("cleared task")
}
// Get task file paths
const { taskDirPath, apiConversationHistoryFilePath, uiMessagesFilePath, contextHistoryFilePath, taskMetadataFilePath } =
await controller.getTaskWithId(id)
// Remove task from state
const updatedTaskHistory = await controller.deleteTaskFromState(id)
// Delete the task files
for (const filePath of [
apiConversationHistoryFilePath,
uiMessagesFilePath,
contextHistoryFilePath,
taskMetadataFilePath,
]) {
const fileExists = await fileExistsAtPath(filePath)
if (fileExists) {
await fs.unlink(filePath)
}
}
// Remove empty task directory
try {
await fs.rmdir(taskDirPath) // succeeds if the dir is empty
} catch (error) {
console.debug("Could not remove task directory (may not be empty):", error)
}
// If no tasks remain, clean up everything
if (updatedTaskHistory.length === 0) {
const taskDirPath = path.join(controller.context.globalStorageUri.fsPath, "tasks")
const checkpointsDirPath = path.join(controller.context.globalStorageUri.fsPath, "checkpoints")
if (await fileExistsAtPath(taskDirPath)) {
await fs.rm(taskDirPath, { recursive: true, force: true })
}
if (await fileExistsAtPath(checkpointsDirPath)) {
await fs.rm(checkpointsDirPath, { recursive: true, force: true })
}
}
} catch (error) {
console.debug(`Error deleting task ${id}:`, error)
throw error // Re-throw to let caller handle the error
}
// Update webview state
await controller.postStateToWebview()
}
-20
View File
@@ -1,20 +0,0 @@
import type { Controller } from "../index"
import type { StringRequest } from "../../../shared/proto/common"
import { Empty } from "../../../shared/proto/common"
import { openUrlInBrowser } from "../../../utils/github-url-utils"
/**
* Opens a URL in the default browser
* @param controller The controller instance
* @param request The URL to open
* @returns Empty response
*/
export async function openUrl(controller: Controller, request: StringRequest): Promise<Empty> {
try {
await openUrlInBrowser(request.value)
return Empty.create({})
} catch (error) {
console.error(`Failed to open URL: ${error}`)
throw error
}
}
+3 -6
View File
@@ -268,9 +268,7 @@ Usage:
</new_task>
## plan_mode_respond
Description: Respond to the user's inquiry in an effort to plan a solution to the user's task. This tool should ONLY be used when you have already explored the relevant files and are ready to present a concrete plan. DO NOT use this tool to announce what files you're going to read - just read them first. This tool is only available in PLAN MODE. The environment_details will specify the current mode, if it is not PLAN_MODE then you should not use this tool. For example, if the user's task is to create a website, you may start by asking some clarifying questions with the ask_followup_question tool if their message was vague, explore the codebase, read files, then present a detailed plan for how you will accomplish the task given the context, and perhaps engage in a back and forth to finalize the details before the user switches you to ACT_MODE to implement the solution.
CRITICAL: You must complete your information gathering (reading files, exploring the codebase) BEFORE using this tool. The user expects to see a well thought-out plan based on actual analysis, not intentions.
Description: Respond to the user's inquiry in an effort to plan a solution to the user's task. This tool should be used when you need to provide a response to a question or statement from the user about how you plan to accomplish the task. This tool is only available in PLAN MODE. The environment_details will specify the current mode, if it is not PLAN MODE then you should not use this tool. Depending on the user's message, you may ask questions to get clarification about the user's request, architect a solution to the task, and to brainstorm ideas with the user. For example, if the user's task is to create a website, you may start by asking some clarifying questions, then present a detailed plan for how you will accomplish the task given the context, and perhaps engage in a back and forth to finalize the details before the user switches you to ACT MODE to implement the solution. IMPORTANT NOTE: You should NOT ask for permission to read files or explore the repo. Just do that proactively. This tool should only be used when you've already gathered enough information to make a plan, or if you have a question for the user.
Parameters:
- response: (required) The response to provide to the user. Do not try to use tools in this parameter, this is simply a chat response. (You MUST use the response parameter, do not simply place the response text directly within <plan_mode_respond> tags.)
Usage:
@@ -574,8 +572,8 @@ In each user message, the environment_details will specify the current mode. The
## What is PLAN MODE?
- While you are usually in ACT MODE, the user may switch to PLAN MODE in order to have a back and forth with you to plan how to best accomplish the task.
- When starting in PLAN MODE, depending on the user's request, you may need to do some information gathering e.g. using read_file or search_files to get more context about the task. You may also ask the user clarifying questions with ask_followup_question to get a better understanding of the task. You may return mermaid diagrams to visually display your understanding.
- Once you've gained more context about the user's request, you should architect a detailed plan for how you will accomplish the task. Returning mermaid diagrams may be helpful here as well. Present the plan to the user using the plan_mode_respond tool.
- When starting in PLAN MODE, depending on the user's request, you may need to do some information gathering e.g. using read_file or search_files to get more context about the task. You may also ask the user clarifying questions to get a better understanding of the task. You may return mermaid diagrams to visually display your understanding.
- Once you've gained more context about the user's request, you should architect a detailed plan for how you will accomplish the task. Returning mermaid diagrams may be helpful here as well.
- Then you might ask the user if they are pleased with this plan, or if they would like to make any changes. Think of this as a brainstorming session where you can discuss the task and plan the best way to accomplish it.
- If at any point a mermaid diagram would make your plan clearer to help the user quickly see the structure, you are encouraged to include a Mermaid code block in the response. (Note: if you use colors in your mermaid diagrams, be sure to use high contrast colors so the text is readable.)
- Finally once it seems like you've reached a good plan, ask the user to switch you back to ACT MODE to implement the solution.
@@ -623,7 +621,6 @@ RULES
- When you want to modify a file, use the replace_in_file or write_to_file tool directly with the desired changes. You do not need to display the changes before using the tool.
- Do not ask for more information than necessary. Use the tools provided to accomplish the user's request efficiently and effectively. When you've completed your task, you must use the attempt_completion tool to present the result to the user. The user may provide feedback, which you can use to make improvements and try again.
- You are only allowed to ask the user questions using the ask_followup_question tool. Use this tool only when you need additional details to complete a task, and be sure to use a clear and concise question that will help you move forward with the task. However if you can use the available tools to avoid having to ask the user questions, you should do so. For example, if the user mentions a file that may be in an outside directory like the Desktop, you should use the list_files tool to list the files in the Desktop and check if the file they are talking about is there, rather than asking the user to provide the file path themselves.
- When the user is being vague, you should be proactive about asking clarifying questions using the ask_followup_question tool to ensure you understand their request. However, if you can infer the user's intent based on the context and available tools, you should proceed without asking unnecessary questions
- When executing commands, if you don't see the expected output, assume the terminal executed the command successfully and proceed with the task. The user's terminal may be unable to stream the output back properly. If you absolutely need to see the actual terminal output, use the ask_followup_question tool to request the user to copy and paste it back to you.
- The user may provide a file's contents directly in their message, in which case you shouldn't use the read_file tool to get the file contents again since you already have it.
- Your goal is to try to accomplish the user's task, NOT engage in a back and forth conversation.${
+3 -3
View File
@@ -12,14 +12,14 @@ export const SYSTEM_PROMPT = async (
supportsBrowserUse: boolean,
mcpHub: McpHub,
browserSettings: BrowserSettings,
isNextGenModel: boolean = false,
isClaude4ModelFamily: boolean = false,
) => {
if (isNextGenModel && USE_EXPERIMENTAL_CLAUDE4_FEATURES) {
if (isClaude4ModelFamily && USE_EXPERIMENTAL_CLAUDE4_FEATURES) {
return SYSTEM_PROMPT_CLAUDE4_EXPERIMENTAL(cwd, supportsBrowserUse, mcpHub, browserSettings)
}
if (isNextGenModel) {
if (isClaude4ModelFamily) {
return SYSTEM_PROMPT_CLAUDE4(cwd, supportsBrowserUse, mcpHub, browserSettings)
}
-147
View File
@@ -1,147 +0,0 @@
import * as vscode from "vscode"
import { ensureRulesDirectoryExists } from "./disk"
import fs from "fs/promises"
import path from "path"
import { getGlobalState, getWorkspaceState, updateGlobalState, updateWorkspaceState } from "./state"
import { GlobalStateKey } from "./state-keys"
export async function migratePlanActGlobalToWorkspaceStorage(context: vscode.ExtensionContext) {
// Keys that were migrated from global storage to workspace storage
const keysToMigrate = [
// Core settings
"apiProvider",
"apiModelId",
"thinkingBudgetTokens",
"reasoningEffort",
"chatSettings",
"vsCodeLmModelSelector",
// Provider-specific model keys
"awsBedrockCustomSelected",
"awsBedrockCustomModelBaseId",
"openRouterModelId",
"openRouterModelInfo",
"openAiModelId",
"openAiModelInfo",
"ollamaModelId",
"lmStudioModelId",
"liteLlmModelId",
"liteLlmModelInfo",
"requestyModelId",
"requestyModelInfo",
"togetherModelId",
"fireworksModelId",
// Previous mode settings
"previousModeApiProvider",
"previousModeModelId",
"previousModeModelInfo",
"previousModeVsCodeLmModelSelector",
"previousModeThinkingBudgetTokens",
"previousModeReasoningEffort",
"previousModeAwsBedrockCustomSelected",
"previousModeAwsBedrockCustomModelBaseId",
]
for (const key of keysToMigrate) {
const globalValue = await getGlobalState(context, key as GlobalStateKey)
if (globalValue !== undefined) {
const workspaceValue = await getWorkspaceState(context, key)
if (workspaceValue === undefined) {
await updateWorkspaceState(context, key, globalValue)
}
// Delete from global storage regardless of whether we copied it
await updateGlobalState(context, key as GlobalStateKey, undefined)
}
}
}
export async function migrateMcpMarketplaceEnableSetting(mcpMarketplaceEnabledRaw: boolean | undefined): Promise<boolean> {
const config = vscode.workspace.getConfiguration("cline")
const mcpMarketplaceEnabled = config.get<boolean>("mcpMarketplace.enabled")
if (mcpMarketplaceEnabled !== undefined) {
// Remove from VSCode configuration
await config.update("mcpMarketplace.enabled", undefined, true)
return !mcpMarketplaceEnabled
}
return mcpMarketplaceEnabledRaw ?? true
}
export async function migrateEnableCheckpointsSetting(enableCheckpointsSettingRaw: boolean | undefined): Promise<boolean> {
const config = vscode.workspace.getConfiguration("cline")
const enableCheckpoints = config.get<boolean>("enableCheckpoints")
if (enableCheckpoints !== undefined) {
// Remove from VSCode configuration
await config.update("enableCheckpoints", undefined, true)
return enableCheckpoints
}
return enableCheckpointsSettingRaw ?? true
}
export async function migrateCustomInstructionsToGlobalRules(context: vscode.ExtensionContext) {
try {
const customInstructions = (await context.globalState.get("customInstructions")) as string | undefined
if (customInstructions?.trim()) {
console.log("Migrating custom instructions to global Cline rules...")
// Create global .clinerules directory if it doesn't exist
const globalRulesDir = await ensureRulesDirectoryExists()
// Use a fixed filename for custom instructions
const migrationFileName = "custom_instructions.md"
const migrationFilePath = path.join(globalRulesDir, migrationFileName)
try {
// Check if file already exists to determine if we should append
let existingContent = ""
try {
existingContent = await fs.readFile(migrationFilePath, "utf8")
} catch (readError) {
// File doesn't exist, which is fine
}
// Append or create the file with custom instructions
const contentToWrite = existingContent
? `${existingContent}\n\n---\n\n${customInstructions.trim()}`
: customInstructions.trim()
await fs.writeFile(migrationFilePath, contentToWrite)
console.log(`Successfully ${existingContent ? "appended to" : "created"} migration file: ${migrationFilePath}`)
} catch (fileError) {
console.error("Failed to write migration file:", fileError)
return
}
// Remove customInstructions from global state only after successful file creation
await context.globalState.update("customInstructions", undefined)
console.log("Successfully migrated custom instructions to global Cline rules")
}
} catch (error) {
console.error("Failed to migrate custom instructions to global rules:", error)
// Continue execution - migration failure shouldn't break extension startup
}
}
export async function migrateModeFromWorkspaceStorageToControllerState(context: vscode.ExtensionContext) {
try {
// Get current chatSettings from workspace storage
const chatSettings = (await getWorkspaceState(context, "chatSettings")) as any
if (chatSettings && typeof chatSettings === "object" && "mode" in chatSettings) {
console.log("Cleaning up mode from workspace storage...")
// Remove mode property from chatSettings
const { mode, ...cleanedChatSettings } = chatSettings
// Save cleaned chatSettings back to workspace storage
await updateWorkspaceState(context, "chatSettings", cleanedChatSettings)
console.log("Successfully removed mode from workspace storage chatSettings")
}
} catch (error) {
console.error("Failed to cleanup mode from workspace storage:", error)
// Continue execution - migration failure shouldn't break extension startup
}
}
+125 -4
View File
@@ -1,5 +1,5 @@
import * as vscode from "vscode"
import { DEFAULT_CHAT_SETTINGS } from "@shared/ChatSettings"
import { DEFAULT_CHAT_SETTINGS, OpenAIReasoningEffort } from "@shared/ChatSettings"
import { DEFAULT_BROWSER_SETTINGS } from "@shared/BrowserSettings"
import { DEFAULT_AUTO_APPROVAL_SETTINGS } from "@shared/AutoApprovalSettings"
import { GlobalStateKey, SecretKey } from "./state-keys"
@@ -7,11 +7,13 @@ import { ApiConfiguration, ApiProvider, BedrockModelId, ModelInfo } from "@share
import { HistoryItem } from "@shared/HistoryItem"
import { AutoApprovalSettings } from "@shared/AutoApprovalSettings"
import { BrowserSettings } from "@shared/BrowserSettings"
import { StoredChatSettings } from "@shared/ChatSettings"
import { ChatSettings } from "@shared/ChatSettings"
import { TelemetrySetting } from "@shared/TelemetrySetting"
import { UserInfo } from "@shared/UserInfo"
import { ClineRulesToggles } from "@shared/cline-rules"
import { migrateEnableCheckpointsSetting, migrateMcpMarketplaceEnableSetting } from "./state-migrations"
import { ensureRulesDirectoryExists } from "./disk"
import fs from "fs/promises"
import path from "path"
/*
Storage
https://dev.to/kompotkot/how-to-use-secretstorage-in-your-vscode-extensions-2hco
@@ -52,6 +54,125 @@ export async function getWorkspaceState(context: vscode.ExtensionContext, key: s
return await context.workspaceState.get(key)
}
export async function migratePlanActGlobalToWorkspaceStorage(context: vscode.ExtensionContext) {
// Keys that were migrated from global storage to workspace storage
const keysToMigrate = [
// Core settings
"apiProvider",
"apiModelId",
"thinkingBudgetTokens",
"reasoningEffort",
"chatSettings",
"vsCodeLmModelSelector",
// Provider-specific model keys
"awsBedrockCustomSelected",
"awsBedrockCustomModelBaseId",
"openRouterModelId",
"openRouterModelInfo",
"openAiModelId",
"openAiModelInfo",
"ollamaModelId",
"lmStudioModelId",
"liteLlmModelId",
"liteLlmModelInfo",
"requestyModelId",
"requestyModelInfo",
"togetherModelId",
"fireworksModelId",
// Previous mode settings
"previousModeApiProvider",
"previousModeModelId",
"previousModeModelInfo",
"previousModeVsCodeLmModelSelector",
"previousModeThinkingBudgetTokens",
"previousModeReasoningEffort",
"previousModeAwsBedrockCustomSelected",
"previousModeAwsBedrockCustomModelBaseId",
]
for (const key of keysToMigrate) {
const globalValue = await getGlobalState(context, key as GlobalStateKey)
if (globalValue !== undefined) {
const workspaceValue = await getWorkspaceState(context, key)
if (workspaceValue === undefined) {
await updateWorkspaceState(context, key, globalValue)
}
// Delete from global storage regardless of whether we copied it
await updateGlobalState(context, key as GlobalStateKey, undefined)
}
}
}
async function migrateMcpMarketplaceEnableSetting(mcpMarketplaceEnabledRaw: boolean | undefined): Promise<boolean> {
const config = vscode.workspace.getConfiguration("cline")
const mcpMarketplaceEnabled = config.get<boolean>("mcpMarketplace.enabled")
if (mcpMarketplaceEnabled !== undefined) {
// Remove from VSCode configuration
await config.update("mcpMarketplace.enabled", undefined, true)
return !mcpMarketplaceEnabled
}
return mcpMarketplaceEnabledRaw ?? true
}
async function migrateEnableCheckpointsSetting(enableCheckpointsSettingRaw: boolean | undefined): Promise<boolean> {
const config = vscode.workspace.getConfiguration("cline")
const enableCheckpoints = config.get<boolean>("enableCheckpoints")
if (enableCheckpoints !== undefined) {
// Remove from VSCode configuration
await config.update("enableCheckpoints", undefined, true)
return enableCheckpoints
}
return enableCheckpointsSettingRaw ?? true
}
export async function migrateCustomInstructionsToGlobalRules(context: vscode.ExtensionContext) {
try {
const customInstructions = (await context.globalState.get("customInstructions")) as string | undefined
if (customInstructions?.trim()) {
console.log("Migrating custom instructions to global Cline rules...")
// Create global .clinerules directory if it doesn't exist
const globalRulesDir = await ensureRulesDirectoryExists()
// Use a fixed filename for custom instructions
const migrationFileName = "custom_instructions.md"
const migrationFilePath = path.join(globalRulesDir, migrationFileName)
try {
// Check if file already exists to determine if we should append
let existingContent = ""
try {
existingContent = await fs.readFile(migrationFilePath, "utf8")
} catch (readError) {
// File doesn't exist, which is fine
}
// Append or create the file with custom instructions
const contentToWrite = existingContent
? `${existingContent}\n\n---\n\n${customInstructions.trim()}`
: customInstructions.trim()
await fs.writeFile(migrationFilePath, contentToWrite)
console.log(`Successfully ${existingContent ? "appended to" : "created"} migration file: ${migrationFilePath}`)
} catch (fileError) {
console.error("Failed to write migration file:", fileError)
return
}
// Remove customInstructions from global state only after successful file creation
await context.globalState.update("customInstructions", undefined)
console.log("Successfully migrated custom instructions to global Cline rules")
}
} catch (error) {
console.error("Failed to migrate custom instructions to global rules:", error)
// Continue execution - migration failure shouldn't break extension startup
}
}
export async function getAllExtensionState(context: vscode.ExtensionContext) {
const [
isNewUser,
@@ -239,7 +360,7 @@ export async function getAllExtensionState(context: vscode.ExtensionContext) {
previousModeSapAiCoreResourceGroup,
previousModeSapAiCoreModelId,
] = await Promise.all([
getWorkspaceState(context, "chatSettings") as Promise<StoredChatSettings | undefined>,
getWorkspaceState(context, "chatSettings") as Promise<ChatSettings | undefined>,
getWorkspaceState(context, "apiProvider") as Promise<ApiProvider | undefined>,
getWorkspaceState(context, "apiModelId") as Promise<string | undefined>,
getWorkspaceState(context, "thinkingBudgetTokens") as Promise<number | undefined>,
+10 -27
View File
@@ -11,7 +11,6 @@ import { ClineIgnoreController } from "@core/ignore/ClineIgnoreController"
import { AutoApprovalSettings } from "@shared/AutoApprovalSettings"
import { ChatSettings } from "@shared/ChatSettings"
import { ToolParamName, ToolUse, ToolUseName } from "../assistant-message"
import { AutoApprove } from "./tools/autoApprove"
import {
BrowserAction,
BrowserActionResult,
@@ -31,7 +30,7 @@ import { ClineAskResponse } from "@shared/WebviewMessage"
import { fixModelHtmlEscaping, removeInvalidChars } from "@utils/string"
import { fileExistsAtPath } from "@utils/fs"
import { formatResponse } from "../prompts/responses"
import { isClaude4ModelFamily, isGemini2dot5ModelFamily } from "@utils/model-utils"
import { isClaude4ModelFamily } from "@utils/model-utils"
import { ToolResponse, USE_EXPERIMENTAL_CLAUDE4_FEATURES } from "."
import { serializeError } from "serialize-error"
import * as path from "path"
@@ -58,17 +57,6 @@ import { setTimeout as setTimeoutPromise } from "node:timers/promises"
import { ChangeLocation, StreamingJsonReplacer } from "../assistant-message/diff-json"
export class ToolExecutor {
private autoApprover: AutoApprove
// Auto-approval methods using the AutoApprove class
private shouldAutoApproveTool(toolName: ToolUseName): boolean | [boolean, boolean] {
return this.autoApprover.shouldAutoApproveTool(toolName)
}
private shouldAutoApproveToolWithPath(blockname: ToolUseName, autoApproveActionpath: string | undefined): boolean {
return this.autoApprover.shouldAutoApproveToolWithPath(blockname, autoApproveActionpath)
}
constructor(
// Core Services & Managers
private context: vscode.ExtensionContext,
@@ -108,28 +96,21 @@ export class ToolExecutor {
private saveCheckpoint: (isAttemptCompletionMessage?: boolean) => Promise<void>,
private reinitExistingTaskFromId: (taskId: string) => Promise<void>,
private cancelTask: () => Promise<void>,
private shouldAutoApproveTool: (toolName: ToolUseName) => boolean | [boolean, boolean],
private shouldAutoApproveToolWithPath: (blockname: ToolUseName, autoApproveActionpath: string | undefined) => boolean,
private sayAndCreateMissingParamError: (toolName: ToolUseName, paramName: string, relPath?: string) => Promise<any>,
private removeLastPartialMessageIfExistsWithType: (type: "ask" | "say", askOrSay: ClineAsk | ClineSay) => Promise<void>,
private executeCommandTool: (command: string) => Promise<[boolean, any]>,
private doesLatestTaskCompletionHaveNewChanges: () => Promise<boolean>,
) {
this.autoApprover = new AutoApprove(autoApprovalSettings)
}
/**
* Updates the auto approval settings
*/
public updateAutoApprovalSettings(settings: AutoApprovalSettings): void {
this.autoApprover.updateSettings(settings)
}
) {}
private pushToolResult = (content: ToolResponse, block: ToolUse) => {
const isNextGenModel = isClaude4ModelFamily(this.api) || isGemini2dot5ModelFamily(this.api)
const isClaude4Model = isClaude4ModelFamily(this.api)
if (typeof content === "string") {
const resultText = content || "(tool did not return anything)"
if (isNextGenModel && USE_EXPERIMENTAL_CLAUDE4_FEATURES) {
if (isClaude4Model && USE_EXPERIMENTAL_CLAUDE4_FEATURES) {
// Claude 4 family: Use function_results format
this.taskState.userMessageContent.push({
type: "text",
@@ -491,9 +472,9 @@ export class ToolExecutor {
const currentFullJson = block.params.diff
// Check if we should use streaming (e.g., for specific models)
const isNextGenModel = isClaude4ModelFamily(this.api) || isGemini2dot5ModelFamily(this.api)
const isClaude4Model = isClaude4ModelFamily(this.api)
// Going through claude family of models
if (isNextGenModel && USE_EXPERIMENTAL_CLAUDE4_FEATURES && currentFullJson) {
if (isClaude4Model && USE_EXPERIMENTAL_CLAUDE4_FEATURES && currentFullJson) {
const streamingResult = await this.handleStreamingJsonReplacement(block, relPath, currentFullJson)
if (streamingResult.error) {
@@ -858,6 +839,7 @@ export class ToolExecutor {
}
}
case "list_files": {
const isClaude4Model = isClaude4ModelFamily(this.api)
const relDirPath: string | undefined = block.params.path
const recursiveRaw: string | undefined = block.params.recursive
const recursive = recursiveRaw?.toLowerCase() === "true"
@@ -1007,6 +989,7 @@ export class ToolExecutor {
}
}
case "search_files": {
const isClaude4Model = isClaude4ModelFamily(this.api)
const relDirPath: string | undefined = block.params.path
const regex: string | undefined = block.params.regex
const filePattern: string | undefined = block.params.file_pattern
+71 -13
View File
@@ -90,7 +90,7 @@ import WorkspaceTracker from "@integrations/workspace/WorkspaceTracker"
import { McpHub } from "@services/mcp/McpHub"
import { isInTestMode } from "../../services/test/TestMode"
import { processFilesIntoText } from "@integrations/misc/extract-text"
import { isClaude4ModelFamily, isGemini2dot5ModelFamily } from "@utils/model-utils"
import { isClaude4ModelFamily } from "@utils/model-utils"
import { MessageStateHandler } from "./message-state"
import { formatErrorWithStatusCode, updateApiReqMsg } from "./utils"
import { TaskState } from "./TaskState"
@@ -314,6 +314,8 @@ export class Task {
this.saveCheckpoint.bind(this),
this.reinitExistingTaskFromId.bind(this),
this.cancelTask.bind(this),
this.shouldAutoApproveTool.bind(this),
this.shouldAutoApproveToolWithPath.bind(this),
this.sayAndCreateMissingParamError.bind(this),
this.removeLastPartialMessageIfExistsWithType.bind(this),
this.executeCommandTool.bind(this),
@@ -331,13 +333,6 @@ export class Task {
return context
}
/**
* Updates the auto approval settings for this task
*/
public updateAutoApprovalSettings(settings: AutoApprovalSettings): void {
this.toolExecutor.updateAutoApprovalSettings(settings)
}
async restoreCheckpoint(messageTs: number, restoreType: ClineCheckpointRestore, offset?: number) {
const clineMessages = this.messageStateHandler.getClineMessages()
const messageIndex = clineMessages.findIndex((m) => m.ts === messageTs) - (offset || 0)
@@ -1559,6 +1554,69 @@ export class Task {
}
}
// Check if the tool should be auto-approved based on the settings
// Returns bool for most tools, and tuple for tools with nested settings
shouldAutoApproveTool(toolName: ToolUseName): boolean | [boolean, boolean] {
if (this.autoApprovalSettings.enabled) {
switch (toolName) {
case "read_file":
case "list_files":
case "list_code_definition_names":
case "search_files":
return [
this.autoApprovalSettings.actions.readFiles,
this.autoApprovalSettings.actions.readFilesExternally ?? false,
]
case "new_rule":
case "write_to_file":
case "replace_in_file":
return [
this.autoApprovalSettings.actions.editFiles,
this.autoApprovalSettings.actions.editFilesExternally ?? false,
]
case "execute_command":
return [
this.autoApprovalSettings.actions.executeSafeCommands ?? false,
this.autoApprovalSettings.actions.executeAllCommands ?? false,
]
case "browser_action":
return this.autoApprovalSettings.actions.useBrowser
case "web_fetch":
return this.autoApprovalSettings.actions.useBrowser
case "access_mcp_resource":
case "use_mcp_tool":
return this.autoApprovalSettings.actions.useMcp
}
}
return false
}
// Check if the tool should be auto-approved based on the settings
// and the path of the action. Returns true if the tool should be auto-approved
// based on the user's settings and the path of the action.
shouldAutoApproveToolWithPath(blockname: ToolUseName, autoApproveActionpath: string | undefined): boolean {
let isLocalRead: boolean = false
if (autoApproveActionpath) {
const absolutePath = path.resolve(cwd, autoApproveActionpath)
isLocalRead = absolutePath.startsWith(cwd)
} else {
// If we do not get a path for some reason, default to a (safer) false return
isLocalRead = false
}
// Get auto-approve settings for local and external edits
const autoApproveResult = this.shouldAutoApproveTool(blockname)
const [autoApproveLocal, autoApproveExternal] = Array.isArray(autoApproveResult)
? autoApproveResult
: [autoApproveResult, false]
if ((isLocalRead && autoApproveLocal) || (!isLocalRead && autoApproveLocal && autoApproveExternal)) {
return true
} else {
return false
}
}
/**
* Migrates the disableBrowserTool setting from VSCode configuration to browserSettings
*/
@@ -1596,8 +1654,8 @@ export class Task {
const supportsBrowserUse = modelSupportsBrowserUse && !disableBrowserTool // only enable browser use if the model supports it and the user hasn't disabled it
const isNextGenModel = isClaude4ModelFamily(this.api) || isGemini2dot5ModelFamily(this.api)
let systemPrompt = await SYSTEM_PROMPT(cwd, supportsBrowserUse, this.mcpHub, this.browserSettings, isNextGenModel)
const isClaude4Model = isClaude4ModelFamily(this.api)
let systemPrompt = await SYSTEM_PROMPT(cwd, supportsBrowserUse, this.mcpHub, this.browserSettings, isClaude4Model)
await this.migratePreferredLanguageToolSetting()
const preferredLanguage = getLanguageKey(this.chatSettings.preferredLanguage as LanguageDisplay)
@@ -1907,7 +1965,7 @@ export class Task {
"mistake_limit_reached",
this.api.getModel().id.includes("claude")
? `This may indicate a failure in his thought process or inability to use a tool properly, which can be mitigated with some user guidance (e.g. "Try breaking down the task into smaller steps").`
: "Cline uses complex prompts and iterative task execution that may be challenging for less capable models. For best results, it's recommended to use Claude 4 Sonnet for its advanced agentic coding capabilities.",
: "Cline uses complex prompts and iterative task execution that may be challenging for less capable models. For best results, it's recommended to use Claude 3.7 Sonnet for its advanced agentic coding capabilities.",
)
if (response === "messageResponse") {
// This userContent is for the *next* API call.
@@ -2165,8 +2223,8 @@ export class Task {
assistantMessage += chunk.text
// parse raw assistant message into content blocks
const prevLength = this.taskState.assistantMessageContent.length
const isNextGenModel = isClaude4ModelFamily(this.api) || isGemini2dot5ModelFamily(this.api)
if (isNextGenModel && USE_EXPERIMENTAL_CLAUDE4_FEATURES) {
const isClaude4Model = isClaude4ModelFamily(this.api)
if (isClaude4Model && USE_EXPERIMENTAL_CLAUDE4_FEATURES) {
this.taskState.assistantMessageContent = parseAssistantMessageV3(assistantMessage)
} else {
this.taskState.assistantMessageContent = parseAssistantMessageV2(assistantMessage)
-83
View File
@@ -1,83 +0,0 @@
import { AutoApprovalSettings } from "@shared/AutoApprovalSettings"
import { ToolUseName } from "@core/assistant-message"
import * as path from "path"
import * as vscode from "vscode"
import os from "os"
export const cwd =
vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath).at(0) ?? path.join(os.homedir(), "Desktop")
export class AutoApprove {
autoApprovalSettings: AutoApprovalSettings
constructor(autoApprovalSettings: AutoApprovalSettings) {
this.autoApprovalSettings = autoApprovalSettings
}
// Check if the tool should be auto-approved based on the settings
// Returns bool for most tools, and tuple for tools with nested settings
shouldAutoApproveTool(toolName: ToolUseName): boolean | [boolean, boolean] {
if (this.autoApprovalSettings.enabled) {
switch (toolName) {
case "read_file":
case "list_files":
case "list_code_definition_names":
case "search_files":
return [
this.autoApprovalSettings.actions.readFiles,
this.autoApprovalSettings.actions.readFilesExternally ?? false,
]
case "new_rule":
case "write_to_file":
case "replace_in_file":
return [
this.autoApprovalSettings.actions.editFiles,
this.autoApprovalSettings.actions.editFilesExternally ?? false,
]
case "execute_command":
return [
this.autoApprovalSettings.actions.executeSafeCommands ?? false,
this.autoApprovalSettings.actions.executeAllCommands ?? false,
]
case "browser_action":
return this.autoApprovalSettings.actions.useBrowser
case "web_fetch":
return this.autoApprovalSettings.actions.useBrowser
case "access_mcp_resource":
case "use_mcp_tool":
return this.autoApprovalSettings.actions.useMcp
}
}
return false
}
// Check if the tool should be auto-approved based on the settings
// and the path of the action. Returns true if the tool should be auto-approved
// based on the user's settings and the path of the action.
shouldAutoApproveToolWithPath(blockname: ToolUseName, autoApproveActionpath: string | undefined): boolean {
let isLocalRead: boolean = false
if (autoApproveActionpath) {
const absolutePath = path.resolve(cwd, autoApproveActionpath)
isLocalRead = absolutePath.startsWith(cwd)
} else {
// If we do not get a path for some reason, default to a (safer) false return
isLocalRead = false
}
// Get auto-approve settings for local and external edits
const autoApproveResult = this.shouldAutoApproveTool(blockname)
const [autoApproveLocal, autoApproveExternal] = Array.isArray(autoApproveResult)
? autoApproveResult
: [autoApproveResult, false]
if ((isLocalRead && autoApproveLocal) || (!isLocalRead && autoApproveLocal && autoApproveExternal)) {
return true
} else {
return false
}
}
updateSettings(settings: AutoApprovalSettings): void {
this.autoApprovalSettings = settings
}
}
+1 -1
View File
@@ -200,7 +200,7 @@ export abstract class WebviewProvider {
<link href="${codiconsUri}" rel="stylesheet" />
<link href="${katexCssUri}" rel="stylesheet" />
<meta http-equiv="Content-Security-Policy" content="default-src 'none';
connect-src https://*.posthog.com https://*.cline.bot https://*.firebaseauth.com https://*.firebaseio.com https://*.googleapis.com https://*.firebase.com;
connect-src https://*.posthog.com https://*.firebaseauth.com https://*.firebaseio.com https://*.googleapis.com https://*.firebase.com;
font-src ${this.getCspSource()} data:;
style-src ${this.getCspSource()} 'unsafe-inline';
img-src ${this.getCspSource()} https: data:;
+5 -13
View File
@@ -22,11 +22,7 @@ import { WebviewProviderType as WebviewProviderTypeEnum } from "@shared/proto/ui
import { WebviewProviderType } from "./shared/webview/types"
import { sendHistoryButtonClickedEvent } from "./core/controller/ui/subscribeToHistoryButtonClicked"
import { sendAccountButtonClickedEvent } from "./core/controller/ui/subscribeToAccountButtonClicked"
import {
migratePlanActGlobalToWorkspaceStorage,
migrateCustomInstructionsToGlobalRules,
migrateModeFromWorkspaceStorageToControllerState,
} from "./core/storage/state-migrations"
import { migratePlanActGlobalToWorkspaceStorage, migrateCustomInstructionsToGlobalRules } from "./core/storage/state"
import { sendFocusChatInputEvent } from "./core/controller/ui/subscribeToFocusChatInput"
import { FileContextTracker } from "./core/context/context-tracking/FileContextTracker"
@@ -34,7 +30,6 @@ import * as hostProviders from "@hosts/host-providers"
import { vscodeHostBridgeClient } from "@/hosts/vscode/client/host-grpc-client"
import { VscodeWebviewProvider } from "./core/webview/VscodeWebviewProvider"
import { ExtensionContext } from "vscode"
import { writeTextToClipboard, readTextFromClipboard } from "@/utils/env"
/*
Built using https://github.com/microsoft/vscode-webview-ui-toolkit
@@ -65,9 +60,6 @@ export async function activate(context: vscode.ExtensionContext) {
// Migrate custom instructions to global Cline rules (one-time cleanup)
await migrateCustomInstructionsToGlobalRules(context)
// Migrate mode from workspace storage to controller state (one-time cleanup)
await migrateModeFromWorkspaceStorageToControllerState(context)
// Clean up orphaned file context warnings (startup cleanup)
await FileContextTracker.cleanupOrphanedWarnings(context)
@@ -370,17 +362,17 @@ export async function activate(context: vscode.ExtensionContext) {
}
// Save current clipboard content
const tempCopyBuffer = await readTextFromClipboard()
const tempCopyBuffer = await vscode.env.clipboard.readText()
try {
// Copy the *existing* terminal selection (without selecting all)
await vscode.commands.executeCommand("workbench.action.terminal.copySelection")
// Get copied content
let terminalContents = (await readTextFromClipboard()).trim()
let terminalContents = (await vscode.env.clipboard.readText()).trim()
// Restore original clipboard content
await writeTextToClipboard(tempCopyBuffer)
await vscode.env.clipboard.writeText(tempCopyBuffer)
if (!terminalContents) {
// No terminal content was copied (either nothing selected or some error)
@@ -406,7 +398,7 @@ export async function activate(context: vscode.ExtensionContext) {
await visibleWebview?.controller.addSelectedTerminalOutputToChat(terminalContents, terminal.name)
} catch (error) {
// Ensure clipboard is restored even if an error occurs
await writeTextToClipboard(tempCopyBuffer)
await vscode.env.clipboard.writeText(tempCopyBuffer)
console.error("Error getting terminal contents:", error)
vscode.window.showErrorMessage("Failed to get terminal contents")
}
+1 -8
View File
@@ -1,9 +1,4 @@
import {
UriServiceClientInterface,
WatchServiceClientInterface,
WorkspaceServiceClientInterface,
EnvServiceClientInterface,
} from "@generated/hosts/host-bridge-client-types"
import { UriServiceClientInterface, WatchServiceClientInterface } from "@generated/hosts/host-bridge-client-types"
/**
* Interface for host bridge client providers
@@ -11,8 +6,6 @@ import {
export interface HostBridgeClientProvider {
uriServiceClient: UriServiceClientInterface
watchServiceClient: WatchServiceClientInterface
workspaceClient: WorkspaceServiceClientInterface
envClient: EnvServiceClientInterface
}
/**
@@ -5,6 +5,4 @@ import * as host from "@shared/proto/index.host"
export const vscodeHostBridgeClient: HostBridgeClientProvider = {
uriServiceClient: createGrpcClient(host.UriServiceDefinition),
watchServiceClient: createGrpcClient(host.WatchServiceDefinition),
workspaceClient: createGrpcClient(host.WorkspaceServiceDefinition),
envClient: createGrpcClient(host.EnvServiceDefinition),
}
-7
View File
@@ -1,7 +0,0 @@
import { EmptyRequest, String } from "@/shared/proto/common"
import * as vscode from "vscode"
export async function clipboardReadText(_: EmptyRequest): Promise<String> {
const text = await vscode.env.clipboard.readText()
return String.create({ value: text })
}
-7
View File
@@ -1,7 +0,0 @@
import { StringRequest, Empty } from "@/shared/proto/common"
import * as vscode from "vscode"
export async function clipboardWriteText(request: StringRequest): Promise<Empty> {
await vscode.env.clipboard.writeText(request.value)
return Empty.create({})
}
@@ -1,6 +0,0 @@
import { GetWorkspacePathsRequest, GetWorkspacePathsResponse } from "@/shared/proto/index.host"
import * as vscode from "vscode"
export async function getWorkspacePaths(_: GetWorkspacePathsRequest): Promise<GetWorkspacePathsResponse> {
const paths = vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath) ?? []
return GetWorkspacePathsResponse.create({ paths: paths })
}
@@ -1,33 +0,0 @@
import type { Anthropic } from "@anthropic-ai/sdk"
/**
* Filters out image blocks from messages since Claude Code doesn't support images.
* Replaces image blocks with text placeholders similar to how VSCode LM provider handles it.
*/
export function filterMessagesForClaudeCode(messages: Anthropic.Messages.MessageParam[]): Anthropic.Messages.MessageParam[] {
return messages.map((message) => {
// Handle simple string messages
if (typeof message.content === "string") {
return message
}
// Handle complex message structures
const filteredContent = message.content.map((block) => {
if (block.type === "image") {
// Replace image blocks with text placeholders
const sourceType = block.source?.type || "unknown"
const mediaType = block.source?.media_type || "unknown"
return {
type: "text" as const,
text: `[Image (${sourceType}): ${mediaType} not supported by Claude Code]`,
}
}
return block
})
return {
...message,
content: filteredContent,
}
})
}
+13 -149
View File
@@ -1,124 +1,31 @@
import * as vscode from "vscode"
import type Anthropic from "@anthropic-ai/sdk"
import Anthropic from "@anthropic-ai/sdk"
import { execa } from "execa"
import { ClaudeCodeMessage } from "./types"
import readline from "readline"
const cwd = vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath).at(0)
type ClaudeCodeOptions = {
export function runClaudeCode({
systemPrompt,
messages,
path,
modelId,
}: {
systemPrompt: string
messages: Anthropic.Messages.MessageParam[]
path?: string
modelId?: string
}
type ProcessState = {
partialData: string | null
error: Error | null
stderrLogs: string
exitCode: number | null
}
export async function* runClaudeCode(options: ClaudeCodeOptions): AsyncGenerator<ClaudeCodeMessage | string> {
const process = runProcess(options)
const rl = readline.createInterface({
input: process.stdout,
})
try {
const processState: ProcessState = {
error: null,
stderrLogs: "",
exitCode: null,
partialData: null,
}
process.stderr.on("data", (data) => {
processState.stderrLogs += data.toString()
})
process.on("close", (code) => {
processState.exitCode = code
})
process.on("error", (err) => {
processState.error = err
})
for await (const line of rl) {
if (processState.error) {
throw processState.error
}
if (line.trim()) {
const chunk = parseChunk(line, processState)
if (!chunk) {
continue
}
yield chunk
}
}
// We rely on the assistant message. If the output was truncated, it's better having a poorly formatted message
// from which to extract something, than throwing an error/showing the model didn't return any messages.
if (processState.partialData && processState.partialData.startsWith(`{"type":"assistant"`)) {
yield processState.partialData
}
const { exitCode } = await process
if (exitCode !== null && exitCode !== 0) {
const errorOutput = processState.error?.message || processState.stderrLogs?.trim()
throw new Error(
`Claude Code process exited with code ${exitCode}.${errorOutput ? ` Error output: ${errorOutput}` : ""}`,
)
}
} finally {
rl.close()
if (!process.killed) {
process.kill()
}
}
}
// We want the model to use our custom tool format instead of built-in tools.
// Disabling built-in tools prevents tool-only responses and ensures text output.
const claudeCodeTools = [
"Task",
"Bash",
"Glob",
"Grep",
"LS",
"exit_plan_mode",
"Read",
"Edit",
"MultiEdit",
"Write",
"NotebookRead",
"NotebookEdit",
"WebFetch",
"TodoRead",
"TodoWrite",
"WebSearch",
].join(",")
const CLAUDE_CODE_TIMEOUT = 600000 // 10 minutes
function runProcess({ systemPrompt, messages, path, modelId }: ClaudeCodeOptions) {
}) {
const claudePath = path || "claude"
// TODO: Is it worh using sessions? Where do we store the session ID?
const args = [
"-p",
JSON.stringify(messages),
"--system-prompt",
systemPrompt,
"--verbose",
"--output-format",
"stream-json",
"--disallowedTools",
claudeCodeTools,
// Cline will handle recursive calls
"--max-turns",
"1",
@@ -128,54 +35,11 @@ function runProcess({ systemPrompt, messages, path, modelId }: ClaudeCodeOptions
args.push("--model", modelId)
}
const claudeCodeProcess = execa(claudePath, args, {
stdin: "pipe",
return execa(claudePath, args, {
stdin: "ignore",
stdout: "pipe",
stderr: "pipe",
env: {
...process.env,
// The default is 32000. However, I've gotten larger responses, so we increase it unless the user specified it.
CLAUDE_CODE_MAX_OUTPUT_TOKENS: process.env.CLAUDE_CODE_MAX_OUTPUT_TOKENS || "64000",
},
env: process.env,
cwd,
maxBuffer: 1024 * 1024 * 1000,
timeout: CLAUDE_CODE_TIMEOUT,
})
claudeCodeProcess.stdin.write(JSON.stringify(messages))
claudeCodeProcess.stdin.end()
return claudeCodeProcess
}
function parseChunk(data: string, processState: ProcessState) {
if (processState.partialData) {
processState.partialData += data
const chunk = attemptParseChunk(processState.partialData)
if (!chunk) {
return null
}
processState.partialData = null
return chunk
}
const chunk = attemptParseChunk(data)
if (!chunk) {
processState.partialData = data
}
return chunk
}
function attemptParseChunk(data: string): ClaudeCodeMessage | null {
try {
return JSON.parse(data)
} catch (error) {
console.error("Error parsing chunk:", error, data.length)
return null
}
}
+23 -5
View File
@@ -1,17 +1,34 @@
import type { Anthropic } from "@anthropic-ai/sdk"
type InitMessage = {
type: "system"
subtype: "init"
session_id: string
tools: string[]
mcp_servers: string[]
apiKeySource: "none" | "/login managed key" | string
}
type ClaudeCodeContent = {
type: "text"
text: string
}
type AssistantMessage = {
type: "assistant"
message: Anthropic.Messages.Message
message: {
id: string
type: "message"
role: "assistant"
model: string
content: ClaudeCodeContent[]
stop_reason: null
stop_sequence: null
usage: {
input_tokens: number
cache_creation_input_tokens?: number
cache_read_input_tokens?: number
output_tokens: number
service_tier: "standard"
}
}
session_id: string
}
@@ -22,12 +39,13 @@ type ErrorMessage = {
type ResultMessage = {
type: "result"
subtype: "success"
total_cost_usd: number
cost_usd: number
is_error: boolean
duration_ms: number
duration_api_ms: number
num_turns: number
result: string
total_cost: number
session_id: string
}
@@ -1,6 +1,5 @@
import * as vscode from "vscode"
import { getWorkingState } from "@utils/git"
import { writeTextToClipboard } from "@utils/env"
/**
* Formats the git diff into a prompt for the AI
@@ -58,7 +57,7 @@ export function extractCommitMessage(aiResponse: string): string {
* @param message The commit message to copy
*/
export async function copyCommitMessageToClipboard(message: string): Promise<void> {
await writeTextToClipboard(message)
await vscode.env.clipboard.writeText(message)
vscode.window.showInformationMessage("Commit message copied to clipboard")
}
+66
View File
@@ -0,0 +1,66 @@
import * as vscode from "vscode"
import fs from "fs/promises"
import * as path from "path"
import sizeOf from "image-size"
export async function selectImages(): Promise<string[]> {
const options: vscode.OpenDialogOptions = {
canSelectMany: true,
openLabel: "Select",
filters: {
Images: ["png", "jpg", "jpeg", "webp"], // supported by anthropic and openrouter
},
}
const fileUris = await vscode.window.showOpenDialog(options)
if (!fileUris || fileUris.length === 0) {
return []
}
const processedImagePromises = fileUris.map(async (uri) => {
const imagePath = uri.fsPath
let buffer: Buffer
try {
// Read the file into a buffer first
buffer = await fs.readFile(imagePath)
// Convert Node.js Buffer to Uint8Array
const uint8Array = new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength)
const dimensions = sizeOf(uint8Array) // Get dimensions from Uint8Array
if (dimensions.width! > 7500 || dimensions.height! > 7500) {
console.warn(`Image dimensions exceed 7500px, skipping: ${imagePath}`)
vscode.window.showErrorMessage(
`Image too large: ${path.basename(imagePath)} was skipped (dimensions exceed 7500px).`,
)
return null
}
} catch (error) {
console.error(`Error reading file or getting dimensions for ${imagePath}:`, error)
vscode.window.showErrorMessage(`Could not read dimensions for ${path.basename(imagePath)}, skipping.`)
return null
}
// If dimensions are valid, proceed to convert the existing buffer to base64
const base64 = buffer.toString("base64")
const mimeType = getMimeType(imagePath)
return `data:${mimeType};base64,${base64}`
})
const dataUrlsWithNulls = await Promise.all(processedImagePromises)
return dataUrlsWithNulls.filter((url) => url !== null) as string[] // Filter out skipped images
}
function getMimeType(filePath: string): string {
const ext = path.extname(filePath).toLowerCase()
switch (ext) {
case ".png":
return "image/png"
case ".jpeg":
case ".jpg":
return "image/jpeg"
case ".webp":
return "image/webp"
default:
throw new Error(`Unsupported file type: ${ext}`)
}
}
@@ -1,5 +1,4 @@
import * as vscode from "vscode"
import { readTextFromClipboard, writeTextToClipboard } from "@utils/env"
/**
* Gets the contents of the active terminal
@@ -7,7 +6,7 @@ import { readTextFromClipboard, writeTextToClipboard } from "@utils/env"
*/
export async function getLatestTerminalOutput(): Promise<string> {
// Store original clipboard content to restore later
const originalClipboard = await readTextFromClipboard()
const originalClipboard = await vscode.env.clipboard.readText()
try {
// Select terminal content
@@ -20,7 +19,7 @@ export async function getLatestTerminalOutput(): Promise<string> {
await vscode.commands.executeCommand("workbench.action.terminal.clearSelection")
// Get terminal contents from clipboard
let terminalContents = (await readTextFromClipboard()).trim()
let terminalContents = (await vscode.env.clipboard.readText()).trim()
// Check if there's actually a terminal open
if (terminalContents === originalClipboard) {
@@ -41,6 +40,6 @@ export async function getLatestTerminalOutput(): Promise<string> {
return terminalContents
} finally {
// Restore original clipboard content
await writeTextToClipboard(originalClipboard)
await vscode.env.clipboard.writeText(originalClipboard)
}
}
-3
View File
@@ -8,9 +8,6 @@ export interface ChatSettings {
export type PartialChatSettings = Partial<ChatSettings>
// Type for chat settings stored in workspace (excludes in-memory mode)
export type StoredChatSettings = Omit<ChatSettings, "mode">
export const DEFAULT_CHAT_SETTINGS: ChatSettings = {
mode: "act",
preferredLanguage: "English",
+1
View File
@@ -12,6 +12,7 @@ export interface WebviewMessage {
| "fetchMcpMarketplace"
| "searchCommits"
| "telemetrySetting"
| "clearAllTaskHistory"
| "grpc_request"
| "grpc_request_cancel"
+9 -164
View File
@@ -10,7 +10,6 @@ export type ApiProvider =
| "ollama"
| "lmstudio"
| "gemini"
| "gemini-cli"
| "openai-native"
| "requesty"
| "together"
@@ -70,8 +69,6 @@ export interface ApiHandlerOptions {
lmStudioBaseUrl?: string
geminiApiKey?: string
geminiBaseUrl?: string
geminiCliOAuthPath?: string
geminiCliProjectId?: string
openAiNativeApiKey?: string
deepSeekApiKey?: string
requestyApiKey?: string
@@ -233,31 +230,11 @@ export const anthropicModels = {
export type ClaudeCodeModelId = keyof typeof claudeCodeModels
export const claudeCodeDefaultModelId: ClaudeCodeModelId = "claude-sonnet-4-20250514"
export const claudeCodeModels = {
"claude-sonnet-4-20250514": {
...anthropicModels["claude-sonnet-4-20250514"],
supportsImages: false,
supportsPromptCache: false,
},
"claude-opus-4-20250514": {
...anthropicModels["claude-opus-4-20250514"],
supportsImages: false,
supportsPromptCache: false,
},
"claude-3-7-sonnet-20250219": {
...anthropicModels["claude-3-7-sonnet-20250219"],
supportsImages: false,
supportsPromptCache: false,
},
"claude-3-5-sonnet-20241022": {
...anthropicModels["claude-3-5-sonnet-20241022"],
supportsImages: false,
supportsPromptCache: false,
},
"claude-3-5-haiku-20241022": {
...anthropicModels["claude-3-5-haiku-20241022"],
supportsImages: false,
supportsPromptCache: false,
},
"claude-sonnet-4-20250514": anthropicModels["claude-sonnet-4-20250514"],
"claude-opus-4-20250514": anthropicModels["claude-opus-4-20250514"],
"claude-3-7-sonnet-20250219": anthropicModels["claude-3-7-sonnet-20250219"],
"claude-3-5-sonnet-20241022": anthropicModels["claude-3-5-sonnet-20241022"],
"claude-3-5-haiku-20241022": anthropicModels["claude-3-5-haiku-20241022"],
} as const satisfies Record<string, ModelInfo>
// AWS Bedrock
@@ -403,7 +380,7 @@ export const bedrockModels = {
// OpenRouter
// https://openrouter.ai/models?order=newest&supported_parameters=tools
export const openRouterDefaultModelId = "anthropic/claude-sonnet-4" // will always exist in openRouterModels
export const openRouterDefaultModelId = "anthropic/claude-3.7-sonnet" // will always exist in openRouterModels
export const openRouterDefaultModelInfo: ModelInfo = {
maxTokens: 8192,
contextWindow: 200_000,
@@ -415,7 +392,7 @@ export const openRouterDefaultModelInfo: ModelInfo = {
cacheWritesPrice: 3.75,
cacheReadsPrice: 0.3,
description:
"Claude Sonnet 4 delivers superior intelligence across coding, agentic search, and AI agent capabilities. It's a powerful choice for agentic coding, and can complete tasks across the entire software development lifecycle—from initial planning to bug fixes, maintenance to large refactors. It offers strong performance in both planning and solving for complex coding tasks, making it an ideal choice to power end-to-end software development processes.\n\nRead more in the [blog post here](https://www.anthropic.com/claude/sonnet)",
"Claude 3.7 Sonnet is an advanced large language model with improved reasoning, coding, and problem-solving capabilities. It introduces a hybrid reasoning approach, allowing users to choose between rapid responses and extended, step-by-step processing for complex tasks. The model demonstrates notable improvements in coding, particularly in front-end development and full-stack updates, and excels in agentic workflows, where it can autonomously navigate multi-step processes. \n\nClaude 3.7 Sonnet maintains performance parity with its predecessor in standard mode while offering an extended reasoning mode for enhanced accuracy in math, coding, and instruction-following tasks.\n\nRead more at the [blog post here](https://www.anthropic.com/news/claude-3-7-sonnet)",
}
// Vertex AI
// https://cloud.google.com/vertex-ai/generative-ai/docs/partner-models/use-claude
@@ -724,7 +701,7 @@ export const openAiModelInfoSaneDefaults: OpenAiCompatibleModelInfo = {
// Gemini
// https://ai.google.dev/gemini-api/docs/models/gemini
export type GeminiModelId = keyof typeof geminiModels
export const geminiDefaultModelId: GeminiModelId = "gemini-2.5-pro"
export const geminiDefaultModelId: GeminiModelId = "gemini-2.0-flash-001"
export const geminiModels = {
"gemini-2.5-pro": {
maxTokens: 65536,
@@ -878,138 +855,6 @@ export const geminiModels = {
},
} as const satisfies Record<string, ModelInfo>
// Gemini CLI (OAuth-based)
export type GeminiCliModelId = keyof typeof geminiCliModels
export const geminiCliDefaultModelId: GeminiCliModelId = "gemini-2.5-flash"
export const geminiCliModels = {
"gemini-2.5-pro": {
maxTokens: 65536,
contextWindow: 1_048_576,
supportsImages: true,
supportsPromptCache: false,
inputPrice: 0, // Free tier via OAuth
outputPrice: 0, // Free tier via OAuth
description: "Google's Gemini 2.5 Pro model via OAuth (free tier)",
},
"gemini-2.5-flash": {
maxTokens: 65536,
contextWindow: 1_048_576,
supportsImages: true,
supportsPromptCache: false,
inputPrice: 0, // Free tier via OAuth
outputPrice: 0, // Free tier via OAuth
description: "Google's Gemini 2.5 Flash model via OAuth (free tier)",
},
"gemini-2.0-flash-001": {
maxTokens: 8192,
contextWindow: 1_048_576,
supportsImages: true,
supportsPromptCache: false,
inputPrice: 0, // Free tier via OAuth
outputPrice: 0, // Free tier via OAuth
description: "Google's Gemini 2.0 Flash model via OAuth (free tier)",
},
"gemini-2.0-flash-lite-preview-02-05": {
maxTokens: 8192,
contextWindow: 1_048_576,
supportsImages: true,
supportsPromptCache: false,
inputPrice: 0,
outputPrice: 0,
description: "Google's Gemini 2.0 Flash Lite Preview model via OAuth",
},
"gemini-2.0-pro-exp-02-05": {
maxTokens: 8192,
contextWindow: 2_097_152,
supportsImages: true,
supportsPromptCache: false,
inputPrice: 0,
outputPrice: 0,
description: "Google's Gemini 2.0 Pro Experimental model via OAuth",
},
"gemini-2.0-flash-thinking-exp-01-21": {
maxTokens: 65_536,
contextWindow: 1_048_576,
supportsImages: true,
supportsPromptCache: false,
inputPrice: 0,
outputPrice: 0,
description: "Google's Gemini 2.0 Flash Thinking Experimental model via OAuth",
},
"gemini-2.0-flash-thinking-exp-1219": {
maxTokens: 8192,
contextWindow: 32_767,
supportsImages: true,
supportsPromptCache: false,
inputPrice: 0,
outputPrice: 0,
description: "Google's Gemini 2.0 Flash Thinking Experimental (1219) model via OAuth",
},
"gemini-2.0-flash-exp": {
maxTokens: 8192,
contextWindow: 1_048_576,
supportsImages: true,
supportsPromptCache: false,
inputPrice: 0,
outputPrice: 0,
description: "Google's Gemini 2.0 Flash Experimental model via OAuth",
},
"gemini-1.5-flash-002": {
maxTokens: 8192,
contextWindow: 1_048_576,
supportsImages: true,
supportsPromptCache: false,
inputPrice: 0, // Free tier via OAuth
outputPrice: 0, // Free tier via OAuth
description: "Google's Gemini 1.5 Flash 002 model via OAuth (free tier)",
},
"gemini-1.5-flash-exp-0827": {
maxTokens: 8192,
contextWindow: 1_048_576,
supportsImages: true,
supportsPromptCache: false,
inputPrice: 0,
outputPrice: 0,
description: "Google's Gemini 1.5 Flash Experimental (0827) model via OAuth",
},
"gemini-1.5-flash-8b-exp-0827": {
maxTokens: 8192,
contextWindow: 1_048_576,
supportsImages: true,
supportsPromptCache: false,
inputPrice: 0,
outputPrice: 0,
description: "Google's Gemini 1.5 Flash 8B Experimental model via OAuth",
},
"gemini-1.5-pro-002": {
maxTokens: 8192,
contextWindow: 2_097_152,
supportsImages: true,
supportsPromptCache: false,
inputPrice: 0,
outputPrice: 0,
description: "Google's Gemini 1.5 Pro 002 model via OAuth",
},
"gemini-1.5-pro-exp-0827": {
maxTokens: 8192,
contextWindow: 2_097_152,
supportsImages: true,
supportsPromptCache: false,
inputPrice: 0,
outputPrice: 0,
description: "Google's Gemini 1.5 Pro Experimental model via OAuth",
},
"gemini-exp-1206": {
maxTokens: 8192,
contextWindow: 2_097_152,
supportsImages: true,
supportsPromptCache: false,
inputPrice: 0,
outputPrice: 0,
description: "Google's Gemini Experimental (1206) model via OAuth",
},
} as const satisfies Record<string, ModelInfo>
// OpenAI Native
// https://openai.com/api/pricing/
export type OpenAiNativeModelId = keyof typeof openAiNativeModels
@@ -2011,7 +1856,7 @@ export const liteLlmModelInfoSaneDefaults: LiteLLMModelInfo = {
// AskSage Models
// https://docs.asksage.ai/
export type AskSageModelId = keyof typeof askSageModels
export const askSageDefaultModelId: AskSageModelId = "claude-4-sonnet"
export const askSageDefaultModelId: AskSageModelId = "claude-35-sonnet"
export const askSageDefaultURL: string = "https://api.asksage.ai/server"
export const askSageModels = {
"gpt-4o": {
@@ -202,8 +202,6 @@ function convertApiProviderToProto(provider: string | undefined): ProtoApiProvid
return ProtoApiProvider.LMSTUDIO
case "gemini":
return ProtoApiProvider.GEMINI
case "gemini-cli":
return ProtoApiProvider.GEMINI_CLI
case "openai-native":
return ProtoApiProvider.OPENAI_NATIVE
case "requesty":
@@ -264,8 +262,6 @@ function convertProtoToApiProvider(provider: ProtoApiProvider): ApiProvider {
return "lmstudio"
case ProtoApiProvider.GEMINI:
return "gemini"
case ProtoApiProvider.GEMINI_CLI:
return "gemini-cli"
case ProtoApiProvider.OPENAI_NATIVE:
return "openai-native"
case ProtoApiProvider.REQUESTY:
@@ -383,8 +379,6 @@ export function convertApiConfigurationToProto(config: ApiConfiguration): ProtoA
sapAiCoreTokenUrl: config.sapAiCoreTokenUrl,
sapAiCoreBaseUrl: config.sapAiCoreBaseUrl,
claudeCodePath: config.claudeCodePath,
geminiCliOauthPath: config.geminiCliOAuthPath,
geminiCliProjectId: config.geminiCliProjectId,
}
}
@@ -464,7 +458,5 @@ export function convertProtoToApiConfiguration(protoConfig: ProtoApiConfiguratio
sapAiCoreTokenUrl: protoConfig.sapAiCoreTokenUrl,
sapAiCoreBaseUrl: protoConfig.sapAiCoreBaseUrl,
claudeCodePath: protoConfig.claudeCodePath,
geminiCliOAuthPath: protoConfig.geminiCliOauthPath,
geminiCliProjectId: protoConfig.geminiCliProjectId,
}
}
@@ -1,34 +0,0 @@
import { TelemetrySettingEnum } from "../../proto/state"
import { TelemetrySetting } from "../../TelemetrySetting"
/**
* Converts a domain TelemetrySetting string to a proto TelemetrySettingEnum
*/
export function convertDomainTelemetrySettingToProto(setting: TelemetrySetting): TelemetrySettingEnum {
switch (setting) {
case "unset":
return TelemetrySettingEnum.UNSET
case "enabled":
return TelemetrySettingEnum.ENABLED
case "disabled":
return TelemetrySettingEnum.DISABLED
default:
return TelemetrySettingEnum.UNSET
}
}
/**
* Converts a proto TelemetrySettingEnum to a domain TelemetrySetting string
*/
export function convertProtoTelemetrySettingToDomain(setting: TelemetrySettingEnum): TelemetrySetting {
switch (setting) {
case TelemetrySettingEnum.UNSET:
return "unset"
case TelemetrySettingEnum.ENABLED:
return "enabled"
case TelemetrySettingEnum.DISABLED:
return "disabled"
default:
return "unset"
}
}
+3 -5
View File
@@ -10,9 +10,7 @@ https://github.com/KumarVariable/vscode-extension-sidebar-html/blob/master/src/c
*/
export class ExternalWebviewProvider extends WebviewProvider {
// This hostname cannot be changed without updating the external webview handler.
private RESOURCE_HOSTNAME: string = "internal.resources"
private RESOURCE_AUTHORITY: string = "file.resources"
constructor(context: vscode.ExtensionContext, outputChannel: vscode.OutputChannel, providerType: WebviewProviderType) {
super(context, outputChannel, providerType)
}
@@ -21,10 +19,10 @@ export class ExternalWebviewProvider extends WebviewProvider {
if (uri.scheme !== "file") {
return uri
}
return URI.from({ scheme: "https", authority: this.RESOURCE_HOSTNAME, path: uri.fsPath })
return URI.from({ scheme: "https", authority: this.RESOURCE_AUTHORITY, path: uri.fsPath })
}
override getCspSource() {
return `'self' https://${this.RESOURCE_HOSTNAME}`
return "csp-source"
}
override postMessageToWebview(message: ExtensionMessage) {
console.log(`postMessageToWebview: ${message}`)
+3 -17
View File
@@ -1,28 +1,16 @@
import { Channel, createChannel } from "nice-grpc"
import {
UriServiceClientImpl,
WatchServiceClientImpl,
WorkspaceServiceClientImpl,
EnvServiceClientImpl,
} from "@generated/standalone/host-bridge-clients"
import {
UriServiceClientInterface,
WatchServiceClientInterface,
WorkspaceServiceClientInterface,
EnvServiceClientInterface,
} from "@generated/hosts/host-bridge-client-types"
import { UriServiceClientImpl, WatchServiceClientImpl } from "@generated/standalone/host-bridge-clients"
import { UriServiceClientInterface, WatchServiceClientInterface } from "@generated/hosts/host-bridge-client-types"
import { HostBridgeClientProvider } from "@/hosts/host-provider-types"
/**
* Manager to hold the gRPC clients for the host bridge. The clients should be re-used to avoid
* Singleton class to hold the gRPC clients for the host bridge. The clients should be re-used to avoid
* creating a new TCP connection every time a rpc is made.
*/
export class ExternalHostBridgeClientManager implements HostBridgeClientProvider {
private channel: Channel
uriServiceClient: UriServiceClientInterface
watchServiceClient: WatchServiceClientInterface
workspaceClient: WorkspaceServiceClientInterface
envClient: EnvServiceClientInterface
constructor() {
const address = process.env.HOST_BRIDGE_ADDRESS || "localhost:50052"
@@ -30,8 +18,6 @@ export class ExternalHostBridgeClientManager implements HostBridgeClientProvider
this.uriServiceClient = new UriServiceClientImpl(this.channel)
this.watchServiceClient = new WatchServiceClientImpl(this.channel)
this.workspaceClient = new WorkspaceServiceClientImpl(this.channel)
this.envClient = new EnvServiceClientImpl(this.channel)
}
public close(): void {
+3 -18
View File
@@ -20,10 +20,6 @@ async function main() {
hostProviders.initializeHostProviders(createWebview, new ExternalHostBridgeClientManager())
activate(extensionContext)
const controller = new Controller(extensionContext, outputChannel, postMessage, uuidv4())
startProtobusService(controller)
}
function startProtobusService(controller: Controller) {
const server = new grpc.Server()
// Set up health check.
@@ -33,15 +29,12 @@ function startProtobusService(controller: Controller) {
// Add all the handlers for the ProtoBus services to the server.
addProtobusServices(server, controller, wrapHandler, wrapStreamingResponseHandler)
// Create reflection service with protobus service names
const packageDefinition = getPackageDefinition()
const reflection = new ReflectionService(packageDefinition, {
services: getProtobusServiceNames(packageDefinition),
})
// Set up reflection.
const reflection = new ReflectionService(getPackageDefinition())
reflection.addToServer(server)
// Start the server.
const host = process.env.PROTOBUS_ADDRESS || "127.0.0.1:50051"
const host = "127.0.0.1:50051"
server.bindAsync(host, grpc.ServerCredentials.createInsecure(), (err) => {
if (err) {
log(`Error: Failed to bind to ${host}, port may be unavailable. ${err.message}`)
@@ -52,14 +45,6 @@ function startProtobusService(controller: Controller) {
})
}
function getProtobusServiceNames(packageDefinition: { [x: string]: any }): string[] {
// Filter service names to only include cline services
const protobusServiceNames = Object.keys(packageDefinition).filter(
(name) => name.startsWith("cline.") || name.startsWith("grpc.health"),
)
return protobusServiceNames
}
const createWebview = () => {
return new ExternalWebviewProvider(extensionContext, outputChannel, WebviewProviderType.SIDEBAR)
}
+2 -2
View File
@@ -11,9 +11,9 @@ const log = (...args: unknown[]) => {
function getPackageDefinition() {
// Load service definitions.
const descriptorSet = fs.readFileSync("proto/descriptor_set.pb")
const descriptorDefs = protoLoader.loadFileDescriptorSetFromBuffer(descriptorSet)
const clineDef = protoLoader.loadFileDescriptorSetFromBuffer(descriptorSet)
const healthDef = protoLoader.loadSync(health.protoPath)
const packageDefinition = { ...descriptorDefs, ...healthDef }
const packageDefinition = { ...clineDef, ...healthDef }
return packageDefinition
}
-32
View File
@@ -1,32 +0,0 @@
import { getHostBridgeProvider } from "@/hosts/host-providers"
import { StringRequest, EmptyRequest } from "@/shared/proto/common"
/**
* Writes text to the system clipboard
* @param text The text to write to the clipboard
* @returns Promise that resolves when the operation is complete
* @throws Error if the operation fails
*/
export async function writeTextToClipboard(text: string): Promise<void> {
try {
await getHostBridgeProvider().envClient.clipboardWriteText(StringRequest.create({ value: text }))
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error)
throw new Error(`Failed to write to clipboard: ${errorMessage}`)
}
}
/**
* Reads text from the system clipboard
* @returns Promise that resolves to the clipboard text
* @throws Error if the operation fails
*/
export async function readTextFromClipboard(): Promise<string> {
try {
const response = await getHostBridgeProvider().envClient.clipboardReadText(EmptyRequest.create({}))
return response.value
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error)
throw new Error(`Failed to read from clipboard: ${errorMessage}`)
}
}
+2 -3
View File
@@ -17,7 +17,6 @@ import * as vscode from "vscode"
import * as cp from "child_process"
import * as os from "os"
import * as util from "util"
import { writeTextToClipboard } from "@/utils/env"
/**
* Creates a properly encoded GitHub issue URL.
@@ -82,7 +81,7 @@ export async function openUrlInBrowser(url: string): Promise<void> {
// Always copy to clipboard as a fallback
try {
await writeTextToClipboard(url)
await vscode.env.clipboard.writeText(url)
console.log("URL copied to clipboard as backup")
} catch (error) {
console.error(`Failed to copy URL to clipboard: ${error}`)
@@ -160,7 +159,7 @@ export async function openUrlInBrowser(url: string): Promise<void> {
)
.then((selection) => {
if (selection === "Copy URL Again") {
writeTextToClipboard(url)
vscode.env.clipboard.writeText(url)
}
})
}
+1 -9
View File
@@ -3,13 +3,5 @@ import { ApiHandler } from "@api/index"
export function isClaude4ModelFamily(api: ApiHandler): boolean {
const model = api.getModel()
const modelId = model.id
return (
modelId.includes("sonnet-4") || modelId.includes("opus-4") || modelId.includes("4-sonnet") || modelId.includes("4-opus")
)
}
export function isGemini2dot5ModelFamily(api: ApiHandler): boolean {
const model = api.getModel()
const modelId = model.id
return modelId.includes("gemini-2.5")
return modelId.includes("sonnet-4") || modelId.includes("opus-4")
}
@@ -1,514 +0,0 @@
const { spawn } = require("child_process")
const { EventEmitter } = require("events")
const path = require("path")
const os = require("os")
// Enhanced terminal management for standalone Cline
// This replaces VSCode's terminal integration with real subprocess management
class StandaloneTerminalProcess extends EventEmitter {
constructor() {
super()
this.waitForShellIntegration = false // We don't need to wait since we control the process
this.isListening = true
this.buffer = ""
this.fullOutput = ""
this.lastRetrievedIndex = 0
this.isHot = false
this.hotTimer = null
this.childProcess = null
this.exitCode = null
this.isCompleted = false
}
async run(terminal, command) {
console.log(`[StandaloneTerminal] Running command: ${command}`)
// Get shell and working directory from terminal
const shell = terminal._shellPath || this.getDefaultShell()
const cwd = terminal._cwd || process.cwd()
// Prepare command for execution
const shellArgs = this.getShellArgs(shell, command)
try {
// Spawn the process
this.childProcess = spawn(shell, shellArgs, {
cwd: cwd,
stdio: ["pipe", "pipe", "pipe"],
env: { ...process.env, TERM: "xterm-256color" },
})
// Track process state
let didEmitEmptyLine = false
// Handle stdout
this.childProcess.stdout.on("data", (data) => {
const output = data.toString()
this.handleOutput(output, didEmitEmptyLine)
if (!didEmitEmptyLine && output) {
this.emit("line", "") // Signal start of output
didEmitEmptyLine = true
}
})
// Handle stderr
this.childProcess.stderr.on("data", (data) => {
const output = data.toString()
this.handleOutput(output, didEmitEmptyLine)
if (!didEmitEmptyLine && output) {
this.emit("line", "")
didEmitEmptyLine = true
}
})
// Handle process completion
this.childProcess.on("close", (code, signal) => {
console.log(`[StandaloneTerminal] Process closed with code ${code}, signal ${signal}`)
this.exitCode = code
this.isCompleted = true
this.emitRemainingBuffer()
// Clear hot timer
if (this.hotTimer) {
clearTimeout(this.hotTimer)
this.isHot = false
}
this.emit("completed")
this.emit("continue")
})
// Handle process errors
this.childProcess.on("error", (error) => {
console.error(`[StandaloneTerminal] Process error:`, error)
this.emit("error", error)
})
// Update terminal's process reference
terminal._process = this.childProcess
terminal._processId = this.childProcess.pid
} catch (error) {
console.error(`[StandaloneTerminal] Failed to spawn process:`, error)
this.emit("error", error)
}
}
handleOutput(data, didEmitEmptyLine) {
// Set process as hot (actively outputting)
this.isHot = true
if (this.hotTimer) {
clearTimeout(this.hotTimer)
}
// Check for compilation markers to adjust hot timeout
const compilingMarkers = ["compiling", "building", "bundling", "transpiling", "generating", "starting"]
const markerNullifiers = [
"compiled",
"success",
"finish",
"complete",
"succeed",
"done",
"end",
"stop",
"exit",
"terminate",
"error",
"fail",
]
const isCompiling =
compilingMarkers.some((marker) => data.toLowerCase().includes(marker.toLowerCase())) &&
!markerNullifiers.some((nullifier) => data.toLowerCase().includes(nullifier.toLowerCase()))
const hotTimeout = isCompiling ? 15000 : 2000
this.hotTimer = setTimeout(() => {
this.isHot = false
}, hotTimeout)
// Store full output
this.fullOutput += data
if (this.isListening) {
this.emitLines(data)
this.lastRetrievedIndex = this.fullOutput.length - this.buffer.length
}
}
emitLines(chunk) {
this.buffer += chunk
let lineEndIndex
while ((lineEndIndex = this.buffer.indexOf("\n")) !== -1) {
let line = this.buffer.slice(0, lineEndIndex).trimEnd()
this.emit("line", line)
this.buffer = this.buffer.slice(lineEndIndex + 1)
}
}
emitRemainingBuffer() {
if (this.buffer && this.isListening) {
const remainingBuffer = this.removeLastLineArtifacts(this.buffer)
if (remainingBuffer) {
this.emit("line", remainingBuffer)
}
this.buffer = ""
this.lastRetrievedIndex = this.fullOutput.length
}
}
continue() {
this.emitRemainingBuffer()
this.isListening = false
this.removeAllListeners("line")
this.emit("continue")
}
getUnretrievedOutput() {
const unretrieved = this.fullOutput.slice(this.lastRetrievedIndex)
this.lastRetrievedIndex = this.fullOutput.length
return this.removeLastLineArtifacts(unretrieved)
}
removeLastLineArtifacts(output) {
const lines = output.trimEnd().split("\n")
if (lines.length > 0) {
const lastLine = lines[lines.length - 1]
lines[lines.length - 1] = lastLine.replace(/[%$#>]\s*$/, "")
}
return lines.join("\n").trimEnd()
}
getDefaultShell() {
if (process.platform === "win32") {
return process.env.COMSPEC || "cmd.exe"
} else {
return process.env.SHELL || "/bin/bash"
}
}
getShellArgs(shell, command) {
if (process.platform === "win32") {
if (shell.toLowerCase().includes("powershell") || shell.toLowerCase().includes("pwsh")) {
return ["-Command", command]
} else {
return ["/c", command]
}
} else {
return ["-c", command]
}
}
// Terminate the process if it's still running
terminate() {
if (this.childProcess && !this.isCompleted) {
console.log(`[StandaloneTerminal] Terminating process ${this.childProcess.pid}`)
this.childProcess.kill("SIGTERM")
// Force kill after timeout
setTimeout(() => {
if (!this.isCompleted) {
console.log(`[StandaloneTerminal] Force killing process ${this.childProcess.pid}`)
this.childProcess.kill("SIGKILL")
}
}, 5000)
}
}
}
class StandaloneTerminal {
constructor(options = {}) {
this.name = options.name || `Terminal ${Math.floor(Math.random() * 10000)}`
this.processId = Promise.resolve(Math.floor(Math.random() * 100000))
this.creationOptions = options
this.exitStatus = undefined
this.state = { isInteractedWith: false }
this._cwd = options.cwd || process.cwd()
this._shellPath = options.shellPath
this._process = null
this._processId = null
// Mock shell integration for compatibility
this.shellIntegration = {
cwd: { fsPath: this._cwd },
executeCommand: (command) => {
// Return a mock execution object that the TerminalProcess expects
return {
read: async function* () {
// This will be handled by our StandaloneTerminalProcess
yield ""
},
}
},
}
console.log(`[StandaloneTerminal] Created terminal: ${this.name} in ${this._cwd}`)
}
sendText(text, addNewLine = true) {
console.log(`[StandaloneTerminal] sendText: ${text}`)
// If we have an active process, send input to it
if (this._process && !this._process.killed) {
try {
this._process.stdin.write(text + (addNewLine ? "\n" : ""))
} catch (error) {
console.error(`[StandaloneTerminal] Error sending text to process:`, error)
}
} else {
// For compatibility with old behavior, we could spawn a new process
console.log(`[StandaloneTerminal] No active process to send text to`)
}
}
show() {
console.log(`[StandaloneTerminal] show: ${this.name}`)
this.state.isInteractedWith = true
}
hide() {
console.log(`[StandaloneTerminal] hide: ${this.name}`)
}
dispose() {
console.log(`[StandaloneTerminal] dispose: ${this.name}`)
if (this._process && !this._process.killed) {
this._process.kill("SIGTERM")
}
}
}
// Terminal registry for tracking terminals
class StandaloneTerminalRegistry {
constructor() {
this.terminals = new Map()
this.nextId = 1
}
createTerminal(options = {}) {
const terminal = new StandaloneTerminal(options)
const id = this.nextId++
const terminalInfo = {
id: id,
terminal: terminal,
busy: false,
lastCommand: "",
shellPath: options.shellPath,
lastActive: Date.now(),
pendingCwdChange: undefined,
cwdResolved: undefined,
}
this.terminals.set(id, terminalInfo)
console.log(`[StandaloneTerminalRegistry] Created terminal ${id}`)
return terminalInfo
}
getTerminal(id) {
return this.terminals.get(id)
}
getAllTerminals() {
return Array.from(this.terminals.values())
}
removeTerminal(id) {
const terminalInfo = this.terminals.get(id)
if (terminalInfo) {
terminalInfo.terminal.dispose()
this.terminals.delete(id)
console.log(`[StandaloneTerminalRegistry] Removed terminal ${id}`)
}
}
updateTerminal(id, updates) {
const terminalInfo = this.terminals.get(id)
if (terminalInfo) {
Object.assign(terminalInfo, updates)
}
}
}
// Enhanced terminal manager
class StandaloneTerminalManager {
constructor() {
this.registry = new StandaloneTerminalRegistry()
this.processes = new Map()
this.terminalIds = new Set()
this.shellIntegrationTimeout = 4000
this.terminalReuseEnabled = true
this.terminalOutputLineLimit = 500
this.defaultTerminalProfile = "default"
}
runCommand(terminalInfo, command) {
console.log(`[StandaloneTerminalManager] Running command on terminal ${terminalInfo.id}: ${command}`)
terminalInfo.busy = true
terminalInfo.lastCommand = command
const process = new StandaloneTerminalProcess()
this.processes.set(terminalInfo.id, process)
process.once("completed", () => {
terminalInfo.busy = false
console.log(`[StandaloneTerminalManager] Command completed on terminal ${terminalInfo.id}`)
})
process.once("error", (error) => {
terminalInfo.busy = false
console.error(`[StandaloneTerminalManager] Command error on terminal ${terminalInfo.id}:`, error)
})
// Create promise for the process
const promise = new Promise((resolve, reject) => {
process.once("continue", () => resolve())
process.once("error", (error) => reject(error))
})
// Run the command immediately (no shell integration wait needed)
process.run(terminalInfo.terminal, command)
// Return merged promise/process object
return this.mergePromise(process, promise)
}
async getOrCreateTerminal(cwd) {
const terminals = this.registry.getAllTerminals()
// Find available terminal with matching CWD
const matchingTerminal = terminals.find((t) => {
if (t.busy) return false
return t.terminal._cwd === cwd
})
if (matchingTerminal) {
this.terminalIds.add(matchingTerminal.id)
console.log(`[StandaloneTerminalManager] Reusing terminal ${matchingTerminal.id}`)
return matchingTerminal
}
// Find any available terminal if reuse is enabled
if (this.terminalReuseEnabled) {
const availableTerminal = terminals.find((t) => !t.busy)
if (availableTerminal) {
// Change directory
await this.runCommand(availableTerminal, `cd "${cwd}"`)
availableTerminal.terminal._cwd = cwd
availableTerminal.terminal.shellIntegration.cwd.fsPath = cwd
this.terminalIds.add(availableTerminal.id)
console.log(`[StandaloneTerminalManager] Reused terminal ${availableTerminal.id} with cd`)
return availableTerminal
}
}
// Create new terminal
const newTerminalInfo = this.registry.createTerminal({
cwd: cwd,
name: `Cline Terminal ${this.registry.nextId}`,
})
this.terminalIds.add(newTerminalInfo.id)
console.log(`[StandaloneTerminalManager] Created new terminal ${newTerminalInfo.id}`)
return newTerminalInfo
}
getTerminals(busy) {
return Array.from(this.terminalIds)
.map((id) => this.registry.getTerminal(id))
.filter((t) => t && t.busy === busy)
.map((t) => ({ id: t.id, lastCommand: t.lastCommand }))
}
getUnretrievedOutput(terminalId) {
if (!this.terminalIds.has(terminalId)) {
return ""
}
const process = this.processes.get(terminalId)
return process ? process.getUnretrievedOutput() : ""
}
isProcessHot(terminalId) {
const process = this.processes.get(terminalId)
return process ? process.isHot : false
}
processOutput(outputLines) {
if (outputLines.length > this.terminalOutputLineLimit) {
const halfLimit = Math.floor(this.terminalOutputLineLimit / 2)
const start = outputLines.slice(0, halfLimit)
const end = outputLines.slice(outputLines.length - halfLimit)
return `${start.join("\n")}\n... (output truncated) ...\n${end.join("\n")}`.trim()
}
return outputLines.join("\n").trim()
}
disposeAll() {
// Terminate all processes
for (const [terminalId, process] of this.processes) {
if (process && process.terminate) {
process.terminate()
}
}
// Clear all tracking
this.terminalIds.clear()
this.processes.clear()
// Dispose all terminals
for (const terminalInfo of this.registry.getAllTerminals()) {
terminalInfo.terminal.dispose()
}
console.log(`[StandaloneTerminalManager] Disposed all terminals`)
}
// Set shell integration timeout (compatibility method)
setShellIntegrationTimeout(timeout) {
this.shellIntegrationTimeout = timeout
console.log(`[StandaloneTerminalManager] Set shell integration timeout to ${timeout}ms`)
}
// Set terminal reuse enabled (compatibility method)
setTerminalReuseEnabled(enabled) {
this.terminalReuseEnabled = enabled
console.log(`[StandaloneTerminalManager] Set terminal reuse enabled to ${enabled}`)
}
// Set terminal output line limit (compatibility method)
setTerminalOutputLineLimit(limit) {
this.terminalOutputLineLimit = limit
console.log(`[StandaloneTerminalManager] Set terminal output line limit to ${limit}`)
}
// Set default terminal profile (compatibility method)
setDefaultTerminalProfile(profile) {
this.defaultTerminalProfile = profile
console.log(`[StandaloneTerminalManager] Set default terminal profile to ${profile}`)
}
// Helper to merge process and promise (similar to execa)
mergePromise(process, promise) {
const nativePromisePrototype = (async () => {})().constructor.prototype
const descriptors = ["then", "catch", "finally"].map((property) => [
property,
Reflect.getOwnPropertyDescriptor(nativePromisePrototype, property),
])
for (const [property, descriptor] of descriptors) {
if (descriptor) {
const value = descriptor.value.bind(promise)
Reflect.defineProperty(process, property, { ...descriptor, value })
}
}
return process
}
}
module.exports = {
StandaloneTerminal,
StandaloneTerminalProcess,
StandaloneTerminalRegistry,
StandaloneTerminalManager,
}
+22 -718
View File
@@ -2,19 +2,8 @@ console.log("Loading stub impls...")
const { createStub } = require("./stub-utils")
const open = require("open").default
const fs = require("fs")
const path = require("path")
const { StandaloneTerminalManager } = require("./enhanced-terminal")
// Import the base vscode object from stubs
const vscode = require("./vscode-stubs.js")
// Create global terminal manager instance
const globalTerminalManager = new StandaloneTerminalManager()
// Extend the existing window object from stubs rather than overwriting it
vscode.window = {
...vscode.window, // Keep existing properties from stubs
showInformationMessage: (...args) => {
console.log("Stubbed showInformationMessage:", ...args)
return Promise.resolve(undefined)
@@ -39,210 +28,9 @@ vscode.window = {
console.log("Stubbed showSaveDialog:", options)
return undefined
},
showTextDocument: async (uri, options) => {
console.log("Stubbed showTextDocument:", uri, options)
// Extract file path from URI
let filePath = uri.path || uri.fsPath || uri
if (typeof filePath !== "string") {
filePath = uri.toString()
}
// Remove file:// prefix if present
if (filePath.startsWith("file://")) {
filePath = filePath.substring(7)
}
// Create a function that always reads the current file content
const getCurrentFileContent = async () => {
try {
const content = await fs.promises.readFile(filePath, "utf8")
console.log(`getCurrentFileContent: Read file ${filePath} (${content.length} chars)`)
return content
} catch (error) {
console.log(`getCurrentFileContent: Could not read file ${filePath}:`, error.message)
return ""
}
}
// Try to read the initial file content
let fileContent = await getCurrentFileContent()
let lineCount = fileContent.split("\n").length
// Check if we already have an active editor for this file path
const existingEditor = vscode.window._documentEditors && vscode.window._documentEditors[filePath]
if (existingEditor) {
console.log(`showTextDocument: Updating existing editor for ${filePath}`)
// Update the existing editor's content
fileContent = await getCurrentFileContent()
lineCount = fileContent.split("\n").length
// Update the document's getText method to return current content
existingEditor.document.getText = (range) => {
// Always read fresh content for getText calls
const currentContent = require("fs").readFileSync(filePath, "utf8")
if (!range) {
return currentContent
}
// Handle range-based getText with current content
const lines = currentContent.split("\n")
const startLine = Math.max(0, range.start.line)
const endLine = Math.min(lines.length - 1, range.end.line)
if (startLine === endLine) {
// Single line
const line = lines[startLine] || ""
const startChar = Math.max(0, range.start.character)
const endChar = Math.min(line.length, range.end.character)
return line.substring(startChar, endChar)
} else {
// Multiple lines
const result = []
for (let i = startLine; i <= endLine; i++) {
const line = lines[i] || ""
if (i === startLine) {
result.push(line.substring(range.start.character))
} else if (i === endLine) {
result.push(line.substring(0, range.end.character))
} else {
result.push(line)
}
}
return result.join("\n")
}
}
// Update other properties
existingEditor.document.lineCount = lineCount
existingEditor.document.fileName = filePath
// Update the active text editor reference
vscode.window.activeTextEditor = existingEditor
return existingEditor
}
// Create a new mock text editor that always reads current file content
const mockEditor = {
document: {
uri: uri,
fileName: filePath,
isDirty: false,
lineCount: lineCount,
getText: (range) => {
// Always read fresh content for getText calls
try {
const currentContent = require("fs").readFileSync(filePath, "utf8")
console.log(`document.getText: Read fresh content (${currentContent.length} chars)`)
if (!range) {
return currentContent
}
// Handle range-based getText with current content
const lines = currentContent.split("\n")
const startLine = Math.max(0, range.start.line)
const endLine = Math.min(lines.length - 1, range.end.line)
if (startLine === endLine) {
// Single line
const line = lines[startLine] || ""
const startChar = Math.max(0, range.start.character)
const endChar = Math.min(line.length, range.end.character)
return line.substring(startChar, endChar)
} else {
// Multiple lines
const result = []
for (let i = startLine; i <= endLine; i++) {
const line = lines[i] || ""
if (i === startLine) {
result.push(line.substring(range.start.character))
} else if (i === endLine) {
result.push(line.substring(0, range.end.character))
} else {
result.push(line)
}
}
return result.join("\n")
}
} catch (error) {
console.error(`Error reading file in getText: ${error.message}`)
return ""
}
},
save: async () => {
console.log("Called mock textDocument.save")
return true
},
positionAt: (offset) => {
try {
const currentContent = require("fs").readFileSync(filePath, "utf8")
const lines = currentContent.split("\n")
let currentOffset = 0
for (let line = 0; line < lines.length; line++) {
const lineLength = lines[line].length + 1 // +1 for newline
if (currentOffset + lineLength > offset) {
return { line: line, character: offset - currentOffset }
}
currentOffset += lineLength
}
return { line: lines.length - 1, character: lines[lines.length - 1]?.length || 0 }
} catch (error) {
return { line: 0, character: 0 }
}
},
offsetAt: (position) => {
try {
const currentContent = require("fs").readFileSync(filePath, "utf8")
const lines = currentContent.split("\n")
let offset = 0
for (let i = 0; i < position.line && i < lines.length; i++) {
offset += lines[i].length + 1 // +1 for newline
}
offset += Math.min(position.character, lines[position.line]?.length || 0)
return offset
} catch (error) {
return 0
}
},
},
selection: { start: { line: 0, character: 0 }, end: { line: 0, character: 0 } },
selections: [],
visibleRanges: [{ start: { line: 0, character: 0 }, end: { line: 0, character: 0 } }],
options: {},
viewColumn: 1,
edit: async (callback) => {
console.log("Called mock textEditor.edit")
return true
},
insertSnippet: async () => true,
setDecorations: () => {},
revealRange: () => {},
show: () => {},
hide: () => {},
}
// Store the editor by file path for future reference
if (!vscode.window._documentEditors) {
vscode.window._documentEditors = {}
}
vscode.window._documentEditors[filePath] = mockEditor
// Update the active text editor
vscode.window.activeTextEditor = mockEditor
// Trigger onDidChangeActiveTextEditor listeners
if (vscode.window._activeTextEditorListeners) {
setTimeout(() => {
vscode.window._activeTextEditorListeners.forEach((listener) => {
try {
listener(mockEditor)
} catch (error) {
console.error("Error calling onDidChangeActiveTextEditor listener:", error)
}
})
}, 10) // Small delay to simulate async behavior
}
return mockEditor
showTextDocument: async (...args) => {
console.log("Stubbed showTextDocument:", ...args)
return {}
},
createOutputChannel: (name) => {
console.log("Stubbed createOutputChannel:", name)
@@ -253,60 +41,19 @@ vscode.window = {
}
},
createTerminal: (...args) => {
console.log("Enhanced createTerminal:", ...args)
// Extract options from arguments
let options = {}
if (args.length > 0) {
if (typeof args[0] === "string") {
// Called with (name, shellPath, shellArgs)
options = {
name: args[0],
shellPath: args[1],
shellArgs: args[2],
}
} else if (typeof args[0] === "object") {
// Called with options object
options = args[0]
}
console.log("Stubbed createTerminal:", ...args)
return {
sendText: console.log,
show: () => {},
dispose: () => {},
}
// Use our enhanced terminal manager to create a terminal
const terminalInfo = globalTerminalManager.registry.createTerminal({
name: options.name || `Terminal ${Date.now()}`,
cwd: options.cwd || process.cwd(),
shellPath: options.shellPath,
})
// Store reference for tracking
vscode.window.terminals.push(terminalInfo.terminal)
if (!vscode.window.activeTerminal) {
vscode.window.activeTerminal = terminalInfo.terminal
}
console.log(`Enhanced terminal created: ${terminalInfo.id}`)
return terminalInfo.terminal
},
activeTextEditor: undefined,
visibleTextEditors: [],
tabGroups: {
all: [
{
tabs: [],
isActive: true,
viewColumn: 1,
},
],
activeTabGroup: {
tabs: [],
isActive: true,
viewColumn: 1,
},
close: async (tab) => {
console.log("Stubbed tabGroups.close:", tab)
return true
},
onDidChangeTabs: createStub("vscode.window.tabGroups.onDidChangeTabs"),
all: [],
close: async () => {},
onDidChangeTabs: createStub("vscode.env.tabGroups.onDidChangeTabs"),
},
withProgress: async (_options, task) => {
console.log("Stubbed withProgress")
@@ -314,56 +61,14 @@ vscode.window = {
},
registerUriHandler: () => ({ dispose: () => {} }),
registerWebviewViewProvider: () => ({ dispose: () => {} }),
onDidChangeActiveTextEditor: (listener) => {
console.log("Called vscode.window.onDidChangeActiveTextEditor")
// Store the listener so we can call it when showTextDocument is called
vscode.window._activeTextEditorListeners = vscode.window._activeTextEditorListeners || []
vscode.window._activeTextEditorListeners.push(listener)
return {
dispose: () => {
console.log("Disposed onDidChangeActiveTextEditor listener")
const index = vscode.window._activeTextEditorListeners.indexOf(listener)
if (index > -1) {
vscode.window._activeTextEditorListeners.splice(index, 1)
}
},
}
},
onDidChangeActiveTextEditor: () => ({ dispose: () => {} }),
createTextEditorDecorationType: () => ({ dispose: () => {} }),
createWebviewPanel: (...args) => {
console.log("Stubbed createWebviewPanel:", ...args)
return {
webview: {},
reveal: () => {},
dispose: () => {},
}
createWebviewPanel: (..._args) => {
throw new Error("WebviewPanel is not supported in standalone app.")
},
onDidChangeTerminalState: (listener) => {
console.log("Called vscode.window.onDidChangeTerminalState")
return {
dispose: () => {
console.log("Disposed onDidChangeTerminalState listener")
},
}
},
onDidChangeTextEditorVisibleRanges: (listener) => {
console.log("Called vscode.window.onDidChangeTextEditorVisibleRanges")
return {
dispose: () => {
console.log("Disposed onDidChangeTextEditorVisibleRanges listener")
},
}
},
terminals: [],
activeTerminal: null,
}
// Initialize env object if it doesn't exist, then extend it
if (!vscode.env) {
vscode.env = {}
}
Object.assign(vscode.env, {
vscode.env = {
uriScheme: "vscode",
appName: "Visual Studio Code",
appRoot: "/tmp/vscode/appRoot",
@@ -373,26 +78,17 @@ Object.assign(vscode.env, {
sessionId: "stub-session-id",
shell: "/bin/bash",
// Add the stub functions that were missing
clipboard: createStub("vscode.env.clipboard"),
openExternal: createStub("vscode.env.openExternal"),
getQueryParameter: createStub("vscode.env.getQueryParameter"),
onDidChangeTelemetryEnabled: createStub("vscode.env.onDidChangeTelemetryEnabled"),
isTelemetryEnabled: createStub("vscode.env.isTelemetryEnabled"),
telemetryConfiguration: createStub("vscode.env.telemetryConfiguration"),
onDidChangeTelemetryConfiguration: createStub("vscode.env.onDidChangeTelemetryConfiguration"),
createTelemetryLogger: createStub("vscode.env.createTelemetryLogger"),
})
// Override the openExternal function with actual implementation
vscode.env.openExternal = async (uri) => {
const url = typeof uri === "string" ? uri : (uri.toString?.() ?? "")
console.log("Opening browser:", url)
await open(url)
return true
}
// Extend Uri object with improved implementations
Object.assign(vscode.Uri, {
vscode.Uri = {
parse: (uriString) => {
const url = new URL(uriString)
return {
@@ -437,405 +133,13 @@ Object.assign(vscode.Uri, {
const joined = segments.map((s) => (typeof s === "string" ? s : s.path)).join("/")
return vscode.Uri.file("/" + joined.replace(/\/+/g, "/"))
},
})
// Extend workspace object with file system operations
Object.assign(vscode.workspace, {
fs: {
readFile: async function (uri) {
console.log(`Called vscode.workspace.fs.readFile with uri:`, uri)
try {
// Extract file path from URI
let filePath = uri.path || uri.fsPath || uri
if (typeof filePath !== "string") {
filePath = uri.toString()
}
// Remove file:// prefix if present
if (filePath.startsWith("file://")) {
filePath = filePath.substring(7)
}
console.log(`Reading file: ${filePath}`)
const content = await fs.promises.readFile(filePath, "utf8")
console.log(
`File content read (${content.length} chars):`,
content.substring(0, 100) + (content.length > 100 ? "..." : ""),
)
return new Uint8Array(Buffer.from(content, "utf8"))
} catch (error) {
console.error(`Error reading file:`, error)
throw error
}
},
writeFile: async function (uri, content) {
console.log(`Called vscode.workspace.fs.writeFile with uri:`, uri)
try {
// Extract file path from URI
let filePath = uri.path || uri.fsPath || uri
if (typeof filePath !== "string") {
filePath = uri.toString()
}
// Remove file:// prefix if present
if (filePath.startsWith("file://")) {
filePath = filePath.substring(7)
}
console.log(`Writing file: ${filePath}`)
// Ensure directory exists
const dir = path.dirname(filePath)
await fs.promises.mkdir(dir, { recursive: true })
// Write the file
await fs.promises.writeFile(filePath, content)
} catch (error) {
console.error(`Error writing file:`, error)
throw error
}
},
},
// Add workspace folder configuration
rootPath: process.cwd(),
workspaceFolders: [
{
uri: vscode.Uri.file(process.cwd()),
name: path.basename(process.cwd()),
index: 0,
},
],
name: path.basename(process.cwd()),
workspaceFile: vscode.Uri.file(path.join(process.cwd(), ".vscode", "workspace.json")),
// Add other workspace methods as stubs
getConfiguration: () => ({
get: () => undefined,
update: () => Promise.resolve(),
has: () => false,
}),
getWorkspaceFolder: (uri) => {
console.log("Called vscode.workspace.getWorkspaceFolder with:", uri)
// Return the first workspace folder for any URI in standalone mode
if (vscode.workspace.workspaceFolders && vscode.workspace.workspaceFolders.length > 0) {
return vscode.workspace.workspaceFolders[0]
}
return undefined
},
createFileSystemWatcher: () => ({
onDidChange: () => ({ dispose: () => {} }),
onDidCreate: () => ({ dispose: () => {} }),
onDidDelete: () => ({ dispose: () => {} }),
dispose: () => {},
}),
onDidChangeConfiguration: () => ({ dispose: () => {} }),
onDidChangeWorkspaceFolders: () => ({ dispose: () => {} }),
onDidCreateFiles: createStub("vscode.workspace.onDidCreateFiles"),
onDidDeleteFiles: createStub("vscode.workspace.onDidDeleteFiles"),
onDidRenameFiles: createStub("vscode.workspace.onDidRenameFiles"),
onWillCreateFiles: createStub("vscode.workspace.onWillCreateFiles"),
onWillDeleteFiles: createStub("vscode.workspace.onWillDeleteFiles"),
onWillRenameFiles: createStub("vscode.workspace.onWillRenameFiles"),
textDocuments: {
find: (predicate) => {
console.log("Called vscode.workspace.textDocuments.find")
// Return a mock text document that behaves like VSCode expects
return {
uri: { fsPath: "/tmp/mock-document" },
fileName: "/tmp/mock-document",
isDirty: false,
save: async () => {
console.log("Called mock textDocument.save")
return true
},
getText: () => "",
lineCount: 0,
}
},
forEach: (callback) => {
console.log("Called vscode.workspace.textDocuments.forEach")
// No documents to iterate over in standalone mode
},
length: 0,
[Symbol.iterator]: function* () {
// Empty iterator for standalone mode
},
},
// Add the crucial applyEdit method
applyEdit: async (workspaceEdit) => {
console.log("Called vscode.workspace.applyEdit", workspaceEdit)
// For standalone mode, we'll simulate applying the edit by actually writing to files
try {
// WorkspaceEdit can contain multiple types of edits
if (workspaceEdit._edits) {
for (const edit of workspaceEdit._edits) {
if (edit._type === 1) {
// TextEdit
const uri = edit._uri
const edits = edit._edits
let filePath = uri.path || uri.fsPath
if (filePath.startsWith("file://")) {
filePath = filePath.substring(7)
}
console.log(`Applying text edits to: ${filePath}`)
// Read current content if file exists
let currentContent = ""
try {
currentContent = await fs.promises.readFile(filePath, "utf8")
} catch (e) {
// File doesn't exist, start with empty content
console.log(`File ${filePath} doesn't exist, starting with empty content`)
}
// Apply edits in reverse order (from end to beginning) to maintain positions
const sortedEdits = edits.sort((a, b) => {
const aStart = a.range.start.line * 1000000 + a.range.start.character
const bStart = b.range.start.line * 1000000 + b.range.start.character
return bStart - aStart
})
let lines = currentContent.split("\n")
for (const edit of sortedEdits) {
const startLine = edit.range.start.line
const startChar = edit.range.start.character
const endLine = edit.range.end.line
const endChar = edit.range.end.character
const newText = edit.newText
console.log(`Applying edit: ${startLine}:${startChar} - ${endLine}:${endChar} -> "${newText}"`)
// Handle the edit
if (startLine === endLine) {
// Single line edit
const line = lines[startLine] || ""
lines[startLine] = line.substring(0, startChar) + newText + line.substring(endChar)
} else {
// Multi-line edit
const firstLine = lines[startLine] || ""
const lastLine = lines[endLine] || ""
const newFirstLine = firstLine.substring(0, startChar) + newText + lastLine.substring(endChar)
// Replace the range with the new content
lines.splice(startLine, endLine - startLine + 1, newFirstLine)
}
}
const newContent = lines.join("\n")
// Ensure directory exists
const dir = path.dirname(filePath)
await fs.promises.mkdir(dir, { recursive: true })
// Write the updated content
await fs.promises.writeFile(filePath, newContent, "utf8")
console.log(`Successfully applied edits to: ${filePath}`)
}
}
}
return true
} catch (error) {
console.error("Error applying workspace edit:", error)
return false
}
},
})
// Fix CodeActionKind to have static properties instead of being a class
vscode.CodeActionKind = {
Empty: "",
QuickFix: "quickfix",
Refactor: "refactor",
RefactorExtract: "refactor.extract",
RefactorInline: "refactor.inline",
RefactorRewrite: "refactor.rewrite",
Source: "source",
SourceOrganizeImports: "source.organizeImports",
SourceFixAll: "source.fixAll",
}
// Add missing commands implementation
if (!vscode.commands) {
vscode.commands = {}
vscode.env.openExternal = async (uri) => {
const url = typeof uri === "string" ? uri : (uri.toString?.() ?? "")
console.log("Opening browser:", url)
await open(url)
return true
}
Object.assign(vscode.commands, {
executeCommand: async (command, ...args) => {
console.log(`Called vscode.commands.executeCommand: ${command}`, args)
// Handle the vscode.diff command specifically
if (command === "vscode.diff") {
const [originalUri, modifiedUri, title, options] = args
console.log("Opening diff view:", { originalUri, modifiedUri, title })
// For standalone mode, just open the modified file directly
// since we can't show a proper diff view
const editor = await vscode.window.showTextDocument(modifiedUri, {
preserveFocus: options?.preserveFocus || false,
preview: false,
})
// Ensure the onDidChangeActiveTextEditor event fires with a slight delay
// This is crucial for DiffViewProvider.openDiffEditor() to work properly
setTimeout(() => {
if (vscode.window._activeTextEditorListeners) {
vscode.window._activeTextEditorListeners.forEach((listener) => {
try {
listener(editor)
} catch (error) {
console.error("Error calling onDidChangeActiveTextEditor listener in vscode.diff:", error)
}
})
}
}, 50) // Slightly longer delay to ensure proper event ordering
return editor
}
// For other commands, just return a resolved promise
return Promise.resolve()
},
registerCommand: (command, callback) => {
console.log(`Registered command: ${command}`)
return { dispose: () => {} }
},
getCommands: async () => {
return []
},
})
// Add missing TabInput classes
vscode.TabInputText = class TabInputText {
constructor(uri) {
this.uri = uri
}
}
vscode.TabInputTextDiff = class TabInputTextDiff {
constructor(original, modified) {
this.original = original
this.modified = modified
}
}
// Add missing WorkspaceEdit and related classes
vscode.WorkspaceEdit = class WorkspaceEdit {
constructor() {
this._edits = []
}
replace(uri, range, newText) {
console.log("WorkspaceEdit.replace:", uri, range, newText)
this._edits.push({
_type: 1, // TextEdit
_uri: uri,
_edits: [
{
range: range,
newText: newText,
},
],
})
}
insert(uri, position, newText) {
console.log("WorkspaceEdit.insert:", uri, position, newText)
this.replace(uri, new vscode.Range(position, position), newText)
}
delete(uri, range) {
console.log("WorkspaceEdit.delete:", uri, range)
this.replace(uri, range, "")
}
}
vscode.Range = class Range {
constructor(startLine, startCharacter, endLine, endCharacter) {
if (typeof startLine === "object") {
// Called with Position objects
this.start = startLine
this.end = startCharacter
} else {
// Called with line/character numbers
this.start = new vscode.Position(startLine, startCharacter)
this.end = new vscode.Position(endLine, endCharacter)
}
}
}
vscode.Position = class Position {
constructor(line, character) {
this.line = line
this.character = character
}
}
vscode.Selection = class Selection extends vscode.Range {
constructor(anchorLine, anchorCharacter, activeLine, activeCharacter) {
if (typeof anchorLine === "object") {
// Called with Position objects
super(anchorLine, anchorCharacter)
this.anchor = anchorLine
this.active = anchorCharacter
} else {
// Called with line/character numbers
super(anchorLine, anchorCharacter, activeLine, activeCharacter)
this.anchor = new vscode.Position(anchorLine, anchorCharacter)
this.active = new vscode.Position(activeLine, activeCharacter)
}
}
}
// Add TextEditorRevealType enum
vscode.TextEditorRevealType = {
Default: 0,
InCenter: 1,
InCenterIfOutsideViewport: 2,
AtTop: 3,
}
// Add missing languages API
if (!vscode.languages) {
vscode.languages = {}
}
Object.assign(vscode.languages, {
getDiagnostics: (uri) => {
console.log("Called vscode.languages.getDiagnostics")
// Return empty diagnostics for standalone mode
if (uri) {
return []
} else {
// Return all diagnostics as empty array
return []
}
},
registerCodeActionsProvider: () => ({ dispose: () => {} }),
createDiagnosticCollection: () => ({
set: () => {},
delete: () => {},
clear: () => {},
dispose: () => {},
}),
})
console.log("Finished loading stub impls...")
// Export the terminal manager globally for Cline core to use
global.standaloneTerminalManager = globalTerminalManager
// Override the TerminalManager to use our standalone implementation
if (typeof global !== "undefined") {
// Replace the TerminalManager class with our standalone implementation
global.StandaloneTerminalManagerClass = require("./enhanced-terminal").StandaloneTerminalManager
}
module.exports = vscode
-1
View File
@@ -28,7 +28,6 @@
"no-extra-semi": "off",
"eslint-rules/no-protobuf-object-literals": "error",
"eslint-rules/no-grpc-client-object-literals": "error",
"eslint-rules/no-vscode-postmessage": "warn",
"no-restricted-syntax": [
"error",
{
+11
View File
@@ -11,6 +11,7 @@
"@floating-ui/react": "^0.27.4",
"@heroui/react": "^2.8.0-beta.2",
"@vscode/webview-ui-toolkit": "^1.4.0",
"clsx": "^2.1.1",
"debounce": "^2.1.1",
"dompurify": "^3.2.4",
"fast-deep-equal": "^3.1.3",
@@ -37,6 +38,7 @@
"remark-math": "^6.0.0",
"remark-stringify": "^11.0.0",
"styled-components": "^6.1.15",
"tailwind-merge": "^3.3.0",
"unified": "^11.0.5",
"uuid": "^9.0.1"
},
@@ -15549,6 +15551,15 @@
"integrity": "sha512-QD9qKY3StfbZqWOPLp0++pOrAVb/HbUi5xCc8cUo4XjP19808oaMiDzn0leBY5mCespIBM0CIZePzZjgzR83kA==",
"license": "MIT"
},
"node_modules/tailwind-merge": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.3.0.tgz",
"integrity": "sha512-fyW/pEfcQSiigd5SNn0nApUOxx0zB/dm6UDU/rEwc2c3sX2smWUNbapHv+QRqLGVp9GWX3THIa7MUGPo+YkDzQ==",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/dcastil"
}
},
"node_modules/tailwind-variants": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/tailwind-variants/-/tailwind-variants-1.0.0.tgz",
+2
View File
@@ -18,6 +18,7 @@
"@floating-ui/react": "^0.27.4",
"@heroui/react": "^2.8.0-beta.2",
"@vscode/webview-ui-toolkit": "^1.4.0",
"clsx": "^2.1.1",
"debounce": "^2.1.1",
"dompurify": "^3.2.4",
"fast-deep-equal": "^3.1.3",
@@ -44,6 +45,7 @@
"remark-math": "^6.0.0",
"remark-stringify": "^11.0.0",
"styled-components": "^6.1.15",
"tailwind-merge": "^3.3.0",
"unified": "^11.0.5",
"uuid": "^9.0.1"
},
+28 -24
View File
@@ -44,23 +44,15 @@ const Announcement = ({ version, hideAnnouncement }: AnnouncementProps) => {
</h3>
<ul style={ulStyle}>
<li>
<b>Optimized for Claude 4:</b> Cline is now optimized to work with the Claude 4 family of models, resulting in
improved performance, reliability, and new capabilities.
<b>Claude 4 Models:</b> Now with support for Anthropic Claude Sonnet 4 and Claude Opus 4 in both Anthropic and
Vertex providers.
</li>
<li>
<b>Gemini CLI Provider:</b> Added a new Gemini CLI provider that allows you to use your local Gemini CLI
authentication to access Gemini models for free.
<b>New Settings Page:</b> Redesigned settings, now split into tabs for easier navigation and a cleaner
experience.
</li>
<li>
<b>WebFetch Tool:</b> Gemini 2.5 Pro and Claude 4 models now support the WebFetch tool, allowing Cline to
retrieve and summarize web content directly in conversations.
</li>
<li>
<b>Self Knowledge:</b> When using frontier models, Cline is self-aware about his capabilities and featureset.
</li>
<li>
<b>Improved Diff Editing:</b> Improved diff editing to achieve record lows in diff edit failures for frontier
models
<b>Nebius AI Studio:</b> Added Nebius AI Studio as a new provider. (Thanks @Aktsvigun!)
</li>
</ul>
<Accordion isCompact className="pl-0">
@@ -75,17 +67,6 @@ const Announcement = ({ version, hideAnnouncement }: AnnouncementProps) => {
"text-[var(--vscode-foreground)] mb-0.5 -rotate-180 data-[open=true]:-rotate-90 rtl:rotate-0 rtl:data-[open=true]:-rotate-90",
}}>
<ul style={ulStyle}>
<li>
<b>Claude 4 Models:</b> Now with support for Anthropic Claude Sonnet 4 and Claude Opus 4 in both
Anthropic and Vertex providers.
</li>
<li>
<b>New Settings Page:</b> Redesigned settings, now split into tabs for easier navigation and a cleaner
experience.
</li>
<li>
<b>Nebius AI Studio:</b> Added Nebius AI Studio as a new provider. (Thanks @Aktsvigun!)
</li>
<li>
<b>Workflows:</b> Create and manage workflow files that can be injected into conversations via slash
commands, making it easy to automate repetitive tasks.
@@ -98,6 +79,29 @@ const Announcement = ({ version, hideAnnouncement }: AnnouncementProps) => {
<b>Global Endpoint for Vertex AI:</b> Improved availability and reduced rate limiting errors for
Vertex AI users.
</li>
<li>
<b>New User Experience:</b> Special components and guidance for new users to help them get started
with Cline.
</li>
<li>
<b>UI Improvements:</b> Fixed loading states and improved settings organization for a smoother
experience.
</li>
<li>
<b>Task Timeline:</b> See the history of your coding journey with a visual timeline of checkpoints.
</li>
<li>
<b>UX Improvements:</b> Type while Cline works, smarter auto-scrolling, and copy buttons for task
headers and messages.
</li>
<li>
<b>Gemini prompt caching:</b> Gemini and Vertex providers now support prompt caching and price
tracking.
</li>
<li>
<b>Global Cline Rules:</b> Store multiple rules files in Documents/Cline/Rules to share between
projects.
</li>
</ul>
</AccordionItem>
</Accordion>
+10 -90
View File
@@ -17,7 +17,7 @@ import McpResponseDisplay from "@/components/mcp/chat-display/McpResponseDisplay
import McpResourceRow from "@/components/mcp/configuration/tabs/installed/server-row/McpResourceRow"
import McpToolRow from "@/components/mcp/configuration/tabs/installed/server-row/McpToolRow"
import { useExtensionState } from "@/context/ExtensionStateContext"
import { FileServiceClient, TaskServiceClient, UiServiceClient } from "@/services/grpc-client"
import { FileServiceClient, TaskServiceClient } from "@/services/grpc-client"
import { findMatchingResourceOrTemplate, getMcpServerDisplayName } from "@/utils/mcp"
import { vscode } from "@/utils/vscode"
import {
@@ -184,7 +184,7 @@ export const ChatRowContent = ({
sendMessageFromChatRow,
onSetQuote,
}: ChatRowContentProps) => {
const { mcpServers, mcpMarketplaceCatalog, onRelinquishControl, apiConfiguration } = useExtensionState()
const { mcpServers, mcpMarketplaceCatalog, onRelinquishControl } = useExtensionState()
const [seeNewChangesDisabled, setSeeNewChangesDisabled] = useState(false)
const [quoteButtonState, setQuoteButtonState] = useState<QuoteButtonState>({
visible: false,
@@ -697,13 +697,15 @@ export const ChatRowContent = ({
msUserSelect: "none",
}}
onClick={() => {
// Open the URL in the default browser using gRPC
// Attempt to open the URL in the default browser
if (tool.path) {
UiServiceClient.openUrl(StringRequest.create({ value: tool.path }))
.catch((err) => {
console.error("Failed to open URL:", err)
})
// Assuming 'openUrl' is a valid action the extension can handle.
// If not, this might need adjustment based on how other external link openings are handled.
vscode.postMessage({
type: "action", // This should be a valid MessageType from WebviewMessage
action: "openUrl", // This should be a valid WebviewAction from WebviewMessage
url: tool.path,
} as any) // Using 'as any' for now if 'openUrl' isn't strictly typed yet
}
}}>
<span
@@ -953,88 +955,6 @@ export const ChatRowContent = ({
}
}
// Check for rate limit errors (status code 429)
const isRateLimitError =
apiRequestFailedMessage?.includes("status code 429") ||
apiRequestFailedMessage?.toLowerCase().includes("rate limit") ||
apiRequestFailedMessage?.toLowerCase().includes("too many requests") ||
apiRequestFailedMessage?.toLowerCase().includes("quota exceeded") ||
apiRequestFailedMessage?.toLowerCase().includes("resource exhausted")
if (isRateLimitError) {
// Check if current provider is Gemini CLI to show specific message
const isGeminiCliProvider = apiConfiguration?.apiProvider === "gemini-cli"
if (isGeminiCliProvider) {
return (
<div
style={{
backgroundColor: "rgba(255, 191, 0, 0.1)",
padding: "12px",
borderRadius: "4px",
border: "1px solid rgba(255, 191, 0, 0.3)",
}}>
<div
style={{
display: "flex",
alignItems: "center",
marginBottom: "8px",
}}>
<i
className="codicon codicon-warning"
style={{
marginRight: "8px",
fontSize: "16px",
color: "#FFA500",
}}></i>
<span
style={{
fontWeight: "bold",
color: "#FFA500",
}}>
Rate Limit Exceeded
</span>
</div>
<p style={{ margin: 0, fontSize: "14px", lineHeight: "1.4" }}>
You've hit the API rate limit. This is likely due to free tier limits.
</p>
<p style={{ margin: "8px 0 0 0", fontSize: "12px", lineHeight: "1.4" }}>
You can read about the tier limits{" "}
<a
href="https://codeassist.google/"
style={{
color: "inherit",
textDecoration: "underline",
}}
onClick={(e) => {
e.preventDefault()
UiServiceClient.openUrl(
StringRequest.create({
value: "https://codeassist.google/",
}),
).catch((err) => console.error("Failed to open URL:", err))
}}>
here
</a>
, or alternatively, you can use the Gemini Flash Model that will give
you better limits.
</p>
</div>
)
} else {
// Generic rate limit error for other providers
return (
<p
style={{
...pStyle,
color: "var(--vscode-errorForeground)",
}}>
{apiRequestFailedMessage || apiReqStreamingFailedMessage}
</p>
)
}
}
// Default error display
return (
<p
@@ -74,22 +74,6 @@ const TaskTimeline: React.FC<TaskTimelineProps> = ({ messages, onBlockClick }) =
// Render a timeline block
const TimelineBlock = useCallback(
(index: number) => {
// Show placeholder block when no items exist
if (taskTimelinePropsMessages.length === 0 || index >= taskTimelinePropsMessages.length) {
return (
<div
style={{
width: BLOCK_WIDTH,
height: "100%",
backgroundColor: "#e5e5e5", // Light gray placeholder
flexShrink: 0,
marginRight: BLOCK_GAP,
opacity: 0.5,
}}
/>
)
}
const message = taskTimelinePropsMessages[index]
const originalMessageIndex = messageIndexMap[index]
@@ -128,6 +112,10 @@ const TaskTimeline: React.FC<TaskTimelineProps> = ({ messages, onBlockClick }) =
}
}, [taskTimelinePropsMessages])
if (taskTimelinePropsMessages.length === 0) {
return null
}
return (
<div
ref={containerRef}
@@ -158,7 +146,7 @@ const TaskTimeline: React.FC<TaskTimelineProps> = ({ messages, onBlockClick }) =
height: TIMELINE_HEIGHT,
width: "100%",
}}
totalCount={Math.max(1, taskTimelinePropsMessages.length)}
totalCount={taskTimelinePropsMessages.length}
itemContent={TimelineBlock}
horizontalDirection={true}
increaseViewportBy={12}

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