Compare commits

...
Author SHA1 Message Date
Cline Evaluation 3c6587149d docs 2025-06-23 17:53:27 -07:00
Cline Evaluation 2721ad194b replays cooking 2025-06-23 17:48:12 -07:00
Cline Evaluation fb91128208 ok replays 2025-06-23 16:56:53 -07:00
Cline Evaluation c809af9770 wip 2025-06-23 11:34: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
celestial-vaultandElephant Lumps b26997abc0 Migrate authStateChanged protobus (#3835)
* migrate authStateChanges

* changeset

* fix types and linter errors:

---------

Co-authored-by: Elephant Lumps <celestial_vault@Elephants-MacBook-Pro.local>
2025-06-20 19:46:23 -07:00
Andrew Hood 560c79b885 Add new regions to available Bedrock options (#4056)
* Update ApiOptions.tsx

Added eu-south-1 (Milan) and eu-south-2 (Spain) to selection menu

* Update ApiOptions.tsx

Fixed names of eu-south-1 and eu-south-2 in settings menu
2025-06-20 18:24:30 -07:00
celestial-vault 77cc05c843 Create tool executor class (#4323)
* move apiConversationHistory to MessageStateHandler

* move clineMessages state to MessageStateManager class

* remove unused imports

* reorganize task class state variables and refactor out utility functions in recursivelyMakeClineRequests

* move task ephemeral state to state class

* extract tool logic into tool executor class

* integrate ToolExecutor into task class
2025-06-20 14:53:28 -07:00
Sarah Fortune 7db8c6ae4c Support external webview (#4336)
* Add ui.getWebviewHtml to the protobus

This will return the HTML content for external clients.

* Add getUri to ExternalWebviewProvider

Change getUri to return URIs for files in an appropriate format for the external web view.
Use URI from npm module in ExternalWebviewProvider.
Use a default value for the cline dir, ~/.cline
Turn off gRPC debugging

* Include node modules used as assets in the standalone package.

* Throw an error if trying to recreate webview panel in standalone app.
2025-06-20 13:44:22 -07:00
58 changed files with 4487 additions and 3227 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": minor
---
Remove the clsx / tailwind merge dependencies and replace with template literals
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": minor
---
Migrate fetchUserCreditsData to protobus
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": minor
---
Migrate authStateChanged to protobus
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
Add taskId as metadata to use from LiteLLM
@@ -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
+4 -3
View File
@@ -50,10 +50,11 @@
"outFiles": ["${workspaceFolder}/dist-standalone/**/*.js"],
"preLaunchTask": "compile-standalone",
"env": {
"GRPC_TRACE": "all",
"GRPC_VERBOSITY": "DEBUG",
// Turns on grpc debug log.
//"GRPC_TRACE": "all",
//"GRPC_VERBOSITY": "DEBUG",
"NODE_PATH": "${workspaceFolder}/dist-standalone/node_modules",
"CLINE_DIR": "${userHome}/.cline-standalone",
"HOST_BRIDGE_ADDRESS": "localhost:50052"
},
"program": "standalone.js"
+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)
+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()
+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
}
+54
View File
@@ -19,4 +19,58 @@ service AccountService {
// Subscribe to auth callback events (when authentication tokens are received)
rpc subscribeToAuthCallback(EmptyRequest) returns (stream String);
// 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 {
Metadata metadata = 1;
UserInfo user = 2;
}
message AuthStateChanged {
optional UserInfo user = 1;
}
message UserInfo {
optional string display_name = 1;
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;
}
+3
View File
@@ -262,4 +262,7 @@ service UiService {
// Subscribe to webview visibility change events
rpc subscribeToDidBecomeVisible(EmptyRequest) returns (stream Empty);
// 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);
}
+18 -7
View File
@@ -56,13 +56,24 @@ archive.glob("**/*", {
// Add the whole cline directory under "extension"
archive.directory(process.cwd(), "extension", (entry) => {
// Skip certain directories
if (
entry.name.startsWith(BUILD_DIR + "/") ||
entry.name.startsWith("node_modules/") || // node_modules nearly 1GB.
entry.name.startsWith("webview-ui/node_modules/") || // node_modules nearly 1GB.
entry.name.match(/(^|\/)\./) // exclude dot directories
) {
// Skip certain directories.
const exclude = [
BUILD_DIR + "/",
"node_modules/", // node_modules nearly 1GB.
"webview-ui/node_modules/", // node_modules nearly 1GB.
]
// These node modules are used at runtime as assets, they need to be included.
const include = ["node_modules/@vscode/", "webview-ui/node_modules/katex"]
const name = entry.name
if (include.some((prefix) => name.startsWith(prefix))) {
return entry
}
if (exclude.some((prefix) => name.startsWith(prefix))) {
return false
}
if (name.match(/(^|\/)\./)) {
// exclude dot directories
return false
}
return entry
+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,23 @@
import { AuthStateChangedRequest, AuthStateChanged } from "@shared/proto/account"
import type { Controller } from "../index"
import { updateGlobalState } from "../../storage/state"
/**
* Handles authentication state changes from the Firebase context.
* Updates the user info in global state and returns the updated value.
* @param controller The controller instance
* @param request The auth state change request
* @returns The updated user info
*/
export async function authStateChanged(controller: Controller, request: AuthStateChangedRequest): Promise<AuthStateChanged> {
try {
// Store the user info directly in global state
await updateGlobalState(controller.context, "userInfo", request.user)
// Return the same user info
return AuthStateChanged.create({ user: request.user })
} catch (error) {
console.error(`Failed to update auth state: ${error}`)
throw error
}
}
@@ -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
}
}
+2 -24
View File
@@ -20,6 +20,7 @@ import { ChatSettings } from "@shared/ChatSettings"
import { ExtensionMessage, ExtensionState, Platform } from "@shared/ExtensionMessage"
import { HistoryItem } from "@shared/HistoryItem"
import { McpMarketplaceCatalog } from "@shared/mcp"
import { UserInfo } from "@shared/UserInfo"
import { TelemetrySetting } from "@shared/TelemetrySetting"
import { WebviewMessage } from "@shared/WebviewMessage"
import { fileExistsAtPath } from "@utils/fs"
@@ -124,7 +125,7 @@ export class Controller {
}
}
async setUserInfo(info?: { displayName: string | null; email: string | null; photoURL: string | null }) {
async setUserInfo(info?: UserInfo) {
await updateGlobalState(this.context, "userInfo", info)
}
@@ -204,15 +205,6 @@ export class Controller {
*/
async handleWebviewMessage(message: WebviewMessage) {
switch (message.type) {
case "authStateChanged":
await this.setUserInfo(message.user || undefined)
await this.postStateToWebview()
break
case "fetchUserCreditsData": {
await this.fetchUserCreditsData()
break
}
case "fetchMcpMarketplace": {
await this.fetchMcpMarketplace(message.bool)
break
@@ -496,20 +488,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> {
+16
View File
@@ -0,0 +1,16 @@
import type { Controller } from "../index"
import { EmptyRequest, Empty, String } from "@shared/proto/common"
import * as hostProviders from "@hosts/host-providers"
import { WebviewProviderType } from "@/shared/webview/types"
/**
* Initialize webview when it launches
* @param controller The controller instance
* @param request The empty request
* @returns Empty response
*/
export async function getWebviewHtml(_controller: Controller, _: EmptyRequest): Promise<String> {
const webviewProvider = hostProviders.createWebviewProvider(WebviewProviderType.SIDEBAR)
return Promise.resolve(String.create({ value: webviewProvider.getHtmlContent() }))
}
+3
View File
@@ -34,6 +34,9 @@ export class TaskState {
isAwaitingPlanResponse = false
didRespondToPlanAskBySwitchingMode = false
// Context and history
conversationHistoryDeletedRange?: [number, number]
// Tool execution flags
didRejectTool = false
didAlreadyUseTool = false
File diff suppressed because it is too large Load Diff
+52 -2373
View File
File diff suppressed because it is too large Load Diff
+6 -5
View File
@@ -11,13 +11,14 @@ import * as path from "path"
import CheckpointTracker from "@integrations/checkpoints/CheckpointTracker"
import { HistoryItem } from "@/shared/HistoryItem"
import Anthropic from "@anthropic-ai/sdk"
import { TaskState } from "./TaskState"
interface MessageStateHandlerParams {
context: vscode.ExtensionContext
taskId: string
conversationHistoryDeletedRange?: [number, number]
taskIsFavorited?: boolean
updateTaskHistory: (historyItem: HistoryItem) => Promise<HistoryItem[]>
taskState: TaskState
}
const cwd = vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath).at(0) ?? path.join(os.homedir(), "Desktop") // may or may not exist but fs checking existence would immediately ask for permission which would be bad UX, need to come up with a better solution
@@ -25,17 +26,17 @@ const cwd = vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath
export class MessageStateHandler {
private apiConversationHistory: Anthropic.MessageParam[] = []
private clineMessages: ClineMessage[] = []
private conversationHistoryDeletedRange: [number, number] | undefined
private taskIsFavorited: boolean
private checkpointTracker: CheckpointTracker | undefined
private updateTaskHistory: (historyItem: HistoryItem) => Promise<HistoryItem[]>
private context: vscode.ExtensionContext
private taskId: string
private taskState: TaskState
constructor(params: MessageStateHandlerParams) {
this.context = params.context
this.taskId = params.taskId
this.conversationHistoryDeletedRange = params.conversationHistoryDeletedRange
this.taskState = params.taskState
this.taskIsFavorited = params.taskIsFavorited ?? false
this.updateTaskHistory = params.updateTaskHistory
}
@@ -95,7 +96,7 @@ export class MessageStateHandler {
size: taskDirSize,
shadowGitConfigWorkTree: await this.checkpointTracker?.getShadowGitConfigWorkTree(),
cwdOnTaskInitialization: cwd,
conversationHistoryDeletedRange: this.conversationHistoryDeletedRange,
conversationHistoryDeletedRange: this.taskState.conversationHistoryDeletedRange,
isFavorited: this.taskIsFavorited,
})
} catch (error) {
@@ -117,7 +118,7 @@ export class MessageStateHandler {
// these values allow us to reconstruct the conversation history at the time this cline message was created
// it's important that apiConversationHistory is initialized before we add cline messages
message.conversationHistoryIndex = this.apiConversationHistory.length - 1 // NOTE: this is the index of the last added message which is the user message, and once the clinemessages have been presented we update the apiconversationhistory with the completed assistant message. This means when resetting to a message, we need to +1 this index to get the correct assistant message that this tool use corresponds to
message.conversationHistoryDeletedRange = this.conversationHistoryDeletedRange
message.conversationHistoryDeletedRange = this.taskState.conversationHistoryDeletedRange
this.clineMessages.push(message)
await this.saveClineMessagesAndUpdateHistory()
}
@@ -15,14 +15,6 @@ https://github.com/KumarVariable/vscode-extension-sidebar-html/blob/master/src/c
export class VscodeWebviewProvider extends WebviewProvider implements vscode.WebviewViewProvider {
public webview?: vscode.WebviewView | vscode.WebviewPanel
public static create(
context: vscode.ExtensionContext,
outputChannel: vscode.OutputChannel,
providerType: WebviewProviderType,
) {
return new VscodeWebviewProvider(context, outputChannel, providerType)
}
constructor(context: vscode.ExtensionContext, outputChannel: vscode.OutputChannel, providerType: WebviewProviderType) {
super(context, outputChannel, providerType)
}
+9 -5
View File
@@ -29,6 +29,7 @@ import { FileContextTracker } from "./core/context/context-tracking/FileContextT
import * as hostProviders from "@hosts/host-providers"
import { vscodeHostBridgeClient } from "@/hosts/vscode/client/host-grpc-client"
import { VscodeWebviewProvider } from "./core/webview/VscodeWebviewProvider"
import { ExtensionContext } from "vscode"
/*
Built using https://github.com/microsoft/vscode-webview-ui-toolkit
@@ -51,7 +52,7 @@ export async function activate(context: vscode.ExtensionContext) {
Logger.initialize(outputChannel)
Logger.log("Cline extension activated")
maybeSetupHostProviders()
maybeSetupHostProviders(context)
// Migrate global storage values to workspace storage (one-time cleanup)
await migratePlanActGlobalToWorkspaceStorage(context)
@@ -65,7 +66,7 @@ export async function activate(context: vscode.ExtensionContext) {
// Version checking for autoupdate notification
const currentVersion = context.extension.packageJSON.version
const previousVersion = context.globalState.get<string>("clineVersion")
const sidebarWebview = hostProviders.createWebviewProvider(context, outputChannel, WebviewProviderType.SIDEBAR)
const sidebarWebview = hostProviders.createWebviewProvider(WebviewProviderType.SIDEBAR)
// Initialize test mode and add disposables to context
context.subscriptions.push(...initializeTestMode(context, sidebarWebview))
@@ -168,7 +169,7 @@ export async function activate(context: vscode.ExtensionContext) {
Logger.log("Opening Cline in new tab")
// (this example uses webviewProvider activation event which is necessary to deserialize cached webview, but since we use retainContextWhenHidden, we don't need to use that event)
// https://github.com/microsoft/vscode-extension-samples/blob/main/webview-sample/src/extension.ts
const tabWebview = hostProviders.createWebviewProvider(context, outputChannel, WebviewProviderType.TAB)
const tabWebview = hostProviders.createWebviewProvider(WebviewProviderType.TAB)
//const column = vscode.window.activeTextEditor ? vscode.window.activeTextEditor.viewColumn : undefined
const lastCol = Math.max(...vscode.window.visibleTextEditors.map((editor) => editor.viewColumn || 0))
@@ -653,10 +654,13 @@ export async function activate(context: vscode.ExtensionContext) {
return createClineAPI(outputChannel, sidebarWebview.controller)
}
function maybeSetupHostProviders() {
function maybeSetupHostProviders(context: ExtensionContext) {
if (!hostProviders.isSetup) {
console.log("Setting up vscode host providers...")
hostProviders.initializeHostProviders(VscodeWebviewProvider.create, vscodeHostBridgeClient)
const createWebview = function (type: WebviewProviderType) {
return new VscodeWebviewProvider(context, outputChannel, type)
}
hostProviders.initializeHostProviders(createWebview, vscodeHostBridgeClient)
}
}
+3 -11
View File
@@ -6,11 +6,7 @@ import * as vscode from "vscode"
/**
* A function that creates WebviewProvider instances
*/
export type WebviewProviderCreator = (
context: vscode.ExtensionContext,
outputChannel: vscode.OutputChannel,
providerType: WebviewProviderType,
) => WebviewProvider
export type WebviewProviderCreator = (providerType: WebviewProviderType) => WebviewProvider
let _webviewProviderCreator: WebviewProviderCreator | undefined
let _hostBridgeProvider: HostBridgeClientProvider | undefined
@@ -26,15 +22,11 @@ export function initializeHostProviders(
isSetup = true
}
export function createWebviewProvider(
context: vscode.ExtensionContext,
outputChannel: vscode.OutputChannel,
providerType: WebviewProviderType,
): WebviewProvider {
export function createWebviewProvider(providerType: WebviewProviderType): WebviewProvider {
if (!_webviewProviderCreator) {
throw Error("Host providers not initialized")
}
return _webviewProviderCreator(context, outputChannel, providerType)
return _webviewProviderCreator(providerType)
}
export function getHostBridgeProvider(): HostBridgeClientProvider {
+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
}
}
+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
}
+3 -17
View File
@@ -10,18 +10,11 @@ import { McpServer, McpMarketplaceCatalog, McpDownloadResponse, McpViewTab } fro
import { TelemetrySetting } from "./TelemetrySetting"
import type { BalanceResponse, UsageTransaction, PaymentTransaction } from "../shared/ClineAccount"
import { ClineRulesToggles } from "./cline-rules"
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
@@ -39,9 +32,6 @@ export interface ExtensionMessage {
commits?: GitCommit[]
url?: string
isImage?: boolean
userCreditsBalance?: BalanceResponse
userCreditsUsage?: UsageTransaction[]
userCreditsPayments?: PaymentTransaction[]
success?: boolean
endpoint?: string
isBundled?: boolean
@@ -91,11 +81,7 @@ export interface ExtensionState {
terminalOutputLineLimit: number
defaultTerminalProfile?: string
uriScheme?: string
userInfo?: {
displayName: string | null
email: string | null
photoURL: string | null
}
userInfo?: UserInfo
version: string
distinctId: string
globalClineRulesToggles: ClineRulesToggles
+3 -3
View File
@@ -1,5 +1,5 @@
export interface UserInfo {
displayName: string | null
email: string | null
photoURL: string | null
displayName?: string
email?: string
photoURL?: string
}
-2
View File
@@ -9,12 +9,10 @@ import { McpViewTab } from "./mcp"
export interface WebviewMessage {
type:
| "requestVsCodeLmModels"
| "authStateChanged"
| "fetchMcpMarketplace"
| "searchCommits"
| "telemetrySetting"
| "clearAllTaskHistory"
| "fetchUserCreditsData"
| "grpc_request"
| "grpc_request_cancel"
+8 -19
View File
@@ -1,9 +1,7 @@
import { ExtensionMessage } from "@/shared/ExtensionMessage"
import { WebviewProviderType } from "@/shared/webview/types"
import { sendThemeEvent } from "@core/controller/ui/subscribeToTheme"
import { getTheme } from "@integrations/theme/getTheme"
import * as vscode from "vscode"
import { Uri } from "vscode"
import { URI } from "vscode-uri"
import { WebviewProvider } from "@core/webview"
/*
@@ -12,25 +10,16 @@ https://github.com/KumarVariable/vscode-extension-sidebar-html/blob/master/src/c
*/
export class ExternalWebviewProvider extends WebviewProvider {
public webview?: vscode.WebviewView | vscode.WebviewPanel
public static create(
context: vscode.ExtensionContext,
outputChannel: vscode.OutputChannel,
providerType: WebviewProviderType,
) {
return new ExternalWebviewProvider(context, outputChannel, providerType)
}
private RESOURCE_AUTHORITY: string = "file.resources"
constructor(context: vscode.ExtensionContext, outputChannel: vscode.OutputChannel, providerType: WebviewProviderType) {
super(context, outputChannel, providerType)
}
override getWebviewUri(uri: Uri) {
return uri.with({
scheme: "http",
authority: "resources",
})
override getWebviewUri(uri: URI) {
if (uri.scheme !== "file") {
return uri
}
return URI.from({ scheme: "https", authority: this.RESOURCE_AUTHORITY, path: uri.fsPath })
}
override getCspSource() {
return "csp-source"
@@ -46,7 +35,7 @@ export class ExternalWebviewProvider extends WebviewProvider {
return {}
}
override resolveWebviewView(_: vscode.WebviewView | vscode.WebviewPanel): Promise<void> {
override resolveWebviewView(_: any): Promise<void> {
return Promise.resolve()
}
}
+6 -1
View File
@@ -11,12 +11,13 @@ import { addProtobusServices } from "@generated/standalone/server-setup"
import { StreamingResponseHandler } from "@/core/controller/grpc-handler"
import { ExternalHostBridgeClientManager } from "./host-bridge-client-manager"
import { ExternalWebviewProvider } from "./ExternalWebviewProvider"
import { WebviewProviderType } from "@/shared/webview/types"
import { v4 as uuidv4 } from "uuid"
async function main() {
log("Starting standalone service...")
hostProviders.initializeHostProviders(ExternalWebviewProvider.create, new ExternalHostBridgeClientManager())
hostProviders.initializeHostProviders(createWebview, new ExternalHostBridgeClientManager())
activate(extensionContext)
const controller = new Controller(extensionContext, outputChannel, postMessage, uuidv4())
const server = new grpc.Server()
@@ -44,6 +45,10 @@ async function main() {
})
}
const createWebview = () => {
return new ExternalWebviewProvider(extensionContext, outputChannel, WebviewProviderType.SIDEBAR)
}
/**
* Wraps a Promise-based handler function to make it compatible with gRPC's callback-based API.
* This function converts an async handler that returns a Promise into a function that uses
+4 -8
View File
@@ -1,5 +1,5 @@
import { URI } from "vscode-uri"
import os from "os"
import { mkdirSync, readFileSync } from "fs"
import path, { join } from "path"
import type { Extension, ExtensionContext } from "vscode"
@@ -8,19 +8,15 @@ import { log } from "./utils"
import { outputChannel, postMessage } from "./vscode-context-stubs"
import { EnvironmentVariableCollection, MementoStore, readJson, SecretStore } from "./vscode-context-utils"
if (!process.env.CLINE_DIR) {
console.warn("Environment variable CLINE_DIR was not set.")
process.exit(1)
}
const VERSION = getPackageVersion()
log("Running standalone cline ", VERSION)
const DATA_DIR = path.join(process.env.CLINE_DIR, "data")
const CLINE_DIR = process.env.CLINE_DIR || `${os.homedir()}/.cline`
const DATA_DIR = path.join(CLINE_DIR, "data")
mkdirSync(DATA_DIR, { recursive: true })
log("Using settings dir:", DATA_DIR)
const EXTENSION_DIR = path.join(process.env.CLINE_DIR, "core", VERSION, "extension")
const EXTENSION_DIR = path.join(CLINE_DIR, "core", VERSION, "extension")
const EXTENSION_MODE = process.env.IS_DEV === "true" ? ExtensionMode.Development : ExtensionMode.Production
const extension: Extension<void> = {
@@ -63,13 +63,8 @@ vscode.window = {
registerWebviewViewProvider: () => ({ dispose: () => {} }),
onDidChangeActiveTextEditor: () => ({ dispose: () => {} }),
createTextEditorDecorationType: () => ({ dispose: () => {} }),
createWebviewPanel: (...args) => {
console.log("Stubbed createWebviewPanel:", ...args)
return {
webview: {},
reveal: () => {},
dispose: () => {},
}
createWebviewPanel: (..._args) => {
throw new Error("WebviewPanel is not supported in standalone app.")
},
}
-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>
))}
@@ -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>) {
+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}>
+23 -401
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={{
@@ -681,6 +667,8 @@ const ApiOptions = ({
<VSCodeOption value="eu-west-2">eu-west-2</VSCodeOption>
<VSCodeOption value="eu-west-3">eu-west-3</VSCodeOption>
<VSCodeOption value="eu-north-1">eu-north-1</VSCodeOption>
<VSCodeOption value="eu-south-1">eu-south-1</VSCodeOption>
<VSCodeOption value="eu-south-2">eu-south-2</VSCodeOption>
{/* <VSCodeOption value="me-south-1">me-south-1</VSCodeOption> */}
<VSCodeOption value="sa-east-1">sa-east-1</VSCodeOption>
<VSCodeOption value="us-gov-east-1">us-gov-east-1</VSCodeOption>
@@ -973,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
@@ -1893,37 +1546,6 @@ const ApiOptions = ({
</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>
)}
{selectedProvider === "cerebras" && (
<div>
<VSCodeTextField
@@ -2118,6 +1740,7 @@ const ApiOptions = ({
selectedProvider !== "bedrock" &&
selectedProvider !== "mistral" &&
selectedProvider !== "deepseek" &&
selectedProvider !== "sambanova" &&
showModelOptions && (
<>
<DropdownContainer zIndex={DROPDOWN_Z_INDEX - 2} className="dropdown-container">
@@ -2137,7 +1760,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>
)
}
@@ -30,6 +30,7 @@ import {
import { McpMarketplaceCatalog, McpServer, McpViewTab } from "../../../src/shared/mcp"
import { convertTextMateToHljs } from "../utils/textMateToHljs"
import { OpenRouterCompatibleModelInfo } from "@shared/proto/models"
import { UserInfo } from "@shared/proto/account"
interface ExtensionStateContextType extends ExtensionState {
didHydrateState: boolean
@@ -81,6 +82,7 @@ interface ExtensionStateContextType extends ExtensionState {
// Refresh functions
refreshOpenRouterModels: () => void
setUserInfo: (userInfo?: UserInfo) => void
// Navigation state setters
setShowMcp: (value: boolean) => void
@@ -835,6 +837,7 @@ export const ExtensionStateContextProvider: React.FC<{
setTotalTasksSize,
refreshOpenRouterModels,
onRelinquishControl,
setUserInfo: (userInfo?: UserInfo) => setState((prevState) => ({ ...prevState, userInfo })),
}
return <ExtensionStateContext.Provider value={contextValue}>{children}</ExtensionStateContext.Provider>
+20 -10
View File
@@ -4,6 +4,8 @@ import { EmptyRequest } from "@shared/proto/common"
import { initializeApp } from "firebase/app"
import { User, getAuth, signInWithCustomToken, signOut } from "firebase/auth"
import React, { createContext, useCallback, useContext, useEffect, useState } from "react"
import { useExtensionState } from "./ExtensionStateContext"
import { AuthStateChanged, AuthStateChangedRequest } from "@shared/proto/account"
// Firebase configuration from extension
const firebaseConfig = {
@@ -28,6 +30,7 @@ const FirebaseAuthContext = createContext<FirebaseAuthContextType | undefined>(u
export const FirebaseAuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [user, setUser] = useState<User | null>(null)
const [isInitialized, setIsInitialized] = useState(false)
const { setUserInfo } = useExtensionState()
// Initialize Firebase
const app = initializeApp(firebaseConfig)
@@ -45,16 +48,23 @@ export const FirebaseAuthProvider: React.FC<{ children: React.ReactNode }> = ({
return
}
// Sync auth state with extension
vscode.postMessage({
type: "authStateChanged",
user: user
? {
displayName: user.displayName,
email: user.email,
photoURL: user.photoURL,
}
: null,
})
AccountServiceClient.authStateChanged(
AuthStateChangedRequest.create({
user: user
? {
displayName: user.displayName ?? undefined,
email: user.email ?? undefined,
photoURL: user.photoURL ?? undefined,
}
: undefined,
}),
)
.then((response: AuthStateChanged) => {
setUserInfo(response.user)
})
.catch((error) => {
console.error("Error updating auth state via gRPC:", error)
})
})
return () => unsubscribe()
-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))
}