Compare commits

...
Author SHA1 Message Date
pashpashpashandellipsis-dev[bot] 257080b864 Update docs/provider-config/claude-code.mdx
Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>
2025-06-24 17:10:36 -07:00
Cline Evaluation 7c8bd0e7b2 claude code docs 2025-06-24 17:08:02 -07:00
celestial-vault 59efda3e22 fix link by calling grpc (#4422) 2025-06-24 16:07:19 -07:00
canvrno 936923d334 [PROTOBUS] Move telemetrySetting to protobus (#3711)
* telemtrySetting protobus migration

* merge conflcit fix

* Updated telemetrySettingRequest to use create

* rebase/merge fixes
2025-06-24 16:05:07 -07:00
celestial-vault 0a57ec3b7d remove remaining postMessage calls (#4432) 2025-06-24 15:52:36 -07:00
canvrno 670f3a1d62 [PROTOBUS] Move clearAllTaskHistory to protobus (#3674)
* clearAllTaskHistory protobus migration

* changset

* Fixed protobuf object literal usage

* cleanup

* remove old methods + return zero when user tries to delete favorites but none exist

* Removed deleteTaskWithId from controller and removed legacy claude_messages.json code
2025-06-24 14:30:59 -07:00
8104f18f5a Changeset version bump (#4344)
* changeset version bump

* Updating CHANGELOG.md format

* Update CHANGELOG.md and package.json for version 3.17.16

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: Cline Evaluation <cline@example.com>
2025-06-24 12:38:22 -07:00
canvrno 4de7790fa1 pretty (#4307) 2025-06-24 11:43:05 -07:00
celestial-vault adfb5a2b6e add eslint rule banning postMessage in webview (#4414) 2025-06-24 07:54:23 -10:00
pashpashpashandCline Evaluation d55a23448d fixing case in dashboard where no valid results (#4408)
Co-authored-by: Cline Evaluation <cline@example.com>
2025-06-23 21:04:53 -07:00
celestial-vault 1dd164f482 simplify is (#4404)
streaming boolean to just use lastMessage.partial
2025-06-23 18:29:13 -07:00
pashpashpashandCline Evaluation 50b43c0559 Diff Evals - Replay feature (#4407)
* wip

* ok replays

* replays cooking

* docs

---------

Co-authored-by: Cline Evaluation <cline@example.com>
2025-06-23 18:25:26 -07:00
Sarah Fortune 943c52f0b3 Update scripts/get-vscode-usages.sh (#4402) 2025-06-23 16:19:14 -07:00
Tomás BarreiroandCline Evaluation b84084936b fix: Handle long Claude code messages (#4287)
* Handle partial messages

* Parse chunks separately

* Add changeset

* refactor

* disallow tools

* Handle incomplete chunks

* Do not log costs when using a subscription, improve error handling and refactor rl usage

* Improve output handling. Prefer returning partial data to nothing.

* Set the total cost to 0 instead of leaving it undefined

* Fix the model infos and stop supporting images

* Reduce timeout to 10 minutes

---------

Co-authored-by: Cline Evaluation <cline@example.com>
2025-06-24 03:09:21 +05:30
Sarah Fortune 014910deb9 Add clean script to package.json (#4326)
Add a script to remove build artifacts.
2025-06-23 14:24:08 -07:00
celestial-vault a30cefa595 Move mode to controller (#4343)
* you know what im talking about

* store mode in controller and target sendStateUpdate by controller ID
2025-06-23 14:23:20 -07:00
Daniel Steigman 620f402f36 Added protobuf development rule file (#4401)
* added protobuf development rule file

* new UI based example
2025-06-23 14:10:57 -07:00
Sarah Fortune 22ff68565b Add content security policy for external webview provider. (#4337)
Replace placeholder with correct csp source.
2025-06-23 11:51:05 -07:00
Sarah Fortune 3e1565da59 In the reflection output the standalone service should only show the Protobus services (#4339)
* The standalone service should only show the Protobus services in the reflection output.

The proto descriptor set is including all the proto services, allowlist the services in the cline and health packages.

* Remove debug code

* Fix variable name
2025-06-23 11:35:09 -07:00
f8a284c6fe Fixing the contributor flow for Cline to force users to make issues first (#4324)
* Fixing the contributor flow for Cline to force users to make issues first

* Fixing the contributor flow for Cline to force users to make issues first

* Fixing the contributor flow for Cline to force users to make issues first

* Fixing the contributor flow for Cline to force users to make issues first

* Update feature_contribution.yml

Co-authored-by: Toshii <94262432+0xToshii@users.noreply.github.com>

---------

Co-authored-by: Cline Evaluation <cline@example.com>
Co-authored-by: Toshii <94262432+0xToshii@users.noreply.github.com>
2025-06-22 08:11:01 -07:00
39718cc521 #3775 remove deps clsx tailwind merge (#4147)
* v3.17.12 Release Notes

* changeset version bump

* Updating CHANGELOG.md format

* Update CHANGELOG.md for version 3.17.12

* changelog language

* changelog language

* attribution

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: Cline Evaluation <cline@example.com>

* #3720 first pass remove clsx / tailwind-merge and replace with template literals

* package-lock.json changes

* update changeset for 3720, remove clsx & tailwind-merge deps and replace with template literals

* #3775 Handle empty or null className

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: Cline Evaluation <cline@example.com>
2025-06-21 13:39:15 -07:00
Jorge García Rey e945a45102 feat: Add taskIdas metadata to use from LiteLLM (#3696)
* feat: add cline_task_id metatada to use in LiteLLM

Signed-off-by: Jorge García Rey <jorgegar@inditex.com>

* refactor: remove comment

Signed-off-by: Jorge García Rey <jorgegar@inditex.com>

* docs: add changeset

Signed-off-by: Jorge García Rey <jorgegar@inditex.com>

* refactor: apply suggestions type

Signed-off-by: Jorge García Rey <jorgegar@inditex.com>

* fix: format

Signed-off-by: Jorge García Rey <jorgegar@inditex.com>

---------

Signed-off-by: Jorge García Rey <jorgegar@inditex.com>
2025-06-21 11:17:31 -07:00
celestial-vaultandElephant Lumps e2f9c38902 Migrate fetchUserCreditsData protobus (#3801)
* migrate fetchUserCreditsData

* changeset

* remove parseInt

* linter error

* change proto fields to snake case

* syntax error

---------

Co-authored-by: Elephant Lumps <celestial_vault@Elephants-MacBook-Pro.local>
2025-06-21 11:06:09 -07:00
Toshii 447a6ba4d5 update stale workflow (#4340) 2025-06-20 21:01:00 -07:00
ToshiiandStvLz 3f914f5092 refactor open ai compatible and sambanova providers (#4338)
* open ai compatible

Co-authored-by: StvLz <lizarazo.steven@gmail.com>

* Sambanova

* order

* reusable components

---------

Co-authored-by: StvLz <lizarazo.steven@gmail.com>
2025-06-20 20:29:20 -07:00
77 changed files with 3325 additions and 1189 deletions
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": minor
---
Migrate authStateChanged to protobus
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
telemtrySetting protobus migration
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
clearAllTaskHistory protobus migration
+89
View File
@@ -0,0 +1,89 @@
# 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)
}
}
```
@@ -0,0 +1,116 @@
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
@@ -1,130 +0,0 @@
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@28ca103
- uses: actions/stale@v9
with:
days-before-issue-stale: 60
days-before-issue-close: 14
+5
View File
@@ -1,5 +1,10 @@
# Changelog
## [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!)
+59 -12
View File
@@ -10,13 +10,6 @@ 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
@@ -24,14 +17,70 @@ 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:
- **Bugs:** "Bug Report" template.
- **Features:** "Detailed Feature Proposal" template. Approval from a core Cline contributor required before starting.
- **Claim issues**: Comment your interest.
- **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.
**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
@@ -86,8 +135,6 @@ All contributions must begin with a GitHub Issue, unless the change is for small
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:
-44
View File
@@ -141,50 +141,6 @@ 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,6 +146,7 @@
"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",
+86
View File
@@ -0,0 +1,86 @@
---
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)
@@ -0,0 +1,74 @@
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,11 +1,13 @@
// 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: {
@@ -13,6 +15,7 @@ module.exports = {
rules: {
"local/no-protobuf-object-literals": "error",
"local/no-grpc-client-object-literals": "error",
"local/no-vscode-postmessage": "error",
},
},
},
+61
View File
@@ -0,0 +1,61 @@
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,
},
})
}
},
}
},
})
+3 -1
View File
@@ -19,4 +19,6 @@ diff_editing/test_outputs/
.cache
# Python bytecode cache
*__pycache__/
*__pycache__/
diff-edits/cases.zip
+10
View File
@@ -14,6 +14,8 @@ interface RunDiffEvalOptions {
testPath: string
outputPath: string
replay: boolean
replayRunId?: string
diffApplyFile?: string
maxCases?: number
}
@@ -56,6 +58,14 @@ 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,6 +93,8 @@ 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 {
+5 -1
View File
@@ -9,6 +9,7 @@ import {
AssistantMessageContent,
} from "./parsing/parse-assistant-message-06-06-25" // "../../src/core/assistant-message"
import { constructNewFileContent as constructNewFileContentV1, constructNewFileContentV2 } from "./diff-apply/diff-06-06-25"
import { constructNewFileContent as constructNewFileContentV2_1 } from "./diff-apply/diff-06-23-25"
import { constructNewFileContent as constructNewFileContentV3 } from "../../src/core/assistant-message/diff" // this defaults to the new v1 when called
type ParseAssistantMessageFn = (message: string) => AssistantMessageContent[]
@@ -21,6 +22,8 @@ const parsingFunctions: Record<string, ParseAssistantMessageFn> = {
}
const diffEditingFunctions: Record<string, ConstructNewFileContentFn> = {
"diff-06-06-25": constructNewFileContentV2,
"diff-06-23-25": constructNewFileContentV2_1,
constructNewFileContentV1: constructNewFileContentV1,
constructNewFileContentV2: constructNewFileContentV2,
constructNewFileContentV3: constructNewFileContentV3, // position invariant diff
@@ -151,6 +154,7 @@ export async function runSingleEvaluation(input: TestInput): Promise<TestResult>
diffEditFunction,
thinkingBudgetTokens,
originalDiffEditToolCallMessage,
diffApplyFile,
} = input
const requiredParams = {
@@ -176,7 +180,7 @@ export async function runSingleEvaluation(input: TestInput): Promise<TestResult>
}
const parseAssistantMessage = parsingFunctions[parsingFunction]
const constructNewFileContent = diffEditingFunctions[diffEditFunction]
const constructNewFileContent = diffEditingFunctions[diffApplyFile || diffEditFunction]
if (!parseAssistantMessage || !constructNewFileContent) {
return {
+17 -4
View File
@@ -63,9 +63,22 @@ 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.
## Some known edge cases
## Replays
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:
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.
- ~~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.
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.
+230
View File
@@ -1,4 +1,8 @@
import { runSingleEvaluation, TestInput, TestResult } from "./ClineWrapper"
import { parseAssistantMessageV2, AssistantMessageContent } from "./parsing/parse-assistant-message-06-06-25"
import { constructNewFileContent as constructNewFileContentV2 } from "./diff-apply/diff-06-06-25"
import { constructNewFileContent as constructNewFileContentV2_1 } from "./diff-apply/diff-06-23-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"
@@ -18,6 +22,10 @@ import {
insertResult,
DatabaseClient,
CreateResultInput,
getResultsByRun,
getCaseById,
getFileByHash,
getBenchmarkRun,
} from "./database"
// Load environment variables from .env file
@@ -184,6 +192,78 @@ 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
*/
@@ -394,6 +474,142 @@ 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": constructNewFileContentV2,
"diff-06-23-25": constructNewFileContentV2_1,
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
*/
@@ -420,6 +636,7 @@ 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) {
@@ -712,6 +929,8 @@ 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")
@@ -732,6 +951,17 @@ 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()
+12 -3
View File
@@ -420,16 +420,25 @@ def render_model_comparison_cards(model_performance):
metric_col1, metric_col2, metric_col3, metric_col4 = st.columns(4)
with metric_col1:
st.metric("Avg Latency", f"{model['avg_round_trip_ms']:.0f}ms")
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")
with metric_col2:
st.metric("Avg Cost", f"${model['avg_cost']:.4f}")
if pd.notna(model['avg_cost']):
st.metric("Avg Cost", f"${model['avg_cost']:.4f}")
else:
st.metric("Avg Cost", "N/A")
with metric_col3:
st.metric("Valid Results", f"{model['total_results']}")
with metric_col4:
st.metric("First Token", f"{model['avg_first_token_ms']:.0f}ms")
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")
with col2:
st.write("") # Add some spacing
+13 -1
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`: A unique identifier for the individual attempt.
- `result_id`: The primary key for the result.
- `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,3 +82,15 @@ 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.
@@ -0,0 +1,827 @@
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
}
+2
View File
@@ -33,6 +33,7 @@ export interface TestConfig {
diff_edit_function: string
thinking_tokens_budget: number
replay: boolean
diff_apply_file?: string
}
export interface SystemPromptDetails {
@@ -100,4 +101,5 @@ export interface TestInput {
diffEditFunction: string
thinkingBudgetTokens: number
originalDiffEditToolCallMessage?: string
diffApplyFile?: string
}
+374 -67
View File
@@ -1,12 +1,12 @@
{
"name": "claude-dev",
"version": "3.17.15",
"version": "3.17.16",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "claude-dev",
"version": "3.17.15",
"version": "3.17.16",
"license": "Apache-2.0",
"dependencies": {
"@anthropic-ai/sdk": "^0.37.0",
@@ -109,6 +109,7 @@
"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",
@@ -3701,6 +3702,29 @@
}
}
},
"node_modules/@isaacs/balanced-match": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz",
"integrity": "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": "20 || >=22"
}
},
"node_modules/@isaacs/brace-expansion": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.0.tgz",
"integrity": "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@isaacs/balanced-match": "^4.0.1"
},
"engines": {
"node": "20 || >=22"
}
},
"node_modules/@isaacs/cliui": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
@@ -4025,6 +4049,17 @@
"node": ">= 6.0.0"
}
},
"node_modules/@mapbox/node-pre-gyp/node_modules/brace-expansion": {
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
}
},
"node_modules/@mapbox/node-pre-gyp/node_modules/detect-libc": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz",
@@ -4035,6 +4070,28 @@
"node": ">=8"
}
},
"node_modules/@mapbox/node-pre-gyp/node_modules/glob": {
"version": "7.2.3",
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
"integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
"deprecated": "Glob versions prior to v9 are no longer supported",
"dev": true,
"license": "ISC",
"dependencies": {
"fs.realpath": "^1.0.0",
"inflight": "^1.0.4",
"inherits": "2",
"minimatch": "^3.1.1",
"once": "^1.3.0",
"path-is-absolute": "^1.0.0"
},
"engines": {
"node": "*"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/@mapbox/node-pre-gyp/node_modules/https-proxy-agent": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
@@ -4075,6 +4132,36 @@
"semver": "bin/semver.js"
}
},
"node_modules/@mapbox/node-pre-gyp/node_modules/minimatch": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^1.1.7"
},
"engines": {
"node": "*"
}
},
"node_modules/@mapbox/node-pre-gyp/node_modules/rimraf": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
"integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==",
"deprecated": "Rimraf versions prior to v4 are no longer supported",
"dev": true,
"license": "ISC",
"dependencies": {
"glob": "^7.1.3"
},
"bin": {
"rimraf": "bin.js"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/@mdx-js/mdx": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@mdx-js/mdx/-/mdx-3.1.0.tgz",
@@ -13169,6 +13256,69 @@
"node": "^10.12.0 || >=12.0.0"
}
},
"node_modules/flat-cache/node_modules/brace-expansion": {
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
}
},
"node_modules/flat-cache/node_modules/glob": {
"version": "7.2.3",
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
"integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
"deprecated": "Glob versions prior to v9 are no longer supported",
"dev": true,
"license": "ISC",
"dependencies": {
"fs.realpath": "^1.0.0",
"inflight": "^1.0.4",
"inherits": "2",
"minimatch": "^3.1.1",
"once": "^1.3.0",
"path-is-absolute": "^1.0.0"
},
"engines": {
"node": "*"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/flat-cache/node_modules/minimatch": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^1.1.7"
},
"engines": {
"node": "*"
}
},
"node_modules/flat-cache/node_modules/rimraf": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
"integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==",
"deprecated": "Rimraf versions prior to v4 are no longer supported",
"dev": true,
"license": "ISC",
"dependencies": {
"glob": "^7.1.3"
},
"bin": {
"rimraf": "bin.js"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/flatted": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.1.tgz",
@@ -13206,12 +13356,12 @@
}
},
"node_modules/foreground-child": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.2.1.tgz",
"integrity": "sha512-PXUUyLqrR2XCWICfv6ukppP96sdFwWbNEnfEMt7jNsISjMsvaLNinAHNDYyvkyU+SZG2BTSbT5NjG+vZslfGTA==",
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz",
"integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==",
"license": "ISC",
"dependencies": {
"cross-spawn": "^7.0.0",
"cross-spawn": "^7.0.6",
"signal-exit": "^4.0.1"
},
"engines": {
@@ -20778,66 +20928,106 @@
"license": "MIT"
},
"node_modules/rimraf": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
"integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==",
"deprecated": "Rimraf versions prior to v4 are no longer supported",
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-6.0.1.tgz",
"integrity": "sha512-9dkvaxAsk/xNXSJzMgFqqMCuFgt2+KsOFek3TMLfo8NCPfWpBmqwyNn5Y+NX56QUYfCtsyhF3ayiboEoUmJk/A==",
"dev": true,
"license": "ISC",
"dependencies": {
"glob": "^7.1.3"
"glob": "^11.0.0",
"package-json-from-dist": "^1.0.0"
},
"bin": {
"rimraf": "bin.js"
"rimraf": "dist/esm/bin.mjs"
},
"engines": {
"node": "20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/rimraf/node_modules/brace-expansion": {
"version": "1.1.11",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
"integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
}
},
"node_modules/rimraf/node_modules/glob": {
"version": "7.2.3",
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
"integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
"deprecated": "Glob versions prior to v9 are no longer supported",
"version": "11.0.3",
"resolved": "https://registry.npmjs.org/glob/-/glob-11.0.3.tgz",
"integrity": "sha512-2Nim7dha1KVkaiF4q6Dj+ngPPMdfvLJEOpZk/jKiUAkqKebpGAWQXAq9z1xu9HKu5lWfqw/FASuccEjyznjPaA==",
"dev": true,
"license": "ISC",
"dependencies": {
"fs.realpath": "^1.0.0",
"inflight": "^1.0.4",
"inherits": "2",
"minimatch": "^3.1.1",
"once": "^1.3.0",
"path-is-absolute": "^1.0.0"
"foreground-child": "^3.3.1",
"jackspeak": "^4.1.1",
"minimatch": "^10.0.3",
"minipass": "^7.1.2",
"package-json-from-dist": "^1.0.0",
"path-scurry": "^2.0.0"
},
"bin": {
"glob": "dist/esm/bin.mjs"
},
"engines": {
"node": "*"
"node": "20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/rimraf/node_modules/jackspeak": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.1.1.tgz",
"integrity": "sha512-zptv57P3GpL+O0I7VdMJNBZCu+BPHVQUk55Ft8/QCJjTVxrnJHuVuX/0Bl2A6/+2oyR/ZMEuFKwmzqqZ/U5nPQ==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
"@isaacs/cliui": "^8.0.2"
},
"engines": {
"node": "20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/rimraf/node_modules/lru-cache": {
"version": "11.1.0",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.1.0.tgz",
"integrity": "sha512-QIXZUBJUx+2zHUdQujWejBkcD9+cs94tLn0+YL8UrCh+D5sCXZ4c7LaEH48pNwRY3MLDgqUFyhlCyjJPf1WP0A==",
"dev": true,
"license": "ISC",
"engines": {
"node": "20 || >=22"
}
},
"node_modules/rimraf/node_modules/minimatch": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
"version": "10.0.3",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.0.3.tgz",
"integrity": "sha512-IPZ167aShDZZUMdRk66cyQAW3qr0WzbHkPdMYa8bzZhlHhO3jALbKdxcaak7W9FfT2rZNpQuUu4Od7ILEpXSaw==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^1.1.7"
"@isaacs/brace-expansion": "^5.0.0"
},
"engines": {
"node": "*"
"node": "20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/rimraf/node_modules/path-scurry": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.0.tgz",
"integrity": "sha512-ypGJsmGtdXUOeM5u93TyeIEfEhM6s+ljAhrk5vAvSx8uyY/02OvrZnA0YNGUrPXfpJMgI1ODd3nwz8Npx4O4cg==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
"lru-cache": "^11.0.0",
"minipass": "^7.1.2"
},
"engines": {
"node": "20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/router": {
@@ -26670,6 +26860,21 @@
"dev": true,
"requires": {}
},
"@isaacs/balanced-match": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz",
"integrity": "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==",
"dev": true
},
"@isaacs/brace-expansion": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.0.tgz",
"integrity": "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==",
"dev": true,
"requires": {
"@isaacs/balanced-match": "^4.0.1"
}
},
"@isaacs/cliui": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
@@ -26909,12 +27114,36 @@
"debug": "4"
}
},
"brace-expansion": {
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"dev": true,
"requires": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
}
},
"detect-libc": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz",
"integrity": "sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==",
"dev": true
},
"glob": {
"version": "7.2.3",
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
"integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
"dev": true,
"requires": {
"fs.realpath": "^1.0.0",
"inflight": "^1.0.4",
"inherits": "2",
"minimatch": "^3.1.1",
"once": "^1.3.0",
"path-is-absolute": "^1.0.0"
}
},
"https-proxy-agent": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
@@ -26941,6 +27170,24 @@
"dev": true
}
}
},
"minimatch": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
"dev": true,
"requires": {
"brace-expansion": "^1.1.7"
}
},
"rimraf": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
"integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==",
"dev": true,
"requires": {
"glob": "^7.1.3"
}
}
}
},
@@ -33440,6 +33687,50 @@
"flatted": "^3.2.9",
"keyv": "^4.5.3",
"rimraf": "^3.0.2"
},
"dependencies": {
"brace-expansion": {
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"dev": true,
"requires": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
}
},
"glob": {
"version": "7.2.3",
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
"integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
"dev": true,
"requires": {
"fs.realpath": "^1.0.0",
"inflight": "^1.0.4",
"inherits": "2",
"minimatch": "^3.1.1",
"once": "^1.3.0",
"path-is-absolute": "^1.0.0"
}
},
"minimatch": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
"dev": true,
"requires": {
"brace-expansion": "^1.1.7"
}
},
"rimraf": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
"integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==",
"dev": true,
"requires": {
"glob": "^7.1.3"
}
}
}
},
"flatted": {
@@ -33463,11 +33754,11 @@
}
},
"foreground-child": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.2.1.tgz",
"integrity": "sha512-PXUUyLqrR2XCWICfv6ukppP96sdFwWbNEnfEMt7jNsISjMsvaLNinAHNDYyvkyU+SZG2BTSbT5NjG+vZslfGTA==",
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz",
"integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==",
"requires": {
"cross-spawn": "^7.0.0",
"cross-spawn": "^7.0.6",
"signal-exit": "^4.0.1"
}
},
@@ -38623,45 +38914,61 @@
"dev": true
},
"rimraf": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
"integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==",
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-6.0.1.tgz",
"integrity": "sha512-9dkvaxAsk/xNXSJzMgFqqMCuFgt2+KsOFek3TMLfo8NCPfWpBmqwyNn5Y+NX56QUYfCtsyhF3ayiboEoUmJk/A==",
"dev": true,
"requires": {
"glob": "^7.1.3"
"glob": "^11.0.0",
"package-json-from-dist": "^1.0.0"
},
"dependencies": {
"brace-expansion": {
"version": "1.1.11",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
"integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
"glob": {
"version": "11.0.3",
"resolved": "https://registry.npmjs.org/glob/-/glob-11.0.3.tgz",
"integrity": "sha512-2Nim7dha1KVkaiF4q6Dj+ngPPMdfvLJEOpZk/jKiUAkqKebpGAWQXAq9z1xu9HKu5lWfqw/FASuccEjyznjPaA==",
"dev": true,
"requires": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
"foreground-child": "^3.3.1",
"jackspeak": "^4.1.1",
"minimatch": "^10.0.3",
"minipass": "^7.1.2",
"package-json-from-dist": "^1.0.0",
"path-scurry": "^2.0.0"
}
},
"glob": {
"version": "7.2.3",
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
"integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
"jackspeak": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.1.1.tgz",
"integrity": "sha512-zptv57P3GpL+O0I7VdMJNBZCu+BPHVQUk55Ft8/QCJjTVxrnJHuVuX/0Bl2A6/+2oyR/ZMEuFKwmzqqZ/U5nPQ==",
"dev": true,
"requires": {
"fs.realpath": "^1.0.0",
"inflight": "^1.0.4",
"inherits": "2",
"minimatch": "^3.1.1",
"once": "^1.3.0",
"path-is-absolute": "^1.0.0"
"@isaacs/cliui": "^8.0.2"
}
},
"lru-cache": {
"version": "11.1.0",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.1.0.tgz",
"integrity": "sha512-QIXZUBJUx+2zHUdQujWejBkcD9+cs94tLn0+YL8UrCh+D5sCXZ4c7LaEH48pNwRY3MLDgqUFyhlCyjJPf1WP0A==",
"dev": true
},
"minimatch": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
"version": "10.0.3",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.0.3.tgz",
"integrity": "sha512-IPZ167aShDZZUMdRk66cyQAW3qr0WzbHkPdMYa8bzZhlHhO3jALbKdxcaak7W9FfT2rZNpQuUu4Od7ILEpXSaw==",
"dev": true,
"requires": {
"brace-expansion": "^1.1.7"
"@isaacs/brace-expansion": "^5.0.0"
}
},
"path-scurry": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.0.tgz",
"integrity": "sha512-ypGJsmGtdXUOeM5u93TyeIEfEhM6s+ljAhrk5vAvSx8uyY/02OvrZnA0YNGUrPXfpJMgI1ODd3nwz8Npx4O4cg==",
"dev": true,
"requires": {
"lru-cache": "^11.0.0",
"minipass": "^7.1.2"
}
}
}
+3 -1
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.17.15",
"version": "3.17.16",
"icon": "assets/icons/icon.png",
"engines": {
"vscode": "^1.84.0"
@@ -332,6 +332,7 @@
"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",
@@ -396,6 +397,7 @@
"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",
+35
View File
@@ -23,6 +23,9 @@ service AccountService {
// Handles authentication state changes from the Firebase context.
// Updates the user info in global state and returns the updated value.
rpc authStateChanged(AuthStateChangedRequest) returns (AuthStateChanged);
// Fetches all user credits data (balance, usage transactions, payment transactions)
rpc fetchUserCreditsData(EmptyRequest) returns (UserCreditsData);
}
message AuthStateChangedRequest {
@@ -39,3 +42,35 @@ message UserInfo {
optional string email = 2;
optional string photo_url = 3;
}
// Response containing all user credits data
message UserCreditsData {
UserCreditsBalance balance = 1;
repeated UsageTransaction usage_transactions = 2;
repeated PaymentTransaction payment_transactions = 3;
}
// User's current credit balance
message UserCreditsBalance {
double current_balance = 1;
}
// Usage transaction record
message UsageTransaction {
string spent_at = 1;
string creator_id = 2;
double credits = 3;
string model_provider = 4;
string model = 5;
int32 prompt_tokens = 6;
int32 completion_tokens = 7;
int32 total_tokens = 8;
}
// Payment transaction record
message PaymentTransaction {
string paid_at = 1;
string creator_id = 2;
int32 amount_cents = 3;
double credits = 4;
}
+12
View File
@@ -17,6 +17,7 @@ service StateService {
rpc togglePlanActMode(TogglePlanActModeRequest) returns (Boolean);
rpc updateAutoApprovalSettings(AutoApprovalSettingsRequest) returns (Empty);
rpc updateSettings(UpdateSettingsRequest) returns (Empty);
rpc updateTelemetrySetting(TelemetrySettingRequest) returns (Empty);
}
message State {
@@ -88,6 +89,17 @@ 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;
+7
View File
@@ -35,6 +35,8 @@ 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(BooleanRequest) returns (DeleteAllTaskHistoryCount);
}
// Request message for creating a new task
@@ -116,3 +118,8 @@ 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,4 +265,7 @@ 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);
}
+2 -1
View File
@@ -13,7 +13,8 @@ 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 > $SDK_DEST
sort | uniq -c | sort -n | # Count occurrences
cat > $SDK_DEST
}
echo Wrote uses of the vscode SDK to $(realpath $SDK_DEST)
+62 -77
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 { ClaudeCodeMessage } from "@/integrations/claude-code/types"
import { filterMessagesForClaudeCode } from "@/integrations/claude-code/message-filter"
export class ClaudeCodeHandler implements ApiHandler {
private options: ApiHandlerOptions
@@ -19,39 +19,16 @@ 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,
messages: filteredMessages,
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 = {
@@ -62,61 +39,75 @@ export class ClaudeCodeHandler implements ApiHandler {
cacheWriteTokens: 0,
}
while (exitCode !== 0 || dataQueue.length > 0) {
if (dataQueue.length === 0) {
await new Promise((resolve) => setImmediate(resolve))
}
let isPaidUsage = true
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) {
for await (const chunk of claudeProcess) {
if (typeof chunk === "string") {
yield {
type: "text",
text: data || "",
text: chunk,
}
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 && message.stop_reason !== "tool_use") {
const errorMessage = message.content[0]?.text || `Claude Code stopped with reason: ${message.stop_reason}`
if (message.stop_reason !== null) {
const content = "text" in message.content[0] ? message.content[0] : undefined
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.`,
)
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)
}
throw new Error(errorMessage)
}
for (const content of message.content) {
if (content.type === "text") {
yield {
type: "text",
text: content.text,
}
} else {
console.warn("Unsupported content type:", content.type)
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
}
}
@@ -129,14 +120,18 @@ export class ClaudeCodeHandler implements ApiHandler {
}
if (chunk.type === "result" && "result" in chunk) {
usage.totalCost = chunk.cost_usd || 0
usage.totalCost = isPaidUsage ? chunk.total_cost_usd : 0
yield usage
}
}
}
if (processError) {
throw processError
}
private attemptParse(str: string) {
try {
return JSON.parse(str)
} catch (err) {
return null
}
}
@@ -152,14 +147,4 @@ 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
}
}
}
+9 -2
View File
@@ -101,14 +101,21 @@ export class LiteLlmHandler implements ApiHandler {
return message
})
const stream = await this.client.chat.completions.create({
const requestPayload: OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming & {
metadata?: { cline_task_id: string }
} = {
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
@@ -0,0 +1,34 @@
import type { Controller } from "../index"
import type { EmptyRequest } from "@shared/proto/common"
import { UserCreditsData } from "@shared/proto/account"
/**
* Handles fetching all user credits data (balance, usage, payments)
* @param controller The controller instance
* @param request Empty request
* @returns User credits data response
*/
export async function fetchUserCreditsData(controller: Controller, request: EmptyRequest): Promise<UserCreditsData> {
try {
if (!controller.accountService) {
throw new Error("Account service not available")
}
// Call the individual RPC variants in parallel
const [balance, usageTransactions, paymentTransactions] = await Promise.all([
controller.accountService.fetchBalanceRPC(),
controller.accountService.fetchUsageTransactionsRPC(),
controller.accountService.fetchPaymentTransactionsRPC(),
])
// Since generated types match exactly, no conversion needed!
return UserCreditsData.create({
balance: balance ? { currentBalance: balance.currentBalance } : { currentBalance: 0 },
usageTransactions: usageTransactions || [],
paymentTransactions: paymentTransactions || [],
})
} catch (error) {
console.error(`Failed to fetch user credits data: ${error}`)
throw error
}
}
+25 -156
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 } from "@shared/ChatSettings"
import { ChatSettings, StoredChatSettings } from "@shared/ChatSettings"
import { ExtensionMessage, ExtensionState, Platform } from "@shared/ExtensionMessage"
import { HistoryItem } from "@shared/HistoryItem"
import { McpMarketplaceCatalog } from "@shared/mcp"
@@ -44,6 +44,8 @@ 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
@@ -56,6 +58,7 @@ 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
@@ -135,7 +138,7 @@ export class Controller {
apiConfiguration,
autoApprovalSettings,
browserSettings,
chatSettings,
chatSettings: storedChatSettings,
shellIntegrationTimeout,
terminalReuseEnabled,
terminalOutputLineLimit,
@@ -145,6 +148,12 @@ 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"
@@ -205,43 +214,10 @@ export class Controller {
*/
async handleWebviewMessage(message: WebviewMessage) {
switch (message.type) {
case "fetchUserCreditsData": {
await this.fetchUserCreditsData()
break
}
case "fetchMcpMarketplace": {
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)
@@ -269,6 +245,9 @@ 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)
@@ -438,7 +417,9 @@ export class Controller {
}
}
await updateWorkspaceState(this.context, "chatSettings", chatSettings)
// Save only non-mode properties to workspace storage
const { mode, ...persistentChatSettings }: { mode: string } & StoredChatSettings = chatSettings
await updateWorkspaceState(this.context, "chatSettings", persistentChatSettings)
await this.postStateToWebview()
if (this.task) {
@@ -492,20 +473,6 @@ export class Controller {
}
}
// Account
async fetchUserCreditsData() {
try {
await Promise.all([
this.accountService?.fetchBalance(),
this.accountService?.fetchUsageTransactions(),
this.accountService?.fetchPaymentTransactions(),
])
} catch (error) {
console.error("Failed to fetch user credits data:", error)
}
}
// Auth
public async validateAuthState(state: string | null): Promise<boolean> {
@@ -848,110 +815,6 @@ 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) || []
@@ -966,7 +829,7 @@ export class Controller {
async postStateToWebview() {
const state = await this.getStateToPostToWebview()
await sendStateUpdate(state)
await sendStateUpdate(this.id, state)
}
async getStateToPostToWebview(): Promise<ExtensionState> {
@@ -976,7 +839,7 @@ export class Controller {
taskHistory,
autoApprovalSettings,
browserSettings,
chatSettings,
chatSettings: storedChatSettings,
userInfo,
mcpMarketplaceEnabled,
mcpRichDisplayEnabled,
@@ -993,6 +856,12 @@ 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) || {}
+32 -29
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
const activeStateSubscriptions = new Set<StreamingResponseHandler>()
// Keep track of active state subscriptions by controller ID
const activeStateSubscriptions = new Map<string, StreamingResponseHandler>()
/**
* Subscribe to state updates
@@ -19,23 +19,25 @@ 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")
console.log(`[DEBUG] set up state subscription for controller ${controllerId}`)
await responseStream({
stateJson: initialStateJson,
})
// Add this subscription to the active subscriptions
activeStateSubscriptions.add(responseStream)
// Add this subscription to the active subscriptions with the controller ID
activeStateSubscriptions.set(controllerId, responseStream)
// Register cleanup when the connection is closed
const cleanup = () => {
activeStateSubscriptions.delete(responseStream)
console.log("[DEBUG] Cleaned up state subscription")
activeStateSubscriptions.delete(controllerId)
console.log(`[DEBUG] Cleaned up state subscription for controller ${controllerId}`)
}
// Register the cleanup function with the request registry if we have a requestId
@@ -45,30 +47,31 @@ export async function subscribeToState(
}
/**
* Send a state update to all active subscribers
* Send a state update to a specific controller's subscription
* @param controllerId The ID of the controller to send the state to
* @param state The state to send
*/
export async function sendStateUpdate(state: any): Promise<void> {
const stateJson = JSON.stringify(state)
export async function sendStateUpdate(controllerId: string, state: any): Promise<void> {
// Get the subscription for this specific controller
const responseStream = activeStateSubscriptions.get(controllerId)
// 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)
}
})
if (!responseStream) {
console.log(`[DEBUG] No active state subscription for controller ${controllerId}`)
return
}
await Promise.all(promises)
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)
}
}
@@ -0,0 +1,16 @@
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()
}
@@ -0,0 +1,114 @@
import path from "path"
import fs from "fs/promises"
import { Controller } from ".."
import { DeleteAllTaskHistoryCount } from "../../../shared/proto/task"
import { BooleanRequest } from "../../../shared/proto/common"
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, request: BooleanRequest): 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
// If preserving favorites, filter out non-favorites
if (request.value) {
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 {
return DeleteAllTaskHistoryCount.create({
tasksDeleted: 0,
})
}
}
// 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
}
+70 -2
View File
@@ -1,6 +1,9 @@
import path from "path"
import fs from "fs/promises"
import { Controller } from ".."
import { Empty, StringArrayRequest } from "../../../shared/proto/common"
import { Empty, StringArrayRequest, BooleanRequest } from "../../../shared/proto/common"
import { TaskMethodHandler } from "./index"
import { fileExistsAtPath } from "../../../utils/fs"
/**
* Deletes tasks with the specified IDs
@@ -17,7 +20,72 @@ export const deleteTasksWithIds: TaskMethodHandler = async (
throw new Error("Missing task IDs")
}
await Promise.all(request.value.map((value) => controller.deleteTaskWithId(value)))
for (const id of request.value) {
await deleteTaskWithId(controller, id)
}
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
@@ -0,0 +1,20 @@
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
}
}
+24 -2
View File
@@ -7,7 +7,7 @@ import { ApiConfiguration, ApiProvider, BedrockModelId, ModelInfo } from "@share
import { HistoryItem } from "@shared/HistoryItem"
import { AutoApprovalSettings } from "@shared/AutoApprovalSettings"
import { BrowserSettings } from "@shared/BrowserSettings"
import { ChatSettings } from "@shared/ChatSettings"
import { ChatSettings, StoredChatSettings } from "@shared/ChatSettings"
import { TelemetrySetting } from "@shared/TelemetrySetting"
import { UserInfo } from "@shared/UserInfo"
import { ClineRulesToggles } from "@shared/cline-rules"
@@ -173,6 +173,28 @@ export async function migrateCustomInstructionsToGlobalRules(context: vscode.Ext
}
}
export async function cleanupModeFromWorkspaceStorage(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
}
}
export async function getAllExtensionState(context: vscode.ExtensionContext) {
const [
isNewUser,
@@ -360,7 +382,7 @@ export async function getAllExtensionState(context: vscode.ExtensionContext) {
previousModeSapAiCoreResourceGroup,
previousModeSapAiCoreModelId,
] = await Promise.all([
getWorkspaceState(context, "chatSettings") as Promise<ChatSettings | undefined>,
getWorkspaceState(context, "chatSettings") as Promise<StoredChatSettings | undefined>,
getWorkspaceState(context, "apiProvider") as Promise<ApiProvider | undefined>,
getWorkspaceState(context, "apiModelId") as Promise<string | undefined>,
getWorkspaceState(context, "thinkingBudgetTokens") as Promise<number | undefined>,
+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://*.firebaseauth.com https://*.firebaseio.com https://*.googleapis.com https://*.firebase.com;
connect-src https://*.posthog.com https://*.cline.bot 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:;
+8 -1
View File
@@ -22,7 +22,11 @@ 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 } from "./core/storage/state"
import {
migratePlanActGlobalToWorkspaceStorage,
migrateCustomInstructionsToGlobalRules,
cleanupModeFromWorkspaceStorage,
} from "./core/storage/state"
import { sendFocusChatInputEvent } from "./core/controller/ui/subscribeToFocusChatInput"
import { FileContextTracker } from "./core/context/context-tracking/FileContextTracker"
@@ -60,6 +64,9 @@ export async function activate(context: vscode.ExtensionContext) {
// Migrate custom instructions to global Cline rules (one-time cleanup)
await migrateCustomInstructionsToGlobalRules(context)
// Clean up mode from workspace storage (one-time cleanup)
await cleanupModeFromWorkspaceStorage(context)
// Clean up orphaned file context warnings (startup cleanup)
await FileContextTracker.cleanupOrphanedWarnings(context)
@@ -0,0 +1,33 @@
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,
}
})
}
+142 -10
View File
@@ -1,23 +1,115 @@
import * as vscode from "vscode"
import Anthropic from "@anthropic-ai/sdk"
import type 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)
export function runClaudeCode({
systemPrompt,
messages,
path,
modelId,
}: {
type ClaudeCodeOptions = {
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),
@@ -26,6 +118,8 @@ export function runClaudeCode({
"--verbose",
"--output-format",
"stream-json",
"--disallowedTools",
claudeCodeTools,
// Cline will handle recursive calls
"--max-turns",
"1",
@@ -39,7 +133,45 @@ export function runClaudeCode({
stdin: "ignore",
stdout: "pipe",
stderr: "pipe",
env: process.env,
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",
},
cwd,
maxBuffer: 1024 * 1024 * 1000,
timeout: CLAUDE_CODE_TIMEOUT,
})
}
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
}
}
+5 -23
View File
@@ -1,34 +1,17 @@
import type { Anthropic } from "@anthropic-ai/sdk"
type InitMessage = {
type: "system"
subtype: "init"
session_id: string
tools: string[]
mcp_servers: string[]
}
type ClaudeCodeContent = {
type: "text"
text: string
apiKeySource: "none" | "/login managed key" | string
}
type AssistantMessage = {
type: "assistant"
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"
}
}
message: Anthropic.Messages.Message
session_id: string
}
@@ -39,13 +22,12 @@ type ErrorMessage = {
type ResultMessage = {
type: "result"
subtype: "success"
cost_usd: number
total_cost_usd: number
is_error: boolean
duration_ms: number
duration_api_ms: number
num_turns: number
result: string
total_cost: number
session_id: string
}
+16 -34
View File
@@ -49,61 +49,43 @@ export class ClineAccountService {
}
/**
* Fetches the user's current credit balance
* RPC variant that fetches the user's current credit balance without posting to webview
* @returns Balance data or undefined if failed
*/
async fetchBalance(): Promise<BalanceResponse | undefined> {
async fetchBalanceRPC(): Promise<BalanceResponse | undefined> {
try {
const data = await this.authenticatedRequest<BalanceResponse>("/user/credits/balance")
// Post to webview
await this.postMessageToWebview({
type: "userCreditsBalance",
userCreditsBalance: data,
})
return data
} catch (error) {
console.error("Failed to fetch balance:", error)
console.error("Failed to fetch balance (RPC):", error)
return undefined
}
}
/**
* Fetches the user's usage transactions
* RPC variant that fetches the user's usage transactions without posting to webview
* @returns Usage transactions or undefined if failed
*/
async fetchUsageTransactions(): Promise<UsageTransaction[] | undefined> {
async fetchUsageTransactionsRPC(): Promise<UsageTransaction[] | undefined> {
try {
const data = await this.authenticatedRequest<UsageTransaction[]>("/user/credits/usage")
// Post to webview
await this.postMessageToWebview({
type: "userCreditsUsage",
userCreditsUsage: data,
})
return data
const data = await this.authenticatedRequest<{ usageTransactions: UsageTransaction[] }>("/user/credits/usage")
return data.usageTransactions
} catch (error) {
console.error("Failed to fetch usage transactions:", error)
console.error("Failed to fetch usage transactions (RPC):", error)
return undefined
}
}
/**
* Fetches the user's payment transactions
* RPC variant that fetches the user's payment transactions without posting to webview
* @returns Payment transactions or undefined if failed
*/
async fetchPaymentTransactions(): Promise<PaymentTransaction[] | undefined> {
async fetchPaymentTransactionsRPC(): Promise<PaymentTransaction[] | undefined> {
try {
const data = await this.authenticatedRequest<PaymentTransaction[]>("/user/credits/payments")
// Post to webview
await this.postMessageToWebview({
type: "userCreditsPayments",
userCreditsPayments: data,
})
return data
const data = await this.authenticatedRequest<{ paymentTransactions: PaymentTransaction[] }>("/user/credits/payments")
return data.paymentTransactions
} catch (error) {
console.error("Failed to fetch payment transactions:", error)
console.error("Failed to fetch payment transactions (RPC):", error)
return undefined
}
}
+3
View File
@@ -8,6 +8,9 @@ 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",
+8 -5
View File
@@ -4,15 +4,18 @@ export interface BalanceResponse {
export interface UsageTransaction {
spentAt: string
credits: string
creatorId: string
credits: number
modelProvider: string
model: string
promptTokens: string
completionTokens: string
promptTokens: number
completionTokens: number
totalTokens: number
}
export interface PaymentTransaction {
paidAt: string
amountCents: string
credits: string
creatorId: string
amountCents: number
credits: number
}
+1 -12
View File
@@ -14,15 +14,7 @@ import { UserInfo } from "./UserInfo"
// webview will hold state
export interface ExtensionMessage {
type:
| "action"
| "state"
| "selectedImages"
| "mcpDownloadDetails"
| "userCreditsBalance"
| "userCreditsUsage"
| "userCreditsPayments"
| "grpc_response" // New type for gRPC responses
type: "action" | "state" | "selectedImages" | "mcpDownloadDetails" | "grpc_response" // New type for gRPC responses
text?: string
action?: "accountLogoutClicked"
state?: ExtensionState
@@ -40,9 +32,6 @@ export interface ExtensionMessage {
commits?: GitCommit[]
url?: string
isImage?: boolean
userCreditsBalance?: BalanceResponse
userCreditsUsage?: UsageTransaction[]
userCreditsPayments?: PaymentTransaction[]
success?: boolean
endpoint?: string
isBundled?: boolean
-2
View File
@@ -12,8 +12,6 @@ export interface WebviewMessage {
| "fetchMcpMarketplace"
| "searchCommits"
| "telemetrySetting"
| "clearAllTaskHistory"
| "fetchUserCreditsData"
| "grpc_request"
| "grpc_request_cancel"
+25 -5
View File
@@ -230,11 +230,31 @@ 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"],
"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"],
"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,
},
} as const satisfies Record<string, ModelInfo>
// AWS Bedrock
@@ -0,0 +1,34 @@
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"
}
}
+5 -3
View File
@@ -10,7 +10,9 @@ https://github.com/KumarVariable/vscode-extension-sidebar-html/blob/master/src/c
*/
export class ExternalWebviewProvider extends WebviewProvider {
private RESOURCE_AUTHORITY: string = "file.resources"
// This hostname cannot be changed without updating the external webview handler.
private RESOURCE_HOSTNAME: string = "internal.resources"
constructor(context: vscode.ExtensionContext, outputChannel: vscode.OutputChannel, providerType: WebviewProviderType) {
super(context, outputChannel, providerType)
}
@@ -19,10 +21,10 @@ export class ExternalWebviewProvider extends WebviewProvider {
if (uri.scheme !== "file") {
return uri
}
return URI.from({ scheme: "https", authority: this.RESOURCE_AUTHORITY, path: uri.fsPath })
return URI.from({ scheme: "https", authority: this.RESOURCE_HOSTNAME, path: uri.fsPath })
}
override getCspSource() {
return "csp-source"
return `'self' https://${this.RESOURCE_HOSTNAME}`
}
override postMessageToWebview(message: ExtensionMessage) {
console.log(`postMessageToWebview: ${message}`)
+18 -3
View File
@@ -20,6 +20,10 @@ 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.
@@ -29,12 +33,15 @@ async function main() {
// Add all the handlers for the ProtoBus services to the server.
addProtobusServices(server, controller, wrapHandler, wrapStreamingResponseHandler)
// Set up reflection.
const reflection = new ReflectionService(getPackageDefinition())
// Create reflection service with protobus service names
const packageDefinition = getPackageDefinition()
const reflection = new ReflectionService(packageDefinition, {
services: getProtobusServiceNames(packageDefinition),
})
reflection.addToServer(server)
// Start the server.
const host = "127.0.0.1:50051"
const host = process.env.PROTOBUS_ADDRESS || "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}`)
@@ -45,6 +52,14 @@ async function main() {
})
}
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 clineDef = protoLoader.loadFileDescriptorSetFromBuffer(descriptorSet)
const descriptorDefs = protoLoader.loadFileDescriptorSetFromBuffer(descriptorSet)
const healthDef = protoLoader.loadSync(health.protoPath)
const packageDefinition = { ...clineDef, ...healthDef }
const packageDefinition = { ...descriptorDefs, ...healthDef }
return packageDefinition
}
+1
View File
@@ -28,6 +28,7 @@
"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,7 +11,6 @@
"@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",
@@ -38,7 +37,6 @@
"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"
},
@@ -15551,15 +15549,6 @@
"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,7 +18,6 @@
"@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",
@@ -45,7 +44,6 @@
"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"
},
@@ -42,30 +42,21 @@ export const ClineAccountView = () => {
const [usageData, setUsageData] = useState<UsageTransaction[]>([])
const [paymentsData, setPaymentsData] = useState<PaymentTransaction[]>([])
// Listen for balance and transaction data updates from the extension
// Fetch all account data when component mounts using gRPC
useEffect(() => {
const handleMessage = (event: MessageEvent) => {
const message = event.data
if (message.type === "userCreditsBalance" && message.userCreditsBalance) {
setBalance(message.userCreditsBalance.currentBalance)
} else if (message.type === "userCreditsUsage" && message.userCreditsUsage) {
setUsageData(message.userCreditsUsage.usageTransactions)
} else if (message.type === "userCreditsPayments" && message.userCreditsPayments) {
setPaymentsData(message.userCreditsPayments.paymentTransactions)
}
setIsLoading(false)
}
window.addEventListener("message", handleMessage)
// Fetch all account data when component mounts
if (user) {
setIsLoading(true)
vscode.postMessage({ type: "fetchUserCreditsData" })
}
return () => {
window.removeEventListener("message", handleMessage)
AccountServiceClient.fetchUserCreditsData(EmptyRequest.create())
.then((response) => {
setBalance(response.balance?.currentBalance || 0)
setUsageData(response.usageTransactions)
setPaymentsData(response.paymentTransactions)
setIsLoading(false)
})
.catch((error) => {
console.error("Failed to fetch user credits data:", error)
setIsLoading(false)
})
}
}, [user])
@@ -135,7 +126,20 @@ export const ClineAccountView = () => {
<VSCodeButton
appearance="icon"
className="mt-1"
onClick={() => vscode.postMessage({ type: "fetchUserCreditsData" })}>
onClick={() => {
setIsLoading(true)
AccountServiceClient.fetchUserCreditsData(EmptyRequest.create())
.then((response) => {
setBalance(response.balance?.currentBalance || 0)
setUsageData(response.usageTransactions)
setPaymentsData(response.paymentTransactions)
setIsLoading(false)
})
.catch((error) => {
console.error("Failed to refresh user credits data:", error)
setIsLoading(false)
})
}}>
<span className="codicon codicon-refresh"></span>
</VSCodeButton>
</>
@@ -92,7 +92,7 @@ const CreditsHistoryTable = ({ isLoading, usageData, paymentsData }: CreditsHist
<VSCodeDataGridCell grid-column="1">
{formatTimestamp(row.paidAt)}
</VSCodeDataGridCell>
<VSCodeDataGridCell grid-column="2">{`$${formatDollars(parseInt(row.amountCents))}`}</VSCodeDataGridCell>
<VSCodeDataGridCell grid-column="2">{`$${formatDollars(row.amountCents)}`}</VSCodeDataGridCell>
<VSCodeDataGridCell grid-column="3">{`${row.credits}`}</VSCodeDataGridCell>
</VSCodeDataGridRow>
))}
+7 -9
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 } from "@/services/grpc-client"
import { FileServiceClient, TaskServiceClient, UiServiceClient } from "@/services/grpc-client"
import { findMatchingResourceOrTemplate, getMcpServerDisplayName } from "@/utils/mcp"
import { vscode } from "@/utils/vscode"
import {
@@ -697,15 +697,13 @@ export const ChatRowContent = ({
msUserSelect: "none",
}}
onClick={() => {
// Attempt to open the URL in the default browser
// Open the URL in the default browser using gRPC
if (tool.path) {
// 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
UiServiceClient.openUrl(StringRequest.create({ value: tool.path }))
.catch((err) => {
console.error("Failed to open URL:", err)
})
}
}}>
<span
+2 -22
View File
@@ -414,28 +414,8 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
}, [task?.ts])
const isStreaming = useMemo(() => {
const isLastAsk = !!modifiedMessages.at(-1)?.ask // checking clineAsk isn't enough since messages effect may be called again for a tool for example, set clineAsk to its value, and if the next message is not an ask then it doesn't reset. This is likely due to how much more often we're updating messages as compared to before, and should be resolved with optimizations as it's likely a rendering bug. but as a final guard for now, the cancel button will show if the last message is not an ask
const isToolCurrentlyAsking = isLastAsk && clineAsk !== undefined && enableButtons && primaryButtonText !== undefined
if (isToolCurrentlyAsking) {
return false
}
const isLastMessagePartial = modifiedMessages.at(-1)?.partial === true
if (isLastMessagePartial) {
return true
} else {
const lastApiReqStarted = findLast(modifiedMessages, (message) => message.say === "api_req_started")
if (lastApiReqStarted && lastApiReqStarted.text != null && lastApiReqStarted.say === "api_req_started") {
const cost = JSON.parse(lastApiReqStarted.text).cost
if (cost === undefined) {
// api request has not finished yet
return true
}
}
}
return false
}, [modifiedMessages, clineAsk, enableButtons, primaryButtonText])
return modifiedMessages.at(-1)?.partial === true
}, [modifiedMessages])
const handleSendMessage = useCallback(
async (text: string, images: string[], files: string[]) => {
@@ -1,5 +1,4 @@
import React, { ReactNode } from "react"
import { cn } from "@/utils/cn"
import { VSCodeButton } from "@vscode/webview-ui-toolkit/react"
import { AlertTriangle } from "lucide-react"
import { OPENROUTER_MODEL_PICKER_Z_INDEX } from "../settings/OpenRouterModelPicker"
@@ -22,7 +21,7 @@ export function AlertDialog({ open, onOpenChange, children }: AlertDialogProps)
return (
<div
className={cn(`fixed inset-0 bg-black/50 flex items-center justify-center`)}
className={`fixed inset-0 bg-black/50 flex items-center justify-center`}
onClick={handleBackdropClick}
style={{ zIndex: OPENROUTER_MODEL_PICKER_Z_INDEX + 50 }}>
{children}
@@ -33,10 +32,7 @@ export function AlertDialog({ open, onOpenChange, children }: AlertDialogProps)
export function AlertDialogContent({ className, children, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn(
`fixed top-[50%] left-[50%] grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%]`,
className,
)}
className={`fixed top-[50%] left-[50%] grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] ${className}`}
onClick={(e) => e.stopPropagation()}
{...props}>
<div className="bg-[var(--vscode-editor-background)] rounded-sm gap-3 border border-[var(--vscode-panel-border)] p-4 shadow-lg sm:max-w-md">
@@ -47,27 +43,24 @@ export function AlertDialogContent({ className, children, ...props }: React.HTML
}
export function AlertDialogHeader({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return <div className={cn("flex flex-col gap-1 text-left", className)} {...props} />
return <div className={`flex flex-col gap-1 text-left ${className}`} {...props} />
}
export function AlertDialogFooter({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return <div className={cn("flex flex-row justify-end gap-2 mt-4", className)} {...props} />
return <div className={`flex flex-row justify-end gap-2 mt-4 ${className}`} {...props} />
}
export function AlertDialogTitle({ className, ...props }: React.HTMLAttributes<HTMLHeadingElement>) {
return (
<h2
className={cn(
"text-base font-medium text-[var(--vscode-editor-foreground)] flex items-center gap-2 text-left",
className,
)}
className={`text-base font-medium text-[var(--vscode-editor-foreground)] flex items-center gap-2 text-left ${className}`}
{...props}
/>
)
}
export function AlertDialogDescription({ className, ...props }: React.HTMLAttributes<HTMLParagraphElement>) {
return <p className={cn("text-[var(--vscode-descriptionForeground)] text-sm text-left", className)} {...props} />
return <p className={`text-[var(--vscode-descriptionForeground)] text-sm text-left ${className}`} {...props} />
}
export function AlertDialogAction({ className, ...props }: React.ComponentProps<typeof VSCodeButton>) {
@@ -21,13 +21,6 @@ import {
} from "@vscode/webview-ui-toolkit/react"
function Demo() {
// function handleHowdyClick() {
// vscode.postMessage({
// command: "hello",
// text: "Hey there partner! 🤠",
// })
// }
const rowData = [
{
cell1: "Cell Data",
+5 -6
View File
@@ -1,18 +1,17 @@
import React, { HTMLAttributes, useCallback, forwardRef } from "react"
import { useExtensionState } from "@/context/ExtensionStateContext"
import { cn } from "@/utils/cn"
type TabProps = HTMLAttributes<HTMLDivElement>
export const Tab = ({ className, children, ...props }: TabProps) => (
<div className={cn("fixed inset-0 flex flex-col", className)} {...props}>
<div className={`fixed inset-0 flex flex-col ${className}`} {...props}>
{children}
</div>
)
export const TabHeader = ({ className, children, ...props }: TabProps) => (
<div className={cn("px-5 py-2.5 border-b border-[var(--vscode-panel-border)]", className)} {...props}>
<div className={`px-5 py-2.5 border-b border-[var(--vscode-panel-border)] ${className}`} {...props}>
{children}
</div>
)
@@ -31,7 +30,7 @@ export const TabContent = ({ className, children, ...props }: TabProps) => {
}, [])
return (
<div className={cn("flex-1 overflow-auto", className)} onWheel={onWheel} {...props}>
<div className={`flex-1 overflow-auto ${className}`} onWheel={onWheel} {...props}>
{children}
</div>
)
@@ -53,7 +52,7 @@ export const TabList = forwardRef<
)
return (
<div ref={ref} role="tablist" className={cn("flex", className)} {...props}>
<div ref={ref} role="tablist" className={`flex ${className}`} {...props}>
{React.Children.map(children, (child) => {
if (React.isValidElement(child)) {
// Make sure we're passing the correct props to the TabTrigger
@@ -83,7 +82,7 @@ export const TabTrigger = forwardRef<
role="tab"
aria-selected={isSelected}
tabIndex={isSelected ? 0 : -1}
className={cn("focus:outline-none", className)}
className={`focus:outline-none ${className}`}
onClick={onSelect}
data-value={value} // Add data-value attribute for debugging
{...props}>
@@ -2,8 +2,8 @@ import { VSCodeButton, VSCodeLink } from "@vscode/webview-ui-toolkit/react"
import { memo, useState } from "react"
import styled from "styled-components"
import { useExtensionState } from "@/context/ExtensionStateContext"
import { vscode } from "@/utils/vscode"
import { TelemetrySetting } from "@shared/TelemetrySetting"
import { StateServiceClient } from "@/services/grpc-client"
import { TelemetrySettingEnum, TelemetrySettingRequest } from "@shared/proto/state"
const BannerContainer = styled.div`
background-color: var(--vscode-banner-background);
@@ -53,8 +53,16 @@ const TelemetryBanner = () => {
navigateToSettings()
}
const handleClose = () => {
vscode.postMessage({ type: "telemetrySetting", telemetrySetting: "enabled" satisfies TelemetrySetting })
const handleClose = async () => {
try {
await StateServiceClient.updateTelemetrySetting(
TelemetrySettingRequest.create({
setting: TelemetrySettingEnum.ENABLED,
}),
)
} catch (error) {
console.error("Error updating telemetry setting:", error)
}
}
return (
@@ -4,7 +4,7 @@ import { TaskServiceClient } from "@/services/grpc-client"
import { formatLargeNumber, formatSize } from "@/utils/format"
import { vscode } from "@/utils/vscode"
import { ExtensionMessage } from "@shared/ExtensionMessage"
import { EmptyRequest, StringArrayRequest, StringRequest } from "@shared/proto/common"
import { BooleanRequest, EmptyRequest, StringArrayRequest, StringRequest } from "@shared/proto/common"
import { GetTaskHistoryRequest, TaskFavoriteRequest } from "@shared/proto/task"
import { VSCodeButton, VSCodeCheckbox, VSCodeRadio, VSCodeRadioGroup, VSCodeTextField } from "@vscode/webview-ui-toolkit/react"
import Fuse, { FuseResult } from "fuse.js"
@@ -727,7 +727,17 @@ const HistoryView = ({ onDone }: HistoryViewProps) => {
disabled={deleteAllDisabled || taskHistory.length === 0}
onClick={() => {
setDeleteAllDisabled(true)
vscode.postMessage({ type: "clearAllTaskHistory" })
const confirmDelete = window.confirm("Are you sure you want to delete all task history?")
if (confirmDelete) {
const preserveFavorites = window.confirm(
"Would you like to preserve favorited tasks?\n\nClick 'OK' to preserve favorites, or 'Cancel' to delete everything.",
)
TaskServiceClient.deleteAllTaskHistory(BooleanRequest.create({ value: preserveFavorites }))
.catch((error) => console.error("Error deleting task history:", error))
.finally(() => setDeleteAllDisabled(false))
} else {
setDeleteAllDisabled(false)
}
}}>
Delete All History{totalTasksSize !== null ? ` (${formatSize(totalTasksSize)})` : ""}
</DangerButton>
@@ -11,10 +11,12 @@ import {
import { McpMarketplaceItem } from "@shared/mcp"
import { useExtensionState } from "@/context/ExtensionStateContext"
import { vscode } from "@/utils/vscode"
import { McpServiceClient } from "@/services/grpc-client"
import { EmptyRequest } from "@shared/proto/common"
import McpMarketplaceCard from "./McpMarketplaceCard"
import McpSubmitCard from "./McpSubmitCard"
const McpMarketplaceView = () => {
const { mcpServers, mcpMarketplaceCatalog } = useExtensionState()
const { mcpServers, mcpMarketplaceCatalog, setMcpMarketplaceCatalog, mcpMarketplaceEnabled } = useExtensionState()
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [isRefreshing, setIsRefreshing] = useState(false)
@@ -92,7 +94,19 @@ const McpMarketplaceView = () => {
setIsLoading(true)
}
setError(null)
vscode.postMessage({ type: "fetchMcpMarketplace", bool: forceRefresh })
if (mcpMarketplaceEnabled) {
McpServiceClient.refreshMcpMarketplace(EmptyRequest.create({}))
.then((response) => {
setMcpMarketplaceCatalog(response)
})
.catch((error) => {
console.error("Error refreshing MCP marketplace:", error)
setError("Failed to load marketplace data")
setIsLoading(false)
setIsRefreshing(false)
})
}
}
if (isLoading || isRefreshing) {
+21 -415
View File
@@ -6,7 +6,6 @@ import {
ApiConfiguration,
askSageDefaultURL,
askSageModels,
azureOpenAiDefaultApiVersion,
bedrockDefaultModelId,
bedrockModels,
cerebrasModels,
@@ -18,9 +17,7 @@ import {
mainlandQwenModels,
ModelInfo,
nebiusModels,
openAiModelInfoSaneDefaults,
openAiNativeModels,
sambanovaModels,
vertexGlobalModels,
vertexModels,
xaiModels,
@@ -55,6 +52,8 @@ import { OpenRouterProvider } from "./providers/OpenRouterProvider"
import { MistralProvider } from "./providers/MistralProvider"
import { DeepSeekProvider } from "./providers/DeepSeekProvider"
import { TogetherProvider } from "./providers/TogetherProvider"
import { OpenAICompatibleProvider } from "./providers/OpenAICompatible"
import { SambanovaProvider } from "./providers/SambanovaProvider"
interface ApiOptionsProps {
showModelOptions: boolean
@@ -128,7 +127,6 @@ const ApiOptions = ({
const [vsCodeLmModels, setVsCodeLmModels] = useState<vscodemodels.LanguageModelChatSelector[]>([])
const [anthropicBaseUrlSelected, setAnthropicBaseUrlSelected] = useState(!!apiConfiguration?.anthropicBaseUrl)
const [geminiBaseUrlSelected, setGeminiBaseUrlSelected] = useState(!!apiConfiguration?.geminiBaseUrl)
const [azureApiVersionSelected, setAzureApiVersionSelected] = useState(!!apiConfiguration?.azureApiVersion)
const [awsEndpointSelected, setAwsEndpointSelected] = useState(!!apiConfiguration?.awsBedrockEndpoint)
const [modelConfigurationSelected, setModelConfigurationSelected] = useState(false)
const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false)
@@ -253,36 +251,6 @@ const ApiOptions = ({
)
}
// Debounced function to refresh OpenAI models (prevents excessive API calls while typing)
const debounceTimerRef = useRef<NodeJS.Timeout | null>(null)
useEffect(() => {
return () => {
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current)
}
}
}, [])
const debouncedRefreshOpenAiModels = useCallback((baseUrl?: string, apiKey?: string) => {
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current)
}
if (baseUrl && apiKey) {
debounceTimerRef.current = setTimeout(() => {
ModelsServiceClient.refreshOpenAiModels(
OpenAiModelsRequest.create({
baseUrl,
apiKey,
}),
).catch((error) => {
console.error("Failed to refresh OpenAI models:", error)
})
}, 500)
}
}, [])
return (
<div style={{ display: "flex", flexDirection: "column", gap: 5, marginBottom: isPopup ? -10 : 0 }}>
<DropdownContainer className="dropdown-container">
@@ -594,6 +562,24 @@ const ApiOptions = ({
/>
)}
{apiConfiguration && selectedProvider === "openai" && (
<OpenAICompatibleProvider
apiConfiguration={apiConfiguration}
handleInputChange={handleInputChange}
showModelOptions={showModelOptions}
isPopup={isPopup}
/>
)}
{apiConfiguration && selectedProvider === "sambanova" && (
<SambanovaProvider
apiConfiguration={apiConfiguration}
handleInputChange={handleInputChange}
showModelOptions={showModelOptions}
isPopup={isPopup}
/>
)}
{selectedProvider === "bedrock" && (
<div
style={{
@@ -975,341 +961,6 @@ const ApiOptions = ({
</div>
)}
{selectedProvider === "openai" && (
<div>
<VSCodeTextField
value={apiConfiguration?.openAiBaseUrl || ""}
style={{ width: "100%", marginBottom: 10 }}
type="url"
onInput={(e: any) => {
const baseUrl = e.target.value
handleInputChange("openAiBaseUrl")({ target: { value: baseUrl } })
debouncedRefreshOpenAiModels(baseUrl, apiConfiguration?.openAiApiKey)
}}
placeholder={"Enter base URL..."}>
<span style={{ fontWeight: 500 }}>Base URL</span>
</VSCodeTextField>
<VSCodeTextField
value={apiConfiguration?.openAiApiKey || ""}
style={{ width: "100%", marginBottom: 10 }}
type="password"
onInput={(e: any) => {
const apiKey = e.target.value
handleInputChange("openAiApiKey")({ target: { value: apiKey } })
debouncedRefreshOpenAiModels(apiConfiguration?.openAiBaseUrl, apiKey)
}}
placeholder="Enter API Key...">
<span style={{ fontWeight: 500 }}>API Key</span>
</VSCodeTextField>
<VSCodeTextField
value={apiConfiguration?.openAiModelId || ""}
style={{ width: "100%", marginBottom: 10 }}
onInput={handleInputChange("openAiModelId")}
placeholder={"Enter Model ID..."}>
<span style={{ fontWeight: 500 }}>Model ID</span>
</VSCodeTextField>
{/* OpenAI Compatible Custom Headers */}
{(() => {
const headerEntries = Object.entries(apiConfiguration?.openAiHeaders ?? {})
return (
<div style={{ marginBottom: 10 }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<span style={{ fontWeight: 500 }}>Custom Headers</span>
<VSCodeButton
onClick={() => {
const currentHeaders = { ...(apiConfiguration?.openAiHeaders || {}) }
const headerCount = Object.keys(currentHeaders).length
const newKey = `header${headerCount + 1}`
currentHeaders[newKey] = ""
handleInputChange("openAiHeaders")({
target: {
value: currentHeaders,
},
})
}}>
Add Header
</VSCodeButton>
</div>
<div>
{headerEntries.map(([key, value], index) => (
<div key={index} style={{ display: "flex", gap: 5, marginTop: 5 }}>
<VSCodeTextField
value={key}
style={{ width: "40%" }}
placeholder="Header name"
onInput={(e: any) => {
const currentHeaders = apiConfiguration?.openAiHeaders ?? {}
const newValue = e.target.value
if (newValue && newValue !== key) {
const { [key]: _, ...rest } = currentHeaders
handleInputChange("openAiHeaders")({
target: {
value: {
...rest,
[newValue]: value,
},
},
})
}
}}
/>
<VSCodeTextField
value={value}
style={{ width: "40%" }}
placeholder="Header value"
onInput={(e: any) => {
handleInputChange("openAiHeaders")({
target: {
value: {
...(apiConfiguration?.openAiHeaders ?? {}),
[key]: e.target.value,
},
},
})
}}
/>
<VSCodeButton
appearance="secondary"
onClick={() => {
const { [key]: _, ...rest } = apiConfiguration?.openAiHeaders ?? {}
handleInputChange("openAiHeaders")({
target: {
value: rest,
},
})
}}>
Remove
</VSCodeButton>
</div>
))}
</div>
</div>
)
})()}
<VSCodeCheckbox
checked={azureApiVersionSelected}
onChange={(e: any) => {
const isChecked = e.target.checked === true
setAzureApiVersionSelected(isChecked)
if (!isChecked) {
setApiConfiguration({
...apiConfiguration,
azureApiVersion: "",
})
}
}}>
Set Azure API version
</VSCodeCheckbox>
{azureApiVersionSelected && (
<VSCodeTextField
value={apiConfiguration?.azureApiVersion || ""}
style={{ width: "100%", marginTop: 3 }}
onInput={handleInputChange("azureApiVersion")}
placeholder={`Default: ${azureOpenAiDefaultApiVersion}`}
/>
)}
<div
style={{
color: getAsVar(VSC_DESCRIPTION_FOREGROUND),
display: "flex",
margin: "10px 0",
cursor: "pointer",
alignItems: "center",
}}
onClick={() => setModelConfigurationSelected((val) => !val)}>
<span
className={`codicon ${modelConfigurationSelected ? "codicon-chevron-down" : "codicon-chevron-right"}`}
style={{
marginRight: "4px",
}}></span>
<span
style={{
fontWeight: 700,
textTransform: "uppercase",
}}>
Model Configuration
</span>
</div>
{modelConfigurationSelected && (
<>
<VSCodeCheckbox
checked={!!apiConfiguration?.openAiModelInfo?.supportsImages}
onChange={(e: any) => {
const isChecked = e.target.checked === true
const modelInfo = apiConfiguration?.openAiModelInfo
? apiConfiguration.openAiModelInfo
: { ...openAiModelInfoSaneDefaults }
modelInfo.supportsImages = isChecked
setApiConfiguration({
...apiConfiguration,
openAiModelInfo: modelInfo,
})
}}>
Supports Images
</VSCodeCheckbox>
<VSCodeCheckbox
checked={!!apiConfiguration?.openAiModelInfo?.supportsImages}
onChange={(e: any) => {
const isChecked = e.target.checked === true
let modelInfo = apiConfiguration?.openAiModelInfo
? apiConfiguration.openAiModelInfo
: { ...openAiModelInfoSaneDefaults }
modelInfo.supportsImages = isChecked
setApiConfiguration({
...apiConfiguration,
openAiModelInfo: modelInfo,
})
}}>
Supports browser use
</VSCodeCheckbox>
<VSCodeCheckbox
checked={!!apiConfiguration?.openAiModelInfo?.isR1FormatRequired}
onChange={(e: any) => {
const isChecked = e.target.checked === true
let modelInfo = apiConfiguration?.openAiModelInfo
? apiConfiguration.openAiModelInfo
: { ...openAiModelInfoSaneDefaults }
modelInfo = { ...modelInfo, isR1FormatRequired: isChecked }
setApiConfiguration({
...apiConfiguration,
openAiModelInfo: modelInfo,
})
}}>
Enable R1 messages format
</VSCodeCheckbox>
<div style={{ display: "flex", gap: 10, marginTop: "5px" }}>
<VSCodeTextField
value={
apiConfiguration?.openAiModelInfo?.contextWindow
? apiConfiguration.openAiModelInfo.contextWindow.toString()
: openAiModelInfoSaneDefaults.contextWindow?.toString()
}
style={{ flex: 1 }}
onInput={(input: any) => {
const modelInfo = apiConfiguration?.openAiModelInfo
? apiConfiguration.openAiModelInfo
: { ...openAiModelInfoSaneDefaults }
modelInfo.contextWindow = Number(input.target.value)
setApiConfiguration({
...apiConfiguration,
openAiModelInfo: modelInfo,
})
}}>
<span style={{ fontWeight: 500 }}>Context Window Size</span>
</VSCodeTextField>
<VSCodeTextField
value={
apiConfiguration?.openAiModelInfo?.maxTokens
? apiConfiguration.openAiModelInfo.maxTokens.toString()
: openAiModelInfoSaneDefaults.maxTokens?.toString()
}
style={{ flex: 1 }}
onInput={(input: any) => {
const modelInfo = apiConfiguration?.openAiModelInfo
? apiConfiguration.openAiModelInfo
: { ...openAiModelInfoSaneDefaults }
modelInfo.maxTokens = input.target.value
setApiConfiguration({
...apiConfiguration,
openAiModelInfo: modelInfo,
})
}}>
<span style={{ fontWeight: 500 }}>Max Output Tokens</span>
</VSCodeTextField>
</div>
<div style={{ display: "flex", gap: 10, marginTop: "5px" }}>
<VSCodeTextField
value={
apiConfiguration?.openAiModelInfo?.inputPrice
? apiConfiguration.openAiModelInfo.inputPrice.toString()
: openAiModelInfoSaneDefaults.inputPrice?.toString()
}
style={{ flex: 1 }}
onInput={(input: any) => {
const modelInfo = apiConfiguration?.openAiModelInfo
? apiConfiguration.openAiModelInfo
: { ...openAiModelInfoSaneDefaults }
modelInfo.inputPrice = input.target.value
setApiConfiguration({
...apiConfiguration,
openAiModelInfo: modelInfo,
})
}}>
<span style={{ fontWeight: 500 }}>Input Price / 1M tokens</span>
</VSCodeTextField>
<VSCodeTextField
value={
apiConfiguration?.openAiModelInfo?.outputPrice
? apiConfiguration.openAiModelInfo.outputPrice.toString()
: openAiModelInfoSaneDefaults.outputPrice?.toString()
}
style={{ flex: 1 }}
onInput={(input: any) => {
const modelInfo = apiConfiguration?.openAiModelInfo
? apiConfiguration.openAiModelInfo
: { ...openAiModelInfoSaneDefaults }
modelInfo.outputPrice = input.target.value
setApiConfiguration({
...apiConfiguration,
openAiModelInfo: modelInfo,
})
}}>
<span style={{ fontWeight: 500 }}>Output Price / 1M tokens</span>
</VSCodeTextField>
</div>
<div style={{ display: "flex", gap: 10, marginTop: "5px" }}>
<VSCodeTextField
value={
apiConfiguration?.openAiModelInfo?.temperature
? apiConfiguration.openAiModelInfo.temperature.toString()
: openAiModelInfoSaneDefaults.temperature?.toString()
}
onInput={(input: any) => {
const modelInfo = apiConfiguration?.openAiModelInfo
? apiConfiguration.openAiModelInfo
: { ...openAiModelInfoSaneDefaults }
// Check if the input ends with a decimal point or has trailing zeros after decimal
const value = input.target.value
const shouldPreserveFormat =
value.endsWith(".") || (value.includes(".") && value.endsWith("0"))
modelInfo.temperature =
value === ""
? openAiModelInfoSaneDefaults.temperature
: shouldPreserveFormat
? value // Keep as string to preserve decimal format
: parseFloat(value)
setApiConfiguration({
...apiConfiguration,
openAiModelInfo: modelInfo,
})
}}>
<span style={{ fontWeight: 500 }}>Temperature</span>
</VSCodeTextField>
</div>
</>
)}
<p
style={{
fontSize: "12px",
marginTop: 3,
color: "var(--vscode-descriptionForeground)",
}}>
<span style={{ color: "var(--vscode-errorForeground)" }}>
(<span style={{ fontWeight: 500 }}>Note:</span> Cline uses complex prompts and works best with Claude
models. Less capable models may not work as expected.)
</span>
</p>
</div>
)}
{selectedProvider === "requesty" && (
<div>
<VSCodeTextField
@@ -1878,51 +1529,6 @@ const ApiOptions = ({
</VSCodeLink>
)}
</p>
{/* Note: To fully implement this, you would need to add a handler in ClineProvider.ts */}
{/* {apiConfiguration?.xaiApiKey && (
<button
onClick={() => {
vscode.postMessage({
type: "requestXAIModels",
text: apiConfiguration?.xaiApiKey,
})
}}
style={{ margin: "5px 0 0 0" }}
className="vscode-button">
Fetch Available Models
</button>
)} */}
</div>
)}
{selectedProvider === "sambanova" && (
<div>
<VSCodeTextField
value={apiConfiguration?.sambanovaApiKey || ""}
style={{ width: "100%" }}
type="password"
onInput={handleInputChange("sambanovaApiKey")}
placeholder="Enter API Key...">
<span style={{ fontWeight: 500 }}>SambaNova API Key</span>
</VSCodeTextField>
<p
style={{
fontSize: "12px",
marginTop: 3,
color: "var(--vscode-descriptionForeground)",
}}>
This key is stored locally and only used to make API requests from this extension.
{!apiConfiguration?.sambanovaApiKey && (
<VSCodeLink
href="https://docs.sambanova.ai/cloud/docs/get-started/overview"
style={{
display: "inline",
fontSize: "inherit",
}}>
You can get a SambaNova API key by signing up here.
</VSCodeLink>
)}
</p>
</div>
)}
@@ -2120,6 +1726,7 @@ const ApiOptions = ({
selectedProvider !== "bedrock" &&
selectedProvider !== "mistral" &&
selectedProvider !== "deepseek" &&
selectedProvider !== "sambanova" &&
showModelOptions && (
<>
<DropdownContainer zIndex={DROPDOWN_Z_INDEX - 2} className="dropdown-container">
@@ -2139,7 +1746,6 @@ const ApiOptions = ({
{selectedProvider === "doubao" && createDropdown(doubaoModels)}
{selectedProvider === "asksage" && createDropdown(askSageModels)}
{selectedProvider === "xai" && createDropdown(xaiModels)}
{selectedProvider === "sambanova" && createDropdown(sambanovaModels)}
{selectedProvider === "cerebras" && createDropdown(cerebrasModels)}
{selectedProvider === "nebius" && createDropdown(nebiusModels)}
{selectedProvider === "sapaicore" && createDropdown(sapAiCoreModels)}
@@ -1,10 +1,9 @@
import { HTMLAttributes } from "react"
import { cn } from "@/utils/cn"
type SectionProps = HTMLAttributes<HTMLDivElement>
export const Section = ({ className, ...props }: SectionProps) => (
<div className={cn("flex flex-col gap-3 p-5 py-2", className)} {...props} />
<div className={`flex flex-col gap-3 p-5 py-2 ${className || ""}`} {...props} />
)
export default Section
@@ -1,5 +1,4 @@
import { HTMLAttributes } from "react"
import { cn } from "@/utils/cn"
import { OPENROUTER_MODEL_PICKER_Z_INDEX } from "./OpenRouterModelPicker"
@@ -11,10 +10,7 @@ type SectionHeaderProps = HTMLAttributes<HTMLDivElement> & {
export const SectionHeader = ({ description, children, className, ...props }: SectionHeaderProps) => {
return (
<div
className={cn(
`sticky top-0 text-[var(--vscode-foreground)] bg-[var(--vscode-panel-background)] px-5 py-3`,
className,
)}
className={`sticky top-0 text-[var(--vscode-foreground)] bg-[var(--vscode-panel-background)] px-5 py-3 ${className || ""}`}
{...props}
style={{ zIndex: OPENROUTER_MODEL_PICKER_Z_INDEX + 20 }}>
<h4 className="m-0">{children}</h4>
@@ -2,7 +2,6 @@ import { UnsavedChangesDialog } from "@/components/common/AlertDialog"
import HeroTooltip from "@/components/common/HeroTooltip"
import { useExtensionState } from "@/context/ExtensionStateContext"
import { StateServiceClient } from "@/services/grpc-client"
import { cn } from "@/utils/cn"
import { validateApiConfiguration, validateModelId } from "@/utils/validate"
import { vscode } from "@/utils/vscode"
import { ExtensionMessage } from "@shared/ExtensionMessage"
@@ -504,23 +503,22 @@ const SettingsView = ({ onDone, targetSection }: SettingsViewProps) => {
</TabHeader>
{/* Vertical tabs layout */}
<div ref={containerRef} className={cn(settingsTabsContainer, isCompactMode && "narrow")}>
<div ref={containerRef} className={`${settingsTabsContainer} ${isCompactMode ? "narrow" : ""}`}>
{/* Tab sidebar */}
<TabList
value={activeTab}
onValueChange={handleTabChange}
className={cn(settingsTabList)}
className={settingsTabList}
data-compact={isCompactMode}>
{SETTINGS_TABS.map((tab) =>
isCompactMode ? (
<HeroTooltip key={tab.id} content={tab.tooltipText} placement="right">
<div
className={cn(
className={`${
activeTab === tab.id
? `${settingsTabTrigger} ${settingsTabTriggerActive}`
: settingsTabTrigger,
"focus:ring-0",
)}
: settingsTabTrigger
} focus:ring-0`}
data-compact={isCompactMode}
data-testid={`tab-${tab.id}`}
data-value={tab.id}
@@ -528,7 +526,7 @@ const SettingsView = ({ onDone, targetSection }: SettingsViewProps) => {
console.log("Compact tab clicked:", tab.id)
handleTabChange(tab.id)
}}>
<div className={cn("flex items-center gap-2", isCompactMode && "justify-center")}>
<div className={`flex items-center gap-2 ${isCompactMode ? "justify-center" : ""}`}>
<tab.icon className="w-4 h-4" />
<span className="tab-label">{tab.name}</span>
</div>
@@ -538,15 +536,14 @@ const SettingsView = ({ onDone, targetSection }: SettingsViewProps) => {
<TabTrigger
key={tab.id}
value={tab.id}
className={cn(
className={`${
activeTab === tab.id
? `${settingsTabTrigger} ${settingsTabTriggerActive}`
: settingsTabTrigger,
"focus:ring-0",
)}
: settingsTabTrigger
} focus:ring-0`}
data-compact={isCompactMode}
data-testid={`tab-${tab.id}`}>
<div className={cn("flex items-center gap-2", isCompactMode && "justify-center")}>
<div className={`flex items-center gap-2 ${isCompactMode ? "justify-center" : ""}`}>
<tab.icon className="w-4 h-4" />
<span className="tab-label">{tab.name}</span>
</div>
@@ -0,0 +1,388 @@
import { ApiConfiguration, azureOpenAiDefaultApiVersion, openAiModelInfoSaneDefaults } from "@shared/api"
import { OpenAiModelsRequest } from "@shared/proto/models"
import { ModelsServiceClient } from "@/services/grpc-client"
import { getAsVar, VSC_DESCRIPTION_FOREGROUND } from "@/utils/vscStyles"
import { VSCodeTextField, VSCodeButton, VSCodeCheckbox } from "@vscode/webview-ui-toolkit/react"
import { useCallback, useEffect, useRef, useState } from "react"
import { ModelInfoView } from "../common/ModelInfoView"
import { ApiKeyField } from "../common/ApiKeyField"
import { BaseUrlField } from "../common/BaseUrlField"
import { normalizeApiConfiguration } from "../utils/providerUtils"
/**
* Props for the OpenAICompatibleProvider component
*/
interface OpenAICompatibleProviderProps {
apiConfiguration: ApiConfiguration
handleInputChange: (field: keyof ApiConfiguration) => (event: any) => void
showModelOptions: boolean
isPopup?: boolean
}
/**
* The OpenAI Compatible provider configuration component
*/
export const OpenAICompatibleProvider = ({
apiConfiguration,
handleInputChange,
showModelOptions,
isPopup,
}: OpenAICompatibleProviderProps) => {
const [modelConfigurationSelected, setModelConfigurationSelected] = useState(false)
// Get the normalized configuration
const { selectedModelId, selectedModelInfo } = normalizeApiConfiguration(apiConfiguration)
// Debounced function to refresh OpenAI models (prevents excessive API calls while typing)
const debounceTimerRef = useRef<NodeJS.Timeout | null>(null)
useEffect(() => {
return () => {
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current)
}
}
}, [])
const debouncedRefreshOpenAiModels = useCallback((baseUrl?: string, apiKey?: string) => {
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current)
}
if (baseUrl && apiKey) {
debounceTimerRef.current = setTimeout(() => {
ModelsServiceClient.refreshOpenAiModels(
OpenAiModelsRequest.create({
baseUrl,
apiKey,
}),
).catch((error) => {
console.error("Failed to refresh OpenAI models:", error)
})
}, 500)
}
}, [])
return (
<div>
<VSCodeTextField
value={apiConfiguration?.openAiBaseUrl || ""}
style={{ width: "100%", marginBottom: 10 }}
type="url"
onInput={(e: any) => {
const baseUrl = e.target.value
handleInputChange("openAiBaseUrl")({ target: { value: baseUrl } })
debouncedRefreshOpenAiModels(baseUrl, apiConfiguration?.openAiApiKey)
}}
placeholder={"Enter base URL..."}>
<span style={{ fontWeight: 500 }}>Base URL</span>
</VSCodeTextField>
<ApiKeyField
value={apiConfiguration?.openAiApiKey || ""}
onChange={(e: any) => {
const apiKey = e.target.value
handleInputChange("openAiApiKey")({ target: { value: apiKey } })
debouncedRefreshOpenAiModels(apiConfiguration?.openAiBaseUrl, apiKey)
}}
providerName="OpenAI Compatible"
/>
<VSCodeTextField
value={apiConfiguration?.openAiModelId || ""}
style={{ width: "100%", marginBottom: 10 }}
onInput={handleInputChange("openAiModelId")}
placeholder={"Enter Model ID..."}>
<span style={{ fontWeight: 500 }}>Model ID</span>
</VSCodeTextField>
{/* OpenAI Compatible Custom Headers */}
{(() => {
const headerEntries = Object.entries(apiConfiguration?.openAiHeaders ?? {})
return (
<div style={{ marginBottom: 10 }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<span style={{ fontWeight: 500 }}>Custom Headers</span>
<VSCodeButton
onClick={() => {
const currentHeaders = { ...(apiConfiguration?.openAiHeaders || {}) }
const headerCount = Object.keys(currentHeaders).length
const newKey = `header${headerCount + 1}`
currentHeaders[newKey] = ""
handleInputChange("openAiHeaders")({
target: {
value: currentHeaders,
},
})
}}>
Add Header
</VSCodeButton>
</div>
<div>
{headerEntries.map(([key, value], index) => (
<div key={index} style={{ display: "flex", gap: 5, marginTop: 5 }}>
<VSCodeTextField
value={key}
style={{ width: "40%" }}
placeholder="Header name"
onInput={(e: any) => {
const currentHeaders = apiConfiguration?.openAiHeaders ?? {}
const newValue = e.target.value
if (newValue && newValue !== key) {
const { [key]: _, ...rest } = currentHeaders
handleInputChange("openAiHeaders")({
target: {
value: {
...rest,
[newValue]: value,
},
},
})
}
}}
/>
<VSCodeTextField
value={value}
style={{ width: "40%" }}
placeholder="Header value"
onInput={(e: any) => {
handleInputChange("openAiHeaders")({
target: {
value: {
...(apiConfiguration?.openAiHeaders ?? {}),
[key]: e.target.value,
},
},
})
}}
/>
<VSCodeButton
appearance="secondary"
onClick={() => {
const { [key]: _, ...rest } = apiConfiguration?.openAiHeaders ?? {}
handleInputChange("openAiHeaders")({
target: {
value: rest,
},
})
}}>
Remove
</VSCodeButton>
</div>
))}
</div>
</div>
)
})()}
<BaseUrlField
value={apiConfiguration?.azureApiVersion}
onChange={(value) => handleInputChange("azureApiVersion")({ target: { value } })}
label="Set Azure API version"
placeholder={`Default: ${azureOpenAiDefaultApiVersion}`}
/>
<div
style={{
color: getAsVar(VSC_DESCRIPTION_FOREGROUND),
display: "flex",
margin: "10px 0",
cursor: "pointer",
alignItems: "center",
}}
onClick={() => setModelConfigurationSelected((val) => !val)}>
<span
className={`codicon ${modelConfigurationSelected ? "codicon-chevron-down" : "codicon-chevron-right"}`}
style={{
marginRight: "4px",
}}></span>
<span
style={{
fontWeight: 700,
textTransform: "uppercase",
}}>
Model Configuration
</span>
</div>
{modelConfigurationSelected && (
<>
<VSCodeCheckbox
checked={!!apiConfiguration?.openAiModelInfo?.supportsImages}
onChange={(e: any) => {
const isChecked = e.target.checked === true
const modelInfo = apiConfiguration?.openAiModelInfo
? apiConfiguration.openAiModelInfo
: { ...openAiModelInfoSaneDefaults }
modelInfo.supportsImages = isChecked
handleInputChange("openAiModelInfo")({
target: { value: modelInfo },
})
}}>
Supports Images
</VSCodeCheckbox>
<VSCodeCheckbox
checked={!!apiConfiguration?.openAiModelInfo?.supportsImages}
onChange={(e: any) => {
const isChecked = e.target.checked === true
let modelInfo = apiConfiguration?.openAiModelInfo
? apiConfiguration.openAiModelInfo
: { ...openAiModelInfoSaneDefaults }
modelInfo.supportsImages = isChecked
handleInputChange("openAiModelInfo")({
target: { value: modelInfo },
})
}}>
Supports browser use
</VSCodeCheckbox>
<VSCodeCheckbox
checked={!!apiConfiguration?.openAiModelInfo?.isR1FormatRequired}
onChange={(e: any) => {
const isChecked = e.target.checked === true
let modelInfo = apiConfiguration?.openAiModelInfo
? apiConfiguration.openAiModelInfo
: { ...openAiModelInfoSaneDefaults }
modelInfo = { ...modelInfo, isR1FormatRequired: isChecked }
handleInputChange("openAiModelInfo")({
target: { value: modelInfo },
})
}}>
Enable R1 messages format
</VSCodeCheckbox>
<div style={{ display: "flex", gap: 10, marginTop: "5px" }}>
<VSCodeTextField
value={
apiConfiguration?.openAiModelInfo?.contextWindow
? apiConfiguration.openAiModelInfo.contextWindow.toString()
: openAiModelInfoSaneDefaults.contextWindow?.toString()
}
style={{ flex: 1 }}
onInput={(input: any) => {
const modelInfo = apiConfiguration?.openAiModelInfo
? apiConfiguration.openAiModelInfo
: { ...openAiModelInfoSaneDefaults }
modelInfo.contextWindow = Number(input.target.value)
handleInputChange("openAiModelInfo")({
target: { value: modelInfo },
})
}}>
<span style={{ fontWeight: 500 }}>Context Window Size</span>
</VSCodeTextField>
<VSCodeTextField
value={
apiConfiguration?.openAiModelInfo?.maxTokens
? apiConfiguration.openAiModelInfo.maxTokens.toString()
: openAiModelInfoSaneDefaults.maxTokens?.toString()
}
style={{ flex: 1 }}
onInput={(input: any) => {
const modelInfo = apiConfiguration?.openAiModelInfo
? apiConfiguration.openAiModelInfo
: { ...openAiModelInfoSaneDefaults }
modelInfo.maxTokens = input.target.value
handleInputChange("openAiModelInfo")({
target: { value: modelInfo },
})
}}>
<span style={{ fontWeight: 500 }}>Max Output Tokens</span>
</VSCodeTextField>
</div>
<div style={{ display: "flex", gap: 10, marginTop: "5px" }}>
<VSCodeTextField
value={
apiConfiguration?.openAiModelInfo?.inputPrice
? apiConfiguration.openAiModelInfo.inputPrice.toString()
: openAiModelInfoSaneDefaults.inputPrice?.toString()
}
style={{ flex: 1 }}
onInput={(input: any) => {
const modelInfo = apiConfiguration?.openAiModelInfo
? apiConfiguration.openAiModelInfo
: { ...openAiModelInfoSaneDefaults }
modelInfo.inputPrice = input.target.value
handleInputChange("openAiModelInfo")({
target: { value: modelInfo },
})
}}>
<span style={{ fontWeight: 500 }}>Input Price / 1M tokens</span>
</VSCodeTextField>
<VSCodeTextField
value={
apiConfiguration?.openAiModelInfo?.outputPrice
? apiConfiguration.openAiModelInfo.outputPrice.toString()
: openAiModelInfoSaneDefaults.outputPrice?.toString()
}
style={{ flex: 1 }}
onInput={(input: any) => {
const modelInfo = apiConfiguration?.openAiModelInfo
? apiConfiguration.openAiModelInfo
: { ...openAiModelInfoSaneDefaults }
modelInfo.outputPrice = input.target.value
handleInputChange("openAiModelInfo")({
target: { value: modelInfo },
})
}}>
<span style={{ fontWeight: 500 }}>Output Price / 1M tokens</span>
</VSCodeTextField>
</div>
<div style={{ display: "flex", gap: 10, marginTop: "5px" }}>
<VSCodeTextField
value={
apiConfiguration?.openAiModelInfo?.temperature
? apiConfiguration.openAiModelInfo.temperature.toString()
: openAiModelInfoSaneDefaults.temperature?.toString()
}
onInput={(input: any) => {
const modelInfo = apiConfiguration?.openAiModelInfo
? apiConfiguration.openAiModelInfo
: { ...openAiModelInfoSaneDefaults }
// Check if the input ends with a decimal point or has trailing zeros after decimal
const value = input.target.value
const shouldPreserveFormat = value.endsWith(".") || (value.includes(".") && value.endsWith("0"))
modelInfo.temperature =
value === ""
? openAiModelInfoSaneDefaults.temperature
: shouldPreserveFormat
? value // Keep as string to preserve decimal format
: parseFloat(value)
handleInputChange("openAiModelInfo")({
target: { value: modelInfo },
})
}}>
<span style={{ fontWeight: 500 }}>Temperature</span>
</VSCodeTextField>
</div>
</>
)}
<p
style={{
fontSize: "12px",
marginTop: 3,
color: "var(--vscode-descriptionForeground)",
}}>
<span style={{ color: "var(--vscode-errorForeground)" }}>
(<span style={{ fontWeight: 500 }}>Note:</span> Cline uses complex prompts and works best with Claude models.
Less capable models may not work as expected.)
</span>
</p>
{showModelOptions && (
<ModelInfoView selectedModelId={selectedModelId} modelInfo={selectedModelInfo} isPopup={isPopup} />
)}
</div>
)
}
@@ -0,0 +1,47 @@
import { ApiConfiguration, sambanovaModels } from "@shared/api"
import { ApiKeyField } from "../common/ApiKeyField"
import { ModelSelector } from "../common/ModelSelector"
import { ModelInfoView } from "../common/ModelInfoView"
import { normalizeApiConfiguration } from "../utils/providerUtils"
/**
* Props for the SambanovaProvider component
*/
interface SambanovaProviderProps {
apiConfiguration: ApiConfiguration
handleInputChange: (field: keyof ApiConfiguration) => (event: any) => void
showModelOptions: boolean
isPopup?: boolean
}
/**
* The SambaNova provider configuration component
*/
export const SambanovaProvider = ({ apiConfiguration, handleInputChange, showModelOptions, isPopup }: SambanovaProviderProps) => {
// Get the normalized configuration
const { selectedModelId, selectedModelInfo } = normalizeApiConfiguration(apiConfiguration)
return (
<div>
<ApiKeyField
value={apiConfiguration?.sambanovaApiKey || ""}
onChange={handleInputChange("sambanovaApiKey")}
providerName="SambaNova"
signupUrl="https://docs.sambanova.ai/cloud/docs/get-started/overview"
/>
{showModelOptions && (
<>
<ModelSelector
models={sambanovaModels}
selectedModelId={selectedModelId}
onChange={handleInputChange("apiModelId")}
label="Model"
/>
<ModelInfoView selectedModelId={selectedModelId} modelInfo={selectedModelInfo} isPopup={isPopup} />
</>
)}
</div>
)
}
-13
View File
@@ -1,13 +0,0 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
/**
* A utility function that combines clsx and tailwind-merge to handle class name merging
* with proper Tailwind CSS conflict resolution.
*
* @param inputs - Class values to be merged
* @returns A string of merged class names
*/
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}