Compare commits

..

60 Commits

Author SHA1 Message Date
celestial-vault bd04d9daf6 fix models.proto types 2025-06-18 22:02:10 -07:00
celestial-vault d9c9ad1c99 merge conflicts 2025-06-18 22:01:48 -07:00
Sarah Fortune d92de54645 Update get-vscode-usages script to exclude types, only count methods (#4299) 2025-06-18 16:33:34 -07:00
Sarah Fortune febe795af4 Use the generated client impls for the external host bridge. (#4298)
* .

* Remove unused imports
2025-06-18 15:38:46 -07:00
celestial-vault bb6d02df83 Migrate didBecomeVisible protobus (#3996)
* migrate chatButtonClicked

* changeset

* send targeted event to the controller

* prettier

* migrate didBecomeVisible

* changeset

* fix proto linter issue

* add back event listener

---------

Co-authored-by: Elephant Lumps <celestial_vault@Elephants-MacBook-Pro.local>
2025-06-18 14:46:41 -07:00
Sarah Fortune 72471f5677 In build-protos, create the directories while writing the files. (#4296)
* In build-protos, create the directories while writing the files.

* Update proto/build-proto.js

Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>

* Update proto/build-proto.js

Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>

* Update proto/build-proto.js

Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>

* .

---------

Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>
2025-06-18 14:44:06 -07:00
Sarah Fortune 3550486d4b Move StreamingHandlers type into a common location (#4293)
This type is used by all host bridge clients, move it out of the vscode directory.
2025-06-18 13:51:00 -07:00
Sarah Fortune 30344befe9 Add a script to generate type safe clients for the host bridge (#4269)
* Generate client interfaces and impls. Use the interfaces for the vscode and external clients

* Use interfaces
2025-06-18 12:39:52 -07:00
celestial-vault bf5e2785c3 Reorganize task state and refactor out small functions (#4267)
* 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 (#4273)
2025-06-18 12:36:01 -07:00
wangyijing130 a4a7caff01 Fix the error when submitting remote service form (#3579)
* Fix the error when submitting remote service form

Fix the exception caused by data structure errors when submitting remote service forms.

* fix for Prettier

* typing

---------

Co-authored-by: wangyj20 <wangyj20@asiainfo.com>
Co-authored-by: Toshii <94262432+0xToshii@users.noreply.github.com>
2025-06-18 11:31:59 -07:00
Ara d2cdf7ce18 Fixing the context window jump issue for gemini family of models (#4266) 2025-06-18 19:37:04 +02:00
celestial-vault befc5adf70 Migrate cline messages to message state handler (#4237)
* move apiConversationHistory to MessageStateHandler

* move clineMessages state to MessageStateManager class

* remove unused imports

* remove redundant line
2025-06-18 10:34:20 -07:00
Yukio Nozawa b62f61fbc6 Fix: Make some of the buttons on the task header accessible to screen readers (#4246)
* Fix: The button which closes the currently displayed task is is now accessible with screen readers

* Fix: The button which deletes the currently displayed task is now accessible with screen readers

* Create changeset
2025-06-18 09:31:28 +02:00
Toshii 6dd0bbdd79 env (#4260) 2025-06-18 08:45:05 +02:00
Sam b92a280f51 fix: respect selected litellm model IDs for plan & act (#4193) 2025-06-18 08:44:04 +02:00
watany cecf7304e1 fix(bedrock): remove custom Model encode (#4209)
* fix(bedrock): without any encoding

* fallback for custom model

* changeset

* Update src/api/providers/bedrock.ts

---------

Co-authored-by: Toshii <94262432+0xToshii@users.noreply.github.com>
2025-06-18 08:43:17 +02:00
Tomás Barreiro c5eccfe5d6 fix: Prevent reading env variables from the users environment (#4243)
* Do not destructure env variables

* Add changeset

* Do not set IS_DEV to true in tests

* Do not define values if it is not a production build

* Add eslint rule to prevent destructuring process.env
2025-06-18 08:42:24 +02:00
மனோஜ்குமார் பழனிச்சாமி 1fc796020d remove the MCP notification pop up (#4251)
* remove the MCP notification pop up

* Update src/services/mcp/McpHub.ts

---------

Co-authored-by: Toshii <94262432+0xToshii@users.noreply.github.com>
2025-06-18 08:41:45 +02:00
Sarah Fortune c39a74048a Fix windows build (#4270) 2025-06-17 17:25:29 -07:00
github-actions[bot] c294b86524 v3.17.14 Release Notes
v3.17.14 Release Notes
2025-06-17 16:53:43 -07:00
pashpashpash 8d133d9031 supporting legacy search and replace blocks too (#4264)
* supporting legacy search and replace blocks too

* changeset

* Handle legacy search/replace chars for first/last partial lines

* error recovery + fixing

* throwing error if malformed search block - previously this would cause entire file to be deleted

* Update cyan-books-cry.md

* fixing broken test

---------

Co-authored-by: Cline Evaluation <cline@example.com>
Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2025-06-18 01:14:28 +02:00
Ramesh 1adf19a873 update star alignment (#3935) 2025-06-17 16:07:48 -07:00
canvrno 60d9bd46c5 Fix: Race condition leading to task restoration, checkpoints issues (#4226)
* Fixed race condition where clineAsk was undefined, leading to task restoration and other downstream issues

* changeset
2025-06-18 01:25:58 +05:30
Tomás Barreiro 689afc62eb feat: Integrate Claude Code (#4111)
* Integrate Claude Code

* Add changeset

* handle exits gracefully, select models and update the path

* limit the claude-code models and update message

* expose the claudeCodePath in the apiConfiguration and proto

* remove log

* Update proto settings and properly map the provider
2025-06-18 00:51:07 +05:30
Tomás Barreiro d8e29263df fix: Clear the input when the user changes mode within a task (#4242)
* Clear the input when the user changes mode within a task

* Add changeset

* rename prop
2025-06-17 17:44:52 +02:00
canvrno 189b91ca36 Fix: clineMessages not storing all checkpoints commitHashes (#4225)
* Fixed issue where checkpoint commitHash was not being saved to every clineMessage in state, added handling in case checkpointTracker was not initialized (resumed tasks)

* changeset

* prettier
2025-06-17 11:35:56 +05:30
Ara 7172eb194d Fixing terminal Blocked issue (#4217)
Co-authored-by: Cline Evaluation <cline@example.com>
2025-06-17 06:02:21 +05:30
Sarah Fortune 97838fe349 Generate promise-based TS clients with nice-grpc. (#4259)
Add a manager class to hold all the instances of the clients. They need to be reused, unlike the vscode clients which are just static method calls.

Move the generated file src/standalone/server-setup.ts into src/generated/ directory.

Add the host bridge address env var to the vscode launch.json

In build-proto.js: path.join will normalize slashes in file paths, so use path.join(x, "a/b/c") instead of path.join(x, "a", "b", "c").
2025-06-16 15:15:35 -07:00
canvrno e8e8eac820 Feat: Reduce diff edit errors when restoring tasks w/o checkpoints (#4232)
* Added file context warnings to reduce diff edit errors when resuming a task after it has been restored

* changeset

* Prompt tweak
2025-06-16 12:48:02 -07:00
canvrno 54a7fed77b Exclune clinerules from checkpoints (#4236) 2025-06-16 11:57:41 -07:00
Sarah Fortune ddbe3f47bd Add fixes for the grpc handler and client for the host bridge service (#4221)
* Fix error handling for unary handler in the host bridge grpc handler for vscode.

The unary request handler was return a struct like {message: ..., error: ..., requestId: ...}
But the caller was only looking at the message field, not the error.
Simplify the unary handler and just return the response message or throw if there was an error. The caller already has the request id, it doesn't need it to be returned from handler.

* Update comments

* Return early from cancelRequest if request wasn't cancelled to reduce indent level/complexity.

* Fix bug in cancelRequest in the host bridge grpc handler where cancel message is never sent to the client.

When a request is cancelled it is removed from the registery. The cancel handler was cancelling the request, and then trying to retrieve it again to get the stream handler, but it was already removed from the active request, so the cancel message was never sent to the client.

Fix this by retrieving the stream handler first, and then cancelling the request.
2025-06-15 22:22:10 -07:00
Toshii a9dfc5d0b6 fix sap provider (#4240)
* fix

* proto nit
2025-06-15 13:49:47 -07:00
canvrno 31de5053f3 Fix: Checkmark control menu improvements (#4218)
* Better debounce on checkmark control menu

* Fix issue where Restore Files button was disabled after first use
2025-06-15 11:48:04 -07:00
celestial-vault 5a66cb7819 catch error more broadly (#4235) 2025-06-15 10:15:32 -07:00
Dennise Bartlett 5d4594e82e Update developer reset to allow for resetting workspace settings. (#4229)
* Update developer reset to allow for resetting workspace settings.

* Add Changeset

* Add Metadata field to ResetStateRequest
2025-06-14 23:14:51 -07:00
schardosin c3326973c7 SAP AI Core as a Provider (#3980)
* added changes over a the latest from upstream

* cleanup some comments

* fixed message mispelling and variable naming convention

* added changeset for addition of SAP AI Core provider

* fixed mispelled expires_at

* added sapAiCoreClientId to hasKey

* retrigger tests

* removed bedrock-format.ts, added smal function for message formatting in sapaicore, removed models lazy loading, simplifying the code

* reverted src/core/webview/index.ts to upstream version, once all the tailored implementation for sapaicore were removed

* removed duplicated and not used interfaces

* removed the deployments logic from ApiOptions.tsx, now it loads the list of models available only

* removed references for deployments once it is not in use anymore

* removed unused sapConfig from WebviewMessage and ExtensionMessage

* moved previous state variable according to the request'

* removed supportsComputerUse from sapaicore sonnet

* added grpc fields and updated conversion methods for sap ai core
2025-06-14 17:37:34 -07:00
Ara b0de6390f4 Fix MCP Schema support (#4166)
Co-authored-by: Cline Evaluation <cline@example.com>
2025-06-14 14:27:46 +05:30
Toshii a6c33afb11 add terminal setting to allow users to constrain terminal output (#4150)
* base

* grpc

* grpc

* base 3

* settings stuff

* changeset

* nit

* format

* smol
2025-06-13 17:28:31 -07:00
Derek Gaston ec26a912bc Increase the number of retries for Bedrock. Refs #213 (#4114)
* Increase the number of retries for Bedrock. Refs #213

* bump

---------

Co-authored-by: 0xtoshii <94262432+0xToshii@users.noreply.github.com>
2025-06-13 13:55:24 -07:00
watany 6a11c78288 chore(bedrock): remove @anthropic-ai/bedrock-sdk (#4162)
* remove @anthropic-ai/bedrock-sdk

* changeset
2025-06-13 11:53:58 -07:00
Vladimir d27b199cea Mcp rich display setting (#4029)
* + Adding a global setting for mcp rich display in features settings, storing it in global storage, and using it as the starting value for each new session to still allow local toggle of mcp rich display on the tab, but let users keep the base stored default

* + adding changest

* + fxing linting post conflict merge

* fix

---------

Co-authored-by: 0xtoshii <94262432+0xToshii@users.noreply.github.com>
2025-06-13 11:51:52 -07:00
Sarah Fortune 7ba4c9e15e Add a linter for proto files (#4179)
* Add a protobuf linter

Enforce the standard lint rules: snake case field names, snake case file names, pascal case service names etc.

Add exceptions for the lint rules we are already not following.

Fix linter failures, this only changes the proto file The generated TS types are the same, so the ts files don't need to be updated.

* Formatting
2025-06-13 08:28:05 -10:00
Toshii 802e72f1bf new feature github template + readme (#4211)
* constributing

* nit

* nit
2025-06-13 11:02:00 -07:00
Alberto Valiña Lema 10a223f27d Terminal profile setting (#4079)
* feat: Added a configurable default terminal profile setting

* chore: format

* refactor: migrate terminal profiles to gRPC and remove legacy message handling

- rename AvailableTerminalProfilesResponse to TerminalProfiles in proto
- remove duplicate TerminalProfile type from terminal_types.ts
- update all imports to use TerminalProfile from proto/state
- remove legacy availableTerminalProfiles message handling from ExtensionStateContext
- clean up ExtensionMessage type by removing unused availableTerminalProfiles
- translate Spanish comment to English in TerminalSettingsSection
- update server-side getAvailableTerminalProfiles to use new proto type

* chore: lint

* fix: merge main

* fix: resolve errors

* chore: notify terminal profile settings

* chore: merge main

* feat: improve default terminal profile changes

* fix: update changes on save
2025-06-13 20:51:24 +05:30
Sarah Fortune 0fade12e8e Format files before commit (#4155)
Instead of doing a prettier check in the pre-commit, just format the staged changes.
Use the package lint-stage to handle only formatting staged changes.
2025-06-12 13:28:39 -07:00
Sarah Fortune a4bf34f73b Generate grpc-js services and clients (#4199)
* Generate clientImpls and services for grpc-js.

Generate grpc-js services and clients (as opposed to the generic service definition)
The grpc-js clients are needed to connet to external gRPC services, ie the host bridge.
Switch the standalone gRPC service to use the grpc-js service defintions, these have the correct serialize/deserialize methods and fix the camel/snake case issue.

* Formatting
2025-06-12 12:49:36 -07:00
celestial-vault 227c7195f6 move saveClineMessagesAndUpdateHistory out to a separate state utilities file (#4190) 2025-06-12 11:34:20 -07:00
celestial-vault 5e55a7a095 return files if targeted directory is hidden (#4176) 2025-06-12 11:33:44 -07:00
Hanzen Shou 741b1edf73 Refactor copy buttons (#3456)
* refactor: moved copy logic to CopyButtonComponents.tsx

* refactor: simplified copy button components

* clean: format & cleanup code

* clean: removed comments

* fix: fixed aria labels

* clean: removed old comments

* clean: reduced deltas

* clean: deleted comment in ChatRow.tsx

* updates

* changeset

---------

Co-authored-by: Toshii <94262432+0xToshii@users.noreply.github.com>
2025-06-12 10:50:59 -07:00
github-actions[bot] 8b4e8ce37f v3.17.13 Release Notes
v3.17.13 Release Notes
2025-06-11 22:20:14 -07:00
Toshii 95ad8d879b change text (#4185)
* change text

* changeset
2025-06-11 20:15:27 -07:00
Ara 2a0d60f642 Adding Thinking UX for Gemini (#4137) 2025-06-11 19:44:18 -07:00
Sarah Fortune 9f605a1f6c Add a verbose flag to build-protos.js (#4171)
Reduce the amount of logging unless the flag is set.
2025-06-11 12:58:24 -10:00
Sarah Fortune 5272788f8c During cleanup remove generated files that have been moved to a different location. (#4152) 2025-06-11 12:05:55 -10:00
celestial-vault a9238b425b Update PR Template (#4175)
* update pr template

* change wording
2025-06-12 02:07:03 +05:30
Ara 79edbf9a92 Remove redundant MCP notifications (#4170)
Co-authored-by: Cline Evaluation <cline@example.com>
2025-06-12 01:59:24 +05:30
celestial-vault 542ec2bd4a migrate and remove custom instructions (#4158) 2025-06-11 12:46:10 -07:00
Elephant Lumps c332aced87 migrate requestyModels protobus and clean up old messages 2025-06-06 10:19:53 -07:00
Elephant Lumps 2820f94e12 merge conflicts 2025-06-06 09:41:12 -07:00
Elephant Lumps bb06ff9dd5 migrate apiConfiguration 2025-06-05 23:24:46 -07:00
116 changed files with 8014 additions and 10190 deletions
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
updating o3 model pricing
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
Sorting mcp marketplace by newest listings by default
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
fix: respect setting litellm models for plan and act
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": minor
---
Migrate didBecomeVisible to protobus
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": minor
---
Migrate focusChatInput message to protobus
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
Prevent reading IS_DEV from the users environment
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
fix(bedrock): remove Anthropic-Bedrock SDK
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
fix(bedrock): remove custom Model encode
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
Remove -beta from grok model id
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
fix menu for setting terminal timeout
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
Add prompt caching indicator to grok 3
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
Supporting Notifications MCP with Cline
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
The close task button and delete task button in the task header are now correctly announced by screen readers.
+8 -1
View File
@@ -21,7 +21,14 @@
"semi": "off",
"react-hooks/exhaustive-deps": "off",
"eslint-rules/no-protobuf-object-literals": "error",
"eslint-rules/no-grpc-client-object-literals": "error"
"eslint-rules/no-grpc-client-object-literals": "error",
"no-restricted-syntax": [
"error",
{
"selector": "VariableDeclarator[id.type=\"ObjectPattern\"][init.object.name=\"process\"][init.property.name=\"env\"]",
"message": "Use process.env.VARIABLE_NAME directly instead of destructuring"
}
]
},
"ignorePatterns": ["out", "dist", "**/*.d.ts"]
}
+1 -4
View File
@@ -1,4 +1,4 @@
blank_issues_enabled: true
blank_issues_enabled: false
contact_links:
- name: ✨ Feature Request
url: https://github.com/cline/cline/discussions/categories/feature-requests?discussions_q=is%3Aopen+category%3A%22Feature+Requests%22+sort%3Atop
@@ -6,6 +6,3 @@ contact_links:
- name: 👋 Cline Discord
url: https://discord.gg/cline
about: Join our Discord community for discussions and support
- name: ❓ Other Questions?
url: https://x.com/sdrzn
about: Contact the developer on X @sdrzn for other inquiries
+130
View File
@@ -0,0 +1,130 @@
name: 📝 Detailed Feature Proposal
description: Propose a new feature or improvement
labels: ["proposal"]
body:
- type: markdown
attributes:
value: |
**Feature Proposal for Cline**
Thank you for creating a feature proposal for Cline! This template is for clear, actionable proposals that define a specific problem and a high-confidence solution. Please provide enough detail to enable fast prioritization, discussion, and execution.
Detailed proposals will be prioritized, while vague proposals may be closed or require extensive back and forth communication.
Before submitting:
- Search existing [Issues](https://github.com/cline/cline/issues) and [Discussions](https://github.com/cline/cline/discussions) to avoid duplicates
- Dont start implementation until the proposal is reviewed and approved
- type: textarea
id: problem-description
attributes:
label: What problem does this solve?
description: |
Describe the problem clearly from a user's point of view. Focus on why this matters, who it affects, and when it occurs.
✅ Solid:
- "LLM provider returns 400 error when nearing the context window instead of truncating"
- "Submit button is invisible in dark mode"
❌ Avoid:
- "Performance is bad"
- "UI needs work"
Your description should include:
- Who is affected?
- When does it happen?
- What's the current vs expected behavior?
- What is the impact?
placeholder: Be specific about the problem, who it affects, and the impact.
validations:
required: true
- type: textarea
id: proposed-solution
attributes:
label: Whats the proposed solution?
description: |
Describe how the problem should be solved. Be specific about UX, system behavior, and any flows that would change.
✅ Solid:
- "Add error handling immediately after attempting to create the llm stream and retry after manually truncating"
- "Update button styling to ensure contrast in all themes"
❌ Avoid:
- "Improve performance"
- "Fix the bug"
Your solution should include:
- What exactly will change?
- How will users interact with it?
- Whats the expected outcome?
placeholder: Describe the proposed changes and how they solve the problem.
validations:
required: true
- type: textarea
id: acceptance-criteria
attributes:
label: How will we know it works? (Acceptance Criteria)
description: Define clear, testable success criteria.
placeholder: Provide specific and testable conditions for success.
validations:
required: true
- type: textarea
id: estimated-effort
attributes:
label: Estimated effort and complexity
description: |
Help us understand scope and risks. Include:
- Size estimate (XS/S/M/L/XL or hours/days)
- Why this size? Whats technically involved?
- Any tricky parts, refactors, or risks?
- Performance or compatibility concerns?
- Any dependencies on systems, teams, or libraries?
placeholder: Size, reasoning, risks, and dependencies.
validations:
required: true
- type: textarea
id: technical-considerations
attributes:
label: Technical considerations, tradeoffs, and/or risks (optional)
description: |
Include any technical context that helps us evaluate or implement the proposal more effectively.
You may include:
- Architectural changes or required refactors
- Performance implications or system-level impacts
- Known limitations, risks, or tricky edge cases
- Compatibility concerns or migration steps
- Alternative approaches you considered and why they were not chosen
- Dependencies on other systems, teams, or libraries
- Were other approaches considered? Why is this one preferred?
placeholder: Technical considerations, tradeoffs, and/or risks.
- type: textarea
id: additional-context
attributes:
label: Additional context (optional)
description: Diagrams, mockups, logs, links, or anything else that helps explain or justify the proposal.
placeholder: Diagrams, mockups, logs, links, or anything else that helps explain or justify the proposal.
- type: checkboxes
id: checklist
attributes:
label: Proposal checklist
options:
- label: Ive checked for existing issues or related proposals
required: true
- label: I understand this needs review before implementation can start
required: true
- type: checkboxes
id: willingness-to-contribute
attributes:
label: Interested in implementing this?
description: Optional
options:
- label: Yes, Id like to help implement this feature
+11 -10
View File
@@ -1,16 +1,17 @@
<!-- ⚠️ Important: Discussion Required Before PR (Community Contributors) -->
<!--
Thank you for contributing to Cline!
**For community contributors, before submitting this PR, please ensure you have:**
⚠️ Important: Before submitting this PR, please ensure you have:
- Opened an issue and discussed your proposed changes with the community / contributors
- Received approval from a core Cline contributor prior to proceeding with the implementation
- Link the associated issue in the "Related Issue" section
- [ ] **Opened an issue** to discuss your proposed changes with the community
- [ ] **Received approval** from a core Cline contributor to proceed with the implementation
- [ ] **Linked the issue below** in the "Related Issue" section
Limited exceptions:
Small bug fixes, typo corrections, minor wording improvements, or simple type fixes that don't change functionality may be submitted directly.
**Exceptions:** Small bug fixes, typo corrections, minor wording improvements, or simple type fixes that don't change functionality may be submitted directly.
**Why this requirement?** We deeply appreciate all community contributions - they are the core reason we're able to operate successfully and keep innovating! We welcome community input and want to make it as easy as possible for people to submit quality work. This process helps our core maintainers review new ideas faster and saves contributor time by ensuring you have the go-ahead before spending time on implementation.
---
Why this requirement?
We deeply appreciate all community contributions - they are the core reason we're able to operate successfully and keep innovating! We welcome community input and want to make it as easy as possible for people to submit quality work. This process helps our core maintainers review new ideas faster and saves contributor time by ensuring you have the go-ahead before spending time on implementation.
-->
### Related Issue
+3 -4
View File
@@ -21,7 +21,9 @@ coverage
*evals.env
# Generated proto files
# Generated files
src/generated/
# Core
src/core/controller/*/methods.ts
src/core/controller/*/index.ts
src/core/controller/grpc-service-config.ts
@@ -30,10 +32,7 @@ src/shared/proto/*.ts
src/shared/proto/host/*.ts
# Webview
webview-ui/src/services/grpc-client.ts
# Standalone
src/standalone/server-setup.ts
# Host bridge
src/hosts/vscode/*/methods.ts
src/hosts/vscode/*/index.ts
src/hosts/vscode/client/host-grpc-client.ts
src/hosts/vscode/host-grpc-service-config.ts
+4 -4
View File
@@ -9,9 +9,9 @@ npm run lint || {
# Run Prettier
echo "Running Prettier..."
npm run format || {
echo "❌ Prettier check failed. Run 'npm run format:fix' to automatically fix formatting issues."
exit 1
}
npx lint-staged --verbose || {
echo "❌ Prettier failed. Please fix the errors and try committing again."
exit 1
}
echo "✅ All checks passed!"
+3 -2
View File
@@ -42,7 +42,7 @@
{
"type": "node",
"request": "launch",
"name": "Run Standalone Extension",
"name": "Run Standalone Service",
"skipFiles": ["<node_internals>/**"],
"sourceMaps": true,
"resolveSourceMapLocations": ["${workspaceFolder}/**", "!**/node_modules/**"],
@@ -53,7 +53,8 @@
"GRPC_TRACE": "all",
"GRPC_VERBOSITY": "DEBUG",
"NODE_PATH": "${workspaceFolder}/dist-standalone/node_modules",
"CLINE_DIR": "${userHome}/.cline-standalone"
"CLINE_DIR": "${userHome}/.cline-standalone",
"HOST_BRIDGE_ADDRESS": "localhost:50052"
},
"program": "standalone.js"
}
+29
View File
@@ -1,5 +1,34 @@
# Changelog
## [3.17.14]
- Add Claude Code as a new API provider, allowing integration with Anthropic's Claude Code CLI tool and Claude Max Plan (Thanks @BarreiroT!)
- Add SAP AI Core as a new API provider with support for Claude and GPT models (Thanks @schardosin!)
- Add configurable default terminal profile setting, allowing users to specify which terminal Cline should use (Thanks @valinha!)
- Add terminal output size constraint setting to limit how much terminal output is processed
- Add MCP Rich Display settings to the settings page for persistent configuration (Thanks @Vl4diC0de!)
- Improve copy button functionality with refactored reusable components (Thanks @shouhanzen!)
- Improve AWS Bedrock provider by removing deprecated dependency and using standard AWS SDK (Thanks @watany-dev!)
- Fix list_files tool to properly return files when targeting hidden directories
- Fix search and replace edge case that could cause file deletion, making the algorithm more lenient for models using different diff formats
- Fix task restoration issues that could occur when resuming interrupted tasks
- Fix checkpoint saving to properly track all file changes
- Improve file context warnings to reduce diff edit errors when resuming restored tasks
- Clear chat input when switching between Plan/Act modes within a task
- Exclude .clinerules files from checkpoint tracking
## [3.17.13]
- Add Thinking UX for Gemini models, providing visual feedback during model reasoning
- Add support for Notifications MCP integration with Cline
- Add prompt caching indicator for Grok 3 models
- Sort MCP marketplace by newest listings by default for easier discovery of recent servers
- Update O3 model family pricing to reflect latest OpenAI rates
- Remove '-beta' suffix from Grok model identifiers
- Fix AWS Bedrock provider by removing deprecated Anthropic-Bedrock SDK (Thanks @watany-dev!)
- Fix menu display issue for terminal timeout settings
- Improve chat input field styling and behavior
## [3.17.12]
- **Free Grok Model Available!** Access Grok 3 completely free through the Cline provider
+12
View File
@@ -18,6 +18,18 @@ We also welcome contributions to our [documentation](https://github.com/cline/cl
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
All contributions must begin with a GitHub Issue, unless the change is for small bug fixes, typo corrections, minor wording improvements, or simple type fixes that don't change functionality.
- **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.
**PRs without approved issues may be closed.**
## Development Setup
1. **VS Code Extensions**
+22
View File
@@ -0,0 +1,22 @@
version: v2
modules:
- path: proto
name: cline/cline/lint
lint:
use:
- STANDARD
except: # Add exceptions for current patterns that contradict STANDARD settings
- RPC_PASCAL_CASE # rpcs are camel case (start with lowercase)
- PACKAGE_DIRECTORY_MATCH # the protos in the cline package are not in a dir named cline.
- RPC_REQUEST_RESPONSE_UNIQUE # request messages are not unique.
- RPC_REQUEST_STANDARD_NAME # request messages dont all end with Request
- RPC_RESPONSE_STANDARD_NAME # response messages dont all end with Response
- PACKAGE_VERSION_SUFFIX # package name does not contain version.
- ENUM_VALUE_PREFIX # enum values dont start with the enum name.
- ENUM_ZERO_VALUE_SUFFIX # first value does not have to be UNSPECIFIED.
# breaking:
# use:
# - WIRE_JSON # Detect changes that break the json wire format (this is the minimum recommended level.)
+5 -3
View File
@@ -125,9 +125,11 @@ const baseConfig = {
minify: production,
sourcemap: !production,
logLevel: "silent",
define: {
"process.env.IS_DEV": JSON.stringify(!production),
},
define: production
? {
"process.env.IS_DEV": JSON.stringify(!production),
}
: undefined,
tsconfig: path.resolve(__dirname, "tsconfig.json"),
plugins: [
copyWasmFiles,
+3246 -8548
View File
File diff suppressed because it is too large Load Diff
+15 -7
View File
@@ -2,7 +2,7 @@
"name": "claude-dev",
"displayName": "Cline",
"description": "Autonomous coding agent right in your IDE, capable of creating/editing files, running commands, using the browser, and more with your permission every step of the way.",
"version": "3.17.12",
"version": "3.17.14",
"icon": "assets/icons/icon.png",
"engines": {
"vscode": "^1.84.0"
@@ -330,13 +330,13 @@
"watch:esbuild": "node esbuild.js --watch",
"watch:tsc": "tsc --noEmit --watch --project tsconfig.json",
"package": "npm run check-types && npm run build:webview && npm run lint && node esbuild.js --production",
"protos": "node proto/build-proto.js && node scripts/generate-server-setup.mjs",
"postprotos": "prettier src/shared/proto src/core/controller src/hosts/ webview-ui/src/services src/standalone/server-setup.ts --write --log-level silent",
"protos": "node proto/build-proto.js && node scripts/generate-server-setup.mjs && node scripts/generate-host-bridge-client.mjs",
"postprotos": "prettier src/shared/proto src/core/controller src/hosts/ webview-ui/src/services src/generated --write --log-level warn",
"compile-tests": "node ./scripts/build-tests.js",
"watch-tests": "tsc -p . -w --outDir out",
"pretest": "npm run compile-tests && npm run compile && npm run compile-standalone && npm run lint",
"check-types": "npm run protos && tsc --noEmit",
"lint": "eslint src --ext ts && eslint webview-ui/src --ext ts && cd webview-ui && npm run lint",
"lint": "eslint src --ext ts && eslint webview-ui/src --ext ts && buf lint && cd webview-ui && npm run lint",
"format": "prettier . --check",
"format:fix": "prettier . --write",
"test": "npm-run-all test:unit test:integration",
@@ -358,7 +358,13 @@
"docs:rename-file": "cd docs && mintlify rename",
"report-issue": "node scripts/report-issue.js"
},
"lint-staged": {
"*": [
"prettier --write --ignore-unknown --log-level=log"
]
},
"devDependencies": {
"@bufbuild/buf": "^1.54.0",
"@changesets/cli": "^2.27.12",
"@types/chai": "^5.0.1",
"@types/clone-deep": "^4.0.4",
@@ -384,6 +390,7 @@
"eslint-plugin-eslint-rules": "file:eslint-rules",
"grpc-tools": "^1.13.0",
"husky": "^9.1.7",
"lint-staged": "^16.1.0",
"mintlify": "^4.0.515",
"npm-run-all": "^4.1.5",
"prettier": "^3.3.3",
@@ -397,14 +404,14 @@
"typescript": "^5.4.5"
},
"dependencies": {
"@anthropic-ai/bedrock-sdk": "^0.12.4",
"@anthropic-ai/sdk": "^0.37.0",
"@anthropic-ai/vertex-sdk": "^0.6.4",
"@aws-sdk/client-bedrock-runtime": "^3.821.0",
"@aws-sdk/client-bedrock-runtime": "^3.826.0",
"@aws-sdk/credential-providers": "^3.826.0",
"@bufbuild/protobuf": "^2.2.5",
"@cerebras/cerebras_cloud_sdk": "^1.35.0",
"@google-cloud/vertexai": "^1.9.3",
"@google/genai": "^0.13.0",
"@google/genai": "1.0.0",
"@grpc/grpc-js": "^1.9.15",
"@grpc/reflection": "^1.0.4",
"@mistralai/mistralai": "^1.5.0",
@@ -441,6 +448,7 @@
"jschardet": "^3.1.4",
"mammoth": "^1.8.0",
"monaco-vscode-textmate-theme-converter": "^0.1.7",
"nice-grpc": "^2.1.12",
"ollama": "^0.5.13",
"open": "^10.1.2",
"open-graph-scraper": "^6.9.0",
+144 -125
View File
@@ -10,24 +10,26 @@ import os from "os"
import { createRequire } from "module"
const require = createRequire(import.meta.url)
const protoc = path.join(require.resolve("grpc-tools"), "../bin/protoc")
const PROTOC = path.join(require.resolve("grpc-tools"), "../bin/protoc")
const __filename = fileURLToPath(import.meta.url)
const SCRIPT_DIR = path.dirname(__filename)
const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url))
const ROOT_DIR = path.resolve(SCRIPT_DIR, "..")
const TS_OUT_DIR = path.join(ROOT_DIR, "src/shared/proto")
const GRPC_JS_OUT_DIR = path.join(ROOT_DIR, "src/generated/grpc-js")
const NICE_JS_OUT_DIR = path.join(ROOT_DIR, "src/generated/nice-grpc")
const DESCRIPTOR_OUT_DIR = path.join(ROOT_DIR, "dist-standalone/proto")
const isWindows = process.platform === "win32"
const tsProtoPlugin = isWindows
const TS_PROTO_PLUGIN = isWindows
? path.join(ROOT_DIR, "node_modules", ".bin", "protoc-gen-ts_proto.cmd") // Use the .bin directory path for Windows
: require.resolve("ts-proto/protoc-gen-ts_proto")
const TS_PROTO_OPTIONS = [
"env=node",
"esModuleInterop=true",
"outputServices=generic-definitions", // output generic ServiceDefinitions
"outputIndex=true", // output an index file for each package which exports all protos in the package.
"outputServices=generic-definitions",
"useOptionals=messages", // Message fields are optional, scalars are not.
"useDate=false", // Timestamp fields will not be automatically converted to Date.
]
@@ -49,7 +51,7 @@ const serviceNameMap = {
ui: "cline.UiService",
// Add new services here - no other code changes needed!
}
const serviceDirs = Object.keys(serviceNameMap).map((serviceKey) => path.join(ROOT_DIR, "src", "core", "controller", serviceKey))
const serviceDirs = Object.keys(serviceNameMap).map((serviceKey) => path.join(ROOT_DIR, "src/core/controller", serviceKey))
// List of host gRPC services (IDE API bridge)
// These services are implemented in the IDE extension and called by the standalone Cline Core
@@ -58,9 +60,7 @@ const hostServiceNameMap = {
watch: "host.WatchService",
// Add new host services here
}
const hostServiceDirs = Object.keys(hostServiceNameMap).map((serviceKey) =>
path.join(ROOT_DIR, "src", "hosts", "vscode", serviceKey),
)
const hostServiceDirs = Object.keys(hostServiceNameMap).map((serviceKey) => path.join(ROOT_DIR, "src/hosts/vscode", serviceKey))
async function main() {
console.log(chalk.bold.blue("Starting Protocol Buffer code generation..."))
@@ -68,63 +68,44 @@ async function main() {
// Check for Apple Silicon compatibility before proceeding
checkAppleSiliconCompatibility()
// Define output directories
const TS_OUT_DIR = path.join(ROOT_DIR, "src", "shared", "proto")
// Create output directories if they don't exist
await fs.mkdir(TS_OUT_DIR, { recursive: true })
// Clean up existing generated files
console.log(chalk.cyan("Cleaning up existing generated TypeScript files..."))
const existingFiles = await globby("**/*.ts", { cwd: TS_OUT_DIR })
for (const file of existingFiles) {
await fs.unlink(path.join(TS_OUT_DIR, file))
for (const dir of [TS_OUT_DIR, GRPC_JS_OUT_DIR, NICE_JS_OUT_DIR, DESCRIPTOR_OUT_DIR]) {
await fs.mkdir(dir, { recursive: true })
}
await cleanup()
// Check for missing proto files for services in serviceNameMap
await ensureProtoFilesExist()
// Process all proto files
console.log(chalk.cyan("Processing proto files from"), SCRIPT_DIR)
const protoFiles = await globby("**/*.proto", { cwd: SCRIPT_DIR, realpath: true })
console.log(chalk.cyan(`Processing ${protoFiles.length} proto files from`), SCRIPT_DIR)
// Build the protoc command with proper path handling for cross-platform
const tsProtocCommand = [
protoc,
`--proto_path="${SCRIPT_DIR}"`,
`--plugin=protoc-gen-ts_proto="${tsProtoPlugin}"`,
`--ts_proto_out="${TS_OUT_DIR}"`,
`--ts_proto_opt=${TS_PROTO_OPTIONS.join(",")} `,
...protoFiles,
].join(" ")
try {
console.log(chalk.cyan(`Generating TypeScript code for:\n${protoFiles.join("\n")}...`))
execSync(tsProtocCommand, { stdio: "inherit" })
} catch (error) {
console.error(chalk.red("Error generating TypeScript for proto files:"), error)
process.exit(1)
}
tsProtoc(TS_OUT_DIR, protoFiles, TS_PROTO_OPTIONS)
// grpc-js is used to generate service impls for the ProtoBus service.
tsProtoc(GRPC_JS_OUT_DIR, protoFiles, ["outputServices=grpc-js,outputClientImpl=false", ...TS_PROTO_OPTIONS])
// nice-js is used for the Host Bridge client impls because it uses promises.
tsProtoc(NICE_JS_OUT_DIR, protoFiles, ["outputServices=nice-grpc,useExactTypes=false", ...TS_PROTO_OPTIONS])
const descriptorOutDir = path.join(ROOT_DIR, "dist-standalone", "proto")
await fs.mkdir(descriptorOutDir, { recursive: true })
const descriptorFile = path.join(descriptorOutDir, "descriptor_set.pb")
const descriptorFile = path.join(DESCRIPTOR_OUT_DIR, "descriptor_set.pb")
const descriptorProtocCommand = [
protoc,
PROTOC,
`--proto_path="${SCRIPT_DIR}"`,
`--descriptor_set_out="${descriptorFile}"`,
"--include_imports",
...protoFiles,
].join(" ")
try {
console.log(chalk.cyan("Generating descriptor set..."))
log_verbose(chalk.cyan("Generating descriptor set..."))
execSync(descriptorProtocCommand, { stdio: "inherit" })
} catch (error) {
console.error(chalk.red("Error generating descriptor set for proto file:"), error)
process.exit(1)
}
console.log(chalk.green("Protocol Buffer code generation completed successfully."))
console.log(chalk.green(`TypeScript files generated in: ${TS_OUT_DIR}`))
log_verbose(chalk.green("Protocol Buffer code generation completed successfully."))
log_verbose(chalk.green(`TypeScript files generated in: ${TS_OUT_DIR}`))
await generateMethodRegistrations()
await generateHostMethodRegistrations()
@@ -132,6 +113,28 @@ async function main() {
await generateHostServiceConfig()
await generateGrpcClientConfig()
await generateHostGrpcClientConfig()
console.log(chalk.bold.blue("Finished Protocol Buffer code generation."))
}
async function tsProtoc(outDir, protoFiles, protoOptions) {
// Build the protoc command with proper path handling for cross-platform
const command = [
PROTOC,
`--proto_path="${SCRIPT_DIR}"`,
`--plugin=protoc-gen-ts_proto="${TS_PROTO_PLUGIN}"`,
`--ts_proto_out="${outDir}"`,
`--ts_proto_opt=${protoOptions.join(",")} `,
...protoFiles.map((s) => `"${s}"`),
].join(" ")
try {
log_verbose(chalk.cyan(`Generating TypeScript code in ${outDir} for:\n${protoFiles.join("\n")}...`))
log_verbose(command)
execSync(command, { stdio: "inherit" })
} catch (error) {
console.error(chalk.red("Error generating TypeScript for proto files:"), error)
process.exit(1)
}
}
/**
@@ -139,14 +142,14 @@ async function main() {
* This eliminates the need for manual imports and client creation in grpc-client.ts
*/
async function generateGrpcClientConfig() {
console.log(chalk.cyan("Generating gRPC client configuration..."))
log_verbose(chalk.cyan("Generating gRPC client configuration..."))
const serviceImports = []
const serviceClientCreations = []
const serviceExports = []
// Process each service in the serviceNameMap
for (const [dirName, fullServiceName] of Object.entries(serviceNameMap)) {
for (const [dirName, _fullServiceName] of Object.entries(serviceNameMap)) {
const capitalizedName = dirName.charAt(0).toUpperCase() + dirName.slice(1)
// Add import statement
@@ -174,9 +177,9 @@ export {
${serviceExports.join(",\n\t")}
}`
const configPath = path.join(ROOT_DIR, "webview-ui", "src", "services", "grpc-client.ts")
await fs.writeFile(configPath, content)
console.log(chalk.green(`Generated gRPC client at ${configPath}`))
const filePath = path.join(ROOT_DIR, "webview-ui/src/services/grpc-client.ts")
await writeFileWithMkdirs(filePath, content)
log_verbose(chalk.green(`Generated gRPC client at ${filePath}`))
}
/**
@@ -186,7 +189,7 @@ export {
* @returns Map of service names to their streaming methods
*/
async function parseProtoForStreamingMethods(protoFiles, scriptDir) {
console.log(chalk.cyan("Parsing proto files for streaming methods..."))
log_verbose(chalk.cyan("Parsing proto files for streaming methods..."))
// Map of service name to array of streaming method names
const streamingMethodsMap = new Map()
@@ -238,28 +241,18 @@ async function parseProtoForStreamingMethods(protoFiles, scriptDir) {
}
async function generateMethodRegistrations() {
console.log(chalk.cyan("Generating method registration files..."))
log_verbose(chalk.cyan("Generating method registration files..."))
// Parse proto files for streaming methods
const protoFiles = await globby("*.proto", { cwd: SCRIPT_DIR })
const streamingMethodsMap = await parseProtoForStreamingMethods(protoFiles, SCRIPT_DIR)
for (const serviceDir of serviceDirs) {
try {
await fs.access(serviceDir)
} catch (error) {
console.log(chalk.cyan(`Creating directory ${serviceDir} for new service`))
await fs.mkdir(serviceDir, { recursive: true })
}
const serviceName = path.basename(serviceDir)
const registryFile = path.join(serviceDir, "methods.ts")
const indexFile = path.join(serviceDir, "index.ts")
const fullServiceName = serviceNameMap[serviceName]
const streamingMethods = streamingMethodsMap.get(fullServiceName) || []
console.log(chalk.cyan(`Generating method registrations for ${serviceName}...`))
log_verbose(chalk.cyan(`Generating method registrations for ${serviceName}...`))
// Get all TypeScript files in the service directory
const files = await globby("*.ts", { cwd: serviceDir })
@@ -311,8 +304,9 @@ export function registerAllMethods(): void {
methodsContent += `}`
// Write the methods.ts file
await fs.writeFile(registryFile, methodsContent)
console.log(chalk.green(`Generated ${registryFile}`))
const registryFile = path.join(serviceDir, "methods.ts")
await writeFileWithMkdirs(registryFile, methodsContent)
log_verbose(chalk.green(`Generated ${registryFile}`))
// Generate index.ts file
const capitalizedServiceName = serviceName.charAt(0).toUpperCase() + serviceName.slice(1)
@@ -340,11 +334,12 @@ export const isStreamingMethod = ${serviceName}Service.isStreamingMethod
registerAllMethods()`
// Write the index.ts file
await fs.writeFile(indexFile, indexContent)
console.log(chalk.green(`Generated ${indexFile}`))
const indexFile = path.join(serviceDir, "index.ts")
await writeFileWithMkdirs(indexFile, indexContent)
log_verbose(chalk.green(`Generated ${indexFile}`))
}
console.log(chalk.green("Method registration files generated successfully."))
log_verbose(chalk.green("Method registration files generated successfully."))
}
/**
@@ -352,7 +347,7 @@ registerAllMethods()`
* This eliminates the need for manual switch/case statements in grpc-handler.ts
*/
async function generateServiceConfig() {
console.log(chalk.cyan("Generating service configuration file..."))
log_verbose(chalk.cyan("Generating service configuration file..."))
const serviceImports = []
const serviceConfigs = []
@@ -391,9 +386,9 @@ export interface ServiceHandlerConfig {
export const serviceHandlers: Record<string, ServiceHandlerConfig> = {${serviceConfigs.join(",")}
};`
const configPath = path.join(ROOT_DIR, "src", "core", "controller", "grpc-service-config.ts")
await fs.writeFile(configPath, content)
console.log(chalk.green(`Generated service configuration at ${configPath}`))
const configPath = path.join(ROOT_DIR, "src/core/controller/grpc-service-config.ts")
await writeFileWithMkdirs(configPath, content)
log_verbose(chalk.green(`Generated service configuration at ${configPath}`))
}
/**
@@ -401,7 +396,7 @@ export const serviceHandlers: Record<string, ServiceHandlerConfig> = {${serviceC
* If a .proto file doesn't exist, create a template file
*/
async function ensureProtoFilesExist() {
console.log(chalk.cyan("Checking for missing proto files..."))
log_verbose(chalk.cyan("Checking for missing proto files..."))
// Get existing proto files
const existingProtoFiles = await globby("*.proto", { cwd: SCRIPT_DIR })
@@ -410,7 +405,7 @@ async function ensureProtoFilesExist() {
// Check each service in serviceNameMap
for (const [serviceName, fullServiceName] of Object.entries(serviceNameMap)) {
if (!existingProtoServices.includes(serviceName)) {
console.log(chalk.yellow(`Creating template proto file for ${serviceName}...`))
log_verbose(chalk.yellow(`Creating template proto file for ${serviceName}...`))
// Extract service class name from full name (e.g., "cline.ModelsService" -> "ModelsService")
const serviceClassName = fullServiceName.split(".").pop()
@@ -443,7 +438,7 @@ service ${serviceClassName} {
// Write the template proto file
const protoFilePath = path.join(SCRIPT_DIR, `${serviceName}.proto`)
await fs.writeFile(protoFilePath, protoContent)
console.log(chalk.green(`Created template proto file at ${protoFilePath}`))
log_verbose(chalk.green(`Created template proto file at ${protoFilePath}`))
}
}
}
@@ -452,28 +447,18 @@ service ${serviceClassName} {
* Generate method registration files for host services
*/
async function generateHostMethodRegistrations() {
console.log(chalk.cyan("Generating host method registration files..."))
log_verbose(chalk.cyan("Generating host method registration files..."))
// Parse proto files for streaming methods
const hostProtoFiles = await globby("*.proto", { cwd: path.join(SCRIPT_DIR, "host") })
const streamingMethodsMap = await parseProtoForStreamingMethods(hostProtoFiles, path.join(SCRIPT_DIR, "host"))
for (const serviceDir of hostServiceDirs) {
try {
await fs.access(serviceDir)
} catch (error) {
console.log(chalk.cyan(`Creating directory ${serviceDir} for new host service`))
await fs.mkdir(serviceDir, { recursive: true })
}
const serviceName = path.basename(serviceDir)
const registryFile = path.join(serviceDir, "methods.ts")
const indexFile = path.join(serviceDir, "index.ts")
const fullServiceName = hostServiceNameMap[serviceName]
const streamingMethods = streamingMethodsMap.get(fullServiceName) || []
console.log(chalk.cyan(`Generating method registrations for host ${serviceName}...`))
log_verbose(chalk.cyan(`Generating method registrations for host ${serviceName}...`))
// Get all TypeScript files in the service directory
const files = await globby("*.ts", { cwd: serviceDir })
@@ -525,8 +510,9 @@ export function registerAllMethods(): void {
methodsContent += `}`
// Write the methods.ts file
await fs.writeFile(registryFile, methodsContent)
console.log(chalk.green(`Generated ${registryFile}`))
const registryFile = path.join(serviceDir, "methods.ts")
await writeFileWithMkdirs(registryFile, methodsContent)
log_verbose(chalk.green(`Generated ${registryFile}`))
// Generate index.ts file
const capitalizedServiceName = serviceName.charAt(0).toUpperCase() + serviceName.slice(1)
@@ -554,18 +540,19 @@ export const isStreamingMethod = ${serviceName}Service.isStreamingMethod
registerAllMethods()`
// Write the index.ts file
await fs.writeFile(indexFile, indexContent)
console.log(chalk.green(`Generated ${indexFile}`))
const indexFile = path.join(serviceDir, "index.ts")
await writeFileWithMkdirs(indexFile, indexContent)
log_verbose(chalk.green(`Generated ${indexFile}`))
}
console.log(chalk.green("Host method registration files generated successfully."))
log_verbose(chalk.green("Host method registration files generated successfully."))
}
/**
* Generate a service configuration file for host services
*/
async function generateHostServiceConfig() {
console.log(chalk.cyan("Generating host service configuration file..."))
log_verbose(chalk.cyan("Generating host service configuration file..."))
const serviceImports = []
const serviceConfigs = []
@@ -603,55 +590,81 @@ export interface HostServiceHandlerConfig {
export const hostServiceHandlers: Record<string, HostServiceHandlerConfig> = {${serviceConfigs.join(",")}
};`
const configPath = path.join(ROOT_DIR, "src", "hosts", "vscode", "host-grpc-service-config.ts")
await fs.mkdir(path.dirname(configPath), { recursive: true })
await fs.writeFile(configPath, content)
console.log(chalk.green(`Generated host service configuration at ${configPath}`))
const filePath = path.join(ROOT_DIR, "src/hosts/vscode/host-grpc-service-config.ts")
await writeFileWithMkdirs(filePath, content)
log_verbose(chalk.green(`Generated host service configuration at ${filePath}`))
}
/**
* Generate a gRPC client configuration file for host services
*/
async function generateHostGrpcClientConfig() {
console.log(chalk.cyan("Generating host gRPC client configuration..."))
const serviceImports = []
const serviceClientCreations = []
const serviceExports = []
log_verbose(chalk.cyan("Generating host gRPC client configuration..."))
const clients = []
// Process each service in the hostServiceNameMap
for (const [dirName, fullServiceName] of Object.entries(hostServiceNameMap)) {
const capitalizedName = dirName.charAt(0).toUpperCase() + dirName.slice(1)
// Add import statement
serviceImports.push(`import { ${capitalizedName}ServiceDefinition } from "@shared/proto/host/${dirName}"`)
// Add client creation
serviceClientCreations.push(
`const ${capitalizedName}ServiceClient = createGrpcClient(${capitalizedName}ServiceDefinition)`,
)
// Add to exports
serviceExports.push(`${capitalizedName}ServiceClient`)
for (const [_dirName, fullServiceName] of Object.entries(hostServiceNameMap)) {
const serviceName = fullServiceName.replace(/.*\./, "")
clients.push(`${serviceName}Client: createGrpcClient(${fullServiceName}Definition)`)
}
// Generate the file content
const content = `// AUTO-GENERATED FILE - DO NOT MODIFY DIRECTLY
// Generated by proto/build-proto.js
import { createGrpcClient } from "./host-grpc-client-base"
${serviceImports.join("\n")}
import { createGrpcClient } from "@hosts/vscode/client/host-grpc-client-base"
import { HostBridgeClientProvider } from "@/hosts/host-bridge-client"
import * as host from "@shared/proto/index.host"
${serviceClientCreations.join("\n")}
export {
${serviceExports.join(",\n\t")}
export const vscodeHostBridgeClient: HostBridgeClientProvider = {
${clients.join(",\n\t")}
}`
const configPath = path.join(ROOT_DIR, "src", "hosts", "vscode", "client", "host-grpc-client.ts")
await fs.mkdir(path.dirname(configPath), { recursive: true })
await fs.writeFile(configPath, content)
console.log(chalk.green(`Generated host gRPC client at ${configPath}`))
const filePath = path.join(ROOT_DIR, "src/generated/hosts/vscode/client/host-grpc-client.ts")
await writeFileWithMkdirs(filePath, content)
log_verbose(chalk.green(`Generated host gRPC client at ${filePath}`))
}
async function cleanup() {
// Clean up existing generated files
log_verbose(chalk.cyan("Cleaning up existing generated TypeScript files..."))
const existingFiles = await globby("**/*.ts", { cwd: TS_OUT_DIR })
for (const file of existingFiles) {
await fs.unlink(path.join(TS_OUT_DIR, file))
}
await rmdir(path.join(ROOT_DIR, "src/generated"))
// Clean up generated files that were moved.
await fs.rm(path.join(ROOT_DIR, "src/hosts/vscode/client/host-grpc-client.ts"), { force: true })
await fs.rm(path.join(ROOT_DIR, "src/standalone/services/host-grpc-client.ts"), { force: true })
await rmdir(path.join(ROOT_DIR, "src/standalone/services"))
await fs.rm(path.join(ROOT_DIR, "hosts/vscode"), { force: true, recursive: true })
await rmdir(path.join(ROOT_DIR, "hosts"))
await fs.rm(path.join(ROOT_DIR, "src/standalone/server-setup.ts"), { force: true })
}
/**
* Write `contents` to `filePath`, creating any necessary directories in `filePath`.
*/
async function writeFileWithMkdirs(filePath, content) {
await fs.mkdir(path.dirname(filePath), { recursive: true })
await fs.writeFile(filePath, content)
}
/**
* Remove an empty dir, do nothing if the directory doesn't exist or is not empty.
*/
async function rmdir(path) {
try {
await fs.rmdir(path)
} catch (error) {
if (error.code !== "ENOTEMPTY" && error.code !== "ENOENT") {
// Only re-throw if it's not "not empty" or "doesn't exist"
throw error
}
}
}
// Check for Apple Silicon compatibility
@@ -684,6 +697,12 @@ function checkAppleSiliconCompatibility() {
}
}
function log_verbose(s) {
if (process.argv.includes("-v") || process.argv.includes("--verbose")) {
console.log(s)
}
}
// Run the main function
main().catch((error) => {
console.error(chalk.red("Error:"), error)
+2 -2
View File
@@ -25,12 +25,12 @@ message Uri {
string path = 3;
string query = 4;
string fragment = 5;
string fsPath = 6;
string fs_path = 6;
}
// Request for joining path segments to a URI
message JoinPathRequest {
cline.Metadata metadata = 1;
Uri base = 2;
repeated string pathSegments = 3;
repeated string path_segments = 3;
}
+11 -5
View File
@@ -85,8 +85,8 @@ message OpenRouterCompatibleModelInfo {
// Request for fetching OpenAI models
message OpenAiModelsRequest {
Metadata metadata = 1;
string baseUrl = 2;
string apiKey = 3;
string base_url = 2;
string api_key = 3;
}
// Request for updating API configuration
@@ -121,6 +121,8 @@ enum ApiProvider {
XAI = 21;
SAMBANOVA = 22;
CEREBRAS = 23;
SAPAICORE = 24;
CLAUDE_CODE = 25;
}
// Model info for OpenAI-compatible models
@@ -226,8 +228,12 @@ message ModelsApiConfiguration {
optional string sambanova_api_key = 63;
optional string cerebras_api_key = 64;
optional int32 request_timeout_ms = 65;
// From ApiConfiguration (additional fields)
optional ApiProvider api_provider = 66;
repeated string favorited_model_ids = 67;
}
optional string sap_ai_core_client_id = 68;
optional string sap_ai_core_client_secret = 69;
optional string sap_ai_resource_group = 70;
optional string sap_ai_core_token_url = 71;
optional string sap_ai_core_base_url = 72;
optional string claude_code_path = 73;
}
+47 -13
View File
@@ -7,9 +7,13 @@ import "common.proto";
service StateService {
rpc getLatestState(EmptyRequest) returns (State);
rpc updateTerminalConnectionTimeout(Int64Request) returns (Int64);
rpc updateTerminalReuseEnabled(BooleanRequest) returns (Empty);
rpc updateDefaultTerminalProfile(StringRequest) returns (TerminalProfileUpdateResponse);
rpc getAvailableTerminalProfiles(EmptyRequest) returns (TerminalProfiles);
rpc subscribeToState(EmptyRequest) returns (stream State);
rpc toggleFavoriteModel(StringRequest) returns (Empty);
rpc resetState(EmptyRequest) returns (Empty);
rpc resetState(ResetStateRequest) returns (Empty);
rpc togglePlanActMode(TogglePlanActModeRequest) returns (Empty);
rpc updateAutoApprovalSettings(AutoApprovalSettingsRequest) returns (Empty);
rpc updateSettings(UpdateSettingsRequest) returns (Empty);
@@ -19,6 +23,23 @@ message State {
string state_json = 1;
}
message TerminalProfiles {
repeated TerminalProfile profiles = 1;
}
message TerminalProfile {
string id = 1;
string name = 2;
optional string path = 3;
optional string description = 4;
}
message TerminalProfileUpdateResponse {
int32 closed_count = 1;
int32 busy_terminals_count = 2;
bool has_busy_terminals = 3;
}
message TogglePlanActModeRequest {
Metadata metadata = 1;
ChatSettings chat_settings = 2;
@@ -42,10 +63,13 @@ message ChatContent {
repeated string files = 3;
}
// Message for auto approval settings
message ResetStateRequest {
Metadata metadata = 1;
optional bool global = 2;
}
message AutoApprovalSettingsRequest {
Metadata metadata = 1;
message Actions {
bool read_files = 1;
bool read_files_externally = 2;
@@ -56,7 +80,6 @@ message AutoApprovalSettingsRequest {
bool use_browser = 7;
bool use_mcp = 8;
}
int32 version = 2;
bool enabled = 3;
Actions actions = 4;
@@ -69,15 +92,16 @@ message AutoApprovalSettingsRequest {
message UpdateSettingsRequest {
Metadata metadata = 1;
optional ApiConfiguration api_configuration = 2;
optional string custom_instructions_setting = 3;
optional string telemetry_setting = 4;
optional bool plan_act_separate_models_setting = 5;
optional bool enable_checkpoints_setting = 6;
optional bool mcp_marketplace_enabled = 7;
optional ChatSettings chat_settings = 8;
optional int64 shell_integration_timeout = 9;
optional bool terminal_reuse_enabled = 10;
optional bool mcp_responses_collapsed = 11;
optional string telemetry_setting = 3;
optional bool plan_act_separate_models_setting = 4;
optional bool enable_checkpoints_setting = 5;
optional bool mcp_marketplace_enabled = 6;
optional ChatSettings chat_settings = 7;
optional int64 shell_integration_timeout = 8;
optional bool terminal_reuse_enabled = 9;
optional bool mcp_responses_collapsed = 10;
optional bool mcp_rich_display_enabled = 11;
optional int64 terminal_output_line_limit = 12;
}
// Complete API Configuration message
@@ -186,4 +210,14 @@ message ApiConfiguration {
// Favorited model IDs
repeated string favorited_model_ids = 71;
// SAP AI Core specific
optional string sap_ai_core_client_id = 72;
optional string sap_ai_core_client_secret = 73;
optional string sap_ai_core_base_url = 74;
optional string sap_ai_core_token_url = 75;
optional string sap_ai_resource_group = 76;
// Claude Code specific
optional string claude_code_path = 77;
}
+4 -1
View File
@@ -15,7 +15,7 @@ enum WebviewProviderType {
// Define a new message type for webview provider info
message WebviewProviderTypeRequest {
Metadata metadata = 1;
WebviewProviderType providerType = 2;
WebviewProviderType provider_type = 2;
}
// Enum for ClineMessage type
@@ -259,4 +259,7 @@ service UiService {
// Subscribe to focus chat input events with client ID
rpc subscribeToFocusChatInput(StringRequest) returns (stream Empty);
// Subscribe to webview visibility change events
rpc subscribeToDidBecomeVisible(EmptyRequest) returns (stream Empty);
}
-1
View File
@@ -34,7 +34,6 @@ const srcConfig = {
format: "cjs",
platform: "node",
define: {
"process.env.IS_DEV": "true",
"process.env.IS_TEST": "true",
},
external: ["vscode"],
+192
View File
@@ -0,0 +1,192 @@
#!/usr/bin/env node
import * as fs from "fs/promises"
import * as path from "path"
import * as grpc from "@grpc/grpc-js"
import * as protoLoader from "@grpc/proto-loader"
import chalk from "chalk"
const IMPL_FILE = path.resolve("src/generated/standalone/host-bridge-clients.ts")
const INTERFACE_FILE = path.resolve("src/generated/hosts/host-bridge-client-types.ts")
const DESCRIPTOR_SET = path.resolve("dist-standalone/proto/descriptor_set.pb")
const typeNameToFQN = new Map()
function addTypeNameToFqn(name, fqn) {
if (typeNameToFQN.has(name)) {
throw new Error(`Proto type ${name} redefined (${fqn}).`)
}
typeNameToFQN.set(name, fqn)
}
function getFqn(name) {
if (!typeNameToFQN.has(name)) {
throw Error(`No FQN for ${name}`)
}
return typeNameToFQN.get(name)
}
/**
* Main function to generate the host bridge client
*/
async function main() {
// Load service definitions from descriptor set
const descriptorBuffer = await fs.readFile(DESCRIPTOR_SET)
const packageDefinition = protoLoader.loadFileDescriptorSetFromBuffer(descriptorBuffer)
const proto = grpc.loadPackageDefinition(packageDefinition)
// Extract host services and proto messages from the proto definition
const hostServices = {}
for (const [name, def] of Object.entries(proto.host)) {
if (def && "service" in def) {
hostServices[name] = def
} else {
addTypeNameToFqn(name, `proto.host.${name}`)
}
}
for (const [name, def] of Object.entries(proto.cline)) {
if (def && !("service" in def)) {
addTypeNameToFqn(name, `proto.cline.${name}`)
}
}
// Generate interfaces file
await generateInterfacesFile(hostServices)
// // Generate implementation file
await generateImplementationFile(hostServices)
console.log(`Generated host bridge client files at:`)
console.log(`- ${INTERFACE_FILE}`)
console.log(`- ${IMPL_FILE}`)
}
/**
* Generate the client interfaces file.
*/
async function generateInterfacesFile(hostServices) {
const clientInterfaces = []
for (const [name, def] of Object.entries(hostServices)) {
const clientInterface = generateClientInterface(name, def)
clientInterfaces.push(clientInterface)
}
const content = `// GENERATED CODE -- DO NOT EDIT!
// Generated by scripts/generate-host-bridge-client.mjs
import * as proto from "@shared/proto/index"
import { StreamingCallbacks } from "@hosts/host-bridge-client"
${clientInterfaces.join("\n\n")}
`
// Write output file
await fs.mkdir(path.dirname(INTERFACE_FILE), { recursive: true })
await fs.writeFile(INTERFACE_FILE, content)
}
/**
* Generate a client interface for a service.
*/
function generateClientInterface(serviceName, serviceDefinition) {
// Get the methods from the service definition
const methods = Object.entries(serviceDefinition.service)
.map(([methodName, methodDef]) => {
const requestType = getFqn(methodDef.requestType.type.name)
const responseType = getFqn(methodDef.responseType.type.name)
if (!methodDef.responseStream) {
// Generate unary method signature.
return ` ${methodName}(request: ${requestType}): Promise<${responseType}>;`
}
// Generate streaming method signature.
return ` ${methodName}(request: ${requestType}, callbacks: StreamingCallbacks<${responseType}>): () => void;`
})
.join("\n\n")
// Generate the interface
return `/**
* Interface for ${serviceName} client.
*/
export interface ${serviceName}ClientInterface {
${methods}
}`
}
/**
* Generate the client implementations file.
*/
async function generateImplementationFile(hostServices) {
// Generate imports
const imports = []
// Add imports for the interfaces
for (const [name, _def] of Object.entries(hostServices)) {
imports.push(`import { ${name}ClientInterface } from "@generated/hosts/host-bridge-client-types"`)
}
const clientImplementations = []
for (const [name, def] of Object.entries(hostServices)) {
clientImplementations.push(generateClientImplementation(name, def))
}
const content = `// GENERATED CODE -- DO NOT EDIT!
// Generated by scripts/generate-host-bridge-client.mjs
import { asyncIteratorToCallbacks } from "@/standalone/utils"
import * as niceGrpc from "@generated/nice-grpc/index"
import { StreamingCallbacks } from "@hosts/host-bridge-client"
import * as proto from "@shared/proto/index"
import { Channel, createClient } from "nice-grpc"
${imports.join("\n")}
${clientImplementations.join("\n\n")}
`
// Write output file
await fs.mkdir(path.dirname(IMPL_FILE), { recursive: true })
await fs.writeFile(IMPL_FILE, content)
}
/**
* Generate a client implementation class for a service
*/
function generateClientImplementation(serviceName, serviceDefinition) {
// Get the methods from the service definition
const methods = Object.entries(serviceDefinition.service)
.map(([methodName, methodDef]) => {
// Get fully qualified type names
const requestType = getFqn(methodDef.requestType.type.name)
const responseType = getFqn(methodDef.responseType.type.name)
const isStreamingResponse = methodDef.responseStream
if (!isStreamingResponse) {
return ` ${methodName}(request: ${requestType}): Promise<${responseType}> {
return this.client.${methodName}(request)
}`
} else {
// Generate streaming method
return ` ${methodName}(request: ${requestType}, callbacks: StreamingCallbacks<${responseType}>): () => void {
const abortController = new AbortController()
const stream: AsyncIterable<${responseType}> = this.client.${methodName}(request, {signal: abortController.signal})
asyncIteratorToCallbacks(stream, callbacks)
return () => {abortController.abort()}
}`
}
})
.join("\n\n")
// Generate the class
return `/**
* Type-safe client implementation for ${serviceName}.
*/
export class ${serviceName}ClientImpl implements ${serviceName}ClientInterface {
private client: niceGrpc.host.${serviceName}Client
constructor(channel: Channel) {
this.client = createClient(niceGrpc.host.${serviceName}Definition, channel)
}
${methods}
}`
}
// Run the main function
main().catch((error) => {
console.error(chalk.red("Error:"), error)
process.exit(1)
})
+12 -12
View File
@@ -1,11 +1,11 @@
import * as fs from "fs"
import * as grpc from "@grpc/grpc-js"
import * as protoLoader from "@grpc/proto-loader"
import * as fs from "fs"
import * as health from "grpc-health-check"
import path, { basename, dirname } from "path"
import { fileURLToPath } from "url"
import path from "path"
const OUT_FILE = path.resolve("src/standalone/server-setup.ts")
const OUT_FILE = path.resolve("src/generated/standalone/server-setup.ts")
const DESCRIPTOR_SET = path.resolve("dist-standalone/proto/descriptor_set.pb")
// Load service definitions.
@@ -29,17 +29,17 @@ function generateHandlersAndExports() {
const dir = domain.charAt(0).toLowerCase() + domain.slice(1)
imports.push(`// ${domain} Service`)
handlerSetup.push(` // ${domain} Service`)
handlerSetup.push(` server.addService(proto.cline.${name}.service, {`)
handlerSetup.push(` server.addService(cline.${name}Service, {`)
for (const [rpcName, rpc] of Object.entries(def.service)) {
imports.push(`import { ${rpcName} } from "../core/controller/${dir}/${rpcName}"`)
const requestType = "proto.cline." + rpc.requestType.type.name
imports.push(`import { ${rpcName} } from "@core/controller/${dir}/${rpcName}"`)
const requestType = "cline." + rpc.requestType.type.name
if (rpc.requestStream) {
throw new Error("Request streaming is not supported")
}
if (rpc.responseStream) {
handlerSetup.push(` ${rpcName}: wrapStreamingResponse<${requestType},void>(${rpcName}, controller),`)
} else {
const responseType = "proto.cline." + rpc.responseType.type.name
const responseType = "cline." + rpc.responseType.type.name
handlerSetup.push(` ${rpcName}: wrapper<${requestType},${responseType}>(${rpcName}, controller),`)
}
}
@@ -60,14 +60,13 @@ const scriptName = path.basename(fileURLToPath(import.meta.url))
let output = `// GENERATED CODE -- DO NOT EDIT!
// Generated by ${scriptName}
import * as grpc from "@grpc/grpc-js"
import * as proto from "@/shared/proto"
import { Controller } from "../core/controller"
import { GrpcHandlerWrapper, GrpcStreamingResponseHandlerWrapper } from "./grpc-types"
import { cline } from "@generated/grpc-js"
import { Controller } from "@core/controller"
import { GrpcHandlerWrapper, GrpcStreamingResponseHandlerWrapper } from "@/standalone/grpc-types"
${imports}
export function addServices(
export function addProtobusServices(
server: grpc.Server,
proto: any,
controller: Controller,
wrapper: GrpcHandlerWrapper,
wrapStreamingResponse: GrpcStreamingResponseHandlerWrapper,
@@ -76,6 +75,7 @@ ${handlerSetup}
}
`
// Write output file
fs.mkdirSync(dirname(OUT_FILE), { recursive: true })
fs.writeFileSync(OUT_FILE, output)
console.log(`Generated service handlers in ${OUT_FILE}.`)
+1
View File
@@ -12,6 +12,7 @@ git grep -h 'vscode\.' $DIR |
grep -Ev '//.*vscode' | # remove commented out code
sed 's|.*vscode\.|vscode.|'| # remove everything before vscode.
sed 's/[^a-zA-Z0-9_.].*$//' | # remove everything after last identifier
grep -E '\.[a-z][^.]+$' | # remove types (last part of identifier should be lowercase)
sort | uniq > $SDK_DEST
}
echo Wrote uses of the vscode SDK to $(realpath $SDK_DEST)
+6
View File
@@ -25,6 +25,8 @@ import { AskSageHandler } from "./providers/asksage"
import { XAIHandler } from "./providers/xai"
import { SambanovaHandler } from "./providers/sambanova"
import { CerebrasHandler } from "./providers/cerebras"
import { SapAiCoreHandler } from "./providers/sapaicore"
import { ClaudeCodeHandler } from "./providers/claude-code"
export interface ApiHandler {
createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream
@@ -87,6 +89,10 @@ export function buildApiHandler(configuration: ApiConfiguration): ApiHandler {
return new SambanovaHandler(options)
case "cerebras":
return new CerebrasHandler(options)
case "sapaicore":
return new SapAiCoreHandler(options)
case "claude-code":
return new ClaudeCodeHandler(options)
default:
return new AnthropicHandler(options)
}
+125 -26
View File
@@ -184,34 +184,35 @@ describe("AwsBedrockHandler", () => {
})
})
const mockOptions: ApiHandlerOptions = {
apiModelId: "anthropic.claude-3-7-sonnet-20250219-v1:0",
awsRegion: "us-east-1",
awsAccessKey: "test-key",
awsSecretKey: "test-secret",
awsSessionToken: "",
awsUseProfile: false,
awsProfile: "",
awsBedrockUsePromptCache: false,
awsUseCrossRegionInference: false,
awsBedrockEndpoint: "",
awsBedrockCustomSelected: false,
awsBedrockCustomModelBaseId: undefined,
thinkingBudgetTokens: 1600,
}
const mockModelInfo = {
maxTokens: 8192,
contextWindow: 200000,
supportsPromptCache: true,
supportsImages: true,
inputPrice: 3.0,
outputPrice: 15.0,
cacheWritesPrice: 3.75,
cacheReadsPrice: 0.3,
}
describe("executeConverseStream", () => {
let handler: AwsBedrockHandler
const mockOptions: ApiHandlerOptions = {
apiModelId: "anthropic.claude-3-7-sonnet-20250219-v1:0",
awsRegion: "us-east-1",
awsAccessKey: "test-key",
awsSecretKey: "test-secret",
awsSessionToken: "",
awsUseProfile: false,
awsProfile: "",
awsBedrockUsePromptCache: false,
awsUseCrossRegionInference: false,
awsBedrockEndpoint: "",
awsBedrockCustomSelected: false,
awsBedrockCustomModelBaseId: undefined,
thinkingBudgetTokens: 1600,
}
const mockModelInfo = {
maxTokens: 8192,
contextWindow: 200000,
supportsPromptCache: true,
supportsImages: true,
inputPrice: 3.0,
outputPrice: 15.0,
cacheWritesPrice: 3.75,
cacheReadsPrice: 0.3,
}
beforeEach(() => {
handler = new AwsBedrockHandler(mockOptions)
@@ -591,4 +592,102 @@ describe("AwsBedrockHandler", () => {
})
})
})
describe("getModelId", () => {
it("should return raw model ID for custom models", async () => {
const customOptions: ApiHandlerOptions = {
...mockOptions,
awsBedrockCustomSelected: true,
apiModelId:
"arn:aws:bedrock:us-west-2:123456789012:custom-model/anthropic.claude-3-5-sonnet-20241022-v2:0/Qk8MMyLmRd",
}
const customHandler = new AwsBedrockHandler(customOptions)
const modelId = await customHandler.getModelId()
modelId.should.equal(
"arn:aws:bedrock:us-west-2:123456789012:custom-model/anthropic.claude-3-5-sonnet-20241022-v2:0/Qk8MMyLmRd",
)
})
it("should not encode custom model IDs with slashes", async () => {
const customOptions: ApiHandlerOptions = {
...mockOptions,
awsBedrockCustomSelected: true,
apiModelId: "my-namespace/my-custom-model",
}
const customHandler = new AwsBedrockHandler(customOptions)
const modelId = await customHandler.getModelId()
modelId.should.equal("my-namespace/my-custom-model")
modelId.should.not.match(/%2F/)
})
it("should apply cross-region prefix for non-custom models when enabled", async () => {
const crossRegionOptions: ApiHandlerOptions = {
...mockOptions,
awsUseCrossRegionInference: true,
awsRegion: "us-west-2",
}
const crossRegionHandler = new AwsBedrockHandler(crossRegionOptions)
const modelId = await crossRegionHandler.getModelId()
modelId.should.equal("us.anthropic.claude-3-7-sonnet-20250219-v1:0")
})
it("should apply EU cross-region prefix", async () => {
const euOptions: ApiHandlerOptions = {
...mockOptions,
awsUseCrossRegionInference: true,
awsRegion: "eu-central-1",
}
const euHandler = new AwsBedrockHandler(euOptions)
const modelId = await euHandler.getModelId()
modelId.should.equal("eu.anthropic.claude-3-7-sonnet-20250219-v1:0")
})
it("should apply APAC cross-region prefix", async () => {
const apacOptions: ApiHandlerOptions = {
...mockOptions,
awsUseCrossRegionInference: true,
awsRegion: "ap-northeast-1",
}
const apacHandler = new AwsBedrockHandler(apacOptions)
const modelId = await apacHandler.getModelId()
modelId.should.equal("apac.anthropic.claude-3-7-sonnet-20250219-v1:0")
})
it("should not apply cross-region prefix for custom models even when enabled", async () => {
const customCrossRegionOptions: ApiHandlerOptions = {
...mockOptions,
awsBedrockCustomSelected: true,
apiModelId: "arn:aws:bedrock:us-west-2:123456789012:custom-model/my-model",
awsUseCrossRegionInference: true,
}
const customCrossRegionHandler = new AwsBedrockHandler(customCrossRegionOptions)
const modelId = await customCrossRegionHandler.getModelId()
modelId.should.equal("arn:aws:bedrock:us-west-2:123456789012:custom-model/my-model")
})
it("should handle UltraThink model ARN correctly", async () => {
const ultraThinkOptions: ApiHandlerOptions = {
...mockOptions,
awsBedrockCustomSelected: true,
apiModelId:
"arn:aws:bedrock:us-west-2:123456789012:custom-model/anthropic.claude-3-5-sonnet-20241022-v2:0/Qk8MMyLmRd",
awsBedrockCustomModelBaseId: "anthropic.claude-3-5-sonnet-20241022-v2:0",
}
const ultraThinkHandler = new AwsBedrockHandler(ultraThinkOptions)
const modelId = await ultraThinkHandler.getModelId()
// Should return the raw ARN without any encoding
modelId.should.equal(
"arn:aws:bedrock:us-west-2:123456789012:custom-model/anthropic.claude-3-5-sonnet-20241022-v2:0/Qk8MMyLmRd",
)
modelId.should.not.match(/%2F/)
modelId.should.not.match(/%3A/)
})
})
})
+14 -8
View File
@@ -96,7 +96,7 @@ export class AwsBedrockHandler implements ApiHandler {
this.options = options
}
@withRetry()
@withRetry({ maxRetries: 4 })
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
// cross region inference requires prefixing the model id with the region
const modelId = await this.getModelId()
@@ -133,11 +133,20 @@ export class AwsBedrockHandler implements ApiHandler {
const customSelected = this.options.awsBedrockCustomSelected
const baseModel = this.options.awsBedrockCustomModelBaseId
if (customSelected && modelId && baseModel && baseModel in bedrockModels) {
// Use the user-input model ID but inherit capabilities from the base model
// Handle custom models
if (customSelected && modelId) {
// If base model is provided and valid, use its capabilities
if (baseModel && baseModel in bedrockModels) {
return {
id: modelId,
info: bedrockModels[baseModel],
}
}
// For custom models without valid base model in bedrock model list, use default model's capabilities
return {
id: modelId,
info: bedrockModels[baseModel],
info: bedrockModels[bedrockDefaultModelId],
}
}
@@ -214,12 +223,9 @@ export class AwsBedrockHandler implements ApiHandler {
/**
* Gets the appropriate model ID, accounting for cross-region inference if enabled.
* If the model ID is an ARN that contains a slash, you will get the URL encoded ARN.
* For custom models, returns the raw model ID without any encoding.
*/
async getModelId(): Promise<string> {
if (this.options.awsBedrockCustomSelected && this.getModel().id.includes("/")) {
return encodeURIComponent(this.getModel().id)
}
if (!this.options.awsBedrockCustomSelected && this.options.awsUseCrossRegionInference) {
const regionPrefix = this.getRegion().slice(0, 3)
switch (regionPrefix) {
+165
View File
@@ -0,0 +1,165 @@
import type { Anthropic } from "@anthropic-ai/sdk"
import { claudeCodeDefaultModelId, ClaudeCodeModelId, claudeCodeModels, type ApiHandlerOptions } from "@/shared/api"
import { type ApiHandler } from ".."
import { ApiStreamUsageChunk, type ApiStream } from "../transform/stream"
import { withRetry } from "../retry"
import { runClaudeCode } from "@/integrations/claude-code/run"
import { ClaudeCodeMessage } from "@/integrations/claude-code/types"
export class ClaudeCodeHandler implements ApiHandler {
private options: ApiHandlerOptions
constructor(options: ApiHandlerOptions) {
this.options = options
}
@withRetry({
maxRetries: 4,
baseDelay: 2000,
maxDelay: 15000,
})
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
const claudeProcess = runClaudeCode({
systemPrompt,
messages,
path: this.options.claudeCodePath,
modelId: this.getModel().id,
})
const dataQueue: string[] = []
let processError = null
let errorOutput = ""
let exitCode: number | null = null
claudeProcess.stdout.on("data", (data) => {
const output = data.toString()
const lines = output.split("\n").filter((line: string) => line.trim() !== "")
for (const line of lines) {
dataQueue.push(line)
}
})
claudeProcess.stderr.on("data", (data) => {
errorOutput += data.toString()
})
claudeProcess.on("close", (code) => {
exitCode = code
})
claudeProcess.on("error", (error) => {
processError = error
})
// Usage is included with assistant messages,
// but cost is included in the result chunk
let usage: ApiStreamUsageChunk = {
type: "usage",
inputTokens: 0,
outputTokens: 0,
cacheReadTokens: 0,
cacheWriteTokens: 0,
}
while (exitCode !== 0 || dataQueue.length > 0) {
if (dataQueue.length === 0) {
await new Promise((resolve) => setImmediate(resolve))
}
if (exitCode !== null && exitCode !== 0) {
throw new Error(
`Claude Code process exited with code ${exitCode}.${errorOutput ? ` Error output: ${errorOutput.trim()}` : ""}`,
)
}
const data = dataQueue.shift()
if (!data) {
continue
}
const chunk = this.attemptParseChunk(data)
if (!chunk) {
yield {
type: "text",
text: data || "",
}
continue
}
if (chunk.type === "system" && chunk.subtype === "init") {
continue
}
if (chunk.type === "assistant" && "message" in chunk) {
const message = chunk.message
if (message.stop_reason !== null && message.stop_reason !== "tool_use") {
const errorMessage = message.content[0]?.text || `Claude Code stopped with reason: ${message.stop_reason}`
if (errorMessage.includes("Invalid model name")) {
throw new Error(
errorMessage +
`\n\nAPI keys and subscription plans allow different models. Make sure the selected model is included in your plan.`,
)
}
throw new Error(errorMessage)
}
for (const content of message.content) {
if (content.type === "text") {
yield {
type: "text",
text: content.text,
}
} else {
console.warn("Unsupported content type:", content.type)
}
}
usage.inputTokens += message.usage.input_tokens
usage.outputTokens += message.usage.output_tokens
usage.cacheReadTokens = (usage.cacheReadTokens || 0) + (message.usage.cache_read_input_tokens || 0)
usage.cacheWriteTokens = (usage.cacheWriteTokens || 0) + (message.usage.cache_creation_input_tokens || 0)
continue
}
if (chunk.type === "result" && "result" in chunk) {
usage.totalCost = chunk.cost_usd || 0
yield usage
}
if (processError) {
throw processError
}
}
}
getModel() {
const modelId = this.options.apiModelId
if (modelId && modelId in claudeCodeModels) {
const id = modelId as ClaudeCodeModelId
return { id, info: claudeCodeModels[id] }
}
return {
id: claudeCodeDefaultModelId,
info: claudeCodeModels[claudeCodeDefaultModelId],
}
}
// TOOD: Validate instead of parsing
private attemptParseChunk(data: string): ClaudeCodeMessage | null {
try {
return JSON.parse(data)
} catch (error) {
console.error("Error parsing chunk:", error)
return null
}
}
}
+38 -5
View File
@@ -2,6 +2,7 @@ import type { Anthropic } from "@anthropic-ai/sdk"
// Restore GenerateContentConfig import and add GenerateContentResponseUsageMetadata
import { GoogleGenAI, type GenerateContentConfig, type GenerateContentResponseUsageMetadata } from "@google/genai"
import { withRetry } from "../retry"
import { Part } from "@google/genai"
import { ApiHandler } from "../"
import { ApiHandlerOptions, geminiDefaultModelId, GeminiModelId, geminiModels, ModelInfo } from "@shared/api"
import { convertAnthropicMessageToGemini } from "../transform/gemini-format"
@@ -96,9 +97,10 @@ export class GeminiHandler implements ApiHandler {
}
// Add thinking config if the model supports it
if (info.thinkingConfig?.outputPrice !== undefined && maxBudget > 0) {
if (thinkingBudget > 0) {
requestConfig.thinkingConfig = {
thinkingBudget: thinkingBudget,
includeThoughts: true,
}
}
@@ -111,6 +113,7 @@ export class GeminiHandler implements ApiHandler {
let promptTokens = 0
let outputTokens = 0
let cacheReadTokens = 0
let thoughtsTokenCount = 0 // Initialize thought token counts
let lastUsageMetadata: GenerateContentResponseUsageMetadata | undefined
try {
@@ -130,6 +133,31 @@ export class GeminiHandler implements ApiHandler {
isFirstSdkChunk = false
}
// Handle thinking content from Gemini's response
const candidateForThoughts = chunk?.candidates?.[0]
const partsForThoughts = candidateForThoughts?.content?.parts
let thoughts = "" // Initialize as empty string
if (partsForThoughts) {
// This ensures partsForThoughts is a Part[] array
for (const part of partsForThoughts) {
const { thought, text } = part as Part
if (thought && text) {
// Ensure part.text exists
// Handle the thought part
thoughts += text + "\n" // Append thought and a newline
}
}
}
if (thoughts.trim() !== "") {
yield {
type: "reasoning",
reasoning: thoughts.trim(),
}
thoughts = "" // Reset thoughts after yielding
}
if (chunk.text) {
yield {
type: "text",
@@ -141,6 +169,7 @@ export class GeminiHandler implements ApiHandler {
lastUsageMetadata = chunk.usageMetadata
promptTokens = lastUsageMetadata.promptTokenCount ?? promptTokens
outputTokens = lastUsageMetadata.candidatesTokenCount ?? outputTokens
thoughtsTokenCount = lastUsageMetadata.thoughtsTokenCount ?? thoughtsTokenCount
cacheReadTokens = lastUsageMetadata.cachedContentTokenCount ?? cacheReadTokens
}
}
@@ -151,12 +180,14 @@ export class GeminiHandler implements ApiHandler {
info,
inputTokens: promptTokens,
outputTokens,
thoughtsTokenCount,
cacheReadTokens,
})
yield {
type: "usage",
inputTokens: promptTokens,
inputTokens: promptTokens - cacheReadTokens,
outputTokens,
thoughtsTokenCount,
cacheReadTokens,
cacheWriteTokens: 0,
totalCost,
@@ -239,11 +270,13 @@ export class GeminiHandler implements ApiHandler {
info,
inputTokens,
outputTokens,
thoughtsTokenCount = 0,
cacheReadTokens = 0,
}: {
info: ModelInfo
inputTokens: number
outputTokens: number
thoughtsTokenCount: number
cacheReadTokens?: number
}) {
// Exit early if any required pricing information is missing
@@ -275,18 +308,18 @@ export class GeminiHandler implements ApiHandler {
const inputTokensCost = inputPrice * (uncachedInputTokens / 1_000_000)
// 2. Output token costs
const outputTokensCost = outputPrice * (outputTokens / 1_000_000)
const responseTokensCost = outputPrice * ((outputTokens + thoughtsTokenCount) / 1_000_000)
// 3. Cache read costs (immediate)
const cacheReadCost = (cacheReadTokens ?? 0) > 0 ? cacheReadsPrice * ((cacheReadTokens ?? 0) / 1_000_000) : 0
// Calculate total immediate cost (excluding cache write/storage costs)
const totalCost = inputTokensCost + outputTokensCost + cacheReadCost
const totalCost = inputTokensCost + responseTokensCost + cacheReadCost
// Create the trace object for debugging
const trace: Record<string, { price: number; tokens: number; cost: number }> = {
input: { price: inputPrice, tokens: uncachedInputTokens, cost: inputTokensCost },
output: { price: outputPrice, tokens: outputTokens, cost: outputTokensCost },
output: { price: outputPrice, tokens: outputTokens, cost: responseTokensCost },
}
// Only include cache read costs in the trace (cache write costs are tracked separately)
+540
View File
@@ -0,0 +1,540 @@
import { Anthropic } from "@anthropic-ai/sdk"
import axios from "axios"
import OpenAI from "openai"
import { ApiHandler } from "../"
import { ApiHandlerOptions, ModelInfo, sapAiCoreDefaultModelId, SapAiCoreModelId, sapAiCoreModels } from "../../shared/api"
import { convertToOpenAiMessages } from "../transform/openai-format"
import { ApiStream } from "../transform/stream"
interface Deployment {
id: string
name: string
}
interface Token {
access_token: string
expires_in: number
scope: string
jti: string
token_type: string
expires_at: number
}
export class SapAiCoreHandler implements ApiHandler {
private options: ApiHandlerOptions
private token?: Token
private deployments?: Deployment[]
constructor(options: ApiHandlerOptions) {
this.options = options
}
private async authenticate(): Promise<Token> {
const payload = {
grant_type: "client_credentials",
client_id: this.options.sapAiCoreClientId || "",
client_secret: this.options.sapAiCoreClientSecret || "",
}
const tokenUrl = (this.options.sapAiCoreTokenUrl || "").replace(/\/+$/, "") + "/oauth/token"
const response = await axios.post(tokenUrl, payload, {
headers: { "Content-Type": "application/x-www-form-urlencoded" },
})
const token = response.data as Token
token.expires_at = Date.now() + token.expires_in * 1000
return token
}
private async getToken(): Promise<string> {
if (!this.token || this.token.expires_at < Date.now()) {
this.token = await this.authenticate()
}
return this.token.access_token
}
private async getAiCoreDeployments(): Promise<Deployment[]> {
if (this.options.sapAiCoreClientSecret === "") {
return [{ id: "notconfigured", name: "ai-core-not-configured" }]
}
const token = await this.getToken()
const headers = {
Authorization: `Bearer ${token}`,
"AI-Resource-Group": this.options.sapAiResourceGroup || "default",
"Content-Type": "application/json",
}
const url = `${this.options.sapAiCoreBaseUrl}/v2/lm/deployments?$top=10000&$skip=0`
try {
const response = await axios.get(url, { headers })
const deployments = response.data.resources
return deployments
.filter((deployment: any) => deployment.targetStatus === "RUNNING")
.map((deployment: any) => {
const model = deployment.details?.resources?.backend_details?.model
if (!model?.name || !model?.version) {
return null // Skip this row
}
return {
id: deployment.id,
name: `${model.name}:${model.version}`,
}
})
.filter((deployment: any) => deployment !== null)
} catch (error) {
console.error("Error fetching deployments:", error)
throw new Error("Failed to fetch deployments")
}
}
private async getDeploymentForModel(modelId: string): Promise<string> {
// If deployments are not fetched yet or the model is not found in the fetched deployments, fetch deployments
if (!this.deployments || !this.hasDeploymentForModel(modelId)) {
this.deployments = await this.getAiCoreDeployments()
}
const deployment = this.deployments.find((d) => {
const deploymentBaseName = d.name.split(":")[0].toLowerCase()
const modelBaseName = modelId.split(":")[0].toLowerCase()
return deploymentBaseName === modelBaseName
})
if (!deployment) {
throw new Error(`No running deployment found for model ${modelId}`)
}
return deployment.id
}
private hasDeploymentForModel(modelId: string): boolean {
return this.deployments?.some((d) => d.name.split(":")[0].toLowerCase() === modelId.split(":")[0].toLowerCase()) ?? false
}
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
const token = await this.getToken()
const headers = {
Authorization: `Bearer ${token}`,
"AI-Resource-Group": this.options.sapAiResourceGroup || "default",
"Content-Type": "application/json",
}
const model = this.getModel()
const deploymentId = await this.getDeploymentForModel(model.id)
const anthropicModels = [
"anthropic--claude-3.7-sonnet",
"anthropic--claude-3.5-sonnet",
"anthropic--claude-3-sonnet",
"anthropic--claude-3-haiku",
"anthropic--claude-3-opus",
]
const openAIModels = ["gpt-4o", "gpt-4", "gpt-4o-mini", "o1", "gpt-4.1", "gpt-4.1-nano", "o3-mini", "o3", "o4-mini"]
let url: string
let payload: any
if (anthropicModels.includes(model.id)) {
url = `${this.options.sapAiCoreBaseUrl}/v2/inference/deployments/${deploymentId}/invoke-with-response-stream`
if (model.id === "anthropic--claude-3.7-sonnet") {
url = `${this.options.sapAiCoreBaseUrl}/v2/inference/deployments/${deploymentId}/converse-stream`
payload = {
inferenceConfig: {
maxTokens: model.info.maxTokens,
temperature: 0.0,
},
system: systemPrompt ? [{ text: systemPrompt }] : undefined,
messages: this.formatAnthropicMessages(messages),
}
} else {
payload = {
max_tokens: model.info.maxTokens,
system: systemPrompt,
messages,
anthropic_version: "bedrock-2023-05-31",
}
}
} else if (openAIModels.includes(model.id)) {
let openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [
{ role: "system", content: systemPrompt },
...convertToOpenAiMessages(messages),
]
url = `${this.options.sapAiCoreBaseUrl}/v2/inference/deployments/${deploymentId}/chat/completions?api-version=2024-12-01-preview`
payload = {
stream: true,
messages: openAiMessages,
max_tokens: model.info.maxTokens,
temperature: 0.0,
frequency_penalty: 0,
presence_penalty: 0,
stop: null,
stream_options: { include_usage: true },
}
if (["o1", "o3-mini", "o3", "o4-mini"].includes(model.id)) {
delete payload.max_tokens
delete payload.temperature
}
if (model.id === "o3-mini") {
delete payload.stream
delete payload.stream_options
}
} else {
throw new Error(`Unsupported model: ${model.id}`)
}
try {
const response = await axios.post(url, JSON.stringify(payload, null, 2), {
headers,
responseType: "stream",
})
if (model.id === "o3-mini") {
const response = await axios.post(url, JSON.stringify(payload, null, 2), { headers })
// Yield the usage information
if (response.data.usage) {
yield {
type: "usage",
inputTokens: response.data.usage.prompt_tokens,
outputTokens: response.data.usage.completion_tokens,
}
}
// Yield the content
if (response.data.choices && response.data.choices.length > 0) {
yield {
type: "text",
text: response.data.choices[0].message.content,
}
}
// Final usage yield
if (response.data.usage) {
yield {
type: "usage",
inputTokens: response.data.usage.prompt_tokens,
outputTokens: response.data.usage.completion_tokens,
}
}
} else if (openAIModels.includes(model.id)) {
yield* this.streamCompletionGPT(response.data, model)
} else if (model.id === "anthropic--claude-3.7-sonnet") {
yield* this.streamCompletionSonnet37(response.data, model)
} else {
yield* this.streamCompletion(response.data, model)
}
} catch (error) {
if (error.response) {
// The request was made and the server responded with a status code
// that falls out of the range of 2xx
console.error("Error status:", error.response.status)
console.error("Error data:", error.response.data)
console.error("Error headers:", error.response.headers)
if (error.response.status === 404) {
console.error("404 Error reason:", error.response.data)
throw new Error(`404 Not Found: ${error.response.data}`)
}
} else if (error.request) {
// The request was made but no response was received
console.error("Error request:", error.request)
throw new Error("No response received from server")
} else {
// Something happened in setting up the request that triggered an Error
console.error("Error message:", error.message)
throw new Error(`Error setting up request: ${error.message}`)
}
throw new Error("Failed to create message")
}
}
private async *streamCompletion(
stream: any,
model: { id: SapAiCoreModelId; info: ModelInfo },
): AsyncGenerator<any, void, unknown> {
let usage = { input_tokens: 0, output_tokens: 0 }
try {
for await (const chunk of stream) {
const lines = chunk.toString().split("\n").filter(Boolean)
for (const line of lines) {
if (line.startsWith("data: ")) {
const jsonData = line.slice(6)
try {
const data = JSON.parse(jsonData)
console.log("Received data:", data)
if (data.type === "message_start") {
usage.input_tokens = data.message.usage.input_tokens
yield {
type: "usage",
inputTokens: usage.input_tokens,
outputTokens: usage.output_tokens,
}
} else if (data.type === "content_block_start" || data.type === "content_block_delta") {
const contentBlock = data.type === "content_block_start" ? data.content_block : data.delta
if (contentBlock.type === "text" || contentBlock.type === "text_delta") {
yield {
type: "text",
text: contentBlock.text || "",
}
}
} else if (data.type === "message_delta") {
if (data.usage) {
usage.output_tokens = data.usage.output_tokens
yield {
type: "usage",
inputTokens: 0,
outputTokens: data.usage.output_tokens,
}
}
}
} catch (error) {
console.error("Failed to parse JSON data:", error)
}
}
}
}
} catch (error) {
console.error("Error streaming completion:", error)
throw error
}
}
private async *streamCompletionSonnet37(
stream: any,
model: { id: SapAiCoreModelId; info: ModelInfo },
): AsyncGenerator<any, void, unknown> {
function toStrictJson(str: string): string {
// Wrap it in parentheses so JS will treat it as an expression
const obj = new Function("return " + str)()
return JSON.stringify(obj)
}
let usage = { input_tokens: 0, output_tokens: 0 }
try {
// Iterate over the stream and process each chunk
for await (const chunk of stream) {
const lines = chunk.toString().split("\n").filter(Boolean)
for (const line of lines) {
if (line.startsWith("data: ")) {
const jsonData = line.slice(6)
try {
// Parse the incoming JSON data from the stream
const data = JSON.parse(toStrictJson(jsonData))
console.log("Received data:", data)
// Handle metadata (token usage)
if (data.metadata?.usage) {
const inputTokens = data.metadata.usage.inputTokens || 0
const outputTokens = data.metadata.usage.outputTokens || 0
yield {
type: "usage",
inputTokens,
outputTokens,
}
}
// Handle content block delta (text generation)
if (data.contentBlockDelta) {
if (data.contentBlockDelta?.delta?.text) {
yield {
type: "text",
text: data.contentBlockDelta.delta.text,
}
}
// Handle reasoning content if present
if (data.contentBlockDelta?.delta?.reasoningContent?.text) {
yield {
type: "reasoning",
reasoning: data.contentBlockDelta.delta.reasoningContent.text,
}
}
}
} catch (error) {
console.error("Failed to parse JSON data:", error)
yield {
type: "text",
text: `[ERROR] Failed to parse response data: ${error instanceof Error ? error.message : String(error)}`,
}
}
}
}
}
} catch (error) {
console.error("Error streaming completion:", error)
yield {
type: "text",
text: `[ERROR] Failed to process stream: ${error instanceof Error ? error.message : String(error)}`,
}
}
}
private async *streamCompletionGPT(
stream: any,
model: { id: SapAiCoreModelId; info: ModelInfo },
): AsyncGenerator<any, void, unknown> {
let currentContent = ""
let inputTokens = 0
let outputTokens = 0
try {
for await (const chunk of stream) {
const lines = chunk.toString().split("\n").filter(Boolean)
for (const line of lines) {
if (line.trim() === "data: [DONE]") {
// End of stream, yield final usage
yield {
type: "usage",
inputTokens,
outputTokens,
}
return
}
if (line.startsWith("data: ")) {
const jsonData = line.slice(6)
try {
const data = JSON.parse(jsonData)
console.log("Received GPT data:", data)
if (data.choices && data.choices.length > 0) {
const choice = data.choices[0]
if (choice.delta && choice.delta.content) {
yield {
type: "text",
text: choice.delta.content,
}
currentContent += choice.delta.content
}
}
// Handle usage information
if (data.usage) {
inputTokens = data.usage.prompt_tokens || inputTokens
outputTokens = data.usage.completion_tokens || outputTokens
yield {
type: "usage",
inputTokens,
outputTokens,
}
}
if (data.choices && data.choices[0].finish_reason === "stop") {
// Final usage yield, if not already provided
if (!data.usage) {
yield {
type: "usage",
inputTokens,
outputTokens,
}
}
}
} catch (error) {
console.error("Failed to parse GPT JSON data:", error)
}
}
}
}
} catch (error) {
console.error("Error streaming GPT completion:", error)
throw error
}
}
createUserReadableRequest(
userContent: Array<
Anthropic.TextBlockParam | Anthropic.ImageBlockParam | Anthropic.ToolUseBlockParam | Anthropic.ToolResultBlockParam
>,
): any {
return {
model: this.getModel().id,
max_tokens: this.getModel().info.maxTokens,
system: "(see SYSTEM_PROMPT in src/ClaudeDev.ts)",
messages: [{ conversation_history: "..." }, { role: "user", content: userContent }],
tools: "(see tools in src/ClaudeDev.ts)",
tool_choice: { type: "auto" },
}
}
getModel(): { id: SapAiCoreModelId; info: ModelInfo } {
const modelId = this.options.apiModelId
if (modelId && modelId in sapAiCoreModels) {
const id = modelId as SapAiCoreModelId
return { id, info: sapAiCoreModels[id] }
}
return { id: sapAiCoreDefaultModelId, info: sapAiCoreModels[sapAiCoreDefaultModelId] }
}
private getValidImageFormat(mediaType: string): string {
const format = mediaType.split("/")[1]?.toLowerCase()
const validFormats = ["png", "jpeg", "gif", "webp"]
if (validFormats.includes(format)) {
return format
}
throw new Error(`Unsupported image format: ${format}`)
}
private formatAnthropicMessages(messages: Anthropic.Messages.MessageParam[]): any[] {
return messages.map((m) => {
const contentBlocks: any[] = []
if (typeof m.content === "string") {
contentBlocks.push({ text: m.content })
} else if (Array.isArray(m.content)) {
for (const block of m.content) {
if (block.type === "text") {
if (!block.text) {
throw new Error('Text block is missing the "text" field.')
}
contentBlocks.push({ text: block.text })
} else if (block.type === "image") {
if (!block.source) {
throw new Error('Image block is missing the "source" field.')
}
const { type, media_type, data } = block.source
if (!type || !media_type || !data) {
throw new Error('Image source must have "type", "media_type", and "data" fields.')
}
if (type !== "base64") {
throw new Error(`Unsupported image source type: ${type}. Only "base64" is supported.`)
}
const format = this.getValidImageFormat(media_type)
contentBlocks.push({
image: {
format,
source: {
bytes: data,
},
},
})
} else {
throw new Error(`Unsupported content block type: ${block.type}`)
}
}
} else {
throw new Error("Unsupported content format.")
}
return {
role: m.role,
content: contentBlocks,
}
})
}
}
+1 -1
View File
@@ -57,7 +57,7 @@ export function withRetry(options: RetryOptions = {}) {
const handlerInstance = this as any
if (handlerInstance.options?.onRetryAttempt) {
try {
handlerInstance.options.onRetryAttempt(attempt + 1, maxRetries, delay, error)
await handlerInstance.options.onRetryAttempt(attempt + 1, maxRetries, delay, error)
} catch (e) {
console.error("Error in onRetryAttempt callback:", e)
}
+1
View File
@@ -17,5 +17,6 @@ export interface ApiStreamUsageChunk {
outputTokens: number
cacheWriteTokens?: number
cacheReadTokens?: number
thoughtsTokenCount?: number // openrouter
totalCost?: number // openrouter
}
+51 -15
View File
@@ -19,14 +19,34 @@ new content
isFinal: true,
},
{
name: "full file replacement",
original: "old content",
diff: `------- SEARCH
name: "malformed search - mixed symbols",
original: "line1\nline2\nline3",
diff: `<<-- SEARCH
line2
=======
new content
replaced
+++++++ REPLACE`,
expected: "new content\n",
isFinal: true,
shouldThrow: true,
},
{
name: "malformed search - insufficient dashes",
original: "line1\nline2\nline3",
diff: `-- SEARCH
line2
=======
replaced
+++++++ REPLACE`,
shouldThrow: true,
},
{
name: "malformed search - missing space",
original: "line1\nline2\nline3",
diff: `-------SEARCH
line2
=======
replaced
+++++++ REPLACE`,
shouldThrow: true,
},
{
name: "exact match replacement",
@@ -139,17 +159,33 @@ replaced
]
//.filter(({name}) => name === "multiple ordered replacements")
//.filter(({name}) => name === "delete then replace")
testCases.forEach(({ name, original, diff, expected, isFinal }) => {
testCases.forEach(({ name, original, diff, expected, isFinal, shouldThrow }) => {
it(`should handle ${name} case correctly`, async () => {
const result1 = await cnfc(diff, original, isFinal)
const result2 = await cnfc2(diff, original, isFinal)
const equal = result1 === result2
const equal2 = result1 === expected
// Verify both implementations produce same result
expect(result1).to.equal(result2)
if (shouldThrow) {
try {
await cnfc(diff, original, isFinal ?? true)
expect.fail("Expected an error to be thrown")
} catch (err) {
expect(err).to.be.an("error")
}
// Verify result matches expected
expect(result1).to.equal(expected)
try {
await cnfc2(diff, original, isFinal ?? true)
expect.fail("Expected an error to be thrown")
} catch (err) {
expect(err).to.be.an("error")
}
} else {
const result1 = await cnfc(diff, original, isFinal ?? true)
const result2 = await cnfc2(diff, original, isFinal ?? true)
const equal = result1 === result2
const equal2 = result1 === expected
// Verify both implementations produce same result
expect(result1).to.equal(result2)
// Verify result matches expected
expect(result1).to.equal(expected)
}
})
})
+27 -12
View File
@@ -4,15 +4,19 @@ 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)
return SEARCH_BLOCK_START_REGEX.test(line) || LEGACY_SEARCH_BLOCK_START_REGEX.test(line)
}
function isSearchBlockEnd(line: string): boolean {
@@ -20,7 +24,7 @@ function isSearchBlockEnd(line: string): boolean {
}
function isReplaceBlockEnd(line: string): boolean {
return REPLACE_BLOCK_END_REGEX.test(line)
return REPLACE_BLOCK_END_REGEX.test(line) || LEGACY_REPLACE_BLOCK_END_REGEX.test(line)
}
/**
@@ -269,7 +273,11 @@ async function constructNewFileContentV1(diffContent: string, originalContent: s
const lastLine = lines[lines.length - 1]
if (
lines.length > 0 &&
(lastLine.startsWith(SEARCH_BLOCK_CHAR) || lastLine.startsWith("=") || lastLine.startsWith(REPLACE_BLOCK_CHAR)) &&
(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)
@@ -303,9 +311,12 @@ async function constructNewFileContentV1(diffContent: string, originalContent: s
searchMatchIndex = 0
searchEndIndex = 0
} else {
// Complete file replacement scenario: treat the entire file as matched
searchMatchIndex = 0
searchEndIndex = originalContent.length
// 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
@@ -570,7 +581,7 @@ class NewFileContentConstructor {
pendingNonStandardLineLimit: number,
): number {
let removeLineCount = 0
if (line === SEARCH_BLOCK_START) {
if (isSearchBlockStart(line)) {
removeLineCount = this.trimPendingNonStandardTrailingEmptyLines(pendingNonStandardLineLimit)
if (removeLineCount > 0) {
pendingNonStandardLineLimit = pendingNonStandardLineLimit - removeLineCount
@@ -580,7 +591,7 @@ class NewFileContentConstructor {
canWritependingNonStandardLines && (this.pendingNonStandardLines.length = 0)
}
this.activateSearchState()
} else if (line === SEARCH_BLOCK_END) {
} else if (isSearchBlockEnd(line)) {
// 校验非标内容
if (!this.isSearchingActive()) {
this.tryFixSearchBlock(pendingNonStandardLineLimit)
@@ -588,7 +599,7 @@ class NewFileContentConstructor {
}
this.activateReplaceState()
this.beforeReplace()
} else if (line === REPLACE_BLOCK_END) {
} else if (isReplaceBlockEnd(line)) {
if (!this.isReplacingActive()) {
this.tryFixReplaceBlock(pendingNonStandardLineLimit)
canWritependingNonStandardLines && (this.pendingNonStandardLines.length = 0)
@@ -695,7 +706,7 @@ class NewFileContentConstructor {
if (!lineLimit) {
throw new Error("Invalid SEARCH/REPLACE block structure - no lines available to process")
}
let searchTagRegexp = /^[-]{3,} SEARCH$/
let searchTagRegexp = /^([-]{3,}|[<]{3,}) SEARCH$/
const searchTagIndex = this.findLastMatchingLineIndex(searchTagRegexp, lineLimit)
if (searchTagIndex !== -1) {
let fixLines = this.pendingNonStandardLines.slice(searchTagIndex, lineLimit)
@@ -746,7 +757,7 @@ class NewFileContentConstructor {
throw new Error()
}
let replaceEndTagRegexp = /^[+]{3,} REPLACE$/
let replaceEndTagRegexp = /^([+]{3,}|[>]{3,}) REPLACE$/
const replaceEndTagIndex = this.findLastMatchingLineIndex(replaceEndTagRegexp, lineLimit)
const likeReplaceEndTag = replaceEndTagIndex === lineLimit - 1
if (likeReplaceEndTag) {
@@ -795,7 +806,11 @@ export async function constructNewFileContentV2(diffContent: string, originalCon
const lastLine = lines[lines.length - 1]
if (
lines.length > 0 &&
(lastLine.startsWith(SEARCH_BLOCK_CHAR) || lastLine.startsWith("=") || lastLine.startsWith(REPLACE_BLOCK_CHAR)) &&
(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
@@ -8,10 +8,7 @@ export function checkIsOpenRouterContextWindowError(error: any): boolean {
export function checkIsAnthropicContextWindowError(response: any): boolean {
try {
return (
response?.error?.error?.type === "invalid_request_error" &&
response?.error?.error?.message?.includes("prompt is too long")
)
return response?.error?.error?.type === "invalid_request_error"
} catch (e: unknown) {
return false
}
@@ -1,7 +1,10 @@
import * as path from "path"
import * as vscode from "vscode"
import { getTaskMetadata, saveTaskMetadata } from "@core/storage/disk"
import { getWorkspaceState, updateWorkspaceState } from "@core/storage/state"
import { getGlobalState } from "@core/storage/state"
import type { FileMetadataEntry } from "./ContextTrackerTypes"
import type { ClineMessage } from "@shared/ExtensionMessage"
// This class is responsible for tracking file operations that may result in stale context.
// If a user modifies a file outside of Cline, the context may become stale and need to be updated.
@@ -11,10 +14,12 @@ import type { FileMetadataEntry } from "./ContextTrackerTypes"
// a diff edit because the file was modified since Cline last read it.
// FileContextTracker
//
// This class is responsible for tracking file operations.
// If the full contents of a file are pass to Cline via a tool, mention, or edit, the file is marked as active.
// If a file is modified outside of Cline, we detect and track this change to prevent stale context.
/**
This class is responsible for tracking file operations.
If the full contents of a file are passed to Cline via a tool, mention, or edit, the file is marked as active.
If a file is modified outside of Cline, we detect and track this change to prevent stale context.
This is used when restoring a task (non-git "checkpoint" restore), and mid-task.
*/
export class FileContextTracker {
private context: vscode.ExtensionContext
readonly taskId: string
@@ -29,7 +34,9 @@ export class FileContextTracker {
this.taskId = taskId
}
// Gets the current working directory or returns undefined if it cannot be determined
/**
* Gets the current working directory or returns undefined if it cannot be determined
*/
private getCwd(): string | undefined {
const cwd = vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath).at(0)
if (!cwd) {
@@ -38,7 +45,9 @@ export class FileContextTracker {
return cwd
}
// File watchers are set up for each file that is tracked in the task metadata.
/**
* File watchers are set up for each file that is tracked in the task metadata.
*/
async setupFileWatcher(filePath: string) {
// Only setup watcher if it doesn't already exist for this file
if (this.fileWatchers.has(filePath)) {
@@ -70,8 +79,10 @@ export class FileContextTracker {
this.fileWatchers.set(filePath, watcher)
}
// Tracks a file operation in metadata and sets up a watcher for the file
// This is the main entry point for FileContextTracker and is called when a file is passed to Cline via a tool, mention, or edit.
/**
* Tracks a file operation in metadata and sets up a watcher for the file
* This is the main entry point for FileContextTracker and is called when a file is passed to Cline via a tool, mention, or edit.
*/
async trackFileContext(filePath: string, operation: "read_tool" | "user_edited" | "cline_edited" | "file_mentioned") {
try {
const cwd = this.getCwd()
@@ -89,9 +100,11 @@ export class FileContextTracker {
}
}
// Adds a file to the metadata tracker
// This handles the business logic of determining if the file is new, stale, or active.
// It also updates the metadata with the latest read/edit dates.
/**
* Adds a file to the metadata tracker
* This handles the business logic of determining if the file is new, stale, or active.
* It also updates the metadata with the latest read/edit dates.
*/
async addFileToFileContextTracker(
context: vscode.ExtensionContext,
taskId: string,
@@ -154,23 +167,149 @@ export class FileContextTracker {
}
}
// Returns (and then clears) the set of recently modified files
/**
* Returns (and then clears) the set of recently modified files
*/
getAndClearRecentlyModifiedFiles(): string[] {
const files = Array.from(this.recentlyModifiedFiles)
this.recentlyModifiedFiles.clear()
return files
}
// Marks a file as edited by Cline to prevent false positives in file watchers
/**
* Marks a file as edited by Cline to prevent false positives in file watchers
*/
markFileAsEditedByCline(filePath: string): void {
this.recentlyEditedByCline.add(filePath)
}
// Disposes all file watchers
/**
* Disposes all file watchers
*/
dispose(): void {
for (const watcher of this.fileWatchers.values()) {
watcher.dispose()
}
this.fileWatchers.clear()
}
/**
* Detects files that were edited by Cline or users after a specific message timestamp
* This is used when restoring checkpoints to warn about potential file content mismatches
*/
async detectFilesEditedAfterMessage(messageTs: number, deletedMessages: ClineMessage[]): Promise<string[]> {
const editedFiles: string[] = []
try {
// Check task metadata for files that were edited by Cline or users after the message timestamp
const taskMetadata = await getTaskMetadata(this.context, this.taskId)
if (taskMetadata?.files_in_context) {
for (const fileEntry of taskMetadata.files_in_context) {
const clineEditedAfter = fileEntry.cline_edit_date && fileEntry.cline_edit_date > messageTs
const userEditedAfter = fileEntry.user_edit_date && fileEntry.user_edit_date > messageTs
if (clineEditedAfter || userEditedAfter) {
editedFiles.push(fileEntry.path)
}
}
}
} catch (error) {
console.error("Error checking file context metadata:", error)
}
// Also check deleted task messages for file operations
for (const message of deletedMessages) {
if (message.say === "tool" && message.text) {
try {
const toolData = JSON.parse(message.text)
if ((toolData.tool === "editedExistingFile" || toolData.tool === "newFileCreated") && toolData.path) {
if (!editedFiles.includes(toolData.path)) {
editedFiles.push(toolData.path)
}
}
} catch (error) {
console.error("Error checking task messages:", error)
}
}
}
return [...new Set(editedFiles)]
}
/**
* Stores pending file context warning in workspace state so it persists across task reinitialization
*/
async storePendingFileContextWarning(files: string[]): Promise<void> {
try {
const key = `pendingFileContextWarning_${this.taskId}`
await updateWorkspaceState(this.context, key, files)
} catch (error) {
console.error("Error storing pending file context warning:", error)
}
}
/**
* Retrieves pending file context warning from workspace state (without clearing it)
*/
async retrievePendingFileContextWarning(): Promise<string[] | undefined> {
try {
const key = `pendingFileContextWarning_${this.taskId}`
const files = (await getWorkspaceState(this.context, key)) as string[]
return files
} catch (error) {
console.error("Error retrieving pending file context warning:", error)
}
return undefined
}
/**
* Retrieves and clears pending file context warning from workspace state
*/
async retrieveAndClearPendingFileContextWarning(): Promise<string[] | undefined> {
try {
const files = await this.retrievePendingFileContextWarning()
if (files) {
await updateWorkspaceState(this.context, `pendingFileContextWarning_${this.taskId}`, undefined)
return files
}
} catch (error) {
console.error("Error retrieving pending file context warning:", error)
}
return undefined
}
/**
* Static method to clean up orphaned pending file context warnings at startup
* This removes warnings for tasks that may no longer exist
*/
static async cleanupOrphanedWarnings(context: vscode.ExtensionContext): Promise<void> {
const startTime = Date.now()
try {
const taskHistory = ((await getGlobalState(context, "taskHistory")) as Array<{ id: string }>) || []
const existingTaskIds = new Set(taskHistory.map((task) => task.id))
const allStateKeys = context.workspaceState.keys()
const pendingWarningKeys = allStateKeys.filter((key) => key.startsWith("pendingFileContextWarning_"))
const orphanedPendingContextTasks: string[] = []
for (const key of pendingWarningKeys) {
const taskId = key.replace("pendingFileContextWarning_", "")
if (!existingTaskIds.has(taskId)) {
orphanedPendingContextTasks.push(key)
}
}
if (orphanedPendingContextTasks.length > 0) {
for (const key of orphanedPendingContextTasks) {
await updateWorkspaceState(context, key, undefined)
}
}
const duration = Date.now() - startTime
console.log(
`FileContextTracker: Processed ${existingTaskIds.size} tasks, found ${pendingWarningKeys.length} pending warnings, ${orphanedPendingContextTasks.length} orphaned, deleted ${orphanedPendingContextTasks.length}, took ${duration}ms`,
)
} catch (error) {
console.error("Error cleaning up orphaned file context warnings:", error)
}
}
}
@@ -9,7 +9,7 @@ export async function checkpointRestore(controller: Controller, request: Checkpo
if (request.number) {
// wait for messages to be loaded
await pWaitFor(() => controller.task?.isInitialized === true, {
await pWaitFor(() => controller.task?.taskState.isInitialized === true, {
timeout: 3_000,
}).catch(() => {
console.error("Failed to init new cline instance")
+10 -10
View File
@@ -64,17 +64,17 @@ export class GrpcRequestRegistry {
*/
public cancelRequest(requestId: string): boolean {
const requestInfo = this.activeRequests.get(requestId)
if (requestInfo) {
try {
requestInfo.cleanup()
console.log(`[DEBUG] Cleaned up request: ${requestId}`)
} catch (error) {
console.error(`Error cleaning up request ${requestId}:`, error)
}
this.activeRequests.delete(requestId)
return true
if (!requestInfo) {
return false
}
return false
try {
requestInfo.cleanup()
console.log(`[DEBUG] Cleaned up request: ${requestId}`)
} catch (error) {
console.error(`Error cleaning up request ${requestId}:`, error)
}
this.activeRequests.delete(requestId)
return true
}
/**
+47 -22
View File
@@ -130,12 +130,13 @@ export class Controller {
await this.clearTask() // ensures that an existing task doesn't exist before starting a new one, although this shouldn't be possible since user must clear task before starting a new one
const {
apiConfiguration,
customInstructions,
autoApprovalSettings,
browserSettings,
chatSettings,
shellIntegrationTimeout,
terminalReuseEnabled,
terminalOutputLineLimit,
defaultTerminalProfile,
enableCheckpointsSetting,
isNewUser,
taskHistory,
@@ -171,8 +172,9 @@ export class Controller {
chatSettings,
shellIntegrationTimeout,
terminalReuseEnabled ?? true,
terminalOutputLineLimit ?? 500,
defaultTerminalProfile ?? "default",
enableCheckpointsSetting ?? true,
customInstructions,
task,
images,
files,
@@ -222,6 +224,7 @@ export class Controller {
await this.postStateToWebview()
break
}
case "clearAllTaskHistory": {
const answer = await vscode.window.showWarningMessage(
"What would you like to delete?",
@@ -282,6 +285,12 @@ export class Controller {
previousModeReasoningEffort: newReasoningEffort,
previousModeAwsBedrockCustomSelected: newAwsBedrockCustomSelected,
previousModeAwsBedrockCustomModelBaseId: newAwsBedrockCustomModelBaseId,
previousModeSapAiCoreClientId: newSapAiCoreClientId,
previousModeSapAiCoreClientSecret: newSapAiCoreClientSecret,
previousModeSapAiCoreBaseUrl: newSapAiCoreBaseUrl,
previousModeSapAiCoreTokenUrl: newSapAiCoreTokenUrl,
previousModeSapAiCoreResourceGroup: newSapAiResourceGroup,
previousModeSapAiCoreModelId: newSapAiCoreModelId,
planActSeparateModelsSetting,
} = await getAllExtensionState(this.context)
@@ -347,6 +356,23 @@ export class Controller {
await updateWorkspaceState(this.context, "previousModeModelId", apiConfiguration.requestyModelId)
await updateWorkspaceState(this.context, "previousModeModelInfo", apiConfiguration.requestyModelInfo)
break
case "sapaicore":
await updateWorkspaceState(this.context, "previousModeModelId", apiConfiguration.apiModelId)
await updateWorkspaceState(this.context, "previousModeSapAiCoreClientId", apiConfiguration.sapAiCoreClientId)
await updateWorkspaceState(
this.context,
"previousModeSapAiCoreClientSecret",
apiConfiguration.sapAiCoreClientSecret,
)
await updateWorkspaceState(this.context, "previousModeSapAiCoreBaseUrl", apiConfiguration.sapAiCoreBaseUrl)
await updateWorkspaceState(this.context, "previousModeSapAiCoreTokenUrl", apiConfiguration.sapAiCoreTokenUrl)
await updateWorkspaceState(
this.context,
"previousModeSapAiCoreResourceGroup",
apiConfiguration.sapAiResourceGroup,
)
await updateWorkspaceState(this.context, "previousModeSapAiCoreModelId", apiConfiguration.sapAiCoreModelId)
break
}
// Restore the model used in previous mode
@@ -395,13 +421,16 @@ export class Controller {
await updateWorkspaceState(this.context, "lmStudioModelId", newModelId)
break
case "litellm":
await updateWorkspaceState(this.context, "previousModeModelId", apiConfiguration.liteLlmModelId)
await updateWorkspaceState(this.context, "previousModeModelInfo", apiConfiguration.liteLlmModelInfo)
await updateWorkspaceState(this.context, "liteLlmModelId", newModelId)
await updateWorkspaceState(this.context, "liteLlmModelInfo", newModelInfo)
break
case "requesty":
await updateWorkspaceState(this.context, "requestyModelId", newModelId)
await updateWorkspaceState(this.context, "requestyModelInfo", newModelInfo)
break
case "sapaicore":
await updateWorkspaceState(this.context, "apiModelId", newModelId)
break
}
if (this.task) {
@@ -416,8 +445,8 @@ export class Controller {
if (this.task) {
this.task.chatSettings = chatSettings
if (this.task.isAwaitingPlanResponse && didSwitchToActMode) {
this.task.didRespondToPlanAskBySwitchingMode = true
if (this.task.taskState.isAwaitingPlanResponse && didSwitchToActMode) {
this.task.taskState.didRespondToPlanAskBySwitchingMode = true
// Use chatContent if provided, otherwise use default message
await this.task.handleWebviewAskResponse(
"messageResponse",
@@ -442,9 +471,9 @@ export class Controller {
await pWaitFor(
() =>
this.task === undefined ||
this.task.isStreaming === false ||
this.task.didFinishAbortingStream ||
this.task.isWaitingForFirstChunk, // if only first chunk is processed, then there's no need to wait for graceful abort (closes edits, browser, etc)
this.task.taskState.isStreaming === false ||
this.task.taskState.didFinishAbortingStream ||
this.task.taskState.isWaitingForFirstChunk, // if only first chunk is processed, then there's no need to wait for graceful abort (closes edits, browser, etc)
{
timeout: 3_000,
},
@@ -453,21 +482,13 @@ export class Controller {
})
if (this.task) {
// 'abandoned' will prevent this cline instance from affecting future cline instance gui. this may happen if its hanging on a streaming request
this.task.abandoned = true
this.task.taskState.abandoned = true
}
await this.initTask(undefined, undefined, undefined, historyItem) // clears task again, so we need to abortTask manually above
// await this.postStateToWebview() // new Cline instance will post state when it's ready. having this here sent an empty messages array to webview leading to virtuoso having to reload the entire list
}
}
async updateCustomInstructions(instructions?: string) {
// User may be clearing the field
await updateGlobalState(this.context, "customInstructions", instructions || undefined)
if (this.task) {
this.task.customInstructions = instructions || undefined
}
}
// Account
async fetchUserCreditsData() {
@@ -949,13 +970,13 @@ export class Controller {
const {
apiConfiguration,
lastShownAnnouncementId,
customInstructions,
taskHistory,
autoApprovalSettings,
browserSettings,
chatSettings,
userInfo,
mcpMarketplaceEnabled,
mcpRichDisplayEnabled,
telemetrySetting,
planActSeparateModelsSetting,
enableCheckpointsSetting,
@@ -963,8 +984,10 @@ export class Controller {
globalWorkflowToggles,
shellIntegrationTimeout,
terminalReuseEnabled,
defaultTerminalProfile,
isNewUser,
mcpResponsesCollapsed,
terminalOutputLineLimit,
} = await getAllExtensionState(this.context)
const localClineRulesToggles =
@@ -981,11 +1004,10 @@ export class Controller {
return {
version: this.context.extension?.packageJSON?.version ?? "",
apiConfiguration,
customInstructions,
uriScheme: vscode.env.uriScheme,
currentTaskItem: this.task?.taskId ? (taskHistory || []).find((item) => item.id === this.task?.taskId) : undefined,
checkpointTrackerErrorMessage: this.task?.checkpointTrackerErrorMessage,
clineMessages: this.task?.clineMessages || [],
checkpointTrackerErrorMessage: this.task?.taskState.checkpointTrackerErrorMessage,
clineMessages: this.task?.messageStateHandler.getClineMessages() || [],
taskHistory: (taskHistory || [])
.filter((item) => item.ts && item.task)
.sort((a, b) => b.ts - a.ts)
@@ -997,6 +1019,7 @@ export class Controller {
chatSettings,
userInfo,
mcpMarketplaceEnabled,
mcpRichDisplayEnabled,
telemetrySetting,
planActSeparateModelsSetting,
enableCheckpointsSetting: enableCheckpointsSetting ?? true,
@@ -1009,8 +1032,10 @@ export class Controller {
globalWorkflowToggles: globalWorkflowToggles || {},
shellIntegrationTimeout,
terminalReuseEnabled,
defaultTerminalProfile,
isNewUser,
mcpResponsesCollapsed,
terminalOutputLineLimit,
}
}
@@ -41,11 +41,6 @@ export async function refreshRequestyModels(controller: Controller, _: EmptyRequ
models[model.id] = modelInfo
}
console.log("Requesty models fetched", models)
controller.postMessageToWebview({
type: "requestyModels",
requestyModels: models,
})
} else {
console.error("Invalid response from Requesty API")
}
@@ -0,0 +1,19 @@
import { Controller } from "../index"
import * as proto from "@/shared/proto"
import { getAvailableTerminalProfiles as getTerminalProfilesFromShell } from "../../../utils/shell"
export async function getAvailableTerminalProfiles(
controller: Controller,
request: proto.cline.EmptyRequest,
): Promise<proto.cline.TerminalProfiles> {
const profiles = getTerminalProfilesFromShell()
return proto.cline.TerminalProfiles.create({
profiles: profiles.map((profile) => ({
id: profile.id,
name: profile.name,
path: profile.path || "",
description: profile.description || "",
})),
})
}
+12 -6
View File
@@ -1,19 +1,25 @@
import { Controller } from ".."
import { Empty, EmptyRequest } from "../../../shared/proto/common"
import { resetExtensionState } from "../../../core/storage/state"
import { Empty } from "../../../shared/proto/common"
import { ResetStateRequest } from "../../../shared/proto/state"
import { resetGlobalState, resetWorkspaceState } from "../../../core/storage/state"
import * as vscode from "vscode"
import { sendChatButtonClickedEvent } from "../ui/subscribeToChatButtonClicked"
/**
* Resets the extension state to its defaults
* @param controller The controller instance
* @param request An empty request (no parameters needed)
* @param request The reset state request containing the global flag
* @returns An empty response
*/
export async function resetState(controller: Controller, request: EmptyRequest): Promise<Empty> {
export async function resetState(controller: Controller, request: ResetStateRequest): Promise<Empty> {
try {
vscode.window.showInformationMessage("Resetting state...")
await resetExtensionState(controller.context)
if (request.global) {
vscode.window.showInformationMessage("Resetting global state...")
await resetGlobalState(controller.context)
} else {
vscode.window.showInformationMessage("Resetting workspace state...")
await resetWorkspaceState(controller.context)
}
if (controller.task) {
controller.task.abortTask()
@@ -0,0 +1,50 @@
import * as vscode from "vscode"
import { Controller } from "../index"
import * as proto from "@/shared/proto"
import { updateGlobalState } from "../../storage/state"
import { TerminalInfo } from "@/integrations/terminal/TerminalRegistry"
export async function updateDefaultTerminalProfile(
controller: Controller,
request: proto.cline.StringRequest,
): Promise<proto.cline.TerminalProfileUpdateResponse> {
const profileId = request.value
// Update the terminal profile in the state
await updateGlobalState(controller.context, "defaultTerminalProfile", profileId)
let closedCount = 0
let busyTerminals: TerminalInfo[] = []
// Update the terminal manager of the current task if it exists
if (controller.task) {
// Call the updated setDefaultTerminalProfile method that returns closed terminal info
const result = controller.task.terminalManager.setDefaultTerminalProfile(profileId)
closedCount = result.closedCount
busyTerminals = result.busyTerminals
// Show information message if terminals were closed
if (closedCount > 0) {
vscode.window.showInformationMessage(
`Closed ${closedCount} ${closedCount === 1 ? "terminal" : "terminals"} with different profile.`,
)
}
// Show warning if there are busy terminals that couldn't be closed
if (busyTerminals.length > 0) {
vscode.window.showWarningMessage(
`${busyTerminals.length} busy ${busyTerminals.length === 1 ? "terminal has" : "terminals have"} a different profile. ` +
`Close ${busyTerminals.length === 1 ? "it" : "them"} to use the new profile for all commands.`,
)
}
}
// Broadcast state update to all webviews
await controller.postStateToWebview()
return proto.cline.TerminalProfileUpdateResponse.create({
closedCount,
busyTerminalsCount: busyTerminals.length,
hasBusyTerminals: busyTerminals.length > 0,
})
}
+10 -5
View File
@@ -25,11 +25,6 @@ export async function updateSettings(controller: Controller, request: UpdateSett
}
}
// Update custom instructions
if (request.customInstructionsSetting !== undefined) {
await controller.updateCustomInstructions(request.customInstructionsSetting)
}
// Update telemetry setting
if (request.telemetrySetting) {
await controller.updateTelemetrySetting(request.telemetrySetting as TelemetrySetting)
@@ -55,6 +50,11 @@ export async function updateSettings(controller: Controller, request: UpdateSett
await controller.context.globalState.update("mcpResponsesCollapsed", request.mcpResponsesCollapsed)
}
// Update MCP responses collapsed setting
if (request.mcpRichDisplayEnabled !== undefined) {
await controller.context.globalState.update("mcpRichDisplayEnabled", request.mcpRichDisplayEnabled)
}
// Update chat settings
if (request.chatSettings) {
const chatSettings = convertProtoChatSettingsToChatSettings(request.chatSettings)
@@ -74,6 +74,11 @@ export async function updateSettings(controller: Controller, request: UpdateSett
await controller.context.globalState.update("terminalReuseEnabled", request.terminalReuseEnabled)
}
// Update terminal output line limit
if (request.terminalOutputLineLimit !== undefined) {
await controller.context.globalState.update("terminalOutputLineLimit", Number(request.terminalOutputLineLimit))
}
// Post updated state to webview
await controller.postStateToWebview()
@@ -0,0 +1,18 @@
import { Controller } from "../index"
import * as proto from "@/shared/proto"
import { updateGlobalState } from "../../storage/state"
export async function updateTerminalConnectionTimeout(
controller: Controller,
request: proto.cline.Int64Request,
): Promise<proto.cline.Int64> {
const timeoutValue = request.value
// Update the terminal connection timeout setting in the state
await updateGlobalState(controller.context, "shellIntegrationTimeout", timeoutValue)
// Broadcast state update to all webviews
await controller.postStateToWebview()
return proto.cline.Int64.create({ value: timeoutValue })
}
@@ -0,0 +1,18 @@
import { Controller } from "../index"
import * as proto from "@/shared/proto"
import { updateGlobalState } from "../../storage/state"
export async function updateTerminalReuseEnabled(
controller: Controller,
request: proto.cline.BooleanRequest,
): Promise<proto.cline.Empty> {
const enabled = request.value
// Update the terminal reuse setting in the state
await updateGlobalState(controller.context, "terminalReuseEnabled", enabled)
// Broadcast state update to all webviews
await controller.postStateToWebview()
return proto.cline.Empty.create({})
}
@@ -0,0 +1,63 @@
import { Controller } from "../index"
import { Empty } from "@shared/proto/common"
import { EmptyRequest } from "@shared/proto/common"
import { StreamingResponseHandler, getRequestRegistry } from "../grpc-handler"
// Keep track of active didBecomeVisible subscriptions by controller ID
const activeDidBecomeVisibleSubscriptions = new Map<string, StreamingResponseHandler>()
/**
* Subscribe to didBecomeVisible events
* @param controller The controller instance
* @param request The empty request
* @param responseStream The streaming response handler
* @param requestId The ID of the request (passed by the gRPC handler)
*/
export async function subscribeToDidBecomeVisible(
controller: Controller,
request: EmptyRequest,
responseStream: StreamingResponseHandler,
requestId?: string,
): Promise<void> {
const controllerId = controller.id
console.log(`[DEBUG] set up didBecomeVisible subscription for controller ${controllerId}`)
// Add this subscription to the active subscriptions with the controller ID
activeDidBecomeVisibleSubscriptions.set(controllerId, responseStream)
// Register cleanup when the connection is closed
const cleanup = () => {
activeDidBecomeVisibleSubscriptions.delete(controllerId)
}
// Register the cleanup function with the request registry if we have a requestId
if (requestId) {
getRequestRegistry().registerRequest(requestId, cleanup, { type: "didBecomeVisible_subscription" }, responseStream)
}
}
/**
* Send a didBecomeVisible event to a specific controller's subscription
* @param controllerId The ID of the controller to send the event to
*/
export async function sendDidBecomeVisibleEvent(controllerId: string): Promise<void> {
// Get the subscription for this specific controller
const responseStream = activeDidBecomeVisibleSubscriptions.get(controllerId)
if (!responseStream) {
console.log(`[DEBUG] No active subscription for controller ${controllerId}`)
return
}
try {
const event: Empty = Empty.create({})
await responseStream(
event,
false, // Not the last message
)
} catch (error) {
console.error(`Error sending didBecomeVisible event to controller ${controllerId}:`, error)
// Remove the subscription if there was an error
activeDidBecomeVisibleSubscriptions.delete(controllerId)
}
}
@@ -663,7 +663,6 @@ You accomplish a given task iteratively, breaking it down into clear steps and w
}
export function addUserInstructions(
settingsCustomInstructions?: string,
globalClineRulesFileInstructions?: string,
localClineRulesFileInstructions?: string,
localCursorRulesFileInstructions?: string,
@@ -676,9 +675,6 @@ export function addUserInstructions(
if (preferredLanguageInstructions) {
customInstructions += preferredLanguageInstructions + "\n\n"
}
if (settingsCustomInstructions) {
customInstructions += settingsCustomInstructions + "\n\n"
}
if (globalClineRulesFileInstructions) {
customInstructions += globalClineRulesFileInstructions + "\n\n"
}
+15 -1
View File
@@ -149,13 +149,14 @@ Otherwise, if you have not completed the task and do not need additional informa
cwd: string,
wasRecent: boolean | 0 | undefined,
responseText?: string,
hasPendingFileContextWarnings?: boolean,
): [string, string] => {
const taskResumptionMessage = `[TASK RESUMPTION] ${
mode === "plan"
? `This task was interrupted ${agoText}. The conversation may have been incomplete. Be aware that the project state may have changed since then. The current working directory is now '${cwd.toPosix()}'.\n\nNote: If you previously attempted a tool use that the user did not provide a result for, you should assume the tool use was not successful. However you are in PLAN MODE, so rather than continuing the task, you must respond to the user's message.`
: `This task was interrupted ${agoText}. It may or may not be complete, so please reassess the task context. Be aware that the project state may have changed since then. The current working directory is now '${cwd.toPosix()}'. If the task has not been completed, retry the last step before interruption and proceed with completing the task.\n\nNote: If you previously attempted a tool use that the user did not provide a result for, you should assume the tool use was not successful and assess whether you should retry. If the last tool was a browser_action, the browser has been closed and you must launch a new browser if needed.`
}${
wasRecent
wasRecent && !hasPendingFileContextWarnings
? "\n\nIMPORTANT: If the last tool use was a replace_in_file or write_to_file that was interrupted, the file was reverted back to its original state before the interrupted edit, and you do NOT need to re-read the file as you already have its up-to-date contents."
: ""
}`
@@ -240,6 +241,19 @@ Otherwise, if you have not completed the task and do not need additional informa
cursorRulesLocalDirectoryInstructions: (cwd: string, content: string) =>
`# .cursor/rules\n\nThe following is provided by a root-level .cursor/rules directory where the user has specified instructions for this working directory (${cwd.toPosix()})\n\n${content}`,
fileContextWarning: (editedFiles: string[]): string => {
const fileCount = editedFiles.length
const fileVerb = fileCount === 1 ? "file has" : "files have"
const fileDemonstrativePronoun = fileCount === 1 ? "this file" : "these files"
const filePersonalPronoun = fileCount === 1 ? "it" : "they"
return (
`<explicit_instructions>\nCRITICAL FILE STATE ALERT: ${fileCount} ${fileVerb} been externally modified since your last interaction. Your cached understanding of ${fileDemonstrativePronoun} is now stale and unreliable. Before making ANY modifications to ${fileDemonstrativePronoun}, you must execute read_file to obtain the current state, as ${filePersonalPronoun} may contain completely different content than what you expect:\n` +
`${editedFiles.map((file) => ` ${path.resolve(file).toPosix()}`).join("\n")}\n` +
`Failure to re-read before editing will result in replace_in_file edit errors, requiring subsequent attempts and wasting tokens. You DO NOT need to re-read these files after subsequent edits, unless instructed to do so.\n</explicit_instructions>`
)
},
}
// to avoid circular dependency
-4
View File
@@ -651,7 +651,6 @@ You accomplish a given task iteratively, breaking it down into clear steps and w
export function addUserInstructions(
settingsCustomInstructions?: string,
globalClineRulesFileInstructions?: string,
localClineRulesFileInstructions?: string,
localCursorRulesFileInstructions?: string,
@@ -664,9 +663,6 @@ export function addUserInstructions(
if (preferredLanguageInstructions) {
customInstructions += preferredLanguageInstructions + "\n\n"
}
if (settingsCustomInstructions) {
customInstructions += settingsCustomInstructions + "\n\n"
}
if (globalClineRulesFileInstructions) {
customInstructions += globalClineRulesFileInstructions + "\n\n"
}
+18 -1
View File
@@ -22,6 +22,8 @@ export type SecretKey =
| "nebiusApiKey"
| "sambanovaApiKey"
| "cerebrasApiKey"
| "sapAiCoreClientId"
| "sapAiCoreClientSecret"
export type GlobalStateKey =
| "awsRegion"
@@ -33,7 +35,6 @@ export type GlobalStateKey =
| "vertexProjectId"
| "vertexRegion"
| "lastShownAnnouncementId"
| "customInstructions"
| "taskHistory"
| "openAiBaseUrl"
| "openAiModelId"
@@ -68,7 +69,17 @@ export type GlobalStateKey =
| "shellIntegrationTimeout"
| "mcpResponsesCollapsed"
| "terminalReuseEnabled"
| "defaultTerminalProfile"
| "isNewUser"
| "terminalOutputLineLimit"
| "mcpRichDisplayEnabled"
| "sapAiCoreTokenUrl"
| "sapAiCoreBaseUrl"
| "sapAiResourceGroup"
| "sapAiCoreClientId"
| "sapAiCoreClientSecret"
| "sapAiCoreModelId"
| "claudeCodePath"
export type LocalStateKey =
| "localClineRulesToggles"
@@ -102,3 +113,9 @@ export type LocalStateKey =
| "previousModeReasoningEffort"
| "previousModeAwsBedrockCustomSelected"
| "previousModeAwsBedrockCustomModelBaseId"
| "previousModeSapAiCoreClientId"
| "previousModeSapAiCoreClientSecret"
| "previousModeSapAiCoreBaseUrl"
| "previousModeSapAiCoreTokenUrl"
| "previousModeSapAiCoreResourceGroup"
| "previousModeSapAiCoreModelId"
+120 -4
View File
@@ -11,6 +11,9 @@ import { ChatSettings } from "@shared/ChatSettings"
import { TelemetrySetting } from "@shared/TelemetrySetting"
import { UserInfo } from "@shared/UserInfo"
import { ClineRulesToggles } from "@shared/cline-rules"
import { ensureRulesDirectoryExists } from "./disk"
import fs from "fs/promises"
import path from "path"
/*
Storage
https://dev.to/kompotkot/how-to-use-secretstorage-in-your-vscode-extensions-2hco
@@ -125,6 +128,51 @@ async function migrateEnableCheckpointsSetting(enableCheckpointsSettingRaw: bool
return enableCheckpointsSettingRaw ?? true
}
export async function migrateCustomInstructionsToGlobalRules(context: vscode.ExtensionContext) {
try {
const customInstructions = (await context.globalState.get("customInstructions")) as string | undefined
if (customInstructions?.trim()) {
console.log("Migrating custom instructions to global Cline rules...")
// Create global .clinerules directory if it doesn't exist
const globalRulesDir = await ensureRulesDirectoryExists()
// Use a fixed filename for custom instructions
const migrationFileName = "custom_instructions.md"
const migrationFilePath = path.join(globalRulesDir, migrationFileName)
try {
// Check if file already exists to determine if we should append
let existingContent = ""
try {
existingContent = await fs.readFile(migrationFilePath, "utf8")
} catch (readError) {
// File doesn't exist, which is fine
}
// Append or create the file with custom instructions
const contentToWrite = existingContent
? `${existingContent}\n\n---\n\n${customInstructions.trim()}`
: customInstructions.trim()
await fs.writeFile(migrationFilePath, contentToWrite)
console.log(`Successfully ${existingContent ? "appended to" : "created"} migration file: ${migrationFilePath}`)
} catch (fileError) {
console.error("Failed to write migration file:", fileError)
return
}
// Remove customInstructions from global state only after successful file creation
await context.globalState.update("customInstructions", undefined)
console.log("Successfully migrated custom instructions to global Cline rules")
}
} catch (error) {
console.error("Failed to migrate custom instructions to global rules:", error)
// Continue execution - migration failure shouldn't break extension startup
}
}
export async function getAllExtensionState(context: vscode.ExtensionContext) {
const [
isNewUser,
@@ -161,7 +209,6 @@ export async function getAllExtensionState(context: vscode.ExtensionContext) {
azureApiVersion,
openRouterProviderSorting,
lastShownAnnouncementId,
customInstructions,
taskHistory,
autoApprovalSettings,
browserSettings,
@@ -187,9 +234,19 @@ export async function getAllExtensionState(context: vscode.ExtensionContext) {
shellIntegrationTimeout,
enableCheckpointsSettingRaw,
mcpMarketplaceEnabledRaw,
mcpRichDisplayEnabled,
mcpResponsesCollapsedRaw,
globalWorkflowToggles,
terminalReuseEnabled,
terminalOutputLineLimit,
defaultTerminalProfile,
sapAiCoreClientId,
sapAiCoreClientSecret,
sapAiCoreBaseUrl,
sapAiCoreTokenUrl,
sapAiResourceGroup,
sapAiCoreModelId,
claudeCodePath,
] = await Promise.all([
getGlobalState(context, "isNewUser") as Promise<boolean | undefined>,
getSecret(context, "apiKey") as Promise<string | undefined>,
@@ -225,7 +282,6 @@ export async function getAllExtensionState(context: vscode.ExtensionContext) {
getGlobalState(context, "azureApiVersion") as Promise<string | undefined>,
getGlobalState(context, "openRouterProviderSorting") as Promise<string | undefined>,
getGlobalState(context, "lastShownAnnouncementId") as Promise<string | undefined>,
getGlobalState(context, "customInstructions") as Promise<string | undefined>,
getGlobalState(context, "taskHistory") as Promise<HistoryItem[] | undefined>,
getGlobalState(context, "autoApprovalSettings") as Promise<AutoApprovalSettings | undefined>,
getGlobalState(context, "browserSettings") as Promise<BrowserSettings | undefined>,
@@ -251,9 +307,19 @@ export async function getAllExtensionState(context: vscode.ExtensionContext) {
getGlobalState(context, "shellIntegrationTimeout") as Promise<number | undefined>,
getGlobalState(context, "enableCheckpointsSetting") as Promise<boolean | undefined>,
getGlobalState(context, "mcpMarketplaceEnabled") as Promise<boolean | undefined>,
getGlobalState(context, "mcpRichDisplayEnabled") as Promise<boolean | undefined>,
getGlobalState(context, "mcpResponsesCollapsed") as Promise<boolean | undefined>,
getGlobalState(context, "globalWorkflowToggles") as Promise<ClineRulesToggles | undefined>,
getGlobalState(context, "terminalReuseEnabled") as Promise<boolean | undefined>,
getGlobalState(context, "terminalOutputLineLimit") as Promise<number | undefined>,
getGlobalState(context, "defaultTerminalProfile") as Promise<string | undefined>,
getSecret(context, "sapAiCoreClientId") as Promise<string | undefined>,
getSecret(context, "sapAiCoreClientSecret") as Promise<string | undefined>,
getGlobalState(context, "sapAiCoreBaseUrl") as Promise<string | undefined>,
getGlobalState(context, "sapAiCoreTokenUrl") as Promise<string | undefined>,
getGlobalState(context, "sapAiResourceGroup") as Promise<string | undefined>,
getGlobalState(context, "sapAiCoreModelId") as Promise<string | undefined>,
getGlobalState(context, "claudeCodePath") as Promise<string | undefined>,
])
const localClineRulesToggles = (await getWorkspaceState(context, "localClineRulesToggles")) as ClineRulesToggles
@@ -287,6 +353,12 @@ export async function getAllExtensionState(context: vscode.ExtensionContext) {
previousModeReasoningEffort,
previousModeAwsBedrockCustomSelected,
previousModeAwsBedrockCustomModelBaseId,
previousModeSapAiCoreClientId,
previousModeSapAiCoreClientSecret,
previousModeSapAiCoreBaseUrl,
previousModeSapAiCoreTokenUrl,
previousModeSapAiCoreResourceGroup,
previousModeSapAiCoreModelId,
] = await Promise.all([
getWorkspaceState(context, "chatSettings") as Promise<ChatSettings | undefined>,
getWorkspaceState(context, "apiProvider") as Promise<ApiProvider | undefined>,
@@ -316,6 +388,12 @@ export async function getAllExtensionState(context: vscode.ExtensionContext) {
getWorkspaceState(context, "previousModeReasoningEffort") as Promise<string | undefined>,
getWorkspaceState(context, "previousModeAwsBedrockCustomSelected") as Promise<boolean | undefined>,
getWorkspaceState(context, "previousModeAwsBedrockCustomModelBaseId") as Promise<BedrockModelId | undefined>,
getWorkspaceState(context, "previousModeSapAiCoreClientId") as Promise<string | undefined>,
getWorkspaceState(context, "previousModeSapAiCoreClientSecret") as Promise<string | undefined>,
getWorkspaceState(context, "previousModeSapAiCoreBaseUrl") as Promise<string | undefined>,
getWorkspaceState(context, "previousModeSapAiCoreTokenUrl") as Promise<string | undefined>,
getWorkspaceState(context, "previousModeSapAiCoreResourceGroup") as Promise<string | undefined>,
getWorkspaceState(context, "previousModeSapAiCoreModelId") as Promise<string | undefined>,
])
let apiProvider: ApiProvider
@@ -361,6 +439,7 @@ export async function getAllExtensionState(context: vscode.ExtensionContext) {
apiKey,
openRouterApiKey,
clineApiKey,
claudeCodePath,
awsAccessKey,
awsSecretKey,
awsSessionToken,
@@ -422,10 +501,15 @@ export async function getAllExtensionState(context: vscode.ExtensionContext) {
nebiusApiKey,
favoritedModelIds,
requestTimeoutMs,
sapAiCoreClientId,
sapAiCoreClientSecret,
sapAiCoreBaseUrl,
sapAiCoreTokenUrl,
sapAiResourceGroup,
sapAiCoreModelId,
},
isNewUser: isNewUser ?? true,
lastShownAnnouncementId,
customInstructions,
taskHistory,
autoApprovalSettings: autoApprovalSettings || DEFAULT_AUTO_APPROVAL_SETTINGS, // default value can be 0 or empty string
globalClineRulesToggles: globalClineRulesToggles || {},
@@ -444,13 +528,22 @@ export async function getAllExtensionState(context: vscode.ExtensionContext) {
previousModeReasoningEffort,
previousModeAwsBedrockCustomSelected,
previousModeAwsBedrockCustomModelBaseId,
previousModeSapAiCoreClientId,
previousModeSapAiCoreClientSecret,
previousModeSapAiCoreBaseUrl,
previousModeSapAiCoreTokenUrl,
previousModeSapAiCoreResourceGroup,
previousModeSapAiCoreModelId,
mcpMarketplaceEnabled: mcpMarketplaceEnabled,
mcpRichDisplayEnabled: mcpRichDisplayEnabled ?? true,
mcpResponsesCollapsed: mcpResponsesCollapsed,
telemetrySetting: telemetrySetting || "unset",
planActSeparateModelsSetting,
enableCheckpointsSetting: enableCheckpointsSetting,
shellIntegrationTimeout: shellIntegrationTimeout || 4000,
terminalReuseEnabled: terminalReuseEnabled ?? true,
terminalOutputLineLimit: terminalOutputLineLimit ?? 500,
defaultTerminalProfile: defaultTerminalProfile ?? "default",
globalWorkflowToggles: globalWorkflowToggles || {},
}
}
@@ -522,6 +615,13 @@ export async function updateApiConfiguration(context: vscode.ExtensionContext, a
fireworksModelId,
fireworksModelMaxCompletionTokens,
fireworksModelMaxTokens,
sapAiCoreClientId,
sapAiCoreClientSecret,
sapAiCoreBaseUrl,
sapAiCoreTokenUrl,
sapAiResourceGroup,
sapAiCoreModelId,
claudeCodePath,
} = apiConfiguration
// Workspace state updates
await updateWorkspaceState(context, "apiProvider", apiProvider)
@@ -570,6 +670,13 @@ export async function updateApiConfiguration(context: vscode.ExtensionContext, a
await updateGlobalState(context, "requestTimeoutMs", apiConfiguration.requestTimeoutMs)
await updateGlobalState(context, "fireworksModelMaxCompletionTokens", fireworksModelMaxCompletionTokens)
await updateGlobalState(context, "fireworksModelMaxTokens", fireworksModelMaxTokens)
await updateGlobalState(context, "favoritedModelIds", favoritedModelIds)
await updateGlobalState(context, "requestTimeoutMs", apiConfiguration.requestTimeoutMs)
await updateGlobalState(context, "sapAiCoreBaseUrl", sapAiCoreBaseUrl)
await updateGlobalState(context, "sapAiCoreTokenUrl", sapAiCoreTokenUrl)
await updateGlobalState(context, "sapAiResourceGroup", sapAiResourceGroup)
await updateGlobalState(context, "sapAiCoreModelId", sapAiCoreModelId)
await updateGlobalState(context, "claudeCodePath", claudeCodePath)
// Secret updates
await storeSecret(context, "apiKey", apiKey)
@@ -594,9 +701,18 @@ export async function updateApiConfiguration(context: vscode.ExtensionContext, a
await storeSecret(context, "sambanovaApiKey", sambanovaApiKey)
await storeSecret(context, "cerebrasApiKey", cerebrasApiKey)
await storeSecret(context, "nebiusApiKey", nebiusApiKey)
await storeSecret(context, "sapAiCoreClientId", sapAiCoreClientId)
await storeSecret(context, "sapAiCoreClientSecret", sapAiCoreClientSecret)
}
export async function resetExtensionState(context: vscode.ExtensionContext) {
export async function resetWorkspaceState(context: vscode.ExtensionContext) {
for (const key of context.workspaceState.keys()) {
await context.workspaceState.update(key, undefined)
}
}
export async function resetGlobalState(context: vscode.ExtensionContext) {
// TODO: Reset all workspace states?
for (const key of context.globalState.keys()) {
await context.globalState.update(key, undefined)
}
+57
View File
@@ -0,0 +1,57 @@
import { Anthropic } from "@anthropic-ai/sdk"
import { AssistantMessageContent } from "@core/assistant-message"
import { StreamingJsonReplacer } from "@core/assistant-message/diff-json"
import { ClineAskResponse } from "@shared/WebviewMessage"
export class TaskState {
// Streaming flags
isStreaming = false
isWaitingForFirstChunk = false
didCompleteReadingStream = false
// Content processing
currentStreamingContentIndex = 0
assistantMessageContent: AssistantMessageContent[] = []
userMessageContent: (Anthropic.TextBlockParam | Anthropic.ImageBlockParam)[] = []
userMessageContentReady = false
// Presentation locks
presentAssistantMessageLocked = false
presentAssistantMessageHasPendingUpdates = false
// Claude 4 experimental JSON streaming
streamingJsonReplacer?: StreamingJsonReplacer
lastProcessedJsonLength: number = 0
// Ask/Response handling
askResponse?: ClineAskResponse
askResponseText?: string
askResponseImages?: string[]
askResponseFiles?: string[]
lastMessageTs?: number
// Plan mode specific state
isAwaitingPlanResponse = false
didRespondToPlanAskBySwitchingMode = false
// Tool execution flags
didRejectTool = false
didAlreadyUseTool = false
didEditFile: boolean = false
// Consecutive request tracking
consecutiveAutoApprovedRequestsCount: number = 0
// Error tracking
consecutiveMistakeCount: number = 0
didAutomaticallyRetryFailedApiRequest = false
checkpointTrackerErrorMessage?: string
// Task Initialization
isInitialized = false
// Task Abort / Cancellation
abort: boolean = false
didFinishAbortingStream = false
abandoned = false
}
+581 -533
View File
File diff suppressed because it is too large Load Diff
+141
View File
@@ -0,0 +1,141 @@
import { combineApiRequests } from "@/shared/combineApiRequests"
import { ensureTaskDirectoryExists, saveApiConversationHistory, saveClineMessages } from "../storage/disk"
import * as vscode from "vscode"
import { ClineMessage } from "@/shared/ExtensionMessage"
import { getApiMetrics } from "@/shared/getApiMetrics"
import { combineCommandSequences } from "@/shared/combineCommandSequences"
import { findLastIndex } from "@/shared/array"
import getFolderSize from "get-folder-size"
import os from "os"
import * as path from "path"
import CheckpointTracker from "@integrations/checkpoints/CheckpointTracker"
import { HistoryItem } from "@/shared/HistoryItem"
import Anthropic from "@anthropic-ai/sdk"
interface MessageStateHandlerParams {
context: vscode.ExtensionContext
taskId: string
conversationHistoryDeletedRange?: [number, number]
taskIsFavorited?: boolean
updateTaskHistory: (historyItem: HistoryItem) => Promise<HistoryItem[]>
}
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
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
constructor(params: MessageStateHandlerParams) {
this.context = params.context
this.taskId = params.taskId
this.conversationHistoryDeletedRange = params.conversationHistoryDeletedRange
this.taskIsFavorited = params.taskIsFavorited ?? false
this.updateTaskHistory = params.updateTaskHistory
}
setCheckpointTracker(tracker: CheckpointTracker | undefined) {
this.checkpointTracker = tracker
}
getApiConversationHistory(): Anthropic.MessageParam[] {
return this.apiConversationHistory
}
setApiConversationHistory(newHistory: Anthropic.MessageParam[]): void {
this.apiConversationHistory = newHistory
}
getClineMessages(): ClineMessage[] {
return this.clineMessages
}
setClineMessages(newMessages: ClineMessage[]) {
this.clineMessages = newMessages
}
async saveClineMessagesAndUpdateHistory(): Promise<void> {
try {
await saveClineMessages(this.context, this.taskId, this.clineMessages)
// combined as they are in ChatView
const apiMetrics = getApiMetrics(combineApiRequests(combineCommandSequences(this.clineMessages.slice(1))))
const taskMessage = this.clineMessages[0] // first message is always the task say
const lastRelevantMessage =
this.clineMessages[
findLastIndex(
this.clineMessages,
(message) => !(message.ask === "resume_task" || message.ask === "resume_completed_task"),
)
]
const taskDir = await ensureTaskDirectoryExists(this.context, this.taskId)
let taskDirSize = 0
try {
// getFolderSize.loose silently ignores errors
// returns # of bytes, size/1000/1000 = MB
taskDirSize = await getFolderSize.loose(taskDir)
} catch (error) {
console.error("Failed to get task directory size:", taskDir, error)
}
await this.updateTaskHistory({
id: this.taskId,
ts: lastRelevantMessage.ts,
task: taskMessage.text ?? "",
tokensIn: apiMetrics.totalTokensIn,
tokensOut: apiMetrics.totalTokensOut,
cacheWrites: apiMetrics.totalCacheWrites,
cacheReads: apiMetrics.totalCacheReads,
totalCost: apiMetrics.totalCost,
size: taskDirSize,
shadowGitConfigWorkTree: await this.checkpointTracker?.getShadowGitConfigWorkTree(),
cwdOnTaskInitialization: cwd,
conversationHistoryDeletedRange: this.conversationHistoryDeletedRange,
isFavorited: this.taskIsFavorited,
})
} catch (error) {
console.error("Failed to save cline messages:", error)
}
}
async addToApiConversationHistory(message: Anthropic.MessageParam) {
this.apiConversationHistory.push(message)
await saveApiConversationHistory(this.context, this.taskId, this.apiConversationHistory)
}
async overwriteApiConversationHistory(newHistory: Anthropic.MessageParam[]): Promise<void> {
this.apiConversationHistory = newHistory
await saveApiConversationHistory(this.context, this.taskId, this.apiConversationHistory)
}
async addToClineMessages(message: ClineMessage) {
// 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
this.clineMessages.push(message)
await this.saveClineMessagesAndUpdateHistory()
}
async overwriteClineMessages(newMessages: ClineMessage[]) {
this.clineMessages = newMessages
await this.saveClineMessagesAndUpdateHistory()
}
async updateClineMessage(index: number, updates: Partial<ClineMessage>): Promise<void> {
if (index < 0 || index >= this.clineMessages.length) {
throw new Error(`Invalid message index: ${index}`)
}
// Apply updates to the message
Object.assign(this.clineMessages[index], updates)
// Save changes and update history
await this.saveClineMessagesAndUpdateHistory()
}
}
+70
View File
@@ -0,0 +1,70 @@
import { showSystemNotification } from "@/integrations/notifications"
import { ClineApiReqCancelReason, ClineApiReqInfo } from "@/shared/ExtensionMessage"
import { serializeError } from "serialize-error"
import { MessageStateHandler } from "./message-state"
import { calculateApiCostAnthropic } from "@/utils/cost"
import { ApiHandler } from "@/api"
export function formatErrorWithStatusCode(error: any): string {
const statusCode = error.status || error.statusCode || (error.response && error.response.status)
const message = error.message ?? JSON.stringify(serializeError(error), null, 2)
// Only prepend the statusCode if it's not already part of the message
return statusCode && !message.includes(statusCode.toString()) ? `${statusCode} - ${message}` : message
}
export const showNotificationForApprovalIfAutoApprovalEnabled = (
message: string,
autoApprovalSettingsEnabled: boolean,
notificationsEnabled: boolean,
) => {
if (autoApprovalSettingsEnabled && notificationsEnabled) {
showSystemNotification({
subtitle: "Approval Required",
message,
})
}
}
type UpdateApiReqMsgParams = {
messageStateHandler: MessageStateHandler
lastApiReqIndex: number
inputTokens: number
outputTokens: number
cacheWriteTokens: number
cacheReadTokens: number
totalCost?: number
api: ApiHandler
cancelReason?: ClineApiReqCancelReason
streamingFailedMessage?: string
}
// update api_req_started. we can't use api_req_finished anymore since it's a unique case where it could come after a streaming message (ie in the middle of being updated or executed)
// fortunately api_req_finished was always parsed out for the gui anyways, so it remains solely for legacy purposes to keep track of prices in tasks from history
// (it's worth removing a few months from now)
export const updateApiReqMsg = async (params: UpdateApiReqMsgParams) => {
const clineMessages = params.messageStateHandler.getClineMessages()
const currentApiReqInfo: ClineApiReqInfo = JSON.parse(clineMessages[params.lastApiReqIndex].text || "{}")
delete currentApiReqInfo.retryStatus // Clear retry status when request is finalized
await params.messageStateHandler.updateClineMessage(params.lastApiReqIndex, {
text: JSON.stringify({
...currentApiReqInfo, // Spread the modified info (with retryStatus removed)
tokensIn: params.inputTokens,
tokensOut: params.outputTokens,
cacheWrites: params.cacheWriteTokens,
cacheReads: params.cacheReadTokens,
cost:
params.totalCost ??
calculateApiCostAnthropic(
params.api.getModel().info,
params.inputTokens,
params.outputTokens,
params.cacheWriteTokens,
params.cacheReadTokens,
),
cancelReason: params.cancelReason,
streamingFailedMessage: params.streamingFailedMessage,
} satisfies ClineApiReqInfo),
})
}
+5 -10
View File
@@ -10,6 +10,7 @@ import path from "node:path"
import { WebviewProviderType } from "@/shared/webview/types"
import { sendThemeEvent } from "@core/controller/ui/subscribeToTheme"
import { v4 as uuidv4 } from "uuid"
import { sendDidBecomeVisibleEvent } from "../controller/ui/subscribeToDidBecomeVisible"
/*
https://github.com/microsoft/vscode-webview-ui-toolkit-samples/blob/main/default/weather-webview/src/providers/WeatherViewProvider.ts
@@ -113,12 +114,9 @@ export class WebviewProvider implements vscode.WebviewViewProvider {
// WebviewView and WebviewPanel have all the same properties except for this visibility listener
// panel
webviewView.onDidChangeViewState(
() => {
async () => {
if (this.view?.visible) {
this.controller.postMessageToWebview({
type: "action",
action: "didBecomeVisible",
})
await sendDidBecomeVisibleEvent(this.controller.id)
}
},
null,
@@ -127,12 +125,9 @@ export class WebviewProvider implements vscode.WebviewViewProvider {
} else if ("onDidChangeVisibility" in webviewView) {
// sidebar
webviewView.onDidChangeVisibility(
() => {
async () => {
if (this.view?.visible) {
this.controller.postMessageToWebview({
type: "action",
action: "didBecomeVisible",
})
await sendDidBecomeVisibleEvent(this.controller.id)
}
},
null,
-7
View File
@@ -18,13 +18,6 @@ The Cline extension exposes an API that can be used by other extensions. To use
if (cline) {
// Now you can use the API
// Set custom instructions
await cline.setCustomInstructions("Talk like a pirate")
// Get custom instructions
const instructions = await cline.getCustomInstructions()
console.log("Current custom instructions:", instructions)
// Start a new task with an initial message
await cline.startNewTask("Hello, Cline! Let's make a new project...")
-64
View File
@@ -65,59 +65,6 @@ describe("ClineAPI Core Functionality", () => {
sandbox.restore()
})
describe("setCustomInstructions", () => {
it("should update custom instructions in controller", async () => {
const testInstructions = "Test custom instructions"
await api.setCustomInstructions(testInstructions)
// Verify controller method was called
sinon.assert.calledOnce(mockController.updateCustomInstructions)
sinon.assert.calledWith(mockController.updateCustomInstructions, testInstructions)
// Verify output channel was updated
sinon.assert.calledWith(mockOutputChannel.appendLine, "Custom instructions set")
})
it("should handle empty instructions", async () => {
await api.setCustomInstructions("")
sinon.assert.calledWith(mockController.updateCustomInstructions, "")
sinon.assert.calledWith(mockOutputChannel.appendLine, "Custom instructions set")
})
it("should handle very long instructions", async () => {
const longInstructions = "a".repeat(10000)
await api.setCustomInstructions(longInstructions)
sinon.assert.calledWith(mockController.updateCustomInstructions, longInstructions)
})
})
describe("getCustomInstructions", () => {
it("should retrieve custom instructions from state", async () => {
const testInstructions = "Retrieved instructions"
// The real implementation uses getGlobalState from the state module
getGlobalStateStub.resolves(testInstructions)
const result = await api.getCustomInstructions()
result!.should.equal(testInstructions)
sinon.assert.calledWith(getGlobalStateStub, mockController.context, "customInstructions")
})
it("should return undefined when no instructions set", async () => {
// The real implementation uses getGlobalState from the state module
getGlobalStateStub.resolves(undefined)
const result = await api.getCustomInstructions()
should.not.exist(result)
sinon.assert.calledWith(getGlobalStateStub, mockController.context, "customInstructions")
})
})
describe("startNewTask", () => {
it("should clear existing task and start new one with description", async () => {
const taskDescription = "Create a test function"
@@ -260,17 +207,6 @@ describe("ClineAPI Core Functionality", () => {
})
describe("Error Handling", () => {
it("should handle errors in setCustomInstructions", async () => {
mockController.updateCustomInstructions.rejects(new Error("Update failed"))
try {
await api.setCustomInstructions("test")
should.fail("", "", "Should have thrown an error", "")
} catch (error: any) {
error.message.should.equal("Update failed")
}
})
it("should handle errors in task initialization", async () => {
mockController.initTask.rejects(new Error("Init failed"))
-12
View File
@@ -1,16 +1,4 @@
export interface ClineAPI {
/**
* Sets the custom instructions in the global storage.
* @param value The custom instructions to be saved.
*/
setCustomInstructions(value: string): Promise<void>
/**
* Retrieves the custom instructions from the global storage.
* @returns The saved custom instructions, or undefined if not set.
*/
getCustomInstructions(): Promise<string | undefined>
/**
* Starts a new task with an optional initial message and images.
* @param task Optional initial task message.
-9
View File
@@ -7,15 +7,6 @@ import { WebviewProviderType as WebviewProviderTypeEnum } from "@shared/proto/ui
export function createClineAPI(outputChannel: vscode.OutputChannel, sidebarController: Controller): ClineAPI {
const api: ClineAPI = {
setCustomInstructions: async (value: string) => {
await sidebarController.updateCustomInstructions(value)
outputChannel.appendLine("Custom instructions set")
},
getCustomInstructions: async () => {
return (await getGlobalState(sidebarController.context, "customInstructions")) as string | undefined
},
startNewTask: async (task?: string, images?: string[]) => {
outputChannel.appendLine("Starting new task")
await sidebarController.clearTask()
+15 -2
View File
@@ -22,9 +22,13 @@ import { WebviewProviderType as WebviewProviderTypeEnum } from "@shared/proto/ui
import { WebviewProviderType } from "./shared/webview/types"
import { sendHistoryButtonClickedEvent } from "./core/controller/ui/subscribeToHistoryButtonClicked"
import { sendAccountButtonClickedEvent } from "./core/controller/ui/subscribeToAccountButtonClicked"
import { migratePlanActGlobalToWorkspaceStorage } from "./core/storage/state"
import { migratePlanActGlobalToWorkspaceStorage, migrateCustomInstructionsToGlobalRules } from "./core/storage/state"
import { sendFocusChatInputEvent } from "./core/controller/ui/subscribeToFocusChatInput"
import { FileContextTracker } from "./core/context/context-tracking/FileContextTracker"
import { maybeInitializeHostBridgeClient } from "./hosts/host-bridge-client"
import { vscodeHostBridgeClient } from "@generated/hosts/vscode/client/host-grpc-client"
/*
Built using https://github.com/microsoft/vscode-webview-ui-toolkit
@@ -46,9 +50,17 @@ export async function activate(context: vscode.ExtensionContext) {
Logger.initialize(outputChannel)
Logger.log("Cline extension activated")
maybeInitializeHostBridgeClient(vscodeHostBridgeClient)
// Migrate global storage values to workspace storage (one-time cleanup)
await migratePlanActGlobalToWorkspaceStorage(context)
// Migrate custom instructions to global Cline rules (one-time cleanup)
await migrateCustomInstructionsToGlobalRules(context)
// Clean up orphaned file context warnings (startup cleanup)
await FileContextTracker.cleanupOrphanedWarnings(context)
// Version checking for autoupdate notification
const currentVersion = context.extension.packageJSON.version
const previousVersion = context.globalState.get<string>("clineVersion")
@@ -632,7 +644,8 @@ export async function activate(context: vscode.ExtensionContext) {
//
// This is a workaround to reload the extension when the source code changes
// since vscode doesn't support hot reload for extensions
const { IS_DEV, DEV_WORKSPACE_FOLDER } = process.env
const IS_DEV = process.env.IS_DEV
const DEV_WORKSPACE_FOLDER = process.env.DEV_WORKSPACE_FOLDER
// This method is called when your extension is deactivated
export async function deactivate() {
-23
View File
@@ -1,23 +0,0 @@
import { StringRequest } from "@/shared/proto/common"
import { Uri } from "@/shared/proto/host/uri"
import { FileChangeEvent_ChangeType, SubscribeToFileRequest } from "@/shared/proto/host/watch"
const UriServiceClient = {
parse: function (_: StringRequest): Uri {
throw Error("Unimplemented")
},
}
const WatchServiceClient = {
subscribeToFile: function (
_r: SubscribeToFileRequest,
_h: {
onResponse?: (response: { type: FileChangeEvent_ChangeType }) => void | Promise<void>
onError?: (error: any) => void
onComplete?: () => void
},
) {
throw Error("Unimplemented")
},
}
export { UriServiceClient, WatchServiceClient }
+36 -6
View File
@@ -1,8 +1,38 @@
import * as VscodeClient from "./vscode/client/host-grpc-client"
import * as ExternalClient from "./external/client/host-bridge-client"
import { UriServiceClientInterface, WatchServiceClientInterface } from "@generated/hosts/host-bridge-client-types"
const isHostBridgeExternal = process.env.HOST_BRIDGE_ADDRESS !== undefined && process.env.HOST_BRIDGE_ADDRESS !== "vscode"
const Client = isHostBridgeExternal ? ExternalClient : VscodeClient
/**
* Callback interface for streaming requests
*/
export interface StreamingCallbacks<T = any> {
onResponse: (response: T) => void
onError?: (error: Error) => void
onComplete?: () => void
}
export const UriServiceClient = Client.UriServiceClient
export const WatchServiceClient = Client.WatchServiceClient
/**
* Interface for host bridge client providers
*/
export interface HostBridgeClientProvider {
UriServiceClient: UriServiceClientInterface
WatchServiceClient: WatchServiceClientInterface
}
let isSetup = false
// Export the clients directly - they'll be set during initialization
export let UriServiceClient: UriServiceClientInterface
export let WatchServiceClient: WatchServiceClientInterface
export function initializeHostBridgeClient(provider: HostBridgeClientProvider): void {
UriServiceClient = provider.UriServiceClient
WatchServiceClient = provider.WatchServiceClient
isSetup = true
}
export function maybeInitializeHostBridgeClient(provider: HostBridgeClientProvider): void {
if (isSetup) {
console.log("Host bridge client already initialized, not re-initializing.")
return
}
initializeHostBridgeClient(provider)
}
@@ -1,5 +1,6 @@
import { v4 as uuidv4 } from "uuid"
import { GrpcHandler, StreamingCallbacks } from "../host-grpc-handler"
import { GrpcHandler } from "../host-grpc-handler"
import { StreamingCallbacks } from "@hosts/host-bridge-client"
// Generic type for any protobuf service definition
export type ProtoService = {
@@ -33,10 +34,11 @@ export function createGrpcClient<T extends ProtoService>(service: T): GrpcClient
const grpcHandler = new GrpcHandler()
Object.values(service.methods).forEach((method) => {
// Use lowercase method name as the key in the client object
const methodKey = method.name.charAt(0).toLowerCase() + method.name.slice(1)
// Streaming method implementation
if (method.responseStream) {
// Use lowercase method name as the key in the client object
const methodKey = method.name.charAt(0).toLowerCase() + method.name.slice(1)
client[methodKey as keyof GrpcClientType<T>] = ((
request: any,
options: StreamingCallbacks<InstanceType<typeof method.responseType>>,
@@ -75,7 +77,6 @@ export function createGrpcClient<T extends ProtoService>(service: T): GrpcClient
}) as any
} else {
// Unary method implementation
const methodKey = method.name.charAt(0).toLowerCase() + method.name.slice(1)
client[methodKey as keyof GrpcClientType<T>] = ((request: any) => {
return new Promise(async (resolve, reject) => {
const requestId = uuidv4()
@@ -84,15 +85,12 @@ export function createGrpcClient<T extends ProtoService>(service: T): GrpcClient
const response = await grpcHandler.handleRequest(service.fullName, methodKey, request, requestId)
console.log(`[DEBUG] gRPC host resp to ${service.fullName}.${methodKey} req:${requestId}`)
// Check if the response is a function (streaming) or an object (unary)
// Check if the response is a function (streaming)
if (typeof response === "function") {
// This shouldn't happen for unary requests
throw new Error("Received streaming response for unary request")
} else if (response && response.message) {
resolve(response.message)
} else {
throw new Error("gRPC response didn't have a message")
}
resolve(response)
} catch (e) {
console.log(`[DEBUG] gRPC host ERR to ${service.fullName}.${methodKey} req:${requestId} err:${e}`)
reject(e)
@@ -101,6 +99,5 @@ export function createGrpcClient<T extends ProtoService>(service: T): GrpcClient
}) as any
}
})
return client
}
+78 -105
View File
@@ -1,5 +1,5 @@
import { v4 as uuidv4 } from "uuid"
import { hostServiceHandlers } from "./host-grpc-service-config"
import { StreamingCallbacks } from "@hosts/host-bridge-client"
import { HostServiceHandlerConfig, hostServiceHandlers } from "./host-grpc-service-config"
import { GrpcRequestRegistry } from "@core/controller/grpc-request-registry"
/**
@@ -11,22 +11,13 @@ export type StreamingResponseHandler = (response: any, isLast?: boolean, sequenc
const requestRegistry = new GrpcRequestRegistry()
/**
* Callback interface for streaming requests
*/
export interface StreamingCallbacks<T = any> {
onResponse: (response: T) => void
onError?: (error: Error) => void
onComplete?: () => void
}
/**
* Handles gRPC requests from the webview
* Handles gRPC requests for the host bridge.
*/
export class GrpcHandler {
constructor() {}
/**
* Handle a gRPC request from the webview
* Handle a gRPC request for the host bridge.
* @param service The service name
* @param method The method name
* @param message The request message
@@ -37,90 +28,71 @@ export class GrpcHandler {
async handleRequest<T = any>(
service: string,
method: string,
message: any,
request: any,
requestId: string,
streamingCallbacks?: StreamingCallbacks<T>,
): Promise<
| {
message?: any
error?: string
request_id: string
}
| (() => void)
> {
): Promise<any | (() => void)> {
if (!streamingCallbacks) {
return this.handleUnaryRequest(service, method, request)
}
// If streaming callbacks are provided, handle as a streaming request
if (streamingCallbacks) {
let completionCalled = false
let completionCalled = false
// Create a response handler that will call the client's callbacks
const responseHandler: StreamingResponseHandler = async (response, isLast = false, sequenceNumber) => {
try {
// Call the client's onResponse callback with the response
streamingCallbacks.onResponse(response)
// If this is the last response, call the onComplete callback
if (isLast && streamingCallbacks.onComplete && !completionCalled) {
completionCalled = true
streamingCallbacks.onComplete()
}
} catch (error) {
// If there's an error in the callback, call the onError callback
if (streamingCallbacks.onError) {
streamingCallbacks.onError(error instanceof Error ? error : new Error(String(error)))
}
}
}
// Register the response handler with the registry
requestRegistry.registerRequest(
requestId,
() => {
console.log(`[DEBUG] Cleaning up streaming request: ${requestId}`)
if (streamingCallbacks.onComplete && !completionCalled) {
completionCalled = true
streamingCallbacks.onComplete()
}
},
{ type: "streaming_request", service, method },
responseHandler,
)
// Call the streaming handler directly
console.log(`[DEBUG] Streaming gRPC host call to ${service}.${method} req:${requestId}`)
// Create a response handler that will call the client's callbacks
const responseHandler: StreamingResponseHandler = async (response, isLast = false, sequenceNumber) => {
try {
await this.handleStreamingRequest(service, method, message, requestId)
// Call the client's onResponse callback with the response
streamingCallbacks.onResponse(response)
// If this is the last response, call the onComplete callback
if (isLast && streamingCallbacks.onComplete && !completionCalled) {
completionCalled = true
streamingCallbacks.onComplete()
}
} catch (error) {
// If there's an error in the callback, call the onError callback
if (streamingCallbacks.onError) {
streamingCallbacks.onError(error instanceof Error ? error : new Error(String(error)))
}
}
// Return a function to cancel the stream
return () => {
console.log(`[DEBUG] Cancelling streaming request: ${requestId}`)
this.cancelRequest(requestId)
}
}
// Handle as a unary request
// Register the response handler with the registry
requestRegistry.registerRequest(
requestId,
() => {
console.log(`[DEBUG] Cleaning up streaming request: ${requestId}`)
if (streamingCallbacks.onComplete && !completionCalled) {
completionCalled = true
streamingCallbacks.onComplete()
}
},
{ type: "streaming_request", service, method },
responseHandler,
)
// Call the streaming handler directly
console.log(`[DEBUG] Streaming gRPC host call to ${service}.${method} req:${requestId}`)
try {
// Get the service handler from the config
const serviceConfig = hostServiceHandlers[service]
if (!serviceConfig) {
throw new Error(`Unknown service: ${service}`)
}
// Handle unary request
return {
message: await serviceConfig.requestHandler(method, message),
request_id: requestId,
}
await this.handleStreamingRequest(service, method, request, requestId)
} catch (error) {
return {
error: error instanceof Error ? error.message : String(error),
request_id: requestId,
if (streamingCallbacks.onError) {
streamingCallbacks.onError(error instanceof Error ? error : new Error(String(error)))
}
}
// Return a function to cancel the stream
return () => {
console.log(`[DEBUG] Cancelling streaming request: ${requestId}`)
this.cancelRequest(requestId)
}
}
private async handleUnaryRequest(service: string, method: string, request: any): Promise<any> {
const serviceConfig = this.getServiceHandlerConfig(service)
const response = await serviceConfig.requestHandler(method, request)
return response
}
/**
@@ -129,27 +101,25 @@ export class GrpcHandler {
* @returns True if the request was found and cancelled, false otherwise
*/
public async cancelRequest(requestId: string): Promise<boolean> {
const cancelled = requestRegistry.cancelRequest(requestId)
if (cancelled) {
// Get the registered response handler from the registry
const requestInfo = requestRegistry.getRequestInfo(requestId)
if (requestInfo && requestInfo.responseStream) {
try {
// Send cancellation confirmation using the registered response handler
await requestInfo.responseStream(
{ cancelled: true },
true, // Mark as last message
)
} catch (e) {
console.error(`Error sending cancellation response for ${requestId}:`, e)
}
}
} else {
console.log(`[DEBUG] Request not found for cancellation: ${requestId}`)
const requestInfo = requestRegistry.getRequestInfo(requestId)
if (!requestInfo) {
return false
}
return cancelled
const cancelled = requestRegistry.cancelRequest(requestId)
if (!cancelled) {
console.log(`[DEBUG] Request not found for cancellation: ${requestId}`)
return false
}
if (requestInfo.responseStream) {
try {
// Send cancellation confirmation using the registered response handler
await requestInfo.responseStream({ cancelled: true }, true /* isLast */)
} catch (e) {
console.error(`Error sending cancellation response for ${requestId}:`, e)
}
}
return true
}
/**
@@ -160,11 +130,7 @@ export class GrpcHandler {
* @param requestId The request ID for response correlation
*/
private async handleStreamingRequest(service: string, method: string, message: any, requestId: string): Promise<void> {
// Get the service handler from the config
const serviceConfig = hostServiceHandlers[service]
if (!serviceConfig) {
throw new Error(`Unknown service: ${service}`)
}
const serviceConfig = this.getServiceHandlerConfig(service)
// Check if the service supports streaming
if (!serviceConfig.streamingHandler) {
@@ -186,6 +152,13 @@ export class GrpcHandler {
// Don't send a final message here - the stream should stay open for future updates
// The stream will be closed when the client disconnects or when the service explicitly ends it
}
private getServiceHandlerConfig(serviceName: string): HostServiceHandlerConfig {
if (!(serviceName in hostServiceHandlers)) {
throw new Error(`Unknown service: ${serviceName}`)
}
return hostServiceHandlers[serviceName]
}
}
/**
@@ -83,6 +83,7 @@ function getBuildArtifactPatterns(): string[] {
".sass-cache/",
".vs/",
".vscode/",
".clinerules/",
"Pods/",
"__pycache__/",
"bin/",
+45
View File
@@ -0,0 +1,45 @@
import * as vscode from "vscode"
import Anthropic from "@anthropic-ai/sdk"
import { execa } from "execa"
const cwd = vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath).at(0)
export function runClaudeCode({
systemPrompt,
messages,
path,
modelId,
}: {
systemPrompt: string
messages: Anthropic.Messages.MessageParam[]
path?: string
modelId?: string
}) {
const claudePath = path || "claude"
// TODO: Is it worh using sessions? Where do we store the session ID?
const args = [
"-p",
JSON.stringify(messages),
"--system-prompt",
systemPrompt,
"--verbose",
"--output-format",
"stream-json",
// Cline will handle recursive calls
"--max-turns",
"1",
]
if (modelId) {
args.push("--model", modelId)
}
return execa(claudePath, args, {
stdin: "ignore",
stdout: "pipe",
stderr: "pipe",
env: process.env,
cwd,
})
}
+52
View File
@@ -0,0 +1,52 @@
type InitMessage = {
type: "system"
subtype: "init"
session_id: string
tools: string[]
mcp_servers: string[]
}
type ClaudeCodeContent = {
type: "text"
text: string
}
type AssistantMessage = {
type: "assistant"
message: {
id: string
type: "message"
role: "assistant"
model: string
content: ClaudeCodeContent[]
stop_reason: null
stop_sequence: null
usage: {
input_tokens: number
cache_creation_input_tokens?: number
cache_read_input_tokens?: number
output_tokens: number
service_tier: "standard"
}
}
session_id: string
}
type ErrorMessage = {
type: "error"
}
type ResultMessage = {
type: "result"
subtype: "success"
cost_usd: number
is_error: boolean
duration_ms: number
duration_api_ms: number
num_turns: number
result: string
total_cost: number
session_id: string
}
export type ClaudeCodeMessage = InitMessage | AssistantMessage | ErrorMessage | ResultMessage
+145 -5
View File
@@ -1,6 +1,7 @@
import pWaitFor from "p-wait-for"
import * as vscode from "vscode"
import { arePathsEqual } from "@utils/path"
import { getShellForProfile } from "@utils/shell"
import { mergePromise, TerminalProcess, TerminalProcessResultPromise } from "./TerminalProcess"
import { TerminalInfo, TerminalRegistry } from "./TerminalRegistry"
@@ -95,6 +96,8 @@ export class TerminalManager {
private disposables: vscode.Disposable[] = []
private shellIntegrationTimeout: number = 4000
private terminalReuseEnabled: boolean = true
private terminalOutputLineLimit: number = 500
private defaultTerminalProfile: string = "default"
constructor() {
let disposable: vscode.Disposable | undefined
@@ -153,12 +156,16 @@ export class TerminalManager {
}
runCommand(terminalInfo: TerminalInfo, command: string): TerminalProcessResultPromise {
console.log(`[TerminalManager] Running command on terminal ${terminalInfo.id}: "${command}"`)
console.log(`[TerminalManager] Terminal ${terminalInfo.id} busy state before: ${terminalInfo.busy}`)
terminalInfo.busy = true
terminalInfo.lastCommand = command
const process = new TerminalProcess()
this.processes.set(terminalInfo.id, process)
process.once("completed", () => {
console.log(`[TerminalManager] Terminal ${terminalInfo.id} completed, setting busy to false`)
terminalInfo.busy = false
})
@@ -218,26 +225,40 @@ export class TerminalManager {
async getOrCreateTerminal(cwd: string): Promise<TerminalInfo> {
const terminals = TerminalRegistry.getAllTerminals()
const expectedShellPath =
this.defaultTerminalProfile !== "default" ? getShellForProfile(this.defaultTerminalProfile) : undefined
// Find available terminal from our pool first (created for this task)
console.log(`[TerminalManager] Looking for terminal in cwd: ${cwd}`)
console.log(`[TerminalManager] Available terminals: ${terminals.length}`)
const matchingTerminal = terminals.find((t) => {
if (t.busy) {
console.log(`[TerminalManager] Terminal ${t.id} is busy, skipping`)
return false
}
// Check if shell path matches current configuration
if (t.shellPath !== expectedShellPath) {
return false
}
const terminalCwd = t.terminal.shellIntegration?.cwd // one of cline's commands could have changed the cwd of the terminal
if (!terminalCwd) {
console.log(`[TerminalManager] Terminal ${t.id} has no cwd, skipping`)
return false
}
return arePathsEqual(vscode.Uri.file(cwd).fsPath, terminalCwd.fsPath)
const matches = arePathsEqual(vscode.Uri.file(cwd).fsPath, terminalCwd.fsPath)
console.log(`[TerminalManager] Terminal ${t.id} cwd: ${terminalCwd.fsPath}, matches: ${matches}`)
return matches
})
if (matchingTerminal) {
console.log(`[TerminalManager] Found matching terminal ${matchingTerminal.id} in correct cwd`)
this.terminalIds.add(matchingTerminal.id)
return matchingTerminal
}
// If no non-busy terminal in the current working dir exists and terminal reuse is enabled, try to find any non-busy terminal regardless of CWD
if (this.terminalReuseEnabled) {
const availableTerminal = terminals.find((t) => !t.busy)
const availableTerminal = terminals.find((t) => !t.busy && t.shellPath === expectedShellPath)
if (availableTerminal) {
// Set up promise and tracking for CWD change
const cwdPromise = new Promise<void>((resolve, reject) => {
@@ -246,7 +267,13 @@ export class TerminalManager {
})
// Navigate back to the desired directory
await this.runCommand(availableTerminal, `cd "${cwd}"`)
const cdProcess = this.runCommand(availableTerminal, `cd "${cwd}"`)
// Wait for the cd command to complete before proceeding
await cdProcess
// Add a small delay to ensure terminal is ready after cd
await new Promise((resolve) => setTimeout(resolve, 100))
// Either resolve immediately if CWD already updated or wait for event/timeout
if (this.isCwdMatchingExpected(availableTerminal)) {
@@ -275,8 +302,8 @@ export class TerminalManager {
}
}
// If all terminals are busy, create a new one
const newTerminalInfo = TerminalRegistry.createTerminal(cwd)
// If all terminals are busy or don't match shell profile, create a new one with the configured shell
const newTerminalInfo = TerminalRegistry.createTerminal(cwd, expectedShellPath)
this.terminalIds.add(newTerminalInfo.id)
return newTerminalInfo
}
@@ -318,4 +345,117 @@ export class TerminalManager {
setTerminalReuseEnabled(enabled: boolean): void {
this.terminalReuseEnabled = enabled
}
setTerminalOutputLineLimit(limit: number): void {
this.terminalOutputLineLimit = limit
}
public processOutput(outputLines: string[]): string {
if (outputLines.length > this.terminalOutputLineLimit) {
const halfLimit = Math.floor(this.terminalOutputLineLimit / 2)
const start = outputLines.slice(0, halfLimit)
const end = outputLines.slice(outputLines.length - halfLimit)
return `${start.join("\n")}\n... (output truncated) ...\n${end.join("\n")}`.trim()
}
return outputLines.join("\n").trim()
}
setDefaultTerminalProfile(profileId: string): { closedCount: number; busyTerminals: TerminalInfo[] } {
// Only handle terminal change if profile actually changed
if (this.defaultTerminalProfile === profileId) {
return { closedCount: 0, busyTerminals: [] }
}
const oldProfileId = this.defaultTerminalProfile
this.defaultTerminalProfile = profileId
// Get the shell path for the new profile
const newShellPath = profileId !== "default" ? getShellForProfile(profileId) : undefined
// Handle terminal management for the profile change
const result = this.handleTerminalProfileChange(newShellPath)
// Update lastActive for any remaining terminals
const allTerminals = TerminalRegistry.getAllTerminals()
allTerminals.forEach((terminal) => {
if (terminal.shellPath !== newShellPath) {
TerminalRegistry.updateTerminal(terminal.id, { lastActive: Date.now() })
}
})
return result
}
/**
* Filters terminals based on a provided criteria function
* @param filterFn Function that accepts TerminalInfo and returns boolean
* @returns Array of terminals that match the criteria
*/
filterTerminals(filterFn: (terminal: TerminalInfo) => boolean): TerminalInfo[] {
const terminals = TerminalRegistry.getAllTerminals()
return terminals.filter(filterFn)
}
/**
* Closes terminals that match the provided criteria
* @param filterFn Function that accepts TerminalInfo and returns boolean for terminals to close
* @param force If true, closes even busy terminals (with warning)
* @returns Number of terminals closed
*/
closeTerminals(filterFn: (terminal: TerminalInfo) => boolean, force: boolean = false): number {
const terminalsToClose = this.filterTerminals(filterFn)
let closedCount = 0
for (const terminalInfo of terminalsToClose) {
// Skip busy terminals unless force is true
if (terminalInfo.busy && !force) {
continue
}
// Remove from our tracking
if (this.terminalIds.has(terminalInfo.id)) {
this.terminalIds.delete(terminalInfo.id)
}
this.processes.delete(terminalInfo.id)
// Dispose the actual terminal
terminalInfo.terminal.dispose()
// Remove from registry
TerminalRegistry.removeTerminal(terminalInfo.id)
closedCount++
}
return closedCount
}
/**
* Handles terminal management when the terminal profile changes
* @param newShellPath New shell path to use
* @returns Object with information about closed terminals and remaining busy terminals
*/
handleTerminalProfileChange(newShellPath: string | undefined): {
closedCount: number
busyTerminals: TerminalInfo[]
} {
// Close non-busy terminals with different shell path
const closedCount = this.closeTerminals((terminal) => !terminal.busy && terminal.shellPath !== newShellPath, false)
// Get remaining busy terminals with different shell path
const busyTerminals = this.filterTerminals((terminal) => terminal.busy && terminal.shellPath !== newShellPath)
return {
closedCount,
busyTerminals,
}
}
/**
* Forces closure of all terminals (including busy ones)
* @returns Number of terminals closed
*/
closeAllTerminals(): number {
return this.closeTerminals(() => true, true)
}
}
+177 -4
View File
@@ -23,20 +23,84 @@ export class TerminalProcess extends EventEmitter<TerminalProcessEvents> {
private lastRetrievedIndex: number = 0
isHot: boolean = false
private hotTimer: NodeJS.Timeout | null = null
private command: string = ""
private gracePeriodTimer: NodeJS.Timeout | null = null
private hasEmittedCompleted: boolean = false
// constructor() {
// super()
async run(terminal: vscode.Terminal, command: string) {
// Clear any existing grace period timer from previous commands
if (this.gracePeriodTimer) {
clearTimeout(this.gracePeriodTimer)
this.gracePeriodTimer = null
console.log(`[TerminalProcess] Cleared existing grace period timer before starting new command`)
}
// Clear any existing hot timer
if (this.hotTimer) {
clearTimeout(this.hotTimer)
this.hotTimer = null
}
// Reset state for new command
this.hasEmittedCompleted = false
this.buffer = ""
this.fullOutput = ""
this.lastRetrievedIndex = 0
this.isListening = true
this.isHot = false
this.command = command
console.log(`[TerminalProcess] Starting command: "${command}"`)
console.log(`[TerminalProcess] Shell integration available: ${!!terminal.shellIntegration?.executeCommand}`)
console.log(`[TerminalProcess] Terminal ID: ${terminal.name}`)
if (terminal.shellIntegration && terminal.shellIntegration.executeCommand) {
const execution = terminal.shellIntegration.executeCommand(command)
const stream = execution.read()
let execution
let stream
try {
execution = terminal.shellIntegration.executeCommand(command)
stream = execution.read()
} catch (error) {
console.error(`[TerminalProcess] Failed to execute command: ${error}`)
this.emit("error", error as Error)
return
}
// todo: need to handle errors
let isFirstChunk = true
let didOutputNonCommand = false
let didEmitEmptyLine = false
let receivedFirstChunk = false
// Set up a timeout to emit empty line if no output is received within 3 seconds
// This ensures the "proceed while running" button appears even for commands with no/delayed output
const firstChunkTimeout = setTimeout(() => {
if (!receivedFirstChunk && !didEmitEmptyLine) {
console.log(`[TerminalProcess] First chunk timeout fired - no output received within 3s for: "${command}"`)
this.emit("line", "") // empty line to show proceed button
didEmitEmptyLine = true
// Also emit a message indicating the command might be running without output
this.emit("line", "[Command is running but producing no output]")
}
}, 3000) // 3 second timeout
for await (let data of stream) {
// Clear the timeout since we received output
if (!receivedFirstChunk) {
clearTimeout(firstChunkTimeout)
receivedFirstChunk = true
console.log(`[TerminalProcess] First chunk received for command: "${command}"`)
}
// Log raw data length
console.log(`[TerminalProcess] Raw data chunk received: ${data.length} chars`)
if (!data || data.trim() === "") {
console.log(`[TerminalProcess] WARNING: Received empty or whitespace-only chunk`)
}
// 1. Process chunk and remove artifacts
if (isFirstChunk) {
/*
@@ -176,14 +240,79 @@ export class TerminalProcess extends EventEmitter<TerminalProcessEvents> {
this.emitRemainingBufferIfListening()
// Clean up the first chunk timeout if it's still active
if (!receivedFirstChunk) {
clearTimeout(firstChunkTimeout)
console.log(`[TerminalProcess] WARNING: Stream ended without receiving any chunks for command: "${command}"`)
// If we never received any chunks and haven't emitted anything yet, emit now
if (!didEmitEmptyLine) {
console.log(`[TerminalProcess] Emitting fallback empty line for no-output command`)
this.emit("line", "") // empty line to show proceed button
this.emit("line", "[Command completed with no output]")
didEmitEmptyLine = true
}
}
// for now we don't want this delaying requests since we don't send diagnostics automatically anymore (previous: "even though the command is finished, we still want to consider it 'hot' in case so that api request stalls to let diagnostics catch up")
if (this.hotTimer) {
clearTimeout(this.hotTimer)
}
this.isHot = false
this.emit("completed")
this.emit("continue")
console.log(`[TerminalProcess] Stream ended for command: "${command}"`)
console.log(`[TerminalProcess] Final output length: ${this.fullOutput.length} characters`)
// Check if this looks like a command that completed vs one that's still running
const quickCommands = ["cd ", "pwd", "ls ", "echo ", "mkdir ", "touch ", "rm ", "cp ", "mv "]
const isQuickCommand = quickCommands.some((cmd) => command.startsWith(cmd) || command.includes(" && " + cmd))
// Check if output suggests a long-running process
const longRunningIndicators = [
"listening on",
"server running",
"started on",
"watching for",
"compiled successfully",
"webpack",
"vite",
"nodemon",
"dev server",
"press ctrl",
"to quit",
"to exit",
"to stop",
]
const hasLongRunningOutput = longRunningIndicators.some((indicator) =>
this.fullOutput.toLowerCase().includes(indicator),
)
// Check if this is likely a command that starts a server or long-running process
const longRunningCommands = ["npm run", "npm start", "yarn", "node ", "python ", "serve", "dev", "watch"]
const isLongRunningCommand = longRunningCommands.some((cmd) => command.includes(cmd))
if (this.fullOutput.length === 0) {
console.log(`[TerminalProcess] WARNING: Process completed but no output was captured`)
// Ensure we emit at least one line for UI feedback
if (!didEmitEmptyLine) {
this.emit("line", "[Command completed silently]")
}
}
// Only skip grace period for truly quick commands that have no output or are known to complete instantly
if ((this.fullOutput.length === 0 || isQuickCommand) && !isLongRunningCommand && !hasLongRunningOutput) {
console.log(`[TerminalProcess] Command appears to have completed immediately, skipping grace period`)
this.emit("completed")
this.emit("continue")
} else {
console.log(
`[TerminalProcess] Command may still be running (longRunningCommand: ${isLongRunningCommand}, longRunningOutput: ${hasLongRunningOutput})`,
)
console.log(`[TerminalProcess] Starting grace period to detect true completion...`)
// Start grace period - wait 2.5 seconds to see if more output comes
// This prevents premature "proceed while running" for commands that clearly finished
this.startGracePeriod()
}
} else {
terminal.sendText(command, true)
// For terminals without shell integration, we can't know when the command completes
@@ -202,14 +331,24 @@ export class TerminalProcess extends EventEmitter<TerminalProcessEvents> {
private emitIfEol(chunk: string) {
this.buffer += chunk
let lineEndIndex: number
let lineCount = 0
while ((lineEndIndex = this.buffer.indexOf("\n")) !== -1) {
let line = this.buffer.slice(0, lineEndIndex).trimEnd() // removes trailing \r
// Remove \r if present (for Windows-style line endings)
// if (line.endsWith("\r")) {
// line = line.slice(0, -1)
// }
if (!line || line.trim() === "") {
console.log(`[TerminalProcess] Emitting empty line`)
} else {
console.log(`[TerminalProcess] Emitting line: ${line.substring(0, 100)}${line.length > 100 ? "..." : ""}`)
}
this.emit("line", line)
this.buffer = this.buffer.slice(lineEndIndex + 1)
lineCount++
}
if (lineCount === 0 && chunk.length > 0) {
console.log(`[TerminalProcess] Buffering partial line, buffer size: ${this.buffer.length}`)
}
}
@@ -224,7 +363,41 @@ export class TerminalProcess extends EventEmitter<TerminalProcessEvents> {
}
}
private startGracePeriod() {
// Clear any existing grace period timer
if (this.gracePeriodTimer) {
clearTimeout(this.gracePeriodTimer)
}
// Emit completed event for UI to show "proceed while running" button
console.log(`[TerminalProcess] Emitting completed event for UI (grace period active)`)
this.emit("completed")
// Wait 2.5 seconds to see if the command is truly finished
this.gracePeriodTimer = setTimeout(() => {
// Double-check the timer hasn't been cleared
if (this.gracePeriodTimer && !this.hasEmittedCompleted) {
console.log(`[TerminalProcess] Grace period completed - command appears truly finished: "${this.command}"`)
console.log(`[TerminalProcess] Auto-continuing without user intervention`)
this.hasEmittedCompleted = true
this.gracePeriodTimer = null
// Only emit continue after the grace period, not immediately
this.emit("continue")
}
}, 2500) // 2.5 second grace period
}
continue() {
console.log(`[TerminalProcess] Manual continue() called for: "${this.command}"`)
// Clear grace period since user manually continued
if (this.gracePeriodTimer) {
console.log(`[TerminalProcess] Clearing grace period timer due to manual continue`)
clearTimeout(this.gracePeriodTimer)
this.gracePeriodTimer = null
}
this.hasEmittedCompleted = true
this.emitRemainingBufferIfListening()
this.isListening = false
this.removeAllListeners("line")
+14 -3
View File
@@ -5,6 +5,8 @@ export interface TerminalInfo {
busy: boolean
lastCommand: string
id: number
shellPath?: string
lastActive: number
pendingCwdChange?: string
cwdResolved?: {
resolve: () => void
@@ -18,17 +20,26 @@ export class TerminalRegistry {
private static terminals: TerminalInfo[] = []
private static nextTerminalId = 1
static createTerminal(cwd?: string | vscode.Uri | undefined): TerminalInfo {
const terminal = vscode.window.createTerminal({
static createTerminal(cwd?: string | vscode.Uri | undefined, shellPath?: string): TerminalInfo {
const terminalOptions: vscode.TerminalOptions = {
cwd,
name: "Cline",
iconPath: new vscode.ThemeIcon("robot"),
})
}
// If a specific shell path is provided, use it
if (shellPath) {
terminalOptions.shellPath = shellPath
}
const terminal = vscode.window.createTerminal(terminalOptions)
const newInfo: TerminalInfo = {
terminal,
busy: false,
lastCommand: "",
id: this.nextTerminalId++,
shellPath,
lastActive: Date.now(),
}
this.terminals.push(newInfo)
return newInfo
+56 -30
View File
@@ -1,55 +1,81 @@
import { globby, Options } from "globby"
import os from "os"
import * as os from "os"
import * as path from "path"
import { arePathsEqual } from "@utils/path"
export async function listFiles(dirPath: string, recursive: boolean, limit: number): Promise<[string[], boolean]> {
// First resolve the path normally - path.resolve doesn't care about glob special characters
const absolutePath = path.resolve(dirPath)
// Do not allow listing files in root or home directory, which cline tends to want to do when the user's prompt is vague.
// Constants
const DEFAULT_IGNORE_DIRECTORIES = [
"node_modules",
"__pycache__",
"env",
"venv",
"target/dependency",
"build/dependencies",
"dist",
"out",
"bundle",
"vendor",
"tmp",
"temp",
"deps",
"pkg",
"Pods",
]
// Helper functions
function isRestrictedPath(absolutePath: string): boolean {
const root = process.platform === "win32" ? path.parse(absolutePath).root : "/"
const isRoot = arePathsEqual(absolutePath, root)
if (isRoot) {
return [[root], false]
return true
}
const homeDir = os.homedir()
const isHomeDir = arePathsEqual(absolutePath, homeDir)
if (isHomeDir) {
return [[homeDir], false]
return true
}
const dirsToIgnore = [
"node_modules",
"__pycache__",
"env",
"venv",
"target/dependency",
"build/dependencies",
"dist",
"out",
"bundle",
"vendor",
"tmp",
"temp",
"deps",
"pkg",
"Pods",
".*", // '!**/.*' excludes hidden directories, while '!**/.*/**' excludes only their contents. This way we are at least aware of the existence of hidden directories.
].map((dir) => `**/${dir}/**`)
return false
}
function isTargetingHiddenDirectory(absolutePath: string): boolean {
const dirName = path.basename(absolutePath)
return dirName.startsWith(".")
}
function buildIgnorePatterns(absolutePath: string): string[] {
const isTargetHidden = isTargetingHiddenDirectory(absolutePath)
const patterns = [...DEFAULT_IGNORE_DIRECTORIES]
// Only ignore hidden directories if we're not explicitly targeting a hidden directory
if (!isTargetHidden) {
patterns.push(".*")
}
return patterns.map((dir) => `**/${dir}/**`)
}
export async function listFiles(dirPath: string, recursive: boolean, limit: number): Promise<[string[], boolean]> {
const absolutePath = path.resolve(dirPath)
// Do not allow listing files in root or home directory
if (isRestrictedPath(absolutePath)) {
return [[], false]
}
const options: Options = {
cwd: dirPath,
dot: true, // do not ignore hidden files/directories
absolute: true,
markDirectories: true, // Append a / on any directories matched (/ is used on windows as well, so dont use path.sep)
markDirectories: true, // Append a / on any directories matched
gitignore: recursive, // globby ignores any files that are gitignored
ignore: recursive ? dirsToIgnore : undefined, // just in case there is no gitignore, we ignore sensible defaults
onlyFiles: false, // true by default, false means it will list directories on their own too
ignore: recursive ? buildIgnorePatterns(absolutePath) : undefined,
onlyFiles: false, // include directories in results
suppressErrors: true,
}
// * globs all files in one dir, ** globs files in nested directories
// For non-recursive listing, we still use a simple pattern
const filePaths = recursive ? await globbyLevelByLevel(limit, options) : (await globby("*", options)).slice(0, limit)
return [filePaths, filePaths.length >= limit]
+2 -15
View File
@@ -1,5 +1,5 @@
import { Client } from "@modelcontextprotocol/sdk/client/index.js"
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"
import { StdioClientTransport, getDefaultEnvironment } from "@modelcontextprotocol/sdk/client/stdio.js"
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"
import ReconnectingEventSource from "reconnecting-eventsource"
@@ -153,7 +153,6 @@ export class McpHub {
const settings = await this.readAndValidateMcpSettingsFile()
if (settings) {
try {
vscode.window.showInformationMessage("Updating MCP servers...")
await this.updateServerConnections(settings.mcpServers)
vscode.window.showInformationMessage("MCP servers updated")
} catch (error) {
@@ -216,8 +215,8 @@ export class McpHub {
cwd: config.cwd,
env: {
// ...(config.env ? await injectEnv(config.env) : {}), // Commented out as injectEnv is not found
...getDefaultEnvironment(),
...(config.env || {}), // Use config.env directly or an empty object
...(process.env.PATH ? { PATH: process.env.PATH } : {}),
},
stderr: "pipe",
})
@@ -384,18 +383,6 @@ export class McpHub {
})
}
// Also show as VS Code notification for now (can be removed later if desired)
switch (level) {
case "error":
vscode.window.showErrorMessage(`MCP ${name}: ${message}`)
break
case "warning":
vscode.window.showWarningMessage(`MCP ${name}: ${message}`)
break
default:
vscode.window.showInformationMessage(`MCP ${name}: ${message}`)
}
// Forward to webview if available
if (this.postMessageToWebview) {
await this.postMessageToWebview({
+48 -26
View File
@@ -16,49 +16,71 @@ const createServerTypeSchema = () => {
// Stdio config (has command field)
BaseConfigSchema.extend({
type: z.literal("stdio").optional(),
transportType: z.string().optional(), // Support legacy field
command: z.string(),
args: z.array(z.string()).optional(),
cwd: z.string().optional(),
env: z.record(z.string()).optional(),
// Explicitly disallow other types' fields
url: z.undefined().optional(),
headers: z.undefined().optional(),
// Allow other fields for backward compatibility
url: z.string().optional(),
headers: z.record(z.string()).optional(),
})
.transform((data) => ({
...data,
type: "stdio" as const,
}))
.refine((data) => data.type === undefined || data.type === "stdio", { message: TYPE_ERROR_MESSAGE }),
.transform((data) => {
// Support both type and transportType fields
const finalType = data.type || (data.transportType === "stdio" ? "stdio" : undefined) || "stdio"
return {
...data,
type: finalType as "stdio",
// Remove the legacy field after transformation
transportType: undefined,
}
})
.refine((data) => data.type === "stdio", { message: TYPE_ERROR_MESSAGE }),
// SSE config (has url field)
BaseConfigSchema.extend({
type: z.literal("sse").optional(),
transportType: z.string().optional(), // Support legacy field
url: z.string().url("URL must be a valid URL format"),
headers: z.record(z.string()).optional(),
// Explicitly disallow other types' fields
command: z.undefined().optional(),
args: z.undefined().optional(),
env: z.undefined().optional(),
// Allow other fields for backward compatibility
command: z.string().optional(),
args: z.array(z.string()).optional(),
env: z.record(z.string()).optional(),
})
.transform((data) => ({
...data,
type: "sse" as const,
}))
.refine((data) => data.type === undefined || data.type === "sse", { message: TYPE_ERROR_MESSAGE }),
.transform((data) => {
// Support both type and transportType fields
const finalType = data.type || (data.transportType === "sse" ? "sse" : undefined) || "sse"
return {
...data,
type: finalType as "sse",
// Remove the legacy field after transformation
transportType: undefined,
}
})
.refine((data) => data.type === "sse", { message: TYPE_ERROR_MESSAGE }),
// Streamable HTTP config (has url field)
BaseConfigSchema.extend({
type: z.literal("streamableHttp").optional(),
transportType: z.string().optional(), // Support legacy field
url: z.string().url("URL must be a valid URL format"),
headers: z.record(z.string()).optional(),
// Explicitly disallow other types' fields
command: z.undefined().optional(),
args: z.undefined().optional(),
env: z.undefined().optional(),
// Allow other fields for backward compatibility
command: z.string().optional(),
args: z.array(z.string()).optional(),
env: z.record(z.string()).optional(),
})
.transform((data) => ({
...data,
type: "streamableHttp" as const,
}))
.refine((data) => data.type === undefined || data.type === "streamableHttp", {
.transform((data) => {
// Support both type and transportType fields
// Note: legacy transportType was "http" not "streamableHttp"
const finalType = data.type || (data.transportType === "http" ? "streamableHttp" : undefined) || "streamableHttp"
return {
...data,
type: finalType as "streamableHttp",
// Remove the legacy field after transformation
transportType: undefined,
}
})
.refine((data) => data.type === "streamableHttp", {
message: TYPE_ERROR_MESSAGE,
}),
])
+4 -5
View File
@@ -17,15 +17,13 @@ export interface ExtensionMessage {
| "action"
| "state"
| "selectedImages"
| "openAiModels"
| "requestyModels"
| "mcpDownloadDetails"
| "userCreditsBalance"
| "userCreditsUsage"
| "userCreditsPayments"
| "grpc_response" // New type for gRPC responses
text?: string
action?: "didBecomeVisible" | "accountLogoutClicked"
action?: "accountLogoutClicked"
state?: ExtensionState
images?: string[]
files?: string[]
@@ -33,7 +31,6 @@ export interface ExtensionMessage {
lmStudioModels?: string[]
vsCodeLmModels?: { vendor?: string; family?: string; version?: string; id?: string }[]
openAiModels?: string[]
requestyModels?: Record<string, ModelInfo>
mcpServers?: McpServer[]
customToken?: string
mcpMarketplaceCatalog?: McpMarketplaceCatalog
@@ -81,8 +78,8 @@ export interface ExtensionState {
checkpointTrackerErrorMessage?: string
clineMessages: ClineMessage[]
currentTaskItem?: HistoryItem
customInstructions?: string
mcpMarketplaceEnabled?: boolean
mcpRichDisplayEnabled: boolean
planActSeparateModelsSetting: boolean
enableCheckpointsSetting?: boolean
platform: Platform
@@ -91,6 +88,8 @@ export interface ExtensionState {
telemetrySetting: TelemetrySetting
shellIntegrationTimeout: number
terminalReuseEnabled?: boolean
terminalOutputLineLimit: number
defaultTerminalProfile?: string
uriScheme?: string
userInfo?: {
displayName: string | null
+2 -1
View File
@@ -45,7 +45,7 @@ export interface WebviewMessage {
mcpMarketplaceEnabled?: boolean
mcpResponsesCollapsed?: boolean
telemetrySetting?: TelemetrySetting
customInstructionsSetting?: string
mcpRichDisplayEnabled?: boolean
mentionsRequestId?: string
query?: string
// For toggleFavoriteModel
@@ -70,6 +70,7 @@ export interface WebviewMessage {
offset?: number
shellIntegrationTimeout?: number
terminalReuseEnabled?: boolean
defaultTerminalProfile?: string
}
export type ClineAskResponse = "yesButtonClicked" | "noButtonClicked" | "messageResponse"
+148
View File
@@ -2,6 +2,7 @@ import type { LanguageModelChatSelector } from "../api/providers/types"
export type ApiProvider =
| "anthropic"
| "claude-code"
| "openrouter"
| "bedrock"
| "vertex"
@@ -25,6 +26,7 @@ export type ApiProvider =
| "xai"
| "sambanova"
| "cerebras"
| "sapaicore"
export interface ApiHandlerOptions {
apiModelId?: string
@@ -53,6 +55,7 @@ export interface ApiHandlerOptions {
awsBedrockEndpoint?: string
awsBedrockCustomSelected?: boolean
awsBedrockCustomModelBaseId?: BedrockModelId
claudeCodePath?: string
vertexProjectId?: string
vertexRegion?: string
openAiBaseUrl?: string
@@ -92,6 +95,12 @@ export interface ApiHandlerOptions {
sambanovaApiKey?: string
cerebrasApiKey?: string
requestTimeoutMs?: number
sapAiCoreClientId?: string
sapAiCoreClientSecret?: string
sapAiResourceGroup?: string
sapAiCoreTokenUrl?: string
sapAiCoreBaseUrl?: string
sapAiCoreModelId?: string
onRetryAttempt?: (attempt: number, maxRetries: number, delay: number, error: any) => void
}
@@ -217,6 +226,17 @@ export const anthropicModels = {
},
} as const satisfies Record<string, ModelInfo> // as const assertion makes the object deeply readonly
// Claude Code
export type ClaudeCodeModelId = keyof typeof claudeCodeModels
export const claudeCodeDefaultModelId: ClaudeCodeModelId = "claude-sonnet-4-20250514"
export const claudeCodeModels = {
"claude-sonnet-4-20250514": anthropicModels["claude-sonnet-4-20250514"],
"claude-opus-4-20250514": anthropicModels["claude-opus-4-20250514"],
"claude-3-7-sonnet-20250219": anthropicModels["claude-3-7-sonnet-20250219"],
"claude-3-5-sonnet-20241022": anthropicModels["claude-3-5-sonnet-20241022"],
"claude-3-5-haiku-20241022": anthropicModels["claude-3-5-haiku-20241022"],
} as const satisfies Record<string, ModelInfo>
// AWS Bedrock
// https://docs.aws.amazon.com/bedrock/latest/userguide/conversation-inference.html
export type BedrockModelId = keyof typeof bedrockModels
@@ -598,6 +618,9 @@ export const vertexModels = {
cacheReadsPrice: 0.625,
},
],
thinkingConfig: {
maxBudget: 32768,
},
},
"gemini-2.5-flash-preview-04-17": {
maxTokens: 65536,
@@ -766,6 +789,9 @@ export const geminiModels = {
cacheReadsPrice: 0.625,
},
],
thinkingConfig: {
maxBudget: 32768,
},
},
"gemini-2.5-flash-preview-05-20": {
maxTokens: 65536,
@@ -2401,3 +2427,125 @@ export const requestyDefaultModelInfo: ModelInfo = {
cacheReadsPrice: 0.3,
description: "Anthropic's most intelligent model. Highest level of intelligence and capability.",
}
// SAP AI Core
export type SapAiCoreModelId = keyof typeof sapAiCoreModels
export const sapAiCoreDefaultModelId: SapAiCoreModelId = "anthropic--claude-3.5-sonnet"
export const sapAiCoreModels = {
"anthropic--claude-3.7-sonnet": {
maxTokens: 64_000,
contextWindow: 200_000,
supportsImages: true,
supportsPromptCache: false,
inputPrice: 3.0,
outputPrice: 15.0,
},
"anthropic--claude-3.5-sonnet": {
maxTokens: 8192,
contextWindow: 200_000,
supportsImages: true,
supportsPromptCache: false,
inputPrice: 3.0,
outputPrice: 15.0,
},
"anthropic--claude-3-sonnet": {
maxTokens: 4096,
contextWindow: 200_000,
supportsImages: true,
supportsPromptCache: false,
inputPrice: 3.0,
outputPrice: 15.0,
},
"anthropic--claude-3-haiku": {
maxTokens: 4096,
contextWindow: 200_000,
supportsImages: true,
supportsPromptCache: false,
inputPrice: 3.0,
outputPrice: 15.0,
},
"anthropic--claude-3-opus": {
maxTokens: 4096,
contextWindow: 200_000,
supportsImages: true,
supportsPromptCache: false,
inputPrice: 3.0,
outputPrice: 15.0,
},
"gpt-4o": {
maxTokens: 4096,
contextWindow: 200_000,
supportsImages: true,
supportsPromptCache: false,
inputPrice: 3.0,
outputPrice: 15.0,
},
"gpt-4o-mini": {
maxTokens: 4096,
contextWindow: 200_000,
supportsImages: true,
supportsPromptCache: false,
inputPrice: 3.0,
outputPrice: 15.0,
},
"gpt-4": {
maxTokens: 4096,
contextWindow: 200_000,
supportsImages: true,
supportsPromptCache: false,
inputPrice: 3.0,
outputPrice: 15.0,
},
o1: {
maxTokens: 4096,
contextWindow: 200_000,
supportsImages: true,
supportsPromptCache: false,
inputPrice: 3.0,
outputPrice: 15.0,
},
"o3-mini": {
maxTokens: 4096,
contextWindow: 200_000,
supportsImages: true,
supportsPromptCache: false,
inputPrice: 3.0,
outputPrice: 15.0,
},
"gpt-4.1": {
maxTokens: 32_768,
contextWindow: 1_047_576,
supportsImages: true,
supportsPromptCache: true,
inputPrice: 2,
outputPrice: 8,
cacheReadsPrice: 0.5,
},
"gpt-4.1-nano": {
maxTokens: 32_768,
contextWindow: 1_047_576,
supportsImages: true,
supportsPromptCache: true,
inputPrice: 0.1,
outputPrice: 0.4,
cacheReadsPrice: 0.025,
},
o3: {
maxTokens: 100_000,
contextWindow: 200_000,
supportsImages: true,
supportsPromptCache: true,
inputPrice: 10.0,
outputPrice: 40.0,
cacheReadsPrice: 2.5,
},
"o4-mini": {
maxTokens: 100_000,
contextWindow: 200_000,
supportsImages: true,
supportsPromptCache: true,
inputPrice: 1.1,
outputPrice: 4.4,
cacheReadsPrice: 0.275,
},
} as const satisfies Record<string, ModelInfo>
@@ -234,6 +234,10 @@ function convertApiProviderToProto(provider: string | undefined): ProtoApiProvid
return ProtoApiProvider.SAMBANOVA
case "cerebras":
return ProtoApiProvider.CEREBRAS
case "sapaicore":
return ProtoApiProvider.SAPAICORE
case "claude-code":
return ProtoApiProvider.CLAUDE_CODE
default:
return ProtoApiProvider.ANTHROPIC
}
@@ -290,6 +294,10 @@ function convertProtoToApiProvider(provider: ProtoApiProvider): ApiProvider {
return "sambanova"
case ProtoApiProvider.CEREBRAS:
return "cerebras"
case ProtoApiProvider.SAPAICORE:
return "sapaicore"
case ProtoApiProvider.CLAUDE_CODE:
return "claude-code"
default:
return "anthropic"
}
@@ -365,6 +373,12 @@ export function convertApiConfigurationToProto(config: ApiConfiguration): ProtoA
requestTimeoutMs: config.requestTimeoutMs,
apiProvider: config.apiProvider ? convertApiProviderToProto(config.apiProvider) : undefined,
favoritedModelIds: config.favoritedModelIds || [],
sapAiCoreClientId: config.sapAiCoreClientId,
sapAiCoreClientSecret: config.sapAiCoreClientSecret,
sapAiResourceGroup: config.sapAiResourceGroup,
sapAiCoreTokenUrl: config.sapAiCoreTokenUrl,
sapAiCoreBaseUrl: config.sapAiCoreBaseUrl,
claudeCodePath: config.claudeCodePath,
}
}
@@ -438,5 +452,11 @@ export function convertProtoToApiConfiguration(protoConfig: ProtoApiConfiguratio
requestTimeoutMs: protoConfig.requestTimeoutMs,
apiProvider: protoConfig.apiProvider !== undefined ? convertProtoToApiProvider(protoConfig.apiProvider) : undefined,
favoritedModelIds: protoConfig.favoritedModelIds.length > 0 ? protoConfig.favoritedModelIds : undefined,
sapAiCoreClientId: protoConfig.sapAiCoreClientId,
sapAiCoreClientSecret: protoConfig.sapAiCoreClientSecret,
sapAiResourceGroup: protoConfig.sapAiResourceGroup,
sapAiCoreTokenUrl: protoConfig.sapAiCoreTokenUrl,
sapAiCoreBaseUrl: protoConfig.sapAiCoreBaseUrl,
claudeCodePath: protoConfig.claudeCodePath,
}
}
@@ -102,6 +102,13 @@ export function convertApiConfigurationToProtoApiConfiguration(config: ApiConfig
// OpenRouter specific
openrouterProviderSorting: config.openRouterProviderSorting,
// SAP AI Core specific
sapAiCoreClientId: config.sapAiCoreClientId,
sapAiCoreClientSecret: config.sapAiCoreClientSecret,
sapAiCoreBaseUrl: config.sapAiCoreBaseUrl,
sapAiCoreTokenUrl: config.sapAiCoreTokenUrl,
sapAiResourceGroup: config.sapAiResourceGroup,
// Complex objects stored as JSON strings
vscodeLmModelSelector: config.vsCodeLmModelSelector ? JSON.stringify(config.vsCodeLmModelSelector) : undefined,
openrouterModelInfo: config.openRouterModelInfo ? JSON.stringify(config.openRouterModelInfo) : undefined,
@@ -110,6 +117,9 @@ export function convertApiConfigurationToProtoApiConfiguration(config: ApiConfig
litellmModelInfo: config.liteLlmModelInfo ? JSON.stringify(config.liteLlmModelInfo) : undefined,
openaiHeaders: config.openAiHeaders ? JSON.stringify(config.openAiHeaders) : undefined,
// Claude Code specific
claudeCodePath: config.claudeCodePath,
// Arrays
favoritedModelIds: config.favoritedModelIds || [],
})
@@ -208,6 +218,16 @@ export function convertProtoApiConfigurationToApiConfiguration(protoConfig: Prot
// OpenRouter specific
openRouterProviderSorting: protoConfig.openrouterProviderSorting,
// SAP AI Core specific
sapAiCoreClientId: protoConfig.sapAiCoreClientId,
sapAiCoreClientSecret: protoConfig.sapAiCoreClientSecret,
sapAiCoreBaseUrl: protoConfig.sapAiCoreBaseUrl,
sapAiCoreTokenUrl: protoConfig.sapAiCoreTokenUrl,
sapAiResourceGroup: protoConfig.sapAiResourceGroup,
// Claude Code specific
claudeCodePath: protoConfig.claudeCodePath,
// Arrays
favoritedModelIds: protoConfig.favoritedModelIds || [],
}
@@ -0,0 +1,26 @@
import { Channel, createChannel } from "nice-grpc"
import { UriServiceClientImpl, WatchServiceClientImpl } from "@generated/standalone/host-bridge-clients"
import { UriServiceClientInterface, WatchServiceClientInterface } from "@generated/hosts/host-bridge-client-types"
import { HostBridgeClientProvider } from "@/hosts/host-bridge-client"
/**
* Singleton class to hold the gRPC clients for the host bridge. The clients should be re-used to avoid
* creating a new TCP connection every time a rpc is made.
*/
export class ExternalHostBridgeClientManager implements HostBridgeClientProvider {
private channel: Channel
UriServiceClient: UriServiceClientInterface
WatchServiceClient: WatchServiceClientInterface
constructor() {
const address = process.env.HOST_BRIDGE_ADDRESS || "localhost:50052"
this.channel = createChannel(address)
this.UriServiceClient = new UriServiceClientImpl(this.channel)
this.WatchServiceClient = new WatchServiceClientImpl(this.channel)
}
public close(): void {
this.channel.close()
}
}
+12 -14
View File
@@ -5,14 +5,16 @@ import * as health from "grpc-health-check"
import { activate } from "../extension"
import { Controller } from "../core/controller"
import { extensionContext, outputChannel, postMessage } from "./vscode-context"
import { packageDefinition, proto, log, camelToSnakeCase, snakeToCamelCase } from "./utils"
import { getPackageDefinition, log } from "./utils"
import { GrpcHandler, GrpcStreamingResponseHandler } from "./grpc-types"
import { addServices } from "./server-setup"
import { addProtobusServices } from "@generated/standalone/server-setup"
import { StreamingResponseHandler } from "@/core/controller/grpc-handler"
import { initializeHostBridgeClient, StreamingCallbacks, UriServiceClient, WatchServiceClient } from "@/hosts/host-bridge-client"
import { ExternalHostBridgeClientManager } from "./host-bridge-client-manager"
function main() {
async function main() {
log("Starting service...")
initializeHostBridgeClient(new ExternalHostBridgeClientManager())
activate(extensionContext)
const controller = new Controller(extensionContext, outputChannel, postMessage)
const server = new grpc.Server()
@@ -22,10 +24,10 @@ function main() {
healthImpl.addToServer(server)
// Add all the handlers for the ProtoBus services to the server.
addServices(server, proto, controller, wrapHandler, wrapStreamingResponseHandler)
addProtobusServices(server, controller, wrapHandler, wrapStreamingResponseHandler)
// Set up reflection.
const reflection = new ReflectionService(packageDefinition)
const reflection = new ReflectionService(getPackageDefinition())
reflection.addToServer(server)
// Start the server.
@@ -58,10 +60,8 @@ function wrapHandler<TRequest, TResponse>(
return async (call: grpc.ServerUnaryCall<TRequest, TResponse>, callback: grpc.sendUnaryData<TResponse>) => {
try {
log(`gRPC request: ${call.getPath()}`)
const result = await handler(controller, snakeToCamelCase(call.request))
// The grpc-js serializer expects the proto message to be in the same
// case as the proto file. This is a work around until we find a solution.
callback(null, camelToSnakeCase(result))
const result = await handler(controller, call.request)
callback(null, result)
} catch (err: any) {
log(`gRPC handler error: ${call.getPath()}\n${err.stack}`)
callback({
@@ -83,9 +83,7 @@ function wrapStreamingResponseHandler<TRequest, TResponse>(
const responseHandler: StreamingResponseHandler = (response, isLast, sequenceNumber) => {
try {
// The grpc-js serializer expects the proto message to be in the same
// case as the proto file. This is a work around until we find a solution.
call.write(camelToSnakeCase(response)) // Use a bound version of call.write to maintain proper 'this' context
call.write(response) // Use a bound version of call.write to maintain proper 'this' context
if (isLast === true) {
log(`Closing stream for ${requestId}`)
@@ -96,7 +94,7 @@ function wrapStreamingResponseHandler<TRequest, TResponse>(
return Promise.reject(error)
}
}
await handler(controller, snakeToCamelCase(call.request), responseHandler, requestId)
await handler(controller, call.request, responseHandler, requestId)
} catch (err: any) {
log(`gRPC handler error: ${call.getPath()}\n${err.stack}`)
call.destroy({
+29 -51
View File
@@ -1,65 +1,43 @@
import * as fs from "fs"
import * as grpc from "@grpc/grpc-js"
import * as protoLoader from "@grpc/proto-loader"
import * as health from "grpc-health-check"
import { StreamingCallbacks } from "@hosts/host-bridge-client"
const log = (...args: unknown[]) => {
const timestamp = new Date().toISOString()
console.log(`[${timestamp}]`, "#bot.cline.server.ts", ...args)
}
// Load service definitions.
const descriptorSet = fs.readFileSync("proto/descriptor_set.pb")
const clineDef = protoLoader.loadFileDescriptorSetFromBuffer(descriptorSet)
const healthDef = protoLoader.loadSync(health.protoPath)
const packageDefinition = { ...clineDef, ...healthDef }
const proto = grpc.loadPackageDefinition(packageDefinition) as unknown
// Helper function to convert camelCase to snake_case
function camelToSnakeCase(obj: any): any {
if (obj === null || typeof obj !== "object") {
return obj
}
if (Array.isArray(obj)) {
return obj.map(camelToSnakeCase)
}
return Object.keys(obj).reduce((acc: any, key: string) => {
// Convert key from camelCase to snake_case
const snakeKey = key
.replace(/([A-Z])/g, "_$1")
.replace(/^_+/, "")
.toLowerCase()
// Convert value recursively if it's an object
const value = obj[key]
acc[snakeKey] = camelToSnakeCase(value)
return acc
}, {})
function getPackageDefinition() {
// Load service definitions.
const descriptorSet = fs.readFileSync("proto/descriptor_set.pb")
const clineDef = protoLoader.loadFileDescriptorSetFromBuffer(descriptorSet)
const healthDef = protoLoader.loadSync(health.protoPath)
const packageDefinition = { ...clineDef, ...healthDef }
return packageDefinition
}
// Helper function to convert snake_case to camelCase
function snakeToCamelCase(obj: any): any {
if (obj === null || typeof obj !== "object") {
return obj
/**
* Converts an AsyncIterable to a callback-based API
* @param stream The AsyncIterable stream to process
* @param callbacks The callbacks to invoke for stream events
*/
async function asyncIteratorToCallbacks<T>(stream: AsyncIterable<T>, callbacks: StreamingCallbacks<T>): Promise<void> {
try {
// Process each item in the stream
for await (const response of stream) {
callbacks.onResponse && callbacks.onResponse(response)
}
// Stream completed successfully
callbacks.onComplete && callbacks.onComplete()
} catch (err) {
const error = err instanceof Error ? err : new Error(String(err))
if (callbacks.onError) {
callbacks.onError(error)
} else {
log(`Host bridge RPC error: ${error}`)
}
}
if (Array.isArray(obj)) {
return obj.map(snakeToCamelCase)
}
return Object.keys(obj).reduce((acc: any, key: string) => {
// Convert key from snake_case to camelCase
const camelKey = key.replace(/_([a-z0-9])/g, (_, char) => char.toUpperCase())
// Convert value recursively if it's an object
const value = obj[key]
acc[camelKey] = snakeToCamelCase(value)
return acc
}, {})
}
export { packageDefinition, proto, log, camelToSnakeCase, snakeToCamelCase }
export { getPackageDefinition, log, asyncIteratorToCallbacks }
+107 -1
View File
@@ -179,7 +179,113 @@ function getShellFromEnv(): string | null {
}
// -----------------------------------------------------
// 4) Publicly Exposed Shell Getter
// 4) Terminal Profile Interface and Utilities
// -----------------------------------------------------
import { TerminalProfile } from "../shared/proto/state"
/** Gets available terminal profiles for the current platform */
export function getAvailableTerminalProfiles(): TerminalProfile[] {
const profiles: TerminalProfile[] = [
{
id: "default",
name: "Default",
description: "Use VSCode's default terminal configuration",
},
]
if (process.platform === "win32") {
// Windows terminal profiles
profiles.push(
{
id: "powershell-7",
name: "PowerShell 7",
path: SHELL_PATHS.POWERSHELL_7,
description: "PowerShell 7 (pwsh.exe)",
},
{
id: "powershell-legacy",
name: "Windows PowerShell",
path: SHELL_PATHS.POWERSHELL_LEGACY,
description: "Windows PowerShell 5.x",
},
{
id: "cmd",
name: "Command Prompt",
path: SHELL_PATHS.CMD,
description: "Command Prompt (cmd.exe)",
},
{
id: "wsl-bash",
name: "WSL Bash",
path: SHELL_PATHS.WSL_BASH,
description: "Windows Subsystem for Linux Bash",
},
)
} else if (process.platform === "darwin") {
// macOS terminal profiles
profiles.push(
{
id: "zsh",
name: "zsh",
path: SHELL_PATHS.ZSH,
description: "Z shell (default on macOS)",
},
{
id: "bash",
name: "bash",
path: SHELL_PATHS.BASH,
description: "Bourne Again Shell",
},
)
} else if (process.platform === "linux") {
// Linux terminal profiles
profiles.push(
{
id: "bash",
name: "bash",
path: SHELL_PATHS.BASH,
description: "Bourne Again Shell (default on most Linux)",
},
{
id: "zsh",
name: "zsh",
path: SHELL_PATHS.ZSH,
description: "Z shell",
},
{
id: "dash",
name: "dash",
path: SHELL_PATHS.DASH,
description: "Debian Almquist Shell",
},
)
}
return profiles
}
/** Gets the shell path for a specific terminal profile */
export function getShellForProfile(profileId: string): string {
// If it's the default profile, use the existing getShell() logic
if (profileId === "default") {
return getShell()
}
// Find the profile
const profiles = getAvailableTerminalProfiles()
const profile = profiles.find((p) => p.id === profileId)
if (profile?.path) {
return profile.path
}
// Fallback to default shell if profile not found
return getShell()
}
// -----------------------------------------------------
// 5) Publicly Exposed Shell Getter
// -----------------------------------------------------
export function getShell(): string {
+3 -2
View File
@@ -24,12 +24,13 @@
"@/*": ["src/*"],
"@api/*": ["src/api/*"],
"@core/*": ["src/core/*"],
"@generated/*": ["src/generated/*"],
"@hosts/*": ["src/hosts/*"],
"@integrations/*": ["src/integrations/*"],
"@packages/*": ["src/packages/*"],
"@services/*": ["src/services/*"],
"@shared/*": ["src/shared/*"],
"@utils/*": ["src/utils/*"],
"@packages/*": ["src/packages/*"]
"@utils/*": ["src/utils/*"]
}
},
"include": ["src/**/*", "scripts/**/*"],
+8 -1
View File
@@ -27,7 +27,14 @@
"prefer-const": "off",
"no-extra-semi": "off",
"eslint-rules/no-protobuf-object-literals": "error",
"eslint-rules/no-grpc-client-object-literals": "error"
"eslint-rules/no-grpc-client-object-literals": "error",
"no-restricted-syntax": [
"error",
{
"selector": "VariableDeclarator[id.type=\"ObjectPattern\"][init.object.name=\"process\"][init.property.name=\"env\"]",
"message": "Use process.env.VARIABLE_NAME directly instead of destructuring"
}
]
},
"ignorePatterns": ["build"]
}
+16 -62
View File
@@ -1,6 +1,8 @@
import { VSCodeBadge, VSCodeButton, VSCodeProgressRing } from "@vscode/webview-ui-toolkit/react"
import deepEqual from "fast-deep-equal"
import React, { memo, MouseEvent, useCallback, useEffect, useMemo, useRef, useState } from "react"
import styled from "styled-components"
import { useEvent, useSize } from "react-use"
import CreditLimitError from "@/components/chat/CreditLimitError"
import { OptionsButtons } from "@/components/chat/OptionsButtons"
@@ -9,6 +11,8 @@ import { CheckmarkControl } from "@/components/common/CheckmarkControl"
import CodeBlock, { CODE_BLOCK_BG_COLOR } from "@/components/common/CodeBlock"
import MarkdownBlock from "@/components/common/MarkdownBlock"
import SuccessButton from "@/components/common/SuccessButton"
import { WithCopyButton } from "@/components/common/CopyButton"
import Thumbnails from "@/components/common/Thumbnails"
import McpResponseDisplay from "@/components/mcp/chat-display/McpResponseDisplay"
import McpResourceRow from "@/components/mcp/configuration/tabs/installed/server-row/McpResourceRow"
import McpToolRow from "@/components/mcp/configuration/tabs/installed/server-row/McpToolRow"
@@ -28,76 +32,19 @@ import {
} from "@shared/ExtensionMessage"
import { COMMAND_OUTPUT_STRING, COMMAND_REQ_APP_STRING } from "@shared/combineCommandSequences"
import { Int64Request, StringRequest } from "@shared/proto/common"
import { useEvent, useSize } from "react-use"
import styled from "styled-components"
import { CheckpointControls } from "../common/CheckpointControls"
import CodeAccordian, { cleanPathPrefix } from "../common/CodeAccordian"
import { CheckpointControls } from "../common/CheckpointControls"
import NewTaskPreview from "./NewTaskPreview"
import QuoteButton from "./QuoteButton"
import ReportBugPreview from "./ReportBugPreview"
import UserMessage from "./UserMessage"
interface CopyButtonProps {
textToCopy: string | undefined
}
import QuoteButton from "./QuoteButton"
const normalColor = "var(--vscode-foreground)"
const errorColor = "var(--vscode-errorForeground)"
const successColor = "var(--vscode-charts-green)"
const cancelledColor = "var(--vscode-descriptionForeground)"
const CopyButtonStyled = styled(VSCodeButton)`
position: absolute;
bottom: 2px;
right: 2px;
z-index: 1;
opacity: 0;
`
interface WithCopyButtonProps {
children: React.ReactNode
textToCopy?: string
style?: React.CSSProperties
ref?: React.Ref<HTMLDivElement>
onMouseUp?: (event: MouseEvent<HTMLDivElement>) => void
}
const StyledContainer = styled.div`
position: relative;
&:hover ${CopyButtonStyled} {
opacity: 1;
}
`
const WithCopyButton = React.forwardRef<HTMLDivElement, WithCopyButtonProps>(
({ children, textToCopy, style, onMouseUp, ...props }, ref) => {
const [copied, setCopied] = useState(false)
const handleCopy = () => {
if (!textToCopy) return
navigator.clipboard.writeText(textToCopy).then(() => {
setCopied(true)
setTimeout(() => {
setCopied(false)
}, 1500)
})
}
return (
<StyledContainer ref={ref} onMouseUp={onMouseUp} style={style} {...props}>
{children}
{textToCopy && (
<CopyButtonStyled appearance="icon" onClick={handleCopy} aria-label={copied ? "Copied" : "Copy"}>
<span className={`codicon codicon-${copied ? "check" : "copy"}`}></span>
</CopyButtonStyled>
)}
</StyledContainer>
)
},
)
const ChatRowContainer = styled.div`
padding: 10px 6px 10px 15px;
position: relative;
@@ -1086,7 +1033,11 @@ export const ChatRowContent = ({
)
case "text":
return (
<WithCopyButton ref={contentRef} onMouseUp={handleMouseUp} textToCopy={message.text}>
<WithCopyButton
ref={contentRef}
onMouseUp={handleMouseUp}
textToCopy={message.text}
position="bottom-right">
<Markdown markdown={message.text} />
{quoteButtonState.visible && (
<QuoteButton
@@ -1323,6 +1274,7 @@ export const ChatRowContent = ({
ref={contentRef}
onMouseUp={handleMouseUp}
textToCopy={text}
position="bottom-right"
style={{
color: "var(--vscode-charts-green)",
paddingTop: 10,
@@ -1487,6 +1439,7 @@ export const ChatRowContent = ({
ref={contentRef}
onMouseUp={handleMouseUp}
textToCopy={text}
position="bottom-right"
style={{
color: "var(--vscode-charts-green)",
paddingTop: 10,
@@ -1557,6 +1510,7 @@ export const ChatRowContent = ({
ref={contentRef}
onMouseUp={handleMouseUp}
textToCopy={question}
position="bottom-right"
style={{ paddingTop: 10 }}>
<Markdown markdown={question} />
<OptionsButtons
@@ -1640,7 +1594,7 @@ export const ChatRowContent = ({
response = message.text
}
return (
<WithCopyButton ref={contentRef} onMouseUp={handleMouseUp} textToCopy={response}>
<WithCopyButton ref={contentRef} onMouseUp={handleMouseUp} textToCopy={response} position="bottom-right">
<Markdown markdown={response} />
<OptionsButtons
options={options}
@@ -81,6 +81,7 @@ interface ChatTextAreaProps {
shouldDisableFilesAndImages: boolean
onHeightChange?: (height: number) => void
onFocusChange?: (isFocused: boolean) => void
isTaskView: boolean
}
interface GitCommit {
@@ -268,6 +269,7 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
shouldDisableFilesAndImages,
onHeightChange,
onFocusChange,
isTaskView,
},
ref,
) => {
@@ -1015,6 +1017,9 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
)
// Focus the textarea after mode toggle with slight delay
setTimeout(() => {
if (isTaskView) {
setInputValue("")
}
textAreaRef.current?.focus()
}, 100)
}, changeModeDelay)
@@ -1576,7 +1581,7 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
/>
{!inputValue && selectedImages.length === 0 && selectedFiles.length === 0 && (
<div className="absolute bottom-4 left-[25px] right-[60px] text-[10px] text-[var(--vscode-input-placeholderForeground)] opacity-70 whitespace-nowrap overflow-hidden text-ellipsis pointer-events-none z-[1]">
Type @ for context, / for slash commands & workflows
Type @ for context, / for slash commands & workflows, hold shift to drag in files/images
</div>
)}
{(selectedImages.length > 0 || selectedFiles.length > 0) && (
+9 -49
View File
@@ -132,8 +132,6 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
const [selectedImages, setSelectedImages] = useState<string[]>([])
const [selectedFiles, setSelectedFiles] = useState<string[]>([])
// we need to hold on to the ask because useEffect > lastMessage will always let us know when an ask comes in and handle it, but by the time handleMessage is called, the last message might not be the ask anymore (it could be a say that followed)
const [clineAsk, setClineAsk] = useState<ClineAsk | undefined>(undefined)
const [enableButtons, setEnableButtons] = useState<boolean>(false)
const [primaryButtonText, setPrimaryButtonText] = useState<string | undefined>("Approve")
const [secondaryButtonText, setSecondaryButtonText] = useState<string | undefined>("Reject")
@@ -146,6 +144,14 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
const [isAtBottom, setIsAtBottom] = useState(false)
const [pendingScrollToMessage, setPendingScrollToMessage] = useState<number | null>(null)
// UI layout depends on the last 2 messages
// (since it relies on the content of these messages, we are deep comparing. i.e. the button state after hitting button sets enableButtons to false, and this effect otherwise would have to true again even if messages didn't change
const lastMessage = useMemo(() => messages.at(-1), [messages])
const secondLastMessage = useMemo(() => messages.at(-2), [messages])
// Derive clineAsk directly from lastMessage to avoid race conditions
const clineAsk = useMemo(() => (lastMessage?.type === "ask" ? lastMessage.ask : undefined), [lastMessage])
useEffect(() => {
const handleCopy = async (e: ClipboardEvent) => {
const targetElement = e.target as HTMLElement | null
@@ -232,11 +238,6 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
document.removeEventListener("copy", handleCopy)
}
}, [])
// UI layout depends on the last 2 messages
// (since it relies on the content of these messages, we are deep comparing. i.e. the button state after hitting button sets enableButtons to false, and this effect otherwise would have to true again even if messages didn't change
const lastMessage = useMemo(() => messages.at(-1), [messages])
const secondLastMessage = useMemo(() => messages.at(-2), [messages])
useDeepCompareEffect(() => {
// if last message is an ask, show user ask UI
// if user finished a task, then start a new task with a new conversation history since in this moment that the extension is waiting for user response, the user could close the extension and the conversation history would be lost.
@@ -248,42 +249,36 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
switch (lastMessage.ask) {
case "api_req_failed":
setSendingDisabled(true)
setClineAsk("api_req_failed")
setEnableButtons(true)
setPrimaryButtonText("Retry")
setSecondaryButtonText("Start New Task")
break
case "mistake_limit_reached":
setSendingDisabled(false)
setClineAsk("mistake_limit_reached")
setEnableButtons(true)
setPrimaryButtonText("Proceed Anyways")
setSecondaryButtonText("Start New Task")
break
case "auto_approval_max_req_reached":
setSendingDisabled(true)
setClineAsk("auto_approval_max_req_reached")
setEnableButtons(true)
setPrimaryButtonText("Proceed")
setSecondaryButtonText("Start New Task")
break
case "followup":
setSendingDisabled(isPartial)
setClineAsk("followup")
setEnableButtons(false)
// setPrimaryButtonText(undefined)
// setSecondaryButtonText(undefined)
break
case "plan_mode_respond":
setSendingDisabled(isPartial)
setClineAsk("plan_mode_respond")
setEnableButtons(false)
// setPrimaryButtonText(undefined)
// setSecondaryButtonText(undefined)
break
case "tool":
setSendingDisabled(isPartial)
setClineAsk("tool")
setEnableButtons(!isPartial)
const tool = JSON.parse(lastMessage.text || "{}") as ClineSayTool
switch (tool.tool) {
@@ -300,28 +295,24 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
break
case "browser_action_launch":
setSendingDisabled(isPartial)
setClineAsk("browser_action_launch")
setEnableButtons(!isPartial)
setPrimaryButtonText("Approve")
setSecondaryButtonText("Reject")
break
case "command":
setSendingDisabled(isPartial)
setClineAsk("command")
setEnableButtons(!isPartial)
setPrimaryButtonText("Run Command")
setSecondaryButtonText("Reject")
break
case "command_output":
setSendingDisabled(false)
setClineAsk("command_output")
setEnableButtons(true)
setPrimaryButtonText("Proceed While Running")
setSecondaryButtonText(undefined)
break
case "use_mcp_server":
setSendingDisabled(isPartial)
setClineAsk("use_mcp_server")
setEnableButtons(!isPartial)
setPrimaryButtonText("Approve")
setSecondaryButtonText("Reject")
@@ -329,14 +320,12 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
case "completion_result":
// extension waiting for feedback. but we can just present a new task button
setSendingDisabled(isPartial)
setClineAsk("completion_result")
setEnableButtons(!isPartial)
setPrimaryButtonText("Start New Task")
setSecondaryButtonText(undefined)
break
case "resume_task":
setSendingDisabled(false)
setClineAsk("resume_task")
setEnableButtons(true)
setPrimaryButtonText("Resume Task")
setSecondaryButtonText(undefined)
@@ -344,7 +333,6 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
break
case "resume_completed_task":
setSendingDisabled(false)
setClineAsk("resume_completed_task")
setEnableButtons(true)
setPrimaryButtonText("Start New Task")
setSecondaryButtonText(undefined)
@@ -352,21 +340,18 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
break
case "new_task":
setSendingDisabled(isPartial)
setClineAsk("new_task")
setEnableButtons(!isPartial)
setPrimaryButtonText("Start New Task with Context")
setSecondaryButtonText(undefined)
break
case "condense":
setSendingDisabled(isPartial)
setClineAsk("condense")
setEnableButtons(!isPartial)
setPrimaryButtonText("Condense Conversation")
setSecondaryButtonText(undefined)
break
case "report_bug":
setSendingDisabled(isPartial)
setClineAsk("report_bug")
setEnableButtons(!isPartial)
setPrimaryButtonText("Report GitHub issue")
setSecondaryButtonText(undefined)
@@ -383,7 +368,6 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
setSendingDisabled(true)
setSelectedImages([])
setSelectedFiles([])
setClineAsk(undefined)
setEnableButtons(false)
}
break
@@ -419,7 +403,6 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
useEffect(() => {
if (messages.length === 0) {
setSendingDisabled(false)
setClineAsk(undefined)
setEnableButtons(false)
setPrimaryButtonText("Approve")
setSecondaryButtonText("Reject")
@@ -504,7 +487,6 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
setSendingDisabled(true)
setSelectedImages([])
setSelectedFiles([])
setClineAsk(undefined)
setEnableButtons(false)
// setPrimaryButtonText(undefined)
// setSecondaryButtonText(undefined)
@@ -584,7 +566,6 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
break
}
setSendingDisabled(true)
setClineAsk(undefined)
setEnableButtons(false)
// setPrimaryButtonText(undefined)
// setSecondaryButtonText(undefined)
@@ -637,7 +618,6 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
break
}
setSendingDisabled(true)
setClineAsk(undefined)
setEnableButtons(false)
// setPrimaryButtonText(undefined)
// setSecondaryButtonText(undefined)
@@ -695,27 +675,6 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
const shouldDisableFilesAndImages = selectedImages.length + selectedFiles.length >= MAX_IMAGES_AND_FILES_PER_MESSAGE
const handleMessage = useCallback(
(e: MessageEvent) => {
const message: ExtensionMessage = e.data
switch (message.type) {
case "action":
switch (message.action!) {
case "didBecomeVisible":
if (!isHidden && !sendingDisabled && !enableButtons) {
textAreaRef.current?.focus()
}
break
}
break
}
// textAreaRef.current is not explicitly required here since react guarantees that ref will be stable across re-renders, and we're not using its value but its reference.
},
[isHidden, sendingDisabled, enableButtons, handleSendMessage, handlePrimaryButtonClick, handleSecondaryButtonClick],
)
useEvent("message", handleMessage)
// Listen for local focusChatInput event
useEffect(() => {
const handleFocusChatInput = () => {
@@ -1306,6 +1265,7 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
scrollToBottomAuto()
}
}}
isTaskView={!!task}
/>
</div>
)

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