Compare commits

..
Author SHA1 Message Date
0xtoshii 5f343cbf00 cleanup 2025-08-07 21:15:14 -07:00
0xtoshii 053d5fbd88 updated 2025-08-07 21:11:17 -07:00
Bee 4ab8559fce feat: support sending context to active editor panels (#5239)
* feat: add client-specific targeting for addToInput events

- Add client-specific targeting for addToInput events
- Update subscribeToAddToInput to accept client ID parameter
- Replace global event broadcasting with targeted client messaging
- Remove automatic sidebar focus when adding code to chat
- Use last active webview instance for context menu actions
- Maintain backward compatibility with subscription management

* add changeset

* remove debug profiler

* e2e test

* add type

* Add e2e test

* update teardown
2025-08-07 19:32:40 -07:00
259368e0a3 v3.21.0 Release Notes (#5392)
* changeset version bump

* Updating CHANGELOG.md format

* release notes

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: pashpashpash <nik@cline.bot>
2025-08-07 10:03:39 -07:00
pashpashpashandSaoud Rizwan 9b7839efcd Pashpashpash/gpt 5 release (#5413)
* preparing for gpt5 release

* Update generic system prompt with needs_more_exploration param for plan mode

* changeset

---------

Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2025-08-07 09:47:04 -07:00
Ara 47a2ae83de Switch to ULID from UUID for tasks telemetry (#5407)
* Switch to ULID from UUID for tasks

* Switch to ULID from UUID for tasks

* Switch to ULID from UUID for tasks
2025-08-06 19:51:19 -07:00
Sarah Fortune 1b5590e26c Refactoring: move postMessageToWebview into vscode specific code (#5396) 2025-08-07 02:49:12 +01:00
ToshiiandEndoTheDev 9e493341d2 Add ollama key for cloud endpoint (#5400)
* base

Co-authored-by: EndoTheDev <endothedev@gmail.com>

* toggle showing key box

* typing

---------

Co-authored-by: EndoTheDev <endothedev@gmail.com>
2025-08-06 14:41:48 -07:00
kvyb 1d4cd3187b Hostbridge diff diagnostics (#5368)
* feat: migrate diff edit diagnostics to hostbridge; Migrate diagnostics functionality from direct VS Code API calls to the hostbridge layer to enable multi-host support (VS Code + IntelliJ).

* remove test logging

* refactor: migrate diagnostics to workspace service and host separation
2025-08-06 18:16:17 +03:00
Ara 32f0f9618c Adding UUID to task creation for tracking the metrics of a Task in telemetry (#5379) 2025-08-06 00:14:32 -07:00
Ara 3001f883c2 Add walkthrough button and enable quick wins for new users (#5047)
* Add walkthrough button and enable quick wins for new users

- Add openWalkthrough RPC method to ui.proto
- Enable quick wins display for users with <3 tasks in history
- Add "Take a Tour" button in HomeHeader when quick wins are shown
- Update WelcomeSection to pass shouldShowQuickWins prop to HomeHeader

* Adding Gpt-oss through groq

* Adding Gpt-oss through groq

* Support prompt caching and thinking for Opus 4.1

* Support prompt caching and thinking for Opus 4.1

* Support prompt caching and thinking for Opus 4.1
2025-08-06 00:13:31 -07:00
a64e60b8f6 v3.20.13 Release Notes (#5391)
* changeset version bump

* Updating CHANGELOG.md format

* Update CHANGELOG.md

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2025-08-06 00:12:50 -07:00
Saoud Rizwan 3a0e6a471b Add prompt caching for Opus 4.1 (#5389)
* Add prompt caching for Opus 4.1

* Create forty-poets-doubt.md
2025-08-06 00:10:50 -07:00
c10f4e0a66 v3.20.12 Release Notes (#5387)
* changeset version bump

* Updating CHANGELOG.md format

* Update CHANGELOG.md for version 3.20.12

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: pashpashpash <nik@cline.bot>
2025-08-05 22:25:33 -07:00
Ara 3e11271cf8 Fix support for prompt caching and thinking for Opus 4.1 (#5386)
* Support prompt caching and thinking for Opus 4.1

* Support prompt caching and thinking for Opus 4.1
2025-08-05 22:12:14 -07:00
Jim Tang 29ae2c286d Update index.ts while tree possible as a null. (#5285) 2025-08-05 20:03:22 -07:00
github-actions[bot] 82aee44a9a v3.20.11 Release Notes (#5377) 2025-08-05 16:07:23 -07:00
omercelik 031604ddf6 feat: Added Claude Opus 4.1 to Bedrock (#5381)
* feat: Added Claude Opus 4.1 to Bedrock

* Create ninety-owls-develop.md
2025-08-05 15:03:15 -07:00
Bee f2101e375f fix: update Playwright config and teardown error handling (#5383)
- Remove teardown dependency on e2e tests to fix execution order
- Move server cleanup before file operations in teardown
- Add proper error handling and logging for cleanup operations
2025-08-05 14:22:19 -07:00
Tomás Barreiro 8a65f0c68b feat: Add Opus 4.1 to claude-code (#5382)
* Add opus-4-1 to claude-code

* Add changeset
2025-08-05 13:42:40 -07:00
Bee 32b8fa44cb refactor: Integrate Posthog into Feature Flags & Telemetry & Error Services (#5275)
* refactor: posthog services: feature flags + error + telemtry

- Convert PostHogClientProvider to singleton with lifecycle management
- Update ErrorServices to use PostHogClientProvider
- Update Telementry Service
- Update and enable Feature Flags service

* replace logger

* fix imports - part 1

* update distinct ID

* update

* update

* clean up

* revert autoformat

* fix merge conflicts

* clean up autoformat

* revert autoformatter

* clean up logs
2025-08-05 13:00:44 -07:00
Sarah Fortune 0d067f7470 Add getCallbackUri to the HostProvider (#5361) 2025-08-05 12:06:08 -07:00
Kevin Taylor de6166392c Update Cerebras gpt-oss-120b (#5376)
* Add Cerebras gpt-oss-120b

* Change completion tokens
2025-08-05 12:00:02 -07:00
Kevin Taylorandellipsis-dev[bot] 5f21a9162a Add Cerebras gpt-oss-120b (#5375)
* Add Cerebras gpt-oss-120b

* Update src/shared/api.ts

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-08-05 11:27:11 -07:00
github-actions[bot] 4de991f1b0 v3.20.10 Release Notes (#5374) 2025-08-05 11:22:52 -07:00
pashpashpashandarafatkatze 6e5d4a3f9e openai model in hugging face correct maxtokens (#5371)
* openai model in hugging face correct maxtokens

* maxtokens

* maxtokens 131k i guess?

* maxtokens swap

* changeset

* Adding Gpt-oss through groq

---------

Co-authored-by: arafatkatze <arafat.da.khan@gmail.com>
2025-08-05 11:08:53 -07:00
95af95badf v3.20.9 Release Notes (#5354)
* changeset version bump

* Updating CHANGELOG.md format

* Update CHANGELOG.md and package.json for version 3.20.9 patch release

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: pashpashpash <nik@cline.bot>
2025-08-05 10:12:09 -07:00
Ara 61dcbd390c Adding Anthropic opus 4.1 (#5369)
* Adding Anthropic opus 4.1

* Adding Anthropic opus 4.1
2025-08-05 09:59:15 -07:00
pashpashpash 6255ac0a51 added provider flag to diff edit cli (#5334)
* added provider flag to diff edit cli

* dashboard ux

* more dashboard improvmeents

* native handler instead of just openai
2025-08-05 09:21:12 -07:00
Sarah Fortune 616800fcb9 Refactoring: move vscode specific property out of the WebviewProvider into the VscodeViewProvider (#5364) 2025-08-05 06:48:12 +01:00
Sarah Fortune df3826a59f In the webview grpc client, JSON encode/decode the messages when not running in Vscode (#5362) 2025-08-04 22:39:32 -07:00
Sarah Fortune 873917810d Remove left code from ProtoBus migration (#5363)
There is one place in the McpHub that sends messages mcp notification messages directly to the webview (not using the ProtoBus).

There is nowhere in the webview that is listening for this message, so this code is not doing anything.
2025-08-04 22:39:20 -07:00
e3c966f4e9 Baseten provider (#5238)
* kimi working

* fixed description rendering

* nit

* changeset

* revert openai version in package.json

* revert package-lock.json

* added space back in

* maintained previous protofield map order

* fixed import error due to location change from main

* updated Mode import for BasetenModelPicker

* revert readme since baseten is openai compatible

* refactored extensionStateContext

* added didOutputUsage flag

* fixed frontend loading

* no support for images on llama

* shifted VSCode Option order

* deleted typo

---------

Co-authored-by: Alex Ker <alexker@mac.mynetworksettings.com>
Co-authored-by: Alex Ker <alexker@Alexs-MacBook-Pro.local>
2025-08-04 20:07:59 -07:00
Sarah Fortune 7eeb43ab41 Simplify the GrpcHandler and add tests (#5356)
* Simplify the GrpcHandler

* Use two functions handleUnaryRequest and handleStreamingRequest, instead of creating a GrpcHandler object and calling class methods on it.
* Remove redundant try/catch and empty finally blocks. Each of the two handler functions has it's own try/catch.
* Each of the two functions is responsible for posting the result to the webview- Instead of unary and streaming responses being handled at different levels.
* Use the GrpcRequest and GrpcCancel types.

* Update comment
2025-08-04 19:15:29 -07:00
Bee 7620f177ac fix: clear streamingFailedMessage when user manually retries (#5222)
* fix: clear streamingFailedMessage when user manually retries

- Clear streamingFailedMessage when user manually retries
- Convert imports to type-only where appropriate
- Reorder imports for better organization
- Add explicit type annotations for better type safety
- Move node:timers/promises import to top

* add changeset

* merge main and reset fail flag

* revert autoformat
2025-08-04 17:04:42 -07:00
Bee 67bab94911 Revert "Add getCallUri to the HostProvider (#5322)" (#5359)
This reverts commit b8227c19c3.
2025-08-04 16:30:04 -07:00
Bee eb91bfd738 Update ChatView footer background to use sidebar theme (#5357)
Change footer background from editor to sidebar background color
and remove border styling.
2025-08-04 16:06:02 -07:00
Sarah Fortune b8227c19c3 Add getCallUri to the HostProvider (#5322)
**Centralize callback URI management** through the HostProvider instead of having it in multiple places in the codebase.

**Simplify error handling** by making the callback URI required rather than optional

The changes are related to **authentication callback URI handling** in the Cline extension. Here's what's being modified:

  - Simplified callback URI retrieval
- Changed return type from `Promise<string | undefined>` to `Promise<string>`
- Now throws an error if AuthHandler is not enabled instead of returning undefined

- Added a new `getCallbackUri` property that returns a `Promise<string>`
- This allows the host provider to supply callback URIs for authentication

  - Implemented callback URI provider

  - Updated to use HostProvider for callback URI

  - Updated to match new signature
2025-08-05 00:03:07 +01:00
Sarah Fortune 8fee09f09e Add logging to the webview ProtoBus client if it recieves a badly formed message (#5353) 2025-08-04 22:34:08 +01:00
Bee 1c026c26d2 fix: chatbox position styling (#5352)
* fix: chatbox position styling

* add changeset
2025-08-04 14:18:53 -07:00
Sarah Fortune 5bc4e5a4a0 Add comments for HostBridge RPC showSaveDialog (#5351) 2025-08-04 21:15:45 +01:00
Ara 2cfce5734e Change Vscode LM token counts to use approx counting method (#5280) 2025-08-04 12:41:34 -07:00
Bee e2045bf5c3 fix: flaky check for editor search bar (#5347)
* fix: flaky check for editor search bar

* remove disabling notification
2025-08-04 12:37:33 -07:00
Sarah Fortune 0d933e804f Support mentions for filenames with spaces (#5309)
* feat: support file mentions with spaces using quoted syntax

This change allows users to reference files with spaces in their names, which was previously impossible due to the space-delimited mention syntax.
File names with spaces can be @ mentioned by quoting the file name, e.g. @"/path with spaces/file.txt".

- Update mention regex in `src/shared/context-mentions.ts` to accept quoted file paths
  - Add support for quoted file paths that can contain spaces.
  - Allow multiple trailing punctuation chars; previously only a single limited punctuation characters were allowed.
  - Maintain support for unquoted paths, URLs, git hashes, and special keywords

- Update `src/core/mentions/index.ts` to handle quoted file names in mention parsing
  - Process quoted file paths by removing quotes when accessing the file system
  - Preserve existing functionality for all other mention types

- Update `webview-ui/src/utils/context-mentions.ts` to auto-quote file names with spaces
  - `insertMention()` and `insertMentionDirectly()` now wrap file paths containing spaces in quotes
  - Non-file mentions (URLs, keywords) remain unquoted

- Add comprehensive unit tests:
  - New test file `src/core/mentions/__tests__/index.test.ts` covering all mention types
  - New test file `webview-ui/src/utils/__tests__/context-mentions.test.ts` for webview mention insertion
  - Expanded `src/shared/__tests__/context-mentions.test.ts` to cover quoted paths and edge cases

* Use const instead of var
2025-08-04 19:42:44 +01:00
Sarah Fortune 16f73532f4 Remove things that were left over from the ProtoBus migration. (#5321) 2025-08-04 18:04:54 +01:00
Sarah Fortune 88bea8eeb4 Update saveOpenDocumentIfDirty to return if the doc was saved or not (#5343) 2025-08-04 18:04:36 +01:00
Sarah Fortune 1a570e98ba Update the GitHub test action to produce more readable output (#5333) 2025-08-04 18:04:21 +01:00
Toshii d86b7dd036 Add optional way to enforce no file edits in plan mode (#5299)
* base implementation

* base messaging implementation & ui

* update state
2025-08-04 09:55:59 -07:00
Sarah Fortune 0178c3fa90 Fix flakey test getOpenTabs and re-enable unit tests (#5332)
- Replace fixed 100ms timeout with pWaitFor polling mechanism
- Set 2-second timeout with 50ms polling interval
- Test now waits exactly as long as needed for tabs to be created
2025-08-03 20:51:05 -07:00
a107f45c6a v3.20.8 Release Notes (#5330)
* changeset version bump

* Updating CHANGELOG.md format

* Update CHANGELOG.md

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2025-08-03 17:17:54 -07:00
Saoud Rizwan 3dd2ed9161 Add comment about testing fix (#5329)
* Add comment about testing fix

* Create cool-cherries-brush.md
2025-08-03 17:15:04 -07:00
Saoud Rizwan 9a6603fdfb Disable unit tests in publish pipeline (#5327) 2025-08-03 17:09:59 -07:00
Sarah Fortune 6d5c3e6aa4 Switch remaining uses of vscode.window.show*Message to the HostBridge (#5324)
* Move remaining uses of vscode.window.show*Message to the HostBridge

Switch over the remaining uses.

Turn on the linter check to prevent these APIs being reintroduced later.

Exclude test files from the linter check.

* Update unit test
2025-08-04 01:03:43 +01:00
Sarah Fortune 24b9e821bb refactor: update navbar styling and remove shadow (#5323)
- Replace database icon with MCP server icon (codicon-server)
- Remove shadow-sm class for a flatter appearance
- Maintain consistent button styling with VSCodeButton components
- Add tooltips using HeroTooltip
2025-08-04 00:59:04 +01:00
9960a3c57c v3.20.7 Release Notes (#5328)
* changeset version bump

* Updating CHANGELOG.md format

* Update CHANGELOG.md

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2025-08-03 16:57:50 -07:00
Sarah FortuneandSaoud Rizwan 88947592f0 Fix errors in tests (#5294)
* Fix errors in tests:

```
[TerminalProcess] Terminal ID: Cline
Error capturing terminal output: Error: Failed to read from clipboard: HostProvider not setup. Call HostProvider.initialize() first.
  at readTextFromClipboard (/Users/sjf/cline/out/src/utils/env.js:39:15)
  at getLatestTerminalOutput (/Users/sjf/cline/out/src/integrations/terminal/get-latest-output.js:35:69)
  at TerminalProcess.emitCurrentTerminalContents (/Users/sjf/cline/out/src/integrations/terminal/TerminalProcess.js:26:92)
  at TerminalProcess.runWithoutShellIntegration (/Users/sjf/cline/out/src/integrations/terminal/TerminalProcess.js:366:20)
  at async TerminalProcess.run (/Users/sjf/cline/out/src/integrations/terminal/TerminalProcess.js:47:13)
      ✔ should execute a command that lists files
[TerminalProcess] Starting command: "sleep 0.5 && echo 'Done sleeping'"
[TerminalProcess] Shell integration available: false
[TerminalProcess] Terminal ID: Cline
Error capturing terminal output: Error: Failed to read from clipboard: HostProvider not setup. Call HostProvider.initialize() first.
  at readTextFromClipboard (/Users/sjf/cline/out/src/utils/env.js:39:15)
  at getLatestTerminalOutput (/Users/sjf/cline/out/src/integrations/terminal/get-latest-output.js:35:69)
  at TerminalProcess.emitCurrentTerminalContents (/Users/sjf/cline/out/src/integrations/terminal/TerminalProcess.js:26:92)
  at TerminalProcess.runWithoutShellIntegration (/Users/sjf/cline/out/src/integrations/terminal/TerminalProcess.js:366:20)
  at async TerminalProcess.run (/Users/sjf/cline/out/src/integrations/terminal/TerminalProcess.js:47:13)
  at async Context.<anonymous> (/Users/sjf/cline/out/src/integrations/terminal/TerminalProcess.test.js:119:13)
FakeTimers: clearTimeout was invoked to clear a native timer instead of one created by this library.
To automatically clean-up native timers, use `shouldClearNativeTimers`.
      ✔ should handle a longer running command (3007ms)
[TerminalProcess] Starting command: "echo 'Line 1' 'Line 2'"
[TerminalProcess] Shell integration available: false
[TerminalProcess] Terminal ID: Cline
Error capturing terminal output: Error: Failed to read from clipboard: HostProvider not setup. Call HostProvider.initialize() first.
  at readTextFromClipboard (/Users/sjf/cline/out/src/utils/env.js:39:15)
  at getLatestTerminalOutput (/Users/sjf/cline/out/src/integrations/terminal/get-latest-output.js:35:69)
  at TerminalProcess.emitCurrentTerminalContents (/Users/sjf/cline/out/src/integrations/terminal/TerminalProcess.js:26:92)
  at TerminalProcess.runWithoutShellIntegration (/Users/sjf/cline/out/src/integrations/terminal/TerminalProcess.js:366:20)
  at async TerminalProcess.run (/Users/sjf/cline/out/src/integrations/terminal/TerminalProcess.js:47:13)
      ✔ should execute a command with arguments
[TerminalProcess] Starting command: "echo "Line 1" && echo 'Line 2'"
[TerminalProcess] Shell integration available: false
[TerminalProcess] Terminal ID: Cline
Error capturing terminal output: Error: Failed to read from clipboard: HostProvider not setup. Call HostProvider.initialize() first.
  at readTextFromClipboard (/Users/sjf/cline/out/src/utils/env.js:39:15)
  at getLatestTerminalOutput (/Users/sjf/cline/out/src/integrations/terminal/get-latest-output.js:35:69)
  at TerminalProcess.emitCurrentTerminalContents (/Users/sjf/cline/out/src/integrations/terminal/TerminalProcess.js:26:92)
  at TerminalProcess.runWithoutShellIntegration (/Users/sjf/cline/out/src/integrations/terminal/TerminalProcess.js:366:20)
  at async TerminalProcess.run (/Users/sjf/cline/out/src/integrations/terminal/TerminalProcess.js:47:13)
      ✔ should execute a command with quotes
  ```

* Create brown-papayas-protect.md

---------

Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2025-08-03 16:55:21 -07:00
yuvalman ef4d11df19 fix: circular dependency that affect the github workflow Tests / test (pull_request) (#5317)
* fix: circular dependency that affect test env

* fix: circular dependency that affect test env
2025-08-04 00:38:41 +01:00
23dec509bc v3.20.6 Release Notes (#5326)
* changeset version bump

* Updating CHANGELOG.md format

* Update CHANGELOG.md

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2025-08-03 16:24:15 -07:00
celestial-vault 07ab6b19b8 check auth after initialize cacheservice (#5325)
* initialize cachService in controller constructor; remove authService as a class level variable on Controller

* changeset
2025-08-03 16:19:40 -07:00
Bee 0ddef94d1f feat: use auth callback handling with custom AuthHandler (#5223)
* feat: use auth callback handling with custom AuthHandler

- Add AuthHandler class to manage OAuth flow with local HTTP server
- Move callback logic from extension.ts to SharingUriHandler, making that shared between the original and new authentication ways
- Enabling Custom HTTP for "core only" environments
- Async starting and stopping HTTP server
2025-08-02 15:10:16 -07:00
BeeandCopilot b9ae83b1cd fix: standalone navbar style with chat layout refactor (#5308)
* fix: standalone navbar style with chat layout refactor

* Update webview-ui/src/components/chat/chat-view/components/layout/MessagesArea.tsx

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-08-02 14:17:38 -07:00
Sarah Fortuneandellipsis-dev[bot] e4eaf34827 test: Fix and re-enable unit tests (#5298)
* test: Fix and re-enable unit tests

Re-enable unit tests in CI workflow that were previously disabled

The cline-api test requires VSCode SDK which cannot be easily mocked in unit tests,
so it has been moved to integration tests where the full VSCode environment is available.

The @google/genai module is ES6-only which causes issues when running integration tests
compiled to CommonJS. A mock implementation has been added and the module resolution
is intercepted in test-setup.js to use the mock instead.

The bedrock unit tests for getModelId() functionality are removed as they were failing
and fixing them is out of scope for this PR.

- Move cline-api.test.ts from exports to test directory as it depends on VSCode SDK
- Add gemini-mock.test.ts to mock @google/genai ES6 module for CommonJS compatibility
- Add module interception in test-setup.js to redirect @google/genai to mock
- Remove failing bedrock unit tests introduced in PR #4209 (out of scope)

* Update src/api/providers/__tests__/bedrock.test.ts

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

* Formatting

---------

Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>
2025-08-02 01:47:27 +01:00
6d3ed43c74 v3.20.5 Release Notes (#5297)
* changeset version bump

* Updating CHANGELOG.md format

* Update CHANGELOG.md for version 3.20.5

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: pashpashpash <nik@cline.bot>
2025-08-01 16:50:04 -07:00
celestial-vault cbb67b48f2 fix secrets persistence (#5296) 2025-08-01 16:41:14 -07:00
Sarah Fortune a5f6a97be8 Fix eslint unit tests (#5295) 2025-08-01 23:16:18 +01:00
github-actions[bot] f309b062e7 v3.20.4 Release Notes
v3.20.4 Release Notes
2025-08-01 13:20:27 -07:00
canvrnoandKevin Bond 768df130ab Fix for delete task popup (#5260)
Co-authored-by: Kevin Bond <kevin@Kevins-MacBook-Pro.local>
2025-08-01 13:05:21 -07:00
Toshii 9980cb0938 fix grok browser_user (#5278) 2025-08-01 10:14:19 -07:00
AraandKevin Taylor aca4f842fa Update Cerebras models (#5282)
* Update Cerebras models

* Add changeset

* Modify completion token limits

* Split qwen3 coder into free/paid

* Change -paid to base model name

* Update Cerebras models

* Update Cerebras models

* Update Cerebras models

* Update api.ts

* Update Cerebras models

---------

Co-authored-by: Kevin Taylor <kevin.taylor@cerebras.net>
2025-08-01 01:11:53 -07:00
Bee 3fc91e2afe fix: E2E test stability by reordering sidebar and notification setup (#5279)
* fix: E2E test stability by reordering sidebar and notification setup

- Extract editor menu locator to variable for better readability
- Move sidebar opening to page fixture to ensure it's available earlier
- Wait for chat input visibility before disabling notifications
- Prevents race conditions in test initialization

* fix
2025-07-31 18:03:14 -07:00
celestial-vault 5f4700ce95 Move apiconfiguration to cache layer (#5210)
* remove chatSettings object

* use cache for apiCongfiguration state

* add state persistence debounced, batch state updates, make setters synchronous

* fix types after merge conflicts

* fix global state reset

* remove clearCache; make dispose function private; remove vscode api dependency; call reInitialize in reset functions instead of dispose/initialize
2025-07-31 16:58:47 -07:00
Jim Tang dbaf5e3ee3 Update system.ts for formating the code. (#5270) 2025-07-31 16:19:01 -07:00
Toshii 576176c24f add grok4 to advanced list (#5276) 2025-07-31 14:54:45 -07:00
Akshay Raj Gollahalli c8abcbfdf9 Do not ignore pkg folder (#4483) (#4505) 2025-07-31 12:11:31 -07:00
celestial-vault 8e984f2d98 clean up getStateToPostToWebview in preparation for migration to StateManager service (#5266) 2025-07-31 11:47:06 -07:00
81564faa4e v3.20.3 Release Notes (#5185)
* changeset version bump

* Updating CHANGELOG.md format

* releaseee

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: pashpashpash <nik@cline.bot>
2025-07-30 21:49:57 -07:00
pashpashpash f8b5f1fd72 adding redirectUrl to credits purchasing experience (#5158) 2025-07-30 17:43:04 -07:00
celestial-vault 2eb57384ab remove useEffect (#5261) 2025-07-30 17:11:01 -07:00
Sarah Fortune c80bae504a Add a flag to build the webview without minification, compact, etc. (#5262)
When the webview is built wuth build:test:
* don't compact the compiled code
* don't minify
* use inline source maps (the embedded JCEF browser can't load source maps from .map files).
2025-07-30 19:53:36 -04:00
Sarah Fortune 80f955be9e Add vscode.workspace.findFiles to no-direct-vscode-api eslint rule. (#5263)
Add `vscode.workspace.findFiles` to the list of the Vscode API calls that should not be re-introduced to the extension unintentionally.
2025-07-30 19:49:15 -04:00
wangyijing130andwangyj20 7435ffcd2f fix: use Uri.from to generate valid diff URI (#4882)
* fix: use Uri.from to generate valid diff URI

* fix the conflicts for VscodeDiffViewProvider.ts has been moved

---------

Co-authored-by: wangyj20 <wangyj20@asiainfo.com>
2025-07-30 10:42:19 -07:00
Bee a05d438612 refactor: setup e2e tests to use shared mock server (#5245)
* refactor: e2e test setup to use Playwright projects with global server

- Replace globalSetup/globalTeardown with Playwright projects configuration
- Rename setup.ts to global.setup.ts and teardown.ts to global.teardown.ts
- Convert ClineApiServerMock to use shared global server instance
- Add proper dependency management between setup, tests, and cleanup phases
- Improve server connection tracking and cleanup handling

* Rename Playwright test project names to match

* IS_DEV

* update helpers
2025-07-30 00:16:08 -07:00
Daniel Steigman b4b7512d9f Improve Cline accounts support telemetry (#5242)
* fixed linter rule and added identified telemtry stuff

* Updated the error handling

* fixed error handling
2025-07-29 17:23:50 -07:00
ToshiiandDing Fei e08c65618e read_file can read images (png, jpg, jpeg, webp) (#4411)
* base

* throw

* chat ui

Co-authored-by: Ding Fei <fding@feysh.com>

* chat row logic for image file reads

* dim check change

---------

Co-authored-by: Ding Fei <fding@feysh.com>
2025-07-29 15:41:31 -07:00
Bee d653f1cc27 test: update Playwright test timeouts (#5241)
- Rename isGitHubAction to isCI for broader CI detection
- Adjust timeout logic to use CI or Windows conditions
- Reduce expect timeout from 40s/20s to 5s/2s for faster feedback
- Decrease streaming chunk delay from 50ms to 20ms in server mock
2025-07-29 15:27:46 -07:00
Bee c3a97c3eda e2e test: add mock service for cline API & new test for diff editor (#5196)
* Add mock api service and E2E test infrastructure

- Create AuthServiceMock for testing with mock user data and API responses
- Add AuthProvider interface to standardize authentication providers
- Implement E2E test fixtures with mock server and workspace setup
- Add comprehensive E2E tests for authentication and core functionality
- Export DEFAULT_CLINE_APP_URL config and make getEnvironmentConfig more flexible
- Update AuthService to use mock implementation during E2E tests

* format

* import

* refactor mock server

* rename data

* wait for text

* wait for edit

* increase timeout for windows

* clean up

* rename test and add orgs
2025-07-29 12:39:49 -07:00
celestial-vault 0e56272d65 remove chatSettings object (#5178)
* remove chatSettings object

* fix types after merge conflicts
2025-07-29 12:31:52 -07:00
Kevin Taylor fdc2e2655a Add Cerebras model Qwen 3 235b instruct (#5236) 2025-07-29 11:55:48 -07:00
Wintertee 6050413b8b fix: remove duplicate tool registration for claude4-experimental (#4748) 2025-07-29 10:48:41 -07:00
Bee c54f0da737 feat: adds navigation bar component and restructure app layout (#5220) 2025-07-29 13:05:01 -04:00
Sarah Fortune 6cbfb2b8b0 Remove duplication define property in esbuild.json (#5234) 2025-07-29 08:54:12 -07:00
Sarah Fortune 22788f0f12 Move the OutputChannel to the HostProvider (#5189)
* Move the OutputChannel to the HostProvider

Replace `OutputChannel.appendLine` with `HostProvider.logToChannel`.

Remove places where the cline OutputChannel was being passed around. Now it is stored in the HostProvider, so we don't need to do this.

# Conflicts:
#	src/hosts/vscode/VscodeWebviewProvider.ts

* Dont log the timestamp in logger.ts, the cline-core logger already outputs the timestamp

* Fix imports
2025-07-28 22:56:43 -07:00
DongDong Ling 708b785a97 Add Huawei Cloud MaaS Provider (#5071)
* Add Huawei Cloud MaaS Provider

* Fix case error

* Add missing modelid

* add huawei specific modelId and modelInfo

* add huawei specific model id and model info in state.proto

* more huawei maas specific change
2025-07-28 21:47:21 -07:00
Jose R. Perez 099bc44d42 docs: fix Global Rules directory location for Linux/WSL systems (#5219) 2025-07-28 23:54:03 -04:00
Toshii b9f4678dba add try-catch handling (#5227) 2025-07-28 20:21:51 -07:00
Bee f7d17384f6 refactor & fix: improve account view with better states management (#5182)
* refactor & fix: improve account view with better states management

The previous AccountView implementation suffered from several critical state management issues:

- Incorrect info on display: The active account is not ready when component is mounted because the fetching only start on mount but doesn't get reset correctly
- Excessive re-renders: All data was fetched on component mount, causing cascading state updates
- Race conditions: Multiple concurrent API calls and state dependencies created unpredictable behavior, e.g. 403 rate limits errors
- Monolithic state management: All account data, organizations, and auth state was managed in a single massive component
- Poor user experience: Users saw empty states and loading flickers when switching between organizations
- Tight coupling: User and org info logic was deeply embedded within the account view that cause Effect dependency loops

Solution: Centralized Authentication Context

- Extracted auth logic into dedicated ClineAuthContext with organizations state management
- Eliminated prop drilling by providing clineUser, organizations, and activeOrganization at the context level
- Reduced component re-renders by managing auth state separately from UI state
- Performed authentication guard at higher level and only displays user account to authenticated user. The component will get dismounted when user is not autheticated.
- Move handleSignIn and handleSignout into individual functions instead as they are regular functions with no state dependency

* 60secs

* Optimize state updates in AccountView to prevent unnecessary re-renders

Remove conditional checks before setState calls and use functional updates
with deep equality comparison to avoid redundant state changes and
dependency array bloat in useCallback hooks.

* add docs

* fix format

* fix error test

* setuser on logout
2025-07-28 15:46:23 -07:00
Sarah Fortune bb5a64afb3 Quiet spammy MCP debug logs (#5224) 2025-07-28 15:44:38 -07:00
Sarah Fortune 61224734f8 Change timeout, token budget and line limit fields in the ProtoBus from int64 to int32. (#5221) 2025-07-28 17:40:15 -04:00
Bee a28b995ab1 Fix styled-components prop warnings (#5181)
* Fix styled-components prop warnings

- Fix styled-components shouldForwardProp warnings by filtering non-DOM props
- Clean up unused imports in ChatTextArea and other components

* use mjs

* later

* remove unused imports
2025-07-28 11:19:32 -07:00
Bee a91878efc6 Fix: webview panel state change steals focus (#5193)
* Fix: webview panel state change steals focus

Fix webview visibility detection to check both visible and active states before taking focus. If a panel is visible but not active (focused), it should not steals editor focus.

Also removes unused import & add type imports

* add changeset
2025-07-28 10:59:00 -07:00
245 changed files with 9407 additions and 20406 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
Support sending context to active webview when editor panels are opened.
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
DeepSeek R1 0528 support under Hugging Face
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
Fixed token counting when using VSCode LM API provider
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
trim input value for URL fields
+1 -1
View File
@@ -716,7 +716,7 @@ The Controller class manages MCP servers through the McpHub service:
class Controller {
mcpHub?: McpHub
constructor(context: vscode.ExtensionContext, outputChannel: vscode.OutputChannel, webviewProvider: WebviewProvider) {
constructor(context: vscode.ExtensionContext, webviewProvider: WebviewProvider) {
this.mcpHub = new McpHub(this)
}
+12 -15
View File
@@ -96,16 +96,15 @@ jobs:
- name: Build Tests and Extension
run: npm run pretest
# Unit Tests disabled due to module system conflicts between backend and webview-ui
# - name: Unit Tests
# run: npm run test:unit
- name: Unit Tests
run: npm run test:unit
# Run extension tests with coverage
- name: Extension Tests with Coverage
- name: Extension Integration Tests with Coverage
id: extension_coverage
continue-on-error: true
run: |
node ./scripts/test-ci.js > extension_coverage.txt 2>&1
node ./scripts/test-ci.js 2>&1 | tee extension_coverage.txt
# Default the encoding to UTF-8 - It's not the default on Windows
PYTHONUTF8=1 PYTHONPATH=.github/scripts python -m coverage_check extract-coverage extension_coverage.txt --type=extension --github-output --verbose
@@ -117,7 +116,7 @@ jobs:
cd webview-ui
# Ensure coverage dependency is installed
npm install --no-save @vitest/coverage-v8
npm run test:coverage > webview_coverage.txt 2>&1
npm run test:coverage 2>&1 | tee webview_coverage.txt
cd ..
# Default the encoding to UTF-8 - It's not the default on Windows
PYTHONUTF8=1 PYTHONPATH=.github/scripts python -m coverage_check extract-coverage webview-ui/webview_coverage.txt --type=webview --github-output --verbose
@@ -132,21 +131,19 @@ jobs:
path: |
extension_coverage.txt
webview-ui/webview_coverage.txt
retention-period: workflow # Artifacts are automatically deleted when the workflow completes
# Set the check as failed if any of the tests failed
- name: Print test results and check for failures
- name: Check for test failures
run: |
echo "Extension Tests Result: ${{ steps.extension_coverage.outcome }}"
cat extension_coverage.txt
echo "Webview Tests Result: ${{ steps.webview_coverage.outcome }}"
cat webview-ui/webview_coverage.txt
# Check if any of the test steps failed
# https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/accessing-contextual-information-about-workflow-runs#steps-context
if [ "${{ steps.extension_coverage.outcome }}" != "success" ]; then
echo "Extension Integration Tests failed, see previous step for test output."
fi
if [ "${{ steps.webview_coverage.outcome }}" != "success" ]; then
echo "Webview Tests failed, see previous step for test output."
fi
if [ "${{ steps.extension_coverage.outcome }}" != "success" ] || [ "${{ steps.webview_coverage.outcome }}" != "success" ]; then
echo "Tests failed."
exit 1
fi
+64
View File
@@ -1,5 +1,69 @@
# Changelog
## [3.21.0]
- Add support for GPT-5 model family including GPT-5, GPT-5 Mini, and GPT-5 Nano with prompt caching support and set GPT-5 as the new default model
- Add "Take a Tour" button for new users to easily access the VSCode walkthrough and improve onboarding experience
- Enhance plan mode response handling with better exploration parameter support
## [3.20.13]
- Fix prompt caching support for Opus 4.1 on OpenRouter/Cline
## [3.20.12]
- Add Claude Opus 4.1 model support to AWS Bedrock provider (Thanks @omercelik!)
- Fix prompt caching and extended thinking support for Claude Opus 4.1 in Anthropic provider
## [3.20.11]
Add gpt-oss-120b as a Cerebras model
Add Opus 4.1 through Claude Code
## [3.20.10]
- Add OpenAI's new open-source models (GPT-OSS-120B and GPT-OSS-20B) to Hugging Face and Groq providers
## [3.20.9]
- Add support for Claude Opus 4.1 model in Anthropic provider
- Add Baseten as a new API provider with support for DeepSeek, Llama, and Kimi K2 models (Thanks @AlexKer!)
- Fix error messages not clearing from UI when retrying failed tasks
- Fix chat input box positioning issues
## [3.20.8]
- Add navbar tooltips on hover
## [3.20.7]
- Fix circular dependency that affect the github workflow Tests / test (pull_request)
## [3.20.6]
- Fix login check on extension restart
## [3.20.5]
- Fix authentication persistence issues that could cause users to be logged out unexpectedly
## [3.20.4]
- Add new Cerebras models
- Update rate limits for existing Cerebras models
- Fix for delete task dialog
## [3.20.3]
- Add Huawei Cloud MaaS Provider (Thanks @ddling!)
- Add Cerebras Qwen 3 235B instruct model (Thanks @kevint-cerebras!)
- Add DeepSeek R1 0528 support under Hugging Face (Thanks @0ne0rZer0!)
- Fix Global Rules directory documentation for Linux/WSL systems
- Fix token counting when using VSCode LM API provider
- Fix input field stealing focus issue by only focusing on visible and active editor panels
- Fix duplicate tool registration for claude4-experimental
- Trim input value for URL fields
## [3.20.2]
- Fixed issue with sap ai core client credentials storage
+13 -1
View File
@@ -11,7 +11,19 @@ You can create a rule by clicking the `+` button in the Rules tab. This will ope
Once you save the file:
- Your rule will be stored in the `.clinerules/` directory in your project (if it's a Workspace Rule)
- Or in the `Documents/Cline/Rules` directory (if it's a Global Rule).
- Or in the Global Rules directory (if it's a Global Rule):
### Global Rules Directory Location
The location of your Global Rules directory depends on your operating system:
| Operating System | Default Location | Notes |
|------------------|------------------|-------|
| **Windows** | `Documents\Cline\Rules` | Uses system Documents folder |
| **macOS** | `~/Documents/Cline/Rules` | Uses user Documents folder |
| **Linux/WSL** | `~/Documents/Cline/Rules` | May fall back to `~/Cline/Rules` on some systems |
> **Note for Linux/WSL users**: If you don't find your global rules in `~/Documents/Cline/Rules`, check `~/Cline/Rules` as the location may vary depending on your system configuration and whether the Documents directory exists.
You can also have Cline create a rule for you by using the [`/newrule` slash command](/features/slash-commands/new-rule) in the chat.
+4 -4
View File
@@ -4,17 +4,17 @@ title: "Telemetry"
### Overview
To help make Cline better for everyone, we collect anonymous usage data that helps us understand how developers are using our open-source AI coding agent. This feedback loop is crucial for improving Cline's capabilities and user experience.
To help make Cline better for everyone, we collect usage data that helps us understand how developers are using our open-source AI coding agent. This feedback loop is crucial for improving Cline's capabilities and user experience.
We use PostHog, an open-source analytics platform, for data collection and analysis. Our telemetry implementation is fully transparent - you can review the [source code](https://github.com/cline/cline/blob/main/src/services/posthog/telemetry/TelemetryService.ts) to see exactly what we track.
### Tracking Policy
Privacy is our priority. All collected data is anonymized before being sent to PostHog, with no personally identifiable information (PII) included. Your code, prompts, and conversation content always remain private and are never collected.
Privacy is our priority. By default, all collected data is anonymized. If you log in with a Cline account, your telemetry data will be associated with your account to help us improve the product and provide better support when you encounter issues. Your code, prompts, and conversation content always remain private and are never collected.
### What We Track
We collect basic anonymous usage data including:
We collect basic usage data including:
**Task Interactions:** When tasks start and finish, conversation flow (without content)\
**Mode and Tool Usage:** Switches between plan/act modes, which tools are being used\
@@ -28,7 +28,7 @@ For complete transparency, you can inspect our [telemetry implementation](https:
Telemetry in Cline is entirely optional:
- When you update or install our VS Code extension, you'll see a message about our anonymous telemetry
- When you update or install our VS Code extension, you'll see a message about our telemetry
- You can change your preference anytime in settings
Cline also respects VS Code's global telemetry settings. If you've disabled telemetry at the VS Code level, Cline's telemetry will automatically be disabled as well.
+1
View File
@@ -16,6 +16,7 @@ description: "Learn how to configure and use Anthropic Claude models with Cline.
Cline supports the following Anthropic Claude models:
- `claude-opus-4-1-20250805`
- `claude-opus-4-20250514`
- `claude-opus-4-20250514:thinking` (Extended Thinking variant)
- `claude-sonnet-4-20250514` (Recommended)
+1
View File
@@ -52,6 +52,7 @@ If you're not sure where Claude Code is installed:
The Claude Code provider supports these models:
- `claude-sonnet-4-20250514` (Recommended)
- `claude-opus-4-1-20250805`
- `claude-opus-4-20250514`
- `claude-3-7-sonnet-20250219`
- `claude-3-5-sonnet-20241022`
-1
View File
@@ -143,7 +143,6 @@ const baseConfig = {
format: "cjs",
sourcesContent: false,
platform: "node",
define: { "import.meta.url": "_importMetaUrl" },
banner: {
js: "const _importMetaUrl=require('url').pathToFileURL(__filename)",
},
@@ -34,25 +34,30 @@ directApiRuleTester.run("no-direct-vscode-api", noDirectVscodeApiRule, {
},
// Should allow other vscode API calls
{
code: `vscode.window.showInformationMessage("Hello")`,
filename: "test.ts",
code: `vscode.commands.registerCommand("Hello")`,
filename: "/foo/bar.ts",
},
// Should allow postMessage calls on other objects
{
code: `window.postMessage({ type: "test" }, "*")`,
filename: "test.ts",
filename: "/foo/bar.ts",
},
// Should allow variables named vscode but not calling postMessage
{
code: `const vscode = { other: "method" }; vscode.other()`,
filename: "test.ts",
filename: "/foo/bar.ts",
},
// Should allow vscode.postMessage in test files
{
code: `vscode.postMessage({ type: "newTask", text: message.text })`,
filename: "/foo/bar.test.ts",
},
],
invalid: [
// Should disallow vscode.postMessage in regular files
{
code: `vscode.postMessage({ type: "test", data: {} })`,
filename: "test.ts",
filename: "/foo/bar.ts",
errors: [
{
messageId: "useGrpcClient",
@@ -69,23 +74,13 @@ directApiRuleTester.run("no-direct-vscode-api", noDirectVscodeApiRule, {
},
],
},
// Should disallow vscode.postMessage in test files
{
code: `vscode.postMessage({ type: "newTask", text: message.text })`,
filename: "test.test.ts",
errors: [
{
messageId: "useGrpcClient",
},
],
},
// Should disallow property access for disallowed APIs
{
code: `const folders = vscode.workspace.workspaceFolders;`,
filename: "workspace.ts",
errors: [
{
messageId: "useHostBridge",
messageId: "useHostBridgeWorkspace",
},
],
},
+17 -7
View File
@@ -35,18 +35,24 @@ const disallowedApis = {
"vscode.env.openExternal": {
messageId: "useUtils",
},
// "vscode.window.showWarningMessage": {
// messageId: "useHostBridgeShowMessage",
// },
"vscode.window.showWarningMessage": {
messageId: "useHostBridgeShowMessage",
},
"vscode.window.showOpenDialog": {
messageId: "useHostBridgeShowMessage",
},
"vscode.window.showErrorMessage": {
messageId: "useHostBridgeShowMessage",
},
// "vscode.window.showInformationMessage": {
// messageId: "useHostBridgeShowMessage",
// },
"vscode.window.showInformationMessage": {
messageId: "useHostBridgeShowMessage",
},
"vscode.window.showInputBox": {
messageId: "useHostBridge",
},
"vscode.workspace.findFiles": {
messageId: "useNative",
},
}
module.exports = createRule({
@@ -87,6 +93,10 @@ module.exports = createRule({
"Use utilities in @/utils instead of calling vscode APIs directly.\n" +
"This provides a consistent abstraction across VSCode and standalone environments.\n" +
"Found: {{code}}",
useNative:
"Use a native Javascript API instead of calling the vscode API.\n" +
"This provides a consistent abstraction across VSCode and standalone environments.\n" +
"Found: {{code}}",
},
schema: [],
},
@@ -187,7 +197,7 @@ module.exports = createRule({
if (filename.includes("/standalone/runtime-files/")) {
return true
}
// Skip unit tests
// Skip checking test files
if (filename.endsWith(".test.ts")) {
return true
}
+3
View File
@@ -10,6 +10,7 @@ interface RunDiffEvalOptions {
parsingFunction: string
diffEditFunction: string
thinkingBudget: number
provider: string
parallel: boolean
verbose: boolean
testPath: string
@@ -39,6 +40,8 @@ export async function runDiffEvalHandler(options: RunDiffEvalOptions) {
options.parsingFunction,
"--diff-edit-function",
options.diffEditFunction,
"--provider",
options.provider,
]
// Conditionally add the optional arguments
+1
View File
@@ -92,6 +92,7 @@ program
.option("--parsing-function <name>", "The parsing function to use", "parseAssistantMessageV2")
.option("--diff-edit-function <name>", "The diff editing function to use", "constructNewFileContentV2")
.option("--thinking-budget <tokens>", "Set the thinking tokens budget", "0")
.option("--provider <provider>", "API provider to use (openrouter, openai)", "openrouter")
.option("--parallel", "Run tests in parallel", false)
.option("--replay", "Run evaluation from a pre-recorded LLM output, skipping the API call", false)
.option("--replay-run-id <run_id>", "The ID of the run to replay from the database")
+30 -17
View File
@@ -1,4 +1,5 @@
import { OpenRouterHandler } from "../../src/api/providers/openrouter"
import { OpenAiNativeHandler } from "../../src/api/providers/openai-native"
import { ApiHandlerOptions } from "../../src/shared/api"
import { Anthropic } from "@anthropic-ai/sdk"
@@ -54,7 +55,7 @@ interface StreamResult {
* Process the stream and return full response with timing data
*/
async function processStream(
handler: OpenRouterHandler,
handler: OpenRouterHandler | OpenAiNativeHandler,
systemPrompt: string,
messages: Anthropic.Messages.MessageParam[],
): Promise<StreamResult> {
@@ -190,19 +191,7 @@ export async function runSingleEvaluation(input: TestInput): Promise<TestResult>
}
}
const options: ApiHandlerOptions = {
openRouterApiKey: apiKey,
openRouterModelId: modelId,
thinkingBudgetTokens: thinkingBudgetTokens,
openRouterModelInfo: {
maxTokens: 10_000,
contextWindow: 1_000_000,
supportsImages: true,
supportsPromptCache: true, // may need to turn this on
inputPrice: 0,
outputPrice: 0,
},
}
const provider = input.provider || "openrouter"
// Get the output of streaming output of this llm call
let streamResult: StreamResult
@@ -214,10 +203,34 @@ export async function runSingleEvaluation(input: TestInput): Promise<TestResult>
usage: { inputTokens: 0, outputTokens: 0, cacheWriteTokens: 0, cacheReadTokens: 0, totalCost: 0 },
}
} else {
// Live mode: existing API call logic
// Live mode: provider-specific API call logic
try {
const openRouterHandler = new OpenRouterHandler(options)
streamResult = await processStream(openRouterHandler, systemPrompt, messages)
let handler: OpenRouterHandler | OpenAiNativeHandler
if (provider === "openai") {
const openAiOptions = {
openAiNativeApiKey: apiKey,
apiModelId: modelId,
}
handler = new OpenAiNativeHandler(openAiOptions)
} else {
const openRouterOptions = {
openRouterApiKey: apiKey,
openRouterModelId: modelId,
thinkingBudgetTokens: thinkingBudgetTokens,
openRouterModelInfo: {
maxTokens: 10_000,
contextWindow: 1_000_000,
supportsImages: true,
supportsPromptCache: true,
inputPrice: 0,
outputPrice: 0,
},
}
handler = new OpenRouterHandler(openRouterOptions)
}
streamResult = await processStream(handler, systemPrompt, messages)
} catch (error: any) {
return {
success: false,
+17 -6
View File
@@ -49,16 +49,25 @@ type TestResultSet = { [test_id: string]: (TestResult & { test_id?: string })[]
class NodeTestRunner {
private apiKey: string | undefined
private provider: string
private currentRunId: string | null = null
private systemPromptHash: string | null = null
private processingFunctionsHash: string | null = null
private caseIdMap: Map<string, string> = new Map() // test_id -> case_id mapping
constructor(isReplay: boolean) {
constructor(isReplay: boolean, provider: string = "openrouter") {
this.provider = provider
if (!isReplay) {
this.apiKey = process.env.OPENROUTER_API_KEY
if (!this.apiKey) {
throw new Error("OPENROUTER_API_KEY environment variable not set for a non-replay run.")
if (provider === "openai") {
this.apiKey = process.env.OPENAI_API_KEY
if (!this.apiKey) {
throw new Error("OPENAI_API_KEY environment variable not set for a non-replay run with OpenAI provider.")
}
} else {
this.apiKey = process.env.OPENROUTER_API_KEY
if (!this.apiKey) {
throw new Error("OPENROUTER_API_KEY environment variable not set for a non-replay run with OpenRouter provider.")
}
}
}
}
@@ -635,6 +644,7 @@ class NodeTestRunner {
thinkingBudgetTokens: testConfig.thinking_tokens_budget,
originalDiffEditToolCallMessage: testConfig.replay ? testCase.original_diff_edit_tool_call_message : undefined,
diffApplyFile: testConfig.diff_apply_file,
provider: this.provider,
isVerbose: isVerbose,
}
@@ -927,6 +937,7 @@ async function main() {
.option("--parsing-function <name>", "The parsing function to use", "parseAssistantMessageV2")
.option("--diff-edit-function <name>", "The diff editing function to use", "diff-06-26-25")
.option("--thinking-budget <tokens>", "Set the thinking tokens budget", "0")
.option("--provider <provider>", "API provider to use (openrouter, openai)", "openrouter")
.option("--parallel", "Run tests in parallel", false)
.option("--replay", "Run evaluation from a pre-recorded LLM output, skipping the API call", false)
.option("--replay-run-id <run_id>", "The ID of the run to replay from the database")
@@ -959,7 +970,7 @@ async function main() {
? parseInt(options.maxAttemptsPerCase, 10)
: validAttemptsPerCase * 10;
const runner = new NodeTestRunner(options.replay || !!options.replayRunId)
const runner = new NodeTestRunner(options.replay || !!options.replayRunId, options.provider)
if (options.replayRunId) {
if (!options.diffApplyFile) {
@@ -979,7 +990,7 @@ async function main() {
log(isVerbose, "Warning: Could not load OpenRouter model data. Context window filtering might be affected for OpenRouter models.");
}
const runner = new NodeTestRunner(options.replay)
const runner = new NodeTestRunner(options.replay, options.provider)
let allLoadedTestCases = runner.loadTestCases(testPath, isVerbose) // Pass isVerbose
const allProcessedTestCasesGlobal: ProcessedTestCase[] = allLoadedTestCases.map((tc) => ({
+107 -7
View File
@@ -331,6 +331,42 @@ def get_performance_grade(success_rate):
else:
return "C", "poor"
def get_error_description(error_enum, error_string=None):
"""Map error enum values to user-friendly descriptions"""
error_map = {
1: "No tool calls - Model didn't use the replace_in_file tool",
2: "Multiple tool calls - Model called multiple tools instead of one",
3: "Wrong tool call - Model used wrong tool (not replace_in_file)",
4: "Missing parameters - Tool call missing required path or diff",
5: "Wrong file edited - Model edited different file than expected",
6: "Wrong tool call - Model used wrong tool type",
7: "Wrong file edited - Model targeted incorrect file path",
8: "API/Stream error - Problem with model API connection",
9: "Configuration error - Invalid evaluation parameters",
10: "Function error - Invalid parsing/diff functions",
11: "Other error - Unexpected failure"
}
base_description = error_map.get(error_enum, f"Unknown error (code: {error_enum})")
if error_string:
return f"{base_description}: {error_string}"
return base_description
def get_error_guidance(error_enum):
"""Provide specific guidance based on error type"""
guidance_map = {
1: "💡 The model provided a response but didn't use the replace_in_file tool. Check the raw output to see what the model actually said.",
2: "💡 The model called multiple tools when it should only call replace_in_file once. Check the parsed tool call section.",
3: "💡 The model used a different tool instead of replace_in_file. This might indicate confusion about the task.",
4: "💡 The model called replace_in_file but didn't provide the required 'path' or 'diff' parameters.",
5: "💡 The model tried to edit a different file than expected. Check the parsed tool call to see which file it targeted.",
6: "💡 The model used the wrong tool type. Check the raw output to see what tool it attempted to use.",
7: "💡 The model tried to edit a different file path than expected. This could indicate path confusion or hallucination.",
}
return guidance_map.get(error_enum, "")
def render_hero_section(current_run, model_performance):
"""Render the hero section with key metrics"""
run_title = current_run['description'] if current_run['description'] else f"Run {current_run['run_id'][:8]}..."
@@ -570,12 +606,16 @@ def render_result_detail(result):
"""Render detailed view of a single result"""
st.markdown("### 🔬 Result Deep Dive")
# Check if this is a valid result
is_valid = (result['error_enum'] not in [1, 6, 7]) if not pd.isna(result['error_enum']) else True
# Check if this is a valid result (only invalid if no tool calls or wrong file)
is_valid = True
if not pd.isna(result['error_enum']):
# Only these specific errors make a result "invalid" for the benchmark:
# 1 = no_tool_calls, 5 = wrong_file_edited, 7 = wrong_file_edited
is_valid = result['error_enum'] not in [1, 5, 7]
# Show validity warning if needed
if not is_valid:
st.warning("⚠️ **This is an invalid result** - The model didn't properly call the diff edit tool or edited the wrong file. This result is excluded from success rate calculations.")
st.warning("⚠️ **This is an invalid result** - The model didn't call the replace_in_file tool or edited the wrong file. This result is excluded from success rate calculations.")
# Result metadata
col1, col2, col3, col4 = st.columns(4)
@@ -591,7 +631,10 @@ def render_result_detail(result):
st.markdown(f"**Round Trip:** {result['time_round_trip_ms']:.0f}ms")
with col4:
st.markdown(f"**Cost:** ${result['cost_usd']:.4f}")
if pd.notna(result['cost_usd']) and result['cost_usd'] is not None:
st.markdown(f"**Cost:** ${result['cost_usd']:.4f}")
else:
st.markdown(f"**Cost:** Free")
# Tabbed interface for different views
tab1, tab2, tab3, tab4 = st.tabs(["📄 File & Edits", "🤖 Raw Output", "🔧 Parsed Tool Call", "📊 Metrics"])
@@ -693,8 +736,46 @@ def render_file_and_edits_view(result):
# Show error information
st.error("❌ **Edit Failed**")
# Show detailed error reason
if not pd.isna(result['error_enum']):
st.markdown(f"**Error Code:** {result['error_enum']}")
error_description = get_error_description(
result['error_enum'],
result.get('error_string')
)
st.markdown(f"**Reason:** {error_description}")
# Show specific guidance based on error type
guidance = get_error_guidance(result['error_enum'])
if guidance:
st.info(guidance)
# For valid results that failed, check for diff application failures
elif not result['succeeded']:
# This is a valid result that failed - likely due to diff application issues
raw_output = result.get('raw_model_output', '')
# Check if we have specific error information in the raw output
if 'does not match anything in the file' in str(raw_output).lower():
st.warning("⚠️ **Diff Application Failed**")
st.info("💡 The SEARCH block in the diff didn't match any content in the original file. This usually means the model hallucinated code that doesn't exist.")
elif 'malformatted' in str(raw_output).lower() or 'malformed' in str(raw_output).lower():
st.warning("⚠️ **Diff Format Error**")
st.info("💡 The diff format was incorrect. Check the raw tool call to see the formatting issues.")
elif 'error:' in str(raw_output).lower():
# Try to extract the specific error message
lines = str(raw_output).split('\n')
error_lines = [line for line in lines if 'error:' in line.lower()]
if error_lines:
error_msg = error_lines[0].strip()
st.warning("⚠️ **Diff Application Failed**")
st.info(f"💡 {error_msg}")
else:
st.warning("⚠️ **Diff Application Failed**")
st.info("💡 The diff couldn't be applied to the original file. Check the raw output and parsed tool call for more details.")
else:
# Generic diff application failure
st.warning("⚠️ **Diff Application Failed**")
st.info("💡 The model made a valid tool call but the diff couldn't be applied to the original file. This usually indicates a mismatch between the expected and actual file content.")
else:
# Show successful edit information
st.success("✅ **Edit Successful**")
@@ -725,8 +806,25 @@ def render_file_and_edits_view(result):
if len(edited_lines) > 50:
st.text(f"... ({len(edited_lines) - 50} more lines)")
# Show parsed tool call if available
# Show raw and parsed tool calls if available
if not pd.isna(result['parsed_tool_call_json']):
with st.expander("View Raw Tool Call"):
# Extract the raw tool call text from the model output
raw_output = result['raw_model_output'] if not pd.isna(result['raw_model_output']) else ""
# Try to extract just the tool call portion
if raw_output and '<replace_in_file>' in raw_output:
# Find the tool call block
start_idx = raw_output.find('<replace_in_file>')
end_idx = raw_output.find('</replace_in_file>') + len('</replace_in_file>')
if start_idx != -1 and end_idx != -1:
raw_tool_call = raw_output[start_idx:end_idx]
st.code(raw_tool_call, language='xml')
else:
st.text("Tool call not found in raw output")
else:
st.text("No raw tool call available")
with st.expander("View Parsed Tool Call"):
try:
parsed_call = json.loads(result['parsed_tool_call_json'])
@@ -795,8 +893,10 @@ def render_metrics_view(result):
if not pd.isna(result['completion_tokens']):
st.metric("Completion Tokens", int(result['completion_tokens']))
if not pd.isna(result['cost_usd']):
if pd.notna(result['cost_usd']) and result['cost_usd'] is not None:
st.metric("Cost", f"${result['cost_usd']:.4f}")
else:
st.metric("Cost", "Free")
if not pd.isna(result['tokens_in_context']):
st.metric("Context Tokens", int(result['tokens_in_context']))
+1
View File
@@ -104,5 +104,6 @@ export interface TestInput {
thinkingBudgetTokens: number
originalDiffEditToolCallMessage?: string
diffApplyFile?: string
provider?: string
isVerbose: boolean
}
+236 -16826
View File
File diff suppressed because it is too large Load Diff
+6 -2
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.20.2",
"version": "3.21.0",
"icon": "assets/icons/icon.png",
"engines": {
"vscode": "^1.84.0"
@@ -119,7 +119,8 @@
{
"type": "webview",
"id": "claude-dev.SidebarProvider",
"name": ""
"name": "",
"icon": "assets/icons/icon.svg"
}
]
},
@@ -446,6 +447,7 @@
"@playwright/test": "^1.53.2",
"@sentry/browser": "^9.12.0",
"@streamparser/json": "^0.0.22",
"@types/uuid": "^10.0.0",
"@vscode/codicons": "^0.0.36",
"archiver": "^7.0.1",
"axios": "^1.8.2",
@@ -490,6 +492,8 @@
"tree-sitter-wasms": "^0.1.11",
"ts-morph": "^25.0.1",
"turndown": "^7.2.0",
"ulid": "^2.4.0",
"uuid": "^11.1.0",
"vscode-uri": "^3.1.0",
"web-tree-sitter": "^0.22.6",
"zod": "^3.24.2"
+21 -6
View File
@@ -1,17 +1,32 @@
import { defineConfig } from "@playwright/test"
const isGitHubAction = !!process.env.CI
const isCI = !!process?.env?.CI
const isWindow = process?.platform?.startsWith("win")
export default defineConfig({
workers: 1,
retries: 1,
testDir: "src/test/e2e",
timeout: 20000,
timeout: isCI || isWindow ? 40000 : 20000,
expect: {
timeout: 20000,
timeout: isCI || isWindow ? 5000 : 2000,
},
fullyParallel: true,
reporter: isGitHubAction ? [["github"], ["list"]] : [["list"]],
globalSetup: require.resolve("./src/test/e2e/utils/setup"),
globalTeardown: require.resolve("./src/test/e2e/utils/teardown"),
reporter: isCI ? [["github"], ["list"]] : [["list"]],
projects: [
{
name: "setup test environment",
testMatch: /global\.setup\.ts/,
teardown: "cleanup test environment",
},
{
name: "cleanup test environment",
testMatch: /global\.teardown\.ts/,
},
{
name: "e2e tests",
testMatch: /.*\.test\.ts/,
dependencies: ["setup test environment"],
},
],
})
+15
View File
@@ -27,6 +27,8 @@ service ModelsService {
rpc updateApiConfigurationProto(UpdateApiConfigurationRequest) returns (Empty);
// Refreshes and returns Groq models
rpc refreshGroqModels(EmptyRequest) returns (OpenRouterCompatibleModelInfo);
// Refreshes and returns Baseten models
rpc refreshBasetenModels(EmptyRequest) returns (OpenRouterCompatibleModelInfo);
}
// List of VS Code LM models
@@ -129,6 +131,8 @@ enum ApiProvider {
CLAUDE_CODE = 26;
MOONSHOT = 27;
HUGGINGFACE = 28;
HUAWEI_CLOUD_MAAS = 29;
BASETEN = 30;
}
// Model info for OpenAI-compatible models
@@ -229,6 +233,9 @@ message ModelsApiConfiguration {
optional string cline_account_id = 58;
optional string groq_api_key = 59;
optional string hugging_face_api_key = 60;
optional string huawei_cloud_maas_api_key = 61;
optional string baseten_api_key = 62;
optional string ollama_api_key = 63;
// Plan mode configurations
optional ApiProvider plan_mode_api_provider = 100;
@@ -255,6 +262,10 @@ message ModelsApiConfiguration {
optional OpenRouterModelInfo plan_mode_groq_model_info = 121;
optional string plan_mode_hugging_face_model_id = 122;
optional OpenRouterModelInfo plan_mode_hugging_face_model_info = 123;
optional string plan_mode_huawei_cloud_maas_model_id = 124;
optional OpenRouterModelInfo plan_mode_huawei_cloud_maas_model_info = 125;
optional string plan_mode_baseten_model_id = 126;
optional OpenRouterModelInfo plan_mode_baseten_model_info = 127;
// Act mode configurations
optional ApiProvider act_mode_api_provider = 200;
@@ -281,6 +292,10 @@ message ModelsApiConfiguration {
optional OpenRouterModelInfo act_mode_groq_model_info = 221;
optional string act_mode_hugging_face_model_id = 222;
optional OpenRouterModelInfo act_mode_hugging_face_model_info = 223;
optional string act_mode_huawei_cloud_maas_model_id = 224;
optional OpenRouterModelInfo act_mode_huawei_cloud_maas_model_info = 225;
optional string act_mode_baseten_model_id = 226;
optional OpenRouterModelInfo act_mode_baseten_model_info = 227;
repeated string favorited_model_ids = 300;
}
+28 -17
View File
@@ -6,14 +6,14 @@ option java_multiple_files = true;
service StateService {
rpc getLatestState(EmptyRequest) returns (State);
rpc updateTerminalConnectionTimeout(Int64Request) returns (Int64);
rpc updateTerminalConnectionTimeout(UpdateTerminalConnectionTimeoutRequest) returns (UpdateTerminalConnectionTimeoutResponse);
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(ResetStateRequest) returns (Empty);
rpc togglePlanActMode(TogglePlanActModeRequest) returns (Boolean);
rpc togglePlanActModeProto(TogglePlanActModeRequest) returns (Boolean);
rpc updateAutoApprovalSettings(AutoApprovalSettingsRequest) returns (Empty);
rpc updateSettings(UpdateSettingsRequest) returns (Empty);
rpc updateTelemetrySetting(TelemetrySettingRequest) returns (Empty);
@@ -43,7 +43,7 @@ message TerminalProfileUpdateResponse {
message TogglePlanActModeRequest {
Metadata metadata = 1;
ChatSettings chat_settings = 2;
PlanActMode mode = 2;
optional ChatContent chat_content = 3;
}
@@ -52,12 +52,6 @@ enum PlanActMode {
ACT = 1;
}
message ChatSettings {
PlanActMode mode = 1;
optional string preferred_language = 2;
optional string open_ai_reasoning_effort = 3;
}
message ChatContent {
optional string message = 1;
repeated string images = 2;
@@ -108,12 +102,15 @@ message UpdateSettingsRequest {
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 int32 shell_integration_timeout = 8;
optional bool terminal_reuse_enabled = 9;
optional bool mcp_responses_collapsed = 10;
optional string mcp_display_mode = 11;
optional int64 terminal_output_line_limit = 12;
optional int32 terminal_output_line_limit = 12;
optional PlanActMode mode = 13;
optional string preferred_language = 14;
optional string openai_reasoning_effort = 15;
optional bool strict_plan_mode_enabled = 16;
}
// Complete API Configuration message
@@ -153,8 +150,8 @@ message ApiConfiguration {
optional string requesty_api_key = 32;
optional string together_api_key = 33;
optional string fireworks_api_key = 34;
optional int64 fireworks_model_max_completion_tokens = 35;
optional int64 fireworks_model_max_tokens = 36;
optional int32 fireworks_model_max_completion_tokens = 35;
optional int32 fireworks_model_max_tokens = 36;
optional string qwen_api_key = 37;
optional string doubao_api_key = 38;
optional string mistral_api_key = 39;
@@ -166,7 +163,7 @@ message ApiConfiguration {
optional string xai_api_key = 45;
optional string sambanova_api_key = 46;
optional string cerebras_api_key = 47;
optional int64 request_timeout_ms = 48;
optional int32 request_timeout_ms = 48;
optional string sap_ai_core_client_id = 49;
optional string sap_ai_core_client_secret = 50;
optional string sap_ai_resource_group = 51;
@@ -174,11 +171,13 @@ message ApiConfiguration {
optional string sap_ai_core_base_url = 53;
optional string moonshot_api_key = 54;
optional string moonshot_api_line = 55;
optional string huawei_cloud_maas_api_key = 56;
optional string ollama_api_key = 57;
// Plan mode configurations
optional string plan_mode_api_provider = 100;
optional string plan_mode_api_model_id = 101;
optional int64 plan_mode_thinking_budget_tokens = 102;
optional int32 plan_mode_thinking_budget_tokens = 102;
optional string plan_mode_reasoning_effort = 103;
optional string plan_mode_vscode_lm_model_selector = 104; // JSON string
optional bool plan_mode_aws_bedrock_custom_selected = 105;
@@ -196,11 +195,13 @@ message ApiConfiguration {
optional string plan_mode_together_model_id = 117;
optional string plan_mode_fireworks_model_id = 118;
optional string plan_mode_sap_ai_core_model_id = 119;
optional string plan_mode_huawei_cloud_maas_model_id = 120;
optional string plan_mode_huawei_cloud_maas_model_info = 121;
// Act mode configurations
optional string act_mode_api_provider = 200;
optional string act_mode_api_model_id = 201;
optional int64 act_mode_thinking_budget_tokens = 202;
optional int32 act_mode_thinking_budget_tokens = 202;
optional string act_mode_reasoning_effort = 203;
optional string act_mode_vscode_lm_model_selector = 204; // JSON string
optional bool act_mode_aws_bedrock_custom_selected = 205;
@@ -218,6 +219,8 @@ message ApiConfiguration {
optional string act_mode_together_model_id = 217;
optional string act_mode_fireworks_model_id = 218;
optional string act_mode_sap_ai_core_model_id = 219;
optional string act_mode_huawei_cloud_maas_model_id = 220;
optional string act_mode_huawei_cloud_maas_model_info = 221;
// Favorited model IDs
repeated string favorited_model_ids = 300;
@@ -228,3 +231,11 @@ message ApiConfiguration {
optional string cline_account_id = 303;
}
message UpdateTerminalConnectionTimeoutRequest {
optional int32 timeout_ms = 1;
}
message UpdateTerminalConnectionTimeoutResponse {
optional int32 timeout_ms = 1;
}
+4 -1
View File
@@ -227,7 +227,7 @@ service UiService {
rpc onDidShowAnnouncement(EmptyRequest) returns (Boolean);
// Subscribe to addToInput events (when user adds content via context menu)
rpc subscribeToAddToInput(EmptyRequest) returns (stream String);
rpc subscribeToAddToInput(StringRequest) returns (stream String);
// Subscribe to MCP button clicked events
rpc subscribeToMcpButtonClicked(WebviewProviderTypeRequest) returns (stream Empty);
@@ -267,4 +267,7 @@ service UiService {
// Opens a URL in the default browser
rpc openUrl(StringRequest) returns (Empty);
// Opens the Cline walkthrough
rpc openWalkthrough(EmptyRequest) returns (Empty);
}
+4 -1
View File
@@ -84,6 +84,8 @@ message ShowSaveDialogRequest {
message ShowSaveDialogOptions {
optional string default_path = 1;
// A map of file types to extensions, e.g
// "Text Files": { "extensions": ["txt", "md"] }
map<string, FileExtensionList> filters = 2;
}
@@ -92,6 +94,7 @@ message FileExtensionList {
}
message ShowSaveDialogResponse {
// If the user cancelled the dialog, this will be empty.
optional string selected_path = 1;
}
@@ -129,4 +132,4 @@ message GetVisibleTabsRequest {
message GetVisibleTabsResponse {
repeated string paths = 1;
}
}
+48 -4
View File
@@ -10,8 +10,12 @@ import "cline/common.proto";
service WorkspaceService {
// Returns a list of the top level directories of the workspace.
rpc getWorkspacePaths(GetWorkspacePathsRequest) returns (GetWorkspacePathsResponse);
// Saves an open document if it's dirty
rpc saveOpenDocumentIfDirty(SaveOpenDocumentIfDirtyRequest) returns (cline.Empty);
// Saves an open document if it's open in the editor and has unsaved changes.
// Returns true if the document was saved, returns false if the document was not found, or did not
// need to be saved.
rpc saveOpenDocumentIfDirty(SaveOpenDocumentIfDirtyRequest) returns (SaveOpenDocumentIfDirtyResponse);
// Get diagnostics from the workspace.
rpc getDiagnostics(GetDiagnosticsRequest) returns (GetDiagnosticsResponse);
}
message GetWorkspacePathsRequest {
@@ -28,6 +32,46 @@ message GetWorkspacePathsResponse {
}
message SaveOpenDocumentIfDirtyRequest {
cline.Metadata metadata = 1;
string file_path = 2;
optional string file_path = 2;
}
message SaveOpenDocumentIfDirtyResponse {
// Returns true if the document was saved.
optional bool was_saved = 1;
}
message GetDiagnosticsRequest {
optional cline.Metadata metadata = 1;
}
message GetDiagnosticsResponse {
repeated FileDiagnostics file_diagnostics = 1;
}
message FileDiagnostics {
string file_path = 1;
repeated Diagnostic diagnostics = 2;
}
message Diagnostic {
string message = 1;
DiagnosticRange range = 2;
DiagnosticSeverity severity = 3;
optional string source = 4;
}
message DiagnosticRange {
DiagnosticPosition start = 1;
DiagnosticPosition end = 2;
}
message DiagnosticPosition {
int32 line = 1;
int32 character = 2;
}
enum DiagnosticSeverity {
DIAGNOSTIC_ERROR = 0;
DIAGNOSTIC_WARNING = 1;
DIAGNOSTIC_INFORMATION = 2;
DIAGNOSTIC_HINT = 3;
}
Regular → Executable
+1
View File
@@ -1,3 +1,4 @@
#!/usr/bin/env node
const { execSync } = require("child_process")
const esbuild = require("esbuild")
+2 -2
View File
@@ -40,11 +40,11 @@ async function generateWebviewProtobusClients(protobusServices) {
}
if (!rpc.responseStream) {
rpcs.push(` static async ${rpcName}(request: ${requestType}): Promise<${responseType}> {
return this.makeRequest("${rpcName}", request)
return this.makeUnaryRequest("${rpcName}", request, ${requestType}.toJSON, ${responseType}.fromJSON)
}`)
} else {
rpcs.push(` static ${rpcName}(request: ${requestType}, callbacks: Callbacks<${responseType}>): ()=>void {
return this.makeStreamingRequest("${rpcName}", request, callbacks)
return this.makeStreamingRequest("${rpcName}", request, ${requestType}.toJSON, ${responseType}.fromJSON, callbacks)
}`)
}
}
Regular → Executable
View File
+19 -1
View File
@@ -29,8 +29,10 @@ import { SapAiCoreHandler } from "./providers/sapaicore"
import { ClaudeCodeHandler } from "./providers/claude-code"
import { MoonshotHandler } from "./providers/moonshot"
import { GroqHandler } from "./providers/groq"
import { Mode } from "../shared/ChatSettings"
import { Mode } from "@shared/storage/types"
import { HuggingFaceHandler } from "./providers/huggingface"
import { HuaweiCloudMaaSHandler } from "./providers/huawei-cloud-maas"
import { BasetenHandler } from "./providers/baseten"
export interface ApiHandler {
createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream
@@ -111,6 +113,7 @@ function createHandlerForProvider(
case "ollama":
return new OllamaHandler({
ollamaBaseUrl: options.ollamaBaseUrl,
ollamaApiKey: options.ollamaApiKey,
ollamaModelId: mode === "plan" ? options.planModeOllamaModelId : options.actModeOllamaModelId,
ollamaApiOptionsCtxNum: options.ollamaApiOptionsCtxNum,
requestTimeoutMs: options.requestTimeoutMs,
@@ -256,6 +259,13 @@ function createHandlerForProvider(
groqModelInfo: mode === "plan" ? options.planModeGroqModelInfo : options.actModeGroqModelInfo,
apiModelId: mode === "plan" ? options.planModeApiModelId : options.actModeApiModelId,
})
case "baseten":
return new BasetenHandler({
basetenApiKey: options.basetenApiKey,
basetenModelId: mode === "plan" ? options.planModeBasetenModelId : options.actModeBasetenModelId,
basetenModelInfo: mode === "plan" ? options.planModeBasetenModelInfo : options.actModeBasetenModelInfo,
apiModelId: mode === "plan" ? options.planModeApiModelId : options.actModeApiModelId,
})
case "sapaicore":
return new SapAiCoreHandler({
sapAiCoreClientId: options.sapAiCoreClientId,
@@ -272,6 +282,14 @@ function createHandlerForProvider(
thinkingBudgetTokens:
mode === "plan" ? options.planModeThinkingBudgetTokens : options.actModeThinkingBudgetTokens,
})
case "huawei-cloud-maas":
return new HuaweiCloudMaaSHandler({
huaweiCloudMaasApiKey: options.huaweiCloudMaasApiKey,
huaweiCloudMaasModelId:
mode === "plan" ? options.planModeHuaweiCloudMaasModelId : options.actModeHuaweiCloudMaasModelId,
huaweiCloudMaasModelInfo:
mode === "plan" ? options.planModeHuaweiCloudMaasModelInfo : options.actModeHuaweiCloudMaasModelInfo,
})
default:
return new AnthropicHandler({
apiKey: options.apiKey,
+85 -84
View File
@@ -612,101 +612,102 @@ describe("AwsBedrockHandler", () => {
})
})
describe("getModelId", () => {
it("should return raw model ID for custom models", async () => {
const customOptions: ApiHandlerOptions = {
...mockOptions,
actModeAwsBedrockCustomSelected: true,
actModeApiModelId:
"arn:aws:bedrock:us-west-2:123456789012:custom-model/anthropic.claude-3-5-sonnet-20241022-v2:0/Qk8MMyLmRd",
}
const customHandler = new AwsBedrockHandler(customOptions)
// TODO: Re-enable or remove these tests.
// describe("getModelId", () => {
// it("should return raw model ID for custom models", async () => {
// const customOptions: ApiHandlerOptions = {
// ...mockOptions,
// actModeAwsBedrockCustomSelected: true,
// actModeApiModelId:
// "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",
)
})
// 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,
actModeAwsBedrockCustomSelected: true,
actModeApiModelId: "my-namespace/my-custom-model",
}
const customHandler = new AwsBedrockHandler(customOptions)
// it("should not encode custom model IDs with slashes", async () => {
// const customOptions: ApiHandlerOptions = {
// ...mockOptions,
// actModeAwsBedrockCustomSelected: true,
// actModeApiModelId: "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/)
})
// 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)
// 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")
})
// 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)
// 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")
})
// 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)
// 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")
})
// 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,
actModeAwsBedrockCustomSelected: true,
actModeApiModelId: "arn:aws:bedrock:us-west-2:123456789012:custom-model/my-model",
awsUseCrossRegionInference: true,
}
const customCrossRegionHandler = new AwsBedrockHandler(customCrossRegionOptions)
// it("should not apply cross-region prefix for custom models even when enabled", async () => {
// const customCrossRegionOptions: ApiHandlerOptions = {
// ...mockOptions,
// actModeAwsBedrockCustomSelected: true,
// actModeApiModelId: "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")
})
// 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,
actModeAwsBedrockCustomSelected: true,
actModeApiModelId:
"arn:aws:bedrock:us-west-2:123456789012:custom-model/anthropic.claude-3-5-sonnet-20241022-v2:0/Qk8MMyLmRd",
actModeAwsBedrockCustomModelBaseId: "anthropic.claude-3-5-sonnet-20241022-v2:0",
}
const ultraThinkHandler = new AwsBedrockHandler(ultraThinkOptions)
// it("should handle UltraThink model ARN correctly", async () => {
// const ultraThinkOptions: ApiHandlerOptions = {
// ...mockOptions,
// actModeAwsBedrockCustomSelected: true,
// actModeApiModelId:
// "arn:aws:bedrock:us-west-2:123456789012:custom-model/anthropic.claude-3-5-sonnet-20241022-v2:0/Qk8MMyLmRd",
// actModeAwsBedrockCustomModelBaseId: "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/)
})
})
// 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/)
// })
// })
})
+2
View File
@@ -55,6 +55,7 @@ export class AnthropicHandler implements ApiHandler {
case "claude-3-5-sonnet-20241022":
case "claude-3-5-haiku-20241022":
case "claude-opus-4-20250514":
case "claude-opus-4-1-20250805":
case "claude-3-opus-20240229":
case "claude-3-haiku-20240307": {
/*
@@ -122,6 +123,7 @@ export class AnthropicHandler implements ApiHandler {
switch (modelId) {
case "claude-sonnet-4-20250514":
case "claude-opus-4-20250514":
case "claude-opus-4-1-20250805":
case "claude-3-7-sonnet-20250219":
case "claude-3-5-sonnet-20241022":
case "claude-3-5-haiku-20241022":
+165
View File
@@ -0,0 +1,165 @@
import { Anthropic } from "@anthropic-ai/sdk"
import OpenAI from "openai"
import { withRetry } from "../retry"
import { ApiHandler } from "../"
import { BasetenModelId, ModelInfo, basetenDefaultModelId, basetenModels } from "@shared/api"
import { calculateApiCostOpenAI } from "../../utils/cost"
import { convertToOpenAiMessages } from "../transform/openai-format"
import { ApiStream } from "../transform/stream"
interface BasetenHandlerOptions {
basetenApiKey?: string
basetenModelId?: string
basetenModelInfo?: ModelInfo
apiModelId?: string // For backward compatibility
}
export class BasetenHandler implements ApiHandler {
private options: BasetenHandlerOptions
private client: OpenAI | undefined
constructor(options: BasetenHandlerOptions) {
this.options = options
}
private ensureClient(): OpenAI {
if (!this.client) {
if (!this.options.basetenApiKey) {
throw new Error("Baseten API key is required")
}
try {
this.client = new OpenAI({
baseURL: "https://inference.baseten.co/v1",
apiKey: this.options.basetenApiKey,
})
} catch (error) {
throw new Error(`Error creating Baseten client: ${error.message}`)
}
}
return this.client
}
/**
* Gets the optimal max_tokens based on model capabilities
*/
private getOptimalMaxTokens(model: { id: BasetenModelId; info: ModelInfo }): number {
// Use model-specific max tokens if available
if (model.info.maxTokens && model.info.maxTokens > 0) {
return model.info.maxTokens
}
// Default fallback
return 8192
}
getModel(): { id: BasetenModelId; info: ModelInfo } {
// First priority: basetenModelId and basetenModelInfo
const basetenModelId = this.options.basetenModelId
const basetenModelInfo = this.options.basetenModelInfo
if (basetenModelId && basetenModelInfo) {
return { id: basetenModelId as BasetenModelId, info: basetenModelInfo }
}
// Second priority: basetenModelId with static model info
if (basetenModelId && basetenModelId in basetenModels) {
const id = basetenModelId as BasetenModelId
return { id, info: basetenModels[id] }
}
// Third priority: apiModelId (for backward compatibility)
const apiModelId = this.options.apiModelId
if (apiModelId && apiModelId in basetenModels) {
const id = apiModelId as BasetenModelId
return { id, info: basetenModels[id] }
}
// Default fallback
return {
id: basetenDefaultModelId,
info: basetenModels[basetenDefaultModelId],
}
}
private async *yieldUsage(modelInfo: ModelInfo, usage: any): ApiStream {
if (usage.prompt_tokens || usage.completion_tokens) {
const cost = calculateApiCostOpenAI(modelInfo, usage.prompt_tokens || 0, usage.completion_tokens || 0)
yield {
type: "usage",
inputTokens: usage.prompt_tokens || 0,
outputTokens: usage.completion_tokens || 0,
cacheWriteTokens: 0,
cacheReadTokens: 0,
totalCost: cost,
}
}
}
@withRetry()
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
const client = this.ensureClient()
const model = this.getModel()
const maxTokens = this.getOptimalMaxTokens(model)
const openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [
{ role: "system", content: systemPrompt },
...convertToOpenAiMessages(messages),
]
const stream = await client.chat.completions.create({
model: model.id,
max_tokens: maxTokens,
messages: openAiMessages,
stream: true,
stream_options: { include_usage: true },
temperature: 0,
})
let didOutputUsage = false
for await (const chunk of stream) {
const delta = chunk.choices[0]?.delta
// Handle reasoning field if present (for reasoning models with parsed output)
if ((delta as any)?.reasoning) {
const reasoningContent = (delta as any).reasoning as string
yield {
type: "reasoning",
reasoning: reasoningContent,
}
continue
}
// Handle content field
if (delta?.content) {
yield {
type: "text",
text: delta.content,
}
}
// Handle usage information - only output once
if (!didOutputUsage && chunk.usage) {
yield* this.yieldUsage(model.info, chunk.usage)
didOutputUsage = true
}
}
}
/**
* Checks if the current model supports vision/images
*/
supportsImages(): boolean {
const model = this.getModel()
return model.info.supportsImages === true
}
/**
* Checks if the current model supports tools
*/
supportsTools(): boolean {
const model = this.getModel()
// Baseten models support tools via OpenAI-compatible API
return true
}
}
+10 -3
View File
@@ -102,6 +102,7 @@ export class CerebrasHandler implements ApiHandler {
messages: cerebrasMessages,
temperature: 0,
stream: true,
max_tokens: this.getModel().info.maxTokens,
})
// Handle streaming response
@@ -175,9 +176,15 @@ export class CerebrasHandler implements ApiHandler {
}
getModel(): { id: string; info: ModelInfo } {
const modelId = this.options.apiModelId
if (modelId && modelId in cerebrasModels) {
const id = modelId as CerebrasModelId
const originalModelId = this.options.apiModelId
let apiModelId = originalModelId
if (originalModelId === "qwen-3-coder-480b-free") {
apiModelId = "qwen-3-coder-480b"
return { id: apiModelId, info: cerebrasModels[originalModelId as CerebrasModelId] }
}
if (originalModelId && originalModelId in cerebrasModels) {
const id = originalModelId as CerebrasModelId
return { id, info: cerebrasModels[id] }
}
return {
+16 -43
View File
@@ -133,7 +133,6 @@ export class ClineHandler implements ApiHandler {
if (!didOutputUsage && chunk.usage) {
// @ts-ignore-next-line
let totalCost = (chunk.usage.cost || 0) + (chunk.usage.cost_details?.upstream_inference_cost || 0)
const modelId = this.getModel().id
// const provider = modelId.split("/")[0]
// // If provider is x-ai, set totalCost to 0 (we're doing a promo)
@@ -141,27 +140,14 @@ export class ClineHandler implements ApiHandler {
// totalCost = 0
// }
if (modelId.includes("gemini")) {
yield {
type: "usage",
cacheWriteTokens: 0,
cacheReadTokens: chunk.usage.prompt_tokens_details?.cached_tokens || 0,
inputTokens:
(chunk.usage.prompt_tokens || 0) - (chunk.usage.prompt_tokens_details?.cached_tokens || 0),
outputTokens: chunk.usage.completion_tokens || 0,
// @ts-ignore-next-line
totalCost,
}
} else {
yield {
type: "usage",
cacheWriteTokens: 0,
cacheReadTokens: chunk.usage.prompt_tokens_details?.cached_tokens || 0,
inputTokens: chunk.usage.prompt_tokens || 0,
outputTokens: chunk.usage.completion_tokens || 0,
// @ts-ignore-next-line
totalCost,
}
yield {
type: "usage",
cacheWriteTokens: 0,
cacheReadTokens: chunk.usage.prompt_tokens_details?.cached_tokens || 0,
inputTokens: (chunk.usage.prompt_tokens || 0) - (chunk.usage.prompt_tokens_details?.cached_tokens || 0),
outputTokens: chunk.usage.completion_tokens || 0,
// @ts-ignore-next-line
totalCost: totalCost,
}
didOutputUsage = true
}
@@ -195,27 +181,14 @@ export class ClineHandler implements ApiHandler {
})
const generation = response.data
let modelId = this.options.openRouterModelId
if (modelId && modelId.includes("gemini")) {
return {
type: "usage",
cacheWriteTokens: 0,
cacheReadTokens: generation?.native_tokens_cached || 0,
// openrouter generation endpoint fails often
inputTokens: (generation?.native_tokens_prompt || 0) - (generation?.native_tokens_cached || 0),
outputTokens: generation?.native_tokens_completion || 0,
totalCost: generation?.total_cost || 0,
}
} else {
return {
type: "usage",
cacheWriteTokens: 0,
cacheReadTokens: generation?.native_tokens_cached || 0,
// openrouter generation endpoint fails often
inputTokens: generation?.native_tokens_prompt || 0,
outputTokens: generation?.native_tokens_completion || 0,
totalCost: generation?.total_cost || 0,
}
return {
type: "usage",
cacheWriteTokens: 0,
cacheReadTokens: generation?.native_tokens_cached || 0,
// openrouter generation endpoint fails often
inputTokens: (generation?.native_tokens_prompt || 0) - (generation?.native_tokens_cached || 0),
outputTokens: generation?.native_tokens_completion || 0,
totalCost: generation?.total_cost || 0,
}
} catch (error) {
// ignore if fails
+53
View File
@@ -0,0 +1,53 @@
// Mock for @google/genai module to avoid ESM compatibility issues in tests
export class GoogleGenAI {
constructor(options: any) {
// Mock constructor
}
models = {
generateContentStream: async (params: any) => {
// Mock implementation that returns an async iterator
return {
async *[Symbol.asyncIterator]() {
yield {
text: "Mock response",
candidates: [],
usageMetadata: {
promptTokenCount: 100,
candidatesTokenCount: 50,
thoughtsTokenCount: 0,
cachedContentTokenCount: 0,
},
}
},
}
},
countTokens: async (params: any) => {
// Mock token counting
return {
totalTokens: 100,
}
},
}
}
// Export mock types
export interface GenerateContentConfig {
httpOptions?: any
systemInstruction?: string
temperature?: number
thinkingConfig?: any
}
export interface GenerateContentResponseUsageMetadata {
promptTokenCount?: number
candidatesTokenCount?: number
thoughtsTokenCount?: number
cachedContentTokenCount?: number
}
export interface Part {
thought?: boolean
text?: string
}
+1 -1
View File
@@ -7,7 +7,7 @@ import { ApiHandler } from "../"
import { ApiHandlerOptions, geminiDefaultModelId, GeminiModelId, geminiModels, ModelInfo } from "@shared/api"
import { convertAnthropicMessageToGemini } from "../transform/gemini-format"
import { ApiStream } from "../transform/stream"
import { telemetryService } from "@services/posthog/telemetry/TelemetryService"
import { telemetryService } from "@services/posthog/PostHogClientProvider"
// Define a default TTL for the cache (e.g., 15 minutes in seconds)
const DEFAULT_CACHE_TTL_SECONDS = 900
+132
View File
@@ -0,0 +1,132 @@
import { ApiHandler } from ".."
import { huaweiCloudMaasDefaultModelId, HuaweiCloudMaasModelId, huaweiCloudMaasModels, ModelInfo } from "@shared/api"
import { Anthropic } from "@anthropic-ai/sdk"
import OpenAI from "openai"
import { convertToOpenAiMessages } from "../transform/openai-format"
import { ApiStream } from "../transform/stream"
import { withRetry } from "../retry"
interface HuaweiCloudMaaSHandlerOptions {
huaweiCloudMaasApiKey?: string
huaweiCloudMaasModelId?: string
huaweiCloudMaasModelInfo?: ModelInfo
}
export class HuaweiCloudMaaSHandler implements ApiHandler {
private options: HuaweiCloudMaaSHandlerOptions
private client: OpenAI | undefined
constructor(options: HuaweiCloudMaaSHandlerOptions) {
this.options = options
}
private ensureClient(): OpenAI {
if (!this.client) {
if (!this.options.huaweiCloudMaasApiKey) {
throw new Error("Huawei Cloud MaaS API key is required")
}
try {
this.client = new OpenAI({
baseURL: "https://api.modelarts-maas.com/v1/",
apiKey: this.options.huaweiCloudMaasApiKey,
})
} catch (error) {
throw new Error(`Error creating Huawei Cloud MaaS client: ${error.message}`)
}
}
return this.client
}
getModel(): { id: HuaweiCloudMaasModelId; info: ModelInfo } {
// First priority: huaweiCloudMaasModelId and huaweiCloudMaasModelInfo (like Groq does)
const huaweiCloudMaasModelId = this.options.huaweiCloudMaasModelId
const huaweiCloudMaasModelInfo = this.options.huaweiCloudMaasModelInfo
if (huaweiCloudMaasModelId && huaweiCloudMaasModelInfo) {
return { id: huaweiCloudMaasModelId as HuaweiCloudMaasModelId, info: huaweiCloudMaasModelInfo }
}
// Second priority: huaweiCloudMaasModelId with static model info
if (huaweiCloudMaasModelId && huaweiCloudMaasModelId in huaweiCloudMaasModels) {
const id = huaweiCloudMaasModelId as HuaweiCloudMaasModelId
return { id, info: huaweiCloudMaasModels[id] }
}
// Default fallback
return {
id: huaweiCloudMaasDefaultModelId,
info: huaweiCloudMaasModels[huaweiCloudMaasDefaultModelId],
}
}
@withRetry()
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
const client = this.ensureClient()
const model = this.getModel()
let openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [
{ role: "system", content: systemPrompt },
...convertToOpenAiMessages(messages),
]
const stream = await client.chat.completions.create({
model: model.id,
max_completion_tokens: model.info.maxTokens,
messages: openAiMessages,
stream: true,
stream_options: { include_usage: true },
temperature: 0,
})
let reasoning: string | null = null
let didOutputUsage: boolean = false
let finalUsage: any = null
for await (const chunk of stream) {
const delta = chunk.choices[0]?.delta
// Handle reasoning content detection
if (delta?.content) {
if (reasoning || delta.content.includes("<think>")) {
reasoning = (reasoning || "") + delta.content
} else if (!reasoning) {
yield {
type: "text",
text: delta.content,
}
}
}
// Handle reasoning output
if (reasoning || (delta && "reasoning_content" in delta && delta.reasoning_content)) {
const reasoningContent = delta?.content || ((delta as any)?.reasoning_content as string | undefined) || ""
if (reasoningContent.trim()) {
yield {
type: "reasoning",
reasoning: reasoningContent,
}
}
// Check if reasoning is complete
if (reasoning?.includes("</think>")) {
reasoning = null
}
}
// Store usage information for later output
if (chunk.usage) {
finalUsage = chunk.usage
}
// Output usage when stream is finished
if (!didOutputUsage && chunk.choices?.[0]?.finish_reason) {
if (finalUsage) {
yield {
type: "usage",
inputTokens: finalUsage.prompt_tokens || 0,
outputTokens: finalUsage.completion_tokens || 0,
cacheWriteTokens: 0,
cacheReadTokens: 0,
}
}
didOutputUsage = true
}
}
}
}
+14 -2
View File
@@ -1,5 +1,5 @@
import { Anthropic } from "@anthropic-ai/sdk"
import { Message, Ollama } from "ollama"
import { Message, Ollama, Config } from "ollama"
import { ApiHandler } from "../"
import { ApiHandlerOptions, ModelInfo, openAiModelInfoSaneDefaults } from "../../shared/api"
import { convertToOllamaMessages } from "../transform/ollama-format"
@@ -8,6 +8,7 @@ import { withRetry } from "../retry"
interface OllamaHandlerOptions {
ollamaBaseUrl?: string
ollamaApiKey?: string
ollamaModelId?: string
ollamaApiOptionsCtxNum?: string
requestTimeoutMs?: number
@@ -24,7 +25,18 @@ export class OllamaHandler implements ApiHandler {
private ensureClient(): Ollama {
if (!this.client) {
try {
this.client = new Ollama({ host: this.options.ollamaBaseUrl || "http://localhost:11434" })
const clientOptions: Partial<Config> = {
host: this.options.ollamaBaseUrl || "http://localhost:11434",
}
// Add API key if provided (for Ollama cloud or authenticated instances)
if (this.options.ollamaApiKey) {
clientOptions.headers = {
Authorization: `Bearer ${this.options.ollamaApiKey}`,
}
}
this.client = new Ollama(clientOptions)
} catch (error) {
throw new Error(`Error creating Ollama client: ${error.message}`)
}
+27
View File
@@ -104,6 +104,33 @@ export class OpenAiNativeHandler implements ApiHandler {
}
break
}
case "nectarine-alpha-new-reasoning-effort-2025-07-25":
case "gpt-5-2025-08-07":
case "gpt-5-mini-2025-08-07":
case "gpt-5-nano-2025-08-07":
const stream = await client.chat.completions.create({
model: model.id,
temperature: 1,
messages: [{ role: "developer", content: systemPrompt }, ...convertToOpenAiMessages(messages)],
stream: true,
stream_options: { include_usage: true },
reasoning_effort: (this.options.reasoningEffort as ChatCompletionReasoningEffort) || "medium",
})
for await (const chunk of stream) {
const delta = chunk.choices[0]?.delta
if (delta?.content) {
yield {
type: "text",
text: delta.content,
}
}
if (chunk.usage) {
// Only last chunk contains usage
yield* this.yieldUsage(model.info, chunk.usage)
}
}
break
default: {
const stream = await client.chat.completions.create({
model: model.id,
+16 -42
View File
@@ -132,27 +132,14 @@ export class OpenRouterHandler implements ApiHandler {
}
if (!didOutputUsage && chunk.usage) {
let modelId = this.options.openRouterModelId
if (modelId && modelId.includes("gemini")) {
yield {
type: "usage",
cacheWriteTokens: 0,
cacheReadTokens: chunk.usage.prompt_tokens_details?.cached_tokens || 0,
inputTokens: (chunk.usage.prompt_tokens || 0) - (chunk.usage.prompt_tokens_details?.cached_tokens || 0),
outputTokens: chunk.usage.completion_tokens || 0,
// @ts-ignore-next-line
totalCost: (chunk.usage.cost || 0) + (chunk.usage.cost_details?.upstream_inference_cost || 0),
}
} else {
yield {
type: "usage",
cacheWriteTokens: 0,
cacheReadTokens: chunk.usage.prompt_tokens_details?.cached_tokens || 0,
inputTokens: chunk.usage.prompt_tokens || 0,
outputTokens: chunk.usage.completion_tokens || 0,
// @ts-ignore-next-line
totalCost: (chunk.usage.cost || 0) + (chunk.usage.cost_details?.upstream_inference_cost || 0),
}
yield {
type: "usage",
cacheWriteTokens: 0,
cacheReadTokens: chunk.usage.prompt_tokens_details?.cached_tokens || 0,
inputTokens: (chunk.usage.prompt_tokens || 0) - (chunk.usage.prompt_tokens_details?.cached_tokens || 0),
outputTokens: chunk.usage.completion_tokens || 0,
// @ts-ignore-next-line
totalCost: (chunk.usage.cost || 0) + (chunk.usage.cost_details?.upstream_inference_cost || 0),
}
didOutputUsage = true
}
@@ -174,27 +161,14 @@ export class OpenRouterHandler implements ApiHandler {
const generationIterator = this.fetchGenerationDetails(this.lastGenerationId)
const generation = (await generationIterator.next()).value
// console.log("OpenRouter generation details:", generation)
let modelId = this.options.openRouterModelId
if (modelId && modelId.includes("gemini")) {
return {
type: "usage",
cacheWriteTokens: 0,
cacheReadTokens: generation?.native_tokens_cached || 0,
// openrouter generation endpoint fails often
inputTokens: (generation?.native_tokens_prompt || 0) - (generation?.native_tokens_cached || 0),
outputTokens: generation?.native_tokens_completion || 0,
totalCost: generation?.total_cost || 0,
}
} else {
return {
type: "usage",
cacheWriteTokens: 0,
cacheReadTokens: generation?.native_tokens_cached || 0,
// openrouter generation endpoint fails often
inputTokens: generation?.native_tokens_prompt || 0,
outputTokens: generation?.native_tokens_completion || 0,
totalCost: generation?.total_cost || 0,
}
return {
type: "usage",
cacheWriteTokens: 0,
cacheReadTokens: generation?.native_tokens_cached || 0,
// openrouter generation endpoint fails often
inputTokens: (generation?.native_tokens_prompt || 0) - (generation?.native_tokens_cached || 0),
outputTokens: generation?.native_tokens_completion || 0,
totalCost: generation?.total_cost || 0,
}
} catch (error) {
// ignore if fails
+4 -1
View File
@@ -74,7 +74,10 @@ export class RequestyHandler implements ApiHandler {
? { thinking: { type: "enabled", budget_tokens: thinkingBudget } }
: { thinking: { type: "disabled" } }
const thinkingArgs =
model.id.includes("claude-3-7-sonnet") || model.id.includes("claude-sonnet-4") || model.id.includes("claude-opus-4")
model.id.includes("claude-3-7-sonnet") ||
model.id.includes("claude-sonnet-4") ||
model.id.includes("claude-opus-4") ||
model.id.includes("claude-opus-4-1")
? thinking
: {}
+1
View File
@@ -86,6 +86,7 @@ export class VertexHandler implements ApiHandler {
switch (modelId) {
case "claude-sonnet-4@20250514":
case "claude-opus-4-1@20250805":
case "claude-opus-4@20250514":
case "claude-3-7-sonnet@20250219":
case "claude-3-5-sonnet-v2@20241022":
+13 -71
View File
@@ -252,77 +252,19 @@ export class VsCodeLmHandler implements ApiHandler, SingleCompletionHandler {
}
private async countTokens(text: string | vscode.LanguageModelChatMessage): Promise<number> {
// For Claude models, use character-to-token ratio instead of VSCode LM's inaccurate counting
if (this.isClaudeModel()) {
const textContent = typeof text === "string" ? text : this.extractTextFromMessage(text)
// Use 4 character-to-token ratio for Claude models
return Math.ceil(textContent.length / 4)
}
// Check for required dependencies
if (!this.client) {
console.warn("Cline <Language Model API>: No client available for token counting")
return 0
}
if (!this.currentRequestCancellation) {
console.warn("Cline <Language Model API>: No cancellation token available for token counting")
return 0
}
// Validate input
if (!text) {
console.debug("Cline <Language Model API>: Empty text provided for token counting")
return 0
}
try {
// Handle different input types
let tokenCount: number
if (typeof text === "string") {
tokenCount = await this.client.countTokens(text, this.currentRequestCancellation.token)
} else if (text instanceof vscode.LanguageModelChatMessage) {
// For chat messages, ensure we have content
if (!text.content || (Array.isArray(text.content) && text.content.length === 0)) {
console.debug("Cline <Language Model API>: Empty chat message content")
return 0
}
tokenCount = await this.client.countTokens(text, this.currentRequestCancellation.token)
} else {
console.warn("Cline <Language Model API>: Invalid input type for token counting")
return 0
}
// Validate the result
if (typeof tokenCount !== "number") {
console.warn("Cline <Language Model API>: Non-numeric token count received:", tokenCount)
return 0
}
if (tokenCount < 0) {
console.warn("Cline <Language Model API>: Negative token count received:", tokenCount)
return 0
}
return tokenCount
} catch (error) {
// Handle specific error types
if (error instanceof vscode.CancellationError) {
console.debug("Cline <Language Model API>: Token counting cancelled by user")
return 0
}
const errorMessage = error instanceof Error ? error.message : "Unknown error"
console.warn("Cline <Language Model API>: Token counting failed:", errorMessage)
// Log additional error details if available
if (error instanceof Error && error.stack) {
console.debug("Token counting error stack:", error.stack)
}
return 0 // Fallback to prevent stream interruption
}
/**
* NOTE (intentional trade-off):
* We use a coarse chars/4 heuristic here instead of a real tokenizer (e.g., js-tiktoken with o200k_base).
* Rationale:
* - Avoid pulling multiMB rank files and increasing the extension install/download size.
* - Eliminate encoder lifecycle/memory concerns in long-running sessions.
* Consequences:
* - This is not model-accurate and can under/over-estimate tokens, especially with tool/function calls.
* - It is “good enough” for budgeting/context checks, and we accept the inaccuracy by design.
* If precise accounting becomes a requirement, reintroduce a tokenizer behind a feature flag or backend-only path.
*/
const textContent = typeof text === "string" ? text : this.extractTextFromMessage(text)
return Math.ceil((textContent || "").length / 4)
}
private async calculateTotalInputTokens(vsCodeLmMessages: vscode.LanguageModelChatMessage[]): Promise<number> {
+3
View File
@@ -24,6 +24,7 @@ export async function createOpenRouterStream(
// handles direct model.id match logic
switch (model.id) {
case "anthropic/claude-sonnet-4":
case "anthropic/claude-opus-4.1":
case "anthropic/claude-opus-4":
case "anthropic/claude-3.7-sonnet":
case "anthropic/claude-3.7-sonnet:beta":
@@ -82,6 +83,7 @@ export async function createOpenRouterStream(
let maxTokens: number | undefined
switch (model.id) {
case "anthropic/claude-sonnet-4":
case "anthropic/claude-opus-4.1":
case "anthropic/claude-opus-4":
case "anthropic/claude-3.7-sonnet":
case "anthropic/claude-3.7-sonnet:beta":
@@ -117,6 +119,7 @@ export async function createOpenRouterStream(
let reasoning: { max_tokens: number } | undefined = undefined
switch (model.id) {
case "anthropic/claude-sonnet-4":
case "anthropic/claude-opus-4.1":
case "anthropic/claude-opus-4":
case "anthropic/claude-3.7-sonnet":
case "anthropic/claude-3.7-sonnet:beta":
+1
View File
@@ -60,6 +60,7 @@ export const toolParamNames = [
"steps_to_reproduce",
"api_request_output",
"additional_context",
"needs_more_exploration",
] as const
export type ToolParamName = (typeof toolParamNames)[number]
@@ -1,14 +1,13 @@
import { describe, it, beforeEach, afterEach } from "mocha"
import { HostProvider } from "@/hosts/host-provider"
import { setVscodeHostProviderMock } from "@/test/host-provider-test-utils"
import * as diskModule from "@core/storage/disk"
import { expect } from "chai"
import { afterEach, beforeEach, describe, it } from "mocha"
import * as path from "path"
import * as sinon from "sinon"
import * as vscode from "vscode"
import * as path from "path"
import type { FileMetadataEntry, TaskMetadata } from "./ContextTrackerTypes"
import { FileContextTracker } from "./FileContextTracker"
import * as diskModule from "@core/storage/disk"
import type { TaskMetadata, FileMetadataEntry } from "./ContextTrackerTypes"
import type { DiffViewProviderCreator, WebviewProviderCreator } from "@/hosts/host-provider"
import { HostProvider } from "@/hosts/host-provider"
import { vscodeHostBridgeClient } from "@/hosts/vscode/hostbridge/client/host-grpc-client"
describe("FileContextTracker", () => {
let sandbox: sinon.SinonSandbox
@@ -54,13 +53,7 @@ describe("FileContextTracker", () => {
getTaskMetadataStub = sandbox.stub(diskModule, "getTaskMetadata").resolves(mockTaskMetadata)
saveTaskMetadataStub = sandbox.stub(diskModule, "saveTaskMetadata").resolves()
// Reset HostProvider before initializing to avoid "already initialized" errors
HostProvider.reset()
HostProvider.initialize(
((_) => {}) as WebviewProviderCreator,
(() => {}) as DiffViewProviderCreator,
vscodeHostBridgeClient,
)
setVscodeHostProviderMock()
// Create tracker instance
taskId = "test-task-id"
@@ -16,11 +16,9 @@ export async function relaunchChromeDebugMode(controller: Controller, _: EmptyRe
// Relaunch Chrome in debug mode
await browserSession.relaunchChromeDebugMode(controller)
// The actual result will be sent via postMessageToWebview in the BrowserSession.relaunchChromeDebugMode method
// The actual result will be sent via the ProtoBus in the BrowserSession.relaunchChromeDebugMode method
// Here we just return a message as a placeholder
return StringMessage.create({
value: "Chrome relaunch initiated",
})
return { value: "Chrome relaunch initiated" }
} catch (error) {
throw new Error(`Error relaunching Chrome: ${error instanceof Error ? error.message : globalThis.String(error)}`)
}
+412
View File
@@ -0,0 +1,412 @@
import { describe, it, beforeEach, afterEach } from "mocha"
import { expect } from "chai"
import * as sinon from "sinon"
import { handleGrpcRequest, handleGrpcRequestCancel, getRequestRegistry } from "./grpc-handler"
import { Controller } from "@core/controller"
import { GrpcRequest, GrpcCancel } from "@shared/WebviewMessage"
import { serviceHandlers } from "@generated/hosts/vscode/protobus-services"
describe("grpc-handler", () => {
let sandbox: sinon.SinonSandbox
let mockController: Controller
let mockPostMessageToWebview: sinon.SinonStub
let mockUnaryHandler: sinon.SinonStub
let mockUnaryFailingHandler: sinon.SinonStub
let mockStreamingHandler: sinon.SinonStub
let mockStreamingFailingHandler: sinon.SinonStub
const serviceName = "cline.TestService"
const mockResponse = { result: "result-1234" }
beforeEach(() => {
sandbox = sinon.createSandbox()
// Create a mock controller
mockController = {} as any
mockPostMessageToWebview = sandbox.stub().resolves()
// Create mock service handlers
mockUnaryHandler = sandbox.stub().resolves(mockResponse)
mockStreamingHandler = sandbox.stub().resolves()
mockUnaryFailingHandler = sandbox.stub().rejects(new Error("Test error unary"))
mockStreamingFailingHandler = sandbox.stub().rejects(new Error("Stream error"))
serviceHandlers[serviceName] = {
testUnary: mockUnaryHandler,
testUnaryFailing: mockUnaryFailingHandler,
testStreaming: mockStreamingHandler,
testStreamingFailing: mockStreamingFailingHandler,
}
})
afterEach(() => {
sandbox.restore()
})
describe("handleGrpcRequest", () => {
describe("Unary requests", () => {
it("should handle successful unary requests", async () => {
const request: GrpcRequest = {
service: serviceName,
method: "testUnary",
message: { input: "test" },
request_id: "test-123",
is_streaming: false,
}
await handleGrpcRequest(mockController, mockPostMessageToWebview, request)
// Verify the handler was called
expect(mockUnaryHandler.calledOnce).to.be.true
expect(mockUnaryHandler.firstCall.args[0]).to.equal(mockController)
expect(mockUnaryHandler.firstCall.args[1]).to.deep.equal({ input: "test" })
// Verify the response was sent
expect(mockPostMessageToWebview.calledOnce).to.be.true
const sentMessage = mockPostMessageToWebview.firstCall.args[0]
expect(sentMessage).to.deep.equal({
type: "grpc_response",
grpc_response: {
message: mockResponse,
request_id: "test-123",
},
})
})
it("should handle errors in unary requests", async () => {
const request: GrpcRequest = {
service: serviceName,
method: "testUnaryFailing",
message: { input: "test" },
request_id: "test-456",
is_streaming: false,
}
await handleGrpcRequest(mockController, mockPostMessageToWebview, request)
// Verify the error response was sent
expect(mockPostMessageToWebview.calledOnce).to.be.true
const sentMessage = mockPostMessageToWebview.firstCall.args[0]
expect(sentMessage).to.deep.equal({
type: "grpc_response",
grpc_response: {
error: "Test error unary",
request_id: "test-456",
is_streaming: false,
},
})
})
it("should handle unknown service errors", async () => {
const request: GrpcRequest = {
service: "UnknownService",
method: "someMethod",
message: {},
request_id: "test-789",
is_streaming: false,
}
await handleGrpcRequest(mockController, mockPostMessageToWebview, request)
// Verify the error response was sent
expect(mockPostMessageToWebview.calledOnce).to.be.true
const sentMessage = mockPostMessageToWebview.firstCall.args[0]
expect(sentMessage.type).to.equal("grpc_response")
expect(sentMessage.grpc_response?.error).to.include("Unknown service: UnknownService")
expect(sentMessage.grpc_response?.request_id).to.equal("test-789")
})
it("should handle unknown method errors", async () => {
const request: GrpcRequest = {
service: serviceName,
method: "unknownMethod",
message: {},
request_id: "test-999",
is_streaming: false,
}
await handleGrpcRequest(mockController, mockPostMessageToWebview, request)
// Verify the error response was sent
expect(mockPostMessageToWebview.calledOnce).to.be.true
const sentMessage = mockPostMessageToWebview.firstCall.args[0]
expect(sentMessage.type).to.equal("grpc_response")
expect(sentMessage.grpc_response?.error).to.include("Unknown rpc: cline.TestService.unknownMethod")
expect(sentMessage.grpc_response?.request_id).to.equal("test-999")
})
})
describe("Streaming requests", () => {
it("should handle successful streaming requests", async () => {
// Set up a streaming handler that sends multiple responses
const request: GrpcRequest = {
service: serviceName,
method: "testStreaming",
message: { input: "stream" },
request_id: "stream-123",
is_streaming: true,
}
// Reset the mock and set up the handler using callsFake
mockStreamingHandler.reset()
mockStreamingHandler.callsFake(async (controller: any, message: any, responseStream: any, requestId: string) => {
// Simulate streaming multiple messages
await responseStream({ value: 1 }, false, 0)
await responseStream({ value: 2 }, false, 1)
await responseStream({ value: 3 }, true, 2) // Last message
})
await handleGrpcRequest(mockController, mockPostMessageToWebview, request)
// Verify the handler was called
expect(mockStreamingHandler.calledOnce).to.be.true
expect(mockStreamingHandler.firstCall.args[0]).to.equal(mockController)
expect(mockStreamingHandler.firstCall.args[1]).to.deep.equal({ input: "stream" })
expect(mockStreamingHandler.firstCall.args[3]).to.equal("stream-123")
// Verify all streaming responses were sent
expect(mockPostMessageToWebview.callCount).to.equal(3)
// Check all responses
expect(mockPostMessageToWebview.firstCall.args[0]).to.deep.equal({
type: "grpc_response",
grpc_response: {
message: { value: 1 },
request_id: "stream-123",
is_streaming: true,
sequence_number: 0,
},
})
expect(mockPostMessageToWebview.secondCall.args[0]).to.deep.equal({
type: "grpc_response",
grpc_response: {
message: { value: 2 },
request_id: "stream-123",
is_streaming: true,
sequence_number: 1,
},
})
expect(mockPostMessageToWebview.thirdCall.args[0]).to.deep.equal({
type: "grpc_response",
grpc_response: {
message: { value: 3 },
request_id: "stream-123",
is_streaming: false, // Last message has is_streaming: false
sequence_number: 2,
},
})
})
it("should handle errors in streaming requests", async () => {
const request: GrpcRequest = {
service: serviceName,
method: "testStreamingFailing",
message: { input: "stream" },
request_id: "stream-456",
is_streaming: true,
}
await handleGrpcRequest(mockController, mockPostMessageToWebview, request)
// Verify the error response was sent
expect(mockPostMessageToWebview.calledOnce).to.be.true
const sentMessage = mockPostMessageToWebview.firstCall.args[0]
expect(sentMessage).to.deep.equal({
type: "grpc_response",
grpc_response: {
error: "Stream error",
request_id: "stream-456",
is_streaming: false,
},
})
})
it("should handle streaming with message, error, then another message", async () => {
// This test simulates a scenario where:
// 1. First message is sent successfully
// 2. An error occurs
// 3. Another message is attempted (which should not be sent after error)
const request: GrpcRequest = {
service: serviceName,
method: "testStreaming",
message: { input: "stream-with-error" },
request_id: "stream-error-mid",
is_streaming: true,
}
// Reset the mock and set up the handler to throw an error after being called
mockStreamingHandler.reset()
mockStreamingHandler.callsFake(async (controller: any, message: any, responseStream: any, requestId: string) => {
// Send first message successfully
await responseStream({ value: "first" }, false, 0)
// Throw an error
throw new Error("Mid-stream error")
})
await handleGrpcRequest(mockController, mockPostMessageToWebview, request)
// Verify the handler was called
expect(mockStreamingHandler.calledOnce).to.be.true
// Verify that we got the first message and then the error
expect(mockPostMessageToWebview.callCount).to.equal(2)
// Check first message was sent successfully
expect(mockPostMessageToWebview.firstCall.args[0]).to.deep.equal({
type: "grpc_response",
grpc_response: {
message: { value: "first" },
request_id: "stream-error-mid",
is_streaming: true,
sequence_number: 0,
},
})
// Check error response was sent
expect(mockPostMessageToWebview.secondCall.args[0]).to.deep.equal({
type: "grpc_response",
grpc_response: {
error: "Mid-stream error",
request_id: "stream-error-mid",
is_streaming: false,
},
})
// Try to send another message after the error (simulating what might happen
// if the handler tried to continue after an error)
const responseStream = mockStreamingHandler.firstCall.args[2]
// This should still work as the responseStream function is still valid
await responseStream({ value: "after-error" }, false, 1)
// Verify we now have 3 total calls (first message, error, after-error message)
expect(mockPostMessageToWebview.callCount).to.equal(3)
// Verify the message after error was still sent
// (In a real scenario, the handler would have stopped due to the error,
// but this tests that the responseStream function itself still works)
expect(mockPostMessageToWebview.thirdCall.args[0]).to.deep.equal({
type: "grpc_response",
grpc_response: {
message: { value: "after-error" },
request_id: "stream-error-mid",
is_streaming: true,
sequence_number: 1,
},
})
})
})
describe("handleGrpcRequestCancel", () => {
it("should cancel an active request", async () => {
// Register a request in the registry
const registry = getRequestRegistry()
const cleanupStub = sandbox.stub()
registry.registerRequest("cancel-123", cleanupStub)
const cancelRequest: GrpcCancel = {
request_id: "cancel-123",
}
await handleGrpcRequestCancel(mockPostMessageToWebview, cancelRequest)
// Verify the cleanup was called
expect(cleanupStub.calledOnce).to.be.true
// Verify the cancellation confirmation was sent
expect(mockPostMessageToWebview.calledOnce).to.be.true
const sentMessage = mockPostMessageToWebview.firstCall.args[0]
expect(sentMessage).to.deep.equal({
type: "grpc_response",
grpc_response: {
message: { cancelled: true },
request_id: "cancel-123",
is_streaming: false,
},
})
// Verify the request was removed from the registry
expect(registry.hasRequest("cancel-123")).to.be.false
})
it("should handle cancellation of non-existent request", async () => {
const cancelRequest: GrpcCancel = {
request_id: "non-existent",
}
await handleGrpcRequestCancel(mockPostMessageToWebview, cancelRequest)
// Verify no message was sent (request not found)
expect(mockPostMessageToWebview.called).to.be.false
})
it("should handle cleanup errors gracefully", async () => {
// Register a request with a failing cleanup
const registry = getRequestRegistry()
const cleanupStub = sandbox.stub().throws(new Error("Cleanup failed"))
registry.registerRequest("cancel-error", cleanupStub)
const cancelRequest: GrpcCancel = {
request_id: "cancel-error",
}
// Should not throw
await handleGrpcRequestCancel(mockPostMessageToWebview, cancelRequest)
// Verify the cleanup was attempted
expect(cleanupStub.calledOnce).to.be.true
// Verify the cancellation confirmation was still sent
expect(mockPostMessageToWebview.calledOnce).to.be.true
// Verify the request was removed despite the error
expect(registry.hasRequest("cancel-error")).to.be.false
})
})
describe("Concurrent requests", () => {
it("should handle concurrent requests", async () => {
// Set up handlers
mockUnaryHandler.resolves({ result: "unary" })
mockStreamingHandler.callsFake(async (_controller: any, _message: any, responseStream: any) => {
await responseStream({ value: "stream1" }, false, 0)
await responseStream({ value: "stream2" }, true, 1)
})
// Send multiple requests concurrently
const requests = [
handleGrpcRequest(mockController, mockPostMessageToWebview, {
service: serviceName,
method: "testUnary",
message: { id: 1 },
request_id: "concurrent-1",
is_streaming: false,
}),
handleGrpcRequest(mockController, mockPostMessageToWebview, {
service: serviceName,
method: "testStreaming",
message: { id: 2 },
request_id: "concurrent-2",
is_streaming: true,
}),
handleGrpcRequest(mockController, mockPostMessageToWebview, {
service: serviceName,
method: "testUnary",
message: { id: 3 },
request_id: "concurrent-3",
is_streaming: false,
}),
]
await Promise.all(requests)
// Verify all handlers were called
expect(mockUnaryHandler.callCount).to.equal(2)
expect(mockStreamingHandler.callCount).to.equal(1)
// Verify all responses were sent (2 unary + 2 streaming)
expect(mockPostMessageToWebview.callCount).to.equal(4)
})
})
})
})
+101 -158
View File
@@ -1,6 +1,8 @@
import { Controller } from "./index"
import { serviceHandlers } from "@generated/hosts/vscode/protobus-services"
import { GrpcRequestRegistry } from "./grpc-request-registry"
import { GrpcCancel, GrpcRequest } from "@/shared/WebviewMessage"
import { ExtensionMessage } from "@/shared/ExtensionMessage"
/**
* Type definition for a streaming response handler
@@ -11,184 +13,122 @@ export type StreamingResponseHandler<TResponse> = (
sequenceNumber?: number,
) => Promise<void>
/**
* Handles gRPC requests from the webview
*/
export class GrpcHandler {
constructor(private controller: Controller) {}
/**
* Handle a gRPC request from the webview
* @param service The service name
* @param method The method name
* @param message The request message
* @param requestId The request ID for response correlation
* @param isStreaming Whether this is a streaming request
* @returns The response message or error for unary requests, void for streaming requests
*/
async handleRequest(
service: string,
method: string,
message: any,
requestId: string,
isStreaming: boolean = false,
): Promise<{
message?: any
error?: string
request_id: string
} | void> {
try {
// If this is a streaming request, use the streaming handler
if (isStreaming) {
await this.handleStreamingRequest(service, method, message, requestId)
return
}
// Get the service handler from the config
const handler = getHandler(service, method)
// Handle unary request
return {
message: await handler(this.controller, message),
request_id: requestId,
}
} catch (error) {
console.log("Protobus error:", error)
return {
error: error instanceof Error ? error.message : String(error),
request_id: requestId,
}
}
}
/**
* Handle a streaming gRPC request
* @param service The service name
* @param method The method name
* @param message The request message
* @param requestId The request ID for response correlation
*/
private async handleStreamingRequest(service: string, method: string, message: any, requestId: string): Promise<void> {
// Create a response stream function
const responseStream: StreamingResponseHandler<any> = async (
response: any,
isLast: boolean = false,
sequenceNumber?: number,
) => {
await this.controller.postMessageToWebview({
type: "grpc_response",
grpc_response: {
message: response,
request_id: requestId,
is_streaming: !isLast,
sequence_number: sequenceNumber,
},
})
}
try {
// Get the service handler from the config
const handler = getHandler(service, method)
// Handle streaming request and pass the requestId to all streaming handlers
await handler(this.controller, message, responseStream, requestId)
// 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
} catch (error) {
// Send error response
console.log("Protobus error:", error)
await this.controller.postMessageToWebview({
type: "grpc_response",
grpc_response: {
error: error instanceof Error ? error.message : String(error),
request_id: requestId,
is_streaming: false,
},
})
}
}
}
// Registry to track active gRPC requests and their cleanup functions
const requestRegistry = new GrpcRequestRegistry()
export type PostMessageToWebview = (message: ExtensionMessage) => Thenable<boolean | undefined>
/**
* Handle a gRPC request from the webview
* @param controller The controller instance
* @param request The gRPC request
* Handles a gRPC request from the webview.
*/
export async function handleGrpcRequest(
controller: Controller,
request: {
service: string
method: string
message: any
request_id: string
is_streaming?: boolean
},
) {
postMessageToWebview: PostMessageToWebview,
request: GrpcRequest,
): Promise<void> {
if (request.is_streaming) {
await handleStreamingRequest(controller, postMessageToWebview, request)
} else {
await handleUnaryRequest(controller, postMessageToWebview, request)
}
}
/**
* Handles a gRPC unary request from the webview.
*
* Calls the handler using the service and method name, and then posts the result back to the webview.
*/
async function handleUnaryRequest(
controller: Controller,
postMessageToWebview: PostMessageToWebview,
request: GrpcRequest,
): Promise<void> {
try {
const grpcHandler = new GrpcHandler(controller)
// For streaming requests, handleRequest handles sending responses directly
if (request.is_streaming) {
try {
await grpcHandler.handleRequest(request.service, request.method, request.message, request.request_id, true)
} finally {
// Note: We don't automatically clean up here anymore
// The request will be cleaned up when it completes or is cancelled
}
return
}
// For unary requests, we get a response and send it back
const response = (await grpcHandler.handleRequest(
request.service,
request.method,
request.message,
request.request_id,
false,
)) as {
message?: any
error?: string
request_id: string
}
// Send the response back to the webview
await controller.postMessageToWebview({
// Get the service handler from the config
const handler = getHandler(request.service, request.method)
// Handle unary request
const response = await handler(controller, request.message)
// Send response to the webview
await postMessageToWebview({
type: "grpc_response",
grpc_response: response,
grpc_response: {
message: response,
request_id: request.request_id,
},
})
} catch (error) {
// Send error response
console.log("Protobus error:", error)
await controller.postMessageToWebview({
await postMessageToWebview({
type: "grpc_response",
grpc_response: {
error: error instanceof Error ? error.message : String(error),
request_id: request.request_id,
is_streaming: false,
},
})
}
}
/**
* Handle a gRPC request cancellation from the webview
* Handle a streaming gRPC request from the webview.
*
* Calls the handler using the service and method name, and creates a streaming response handler
* which posts results back to the webview.
*/
async function handleStreamingRequest(
controller: Controller,
postMessageToWebview: PostMessageToWebview,
request: GrpcRequest,
): Promise<void> {
// Create a response stream function
const responseStream: StreamingResponseHandler<any> = async (
response: any,
isLast: boolean = false,
sequenceNumber?: number,
) => {
await postMessageToWebview({
type: "grpc_response",
grpc_response: {
message: response,
request_id: request.request_id,
is_streaming: !isLast,
sequence_number: sequenceNumber,
},
})
}
try {
// Get the service handler from the config
const handler = getHandler(request.service, request.method)
// Handle streaming request and pass the requestId to all streaming handlers
await handler(controller, request.message, responseStream, request.request_id)
// 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
} catch (error) {
// Send error response
console.log("Protobus error:", error)
await postMessageToWebview({
type: "grpc_response",
grpc_response: {
error: error instanceof Error ? error.message : String(error),
request_id: request.request_id,
is_streaming: false,
},
})
}
}
/**
* Handles a gRPC request cancellation from the webview.
* @param controller The controller instance
* @param request The cancellation request
*/
export async function handleGrpcRequestCancel(
controller: Controller,
request: {
request_id: string
},
) {
export async function handleGrpcRequestCancel(postMessageToWebview: PostMessageToWebview, request: GrpcCancel) {
const cancelled = requestRegistry.cancelRequest(request.request_id)
if (cancelled) {
// Send a cancellation confirmation
await controller.postMessageToWebview({
await postMessageToWebview({
type: "grpc_response",
grpc_response: {
message: { cancelled: true },
@@ -201,6 +141,17 @@ export async function handleGrpcRequestCancel(
}
}
// Registry to track active gRPC requests and their cleanup functions
const requestRegistry = new GrpcRequestRegistry()
/**
* Get the request registry instance
* This allows other parts of the code to access the registry
*/
export function getRequestRegistry(): GrpcRequestRegistry {
return requestRegistry
}
function getHandler(serviceName: string, methodName: string): any {
// Get the service handler from the config
const serviceConfig = serviceHandlers[serviceName]
@@ -213,11 +164,3 @@ function getHandler(serviceName: string, methodName: string): any {
}
return handler
}
/**
* Get the request registry instance
* This allows other parts of the code to access the registry
*/
export function getRequestRegistry(): GrpcRequestRegistry {
return requestRegistry
}
+144 -198
View File
@@ -1,8 +1,9 @@
import { clineEnvConfig } from "@/config"
import { HostProvider } from "@/hosts/host-provider"
import { AuthService } from "@/services/auth/AuthService"
import { telemetryService } from "@/services/posthog/telemetry/TelemetryService"
import { PostHogClientProvider, telemetryService } from "@/services/posthog/PostHogClientProvider"
import { ShowMessageType } from "@/shared/proto/host/window"
import { getLatestAnnouncementId } from "@/utils/announcements"
import { getCwd, getDesktopDir } from "@/utils/path"
import { Anthropic } from "@anthropic-ai/sdk"
import { buildApiHandler } from "@api/index"
@@ -13,14 +14,12 @@ import { ClineAccountService } from "@services/account/ClineAccountService"
import { McpHub } from "@services/mcp/McpHub"
import { ApiProvider, ModelInfo } from "@shared/api"
import { ChatContent } from "@shared/ChatContent"
import { ChatSettings, Mode, StoredChatSettings } from "@shared/ChatSettings"
import { ClineRulesToggles } from "@shared/cline-rules"
import { ExtensionMessage, ExtensionState, Platform } from "@shared/ExtensionMessage"
import { ExtensionState, Platform } from "@shared/ExtensionMessage"
import { HistoryItem } from "@shared/HistoryItem"
import { McpMarketplaceCatalog } from "@shared/mcp"
import { Mode } from "@shared/storage/types"
import { TelemetrySetting } from "@shared/TelemetrySetting"
import { UserInfo } from "@shared/UserInfo"
import { WebviewMessage } from "@shared/WebviewMessage"
import { fileExistsAtPath } from "@utils/fs"
import axios from "axios"
import fs from "fs/promises"
@@ -28,13 +27,14 @@ import { setTimeout as setTimeoutPromise } from "node:timers/promises"
import pWaitFor from "p-wait-for"
import * as path from "path"
import * as vscode from "vscode"
import { CacheService, PersistenceErrorEvent } from "../storage/CacheService"
import { ensureMcpServersDirectoryExists, ensureSettingsDirectoryExists, GlobalFileNames } from "../storage/disk"
import { getAllExtensionState, getGlobalState, getWorkspaceState, storeSecret, updateGlobalState } from "../storage/state"
import { getAllExtensionState, getGlobalState, updateGlobalState } from "../storage/state"
import { Task } from "../task"
import { handleGrpcRequest, handleGrpcRequestCancel } from "./grpc-handler"
import { sendMcpMarketplaceCatalogEvent } from "./mcp/subscribeToMcpMarketplaceCatalog"
import { sendStateUpdate } from "./state/subscribeToState"
import { sendAddToInputEvent } from "./ui/subscribeToAddToInput"
import { sendAddToInputEvent, sendAddToInputEventToClient } from "./ui/subscribeToAddToInput"
import { WebviewProvider } from "../webview"
/*
https://github.com/microsoft/vscode-webview-ui-toolkit-samples/blob/main/default/weather-webview/src/providers/WeatherViewProvider.ts
@@ -44,42 +44,63 @@ https://github.com/KumarVariable/vscode-extension-sidebar-html/blob/master/src/c
export class Controller {
readonly id: string
private postMessage: (message: ExtensionMessage) => Thenable<boolean> | undefined
private disposables: vscode.Disposable[] = []
task?: Task
workspaceTracker: WorkspaceTracker
mcpHub: McpHub
accountService: ClineAccountService
authService: AuthService
get latestAnnouncementId(): string {
return this.context.extension?.packageJSON?.version?.split(".").slice(0, 2).join(".") ?? ""
}
readonly cacheService: CacheService
constructor(
readonly context: vscode.ExtensionContext,
private readonly outputChannel: vscode.OutputChannel,
postMessage: (message: ExtensionMessage) => Thenable<boolean> | undefined,
id: string,
) {
this.id = id
this.outputChannel.appendLine("ClineProvider instantiated")
this.postMessage = postMessage
HostProvider.get().logToChannel("ClineProvider instantiated")
this.accountService = ClineAccountService.getInstance()
this.cacheService = new CacheService(context)
const authService = AuthService.getInstance(this)
// Initialize cache service asynchronously - critical for extension functionality
this.cacheService
.initialize()
.then(() => {
authService.restoreRefreshTokenAndRetrieveAuthInfo()
})
.catch((error) => {
console.error("CRITICAL: Failed to initialize CacheService - extension may not function properly:", error)
})
// Set up persistence error recovery
this.cacheService.onPersistenceError = async ({ error }: PersistenceErrorEvent) => {
console.error("Cache persistence failed, recovering:", error)
try {
await this.cacheService.reInitialize()
await this.postStateToWebview()
HostProvider.window.showMessage({
type: ShowMessageType.WARNING,
message: "Saving settings to storage failed.",
})
} catch (recoveryError) {
console.error("Cache recovery failed:", recoveryError)
HostProvider.window.showMessage({
type: ShowMessageType.ERROR,
message: "Failed to save settings. Please restart the extension.",
})
}
}
this.workspaceTracker = new WorkspaceTracker()
this.mcpHub = new McpHub(
() => ensureMcpServersDirectoryExists(),
() => ensureSettingsDirectoryExists(this.context),
(msg) => this.postMessageToWebview(msg),
this.context.extension?.packageJSON?.version ?? "1.0.0",
)
this.accountService = ClineAccountService.getInstance()
this.authService = AuthService.getInstance(context)
this.authService.restoreRefreshTokenAndRetrieveAuthInfo()
// Clean up legacy checkpoints
cleanupLegacyCheckpoints(this.context.globalStorageUri.fsPath, this.outputChannel).catch((error) => {
cleanupLegacyCheckpoints(this.context.globalStorageUri.fsPath).catch((error) => {
console.error("Failed to cleanup legacy checkpoints:", error)
})
}
@@ -111,12 +132,18 @@ export class Controller {
async handleSignOut() {
try {
// TODO: update to clineAccountId and then move clineApiKey to a clear function.
await storeSecret(this.context, "clineAccountId", undefined)
this.cacheService.setSecret("clineAccountId", undefined)
await updateGlobalState(this.context, "userInfo", undefined)
await Promise.all([
updateGlobalState(this.context, "planModeApiProvider", "openrouter"),
updateGlobalState(this.context, "actModeApiProvider", "openrouter"),
])
// Update API providers through cache service
const apiConfiguration = this.cacheService.getApiConfiguration()
const updatedConfig = {
...apiConfiguration,
planModeApiProvider: "openrouter" as ApiProvider,
actModeApiProvider: "openrouter" as ApiProvider,
}
this.cacheService.setApiConfiguration(updatedConfig)
await this.postStateToWebview()
HostProvider.window.showMessage({
type: ShowMessageType.INFORMATION,
@@ -136,11 +163,16 @@ export class Controller {
async initTask(task?: string, images?: string[], files?: string[], historyItem?: HistoryItem) {
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
// Get API configuration from cache for immediate access
const apiConfiguration = this.cacheService.getApiConfiguration()
const {
apiConfiguration,
autoApprovalSettings,
browserSettings,
chatSettings: storedChatSettings,
preferredLanguage,
openaiReasoningEffort,
mode,
shellIntegrationTimeout,
terminalReuseEnabled,
terminalOutputLineLimit,
@@ -148,17 +180,9 @@ export class Controller {
enableCheckpointsSetting,
isNewUser,
taskHistory,
strictPlanModeEnabled,
} = await getAllExtensionState(this.context)
// Get current mode using helper function
const currentMode = await this.getCurrentMode()
// Reconstruct ChatSettings with mode from global state and stored preferences
const chatSettings: ChatSettings = {
...storedChatSettings, // Spread stored preferences (preferredLanguage, openAIReasoningEffort)
mode: currentMode, // Use mode from global state
}
const NEW_USER_TASK_COUNT_THRESHOLD = 10
// Check if the user has completed enough tasks to no longer be considered a "new user"
@@ -185,13 +209,17 @@ export class Controller {
apiConfiguration,
autoApprovalSettings,
browserSettings,
chatSettings,
preferredLanguage,
openaiReasoningEffort,
mode,
strictPlanModeEnabled ?? false,
shellIntegrationTimeout,
terminalReuseEnabled ?? true,
terminalOutputLineLimit ?? 500,
defaultTerminalProfile ?? "default",
enableCheckpointsSetting ?? true,
await getCwd(getDesktopDir()),
this.cacheService,
task,
images,
files,
@@ -206,41 +234,6 @@ export class Controller {
}
}
// Send any JSON serializable data to the react app
async postMessageToWebview(message: ExtensionMessage) {
await this.postMessage(message)
}
/**
* Sets up an event listener to listen for messages passed from the webview context and
* executes code based on the message that is received.
*
* @param webview A reference to the extension webview
*/
async handleWebviewMessage(message: WebviewMessage) {
switch (message.type) {
case "fetchMcpMarketplace": {
await this.fetchMcpMarketplace(message.bool)
break
}
case "grpc_request": {
if (message.grpc_request) {
await handleGrpcRequest(this, message.grpc_request)
}
break
}
case "grpc_request_cancel": {
if (message.grpc_request_cancel) {
await handleGrpcRequestCancel(this, message.grpc_request_cancel)
}
break
}
// Add more switch case statements here as more webview message commands
// are created within the webview context (i.e. inside media/main.js)
}
}
async updateTelemetrySetting(telemetrySetting: TelemetrySetting) {
await updateGlobalState(this.context, "telemetrySetting", telemetrySetting)
const isOptedIn = telemetrySetting !== "disabled"
@@ -248,28 +241,25 @@ export class Controller {
await this.postStateToWebview()
}
async togglePlanActModeWithChatSettings(chatSettings: ChatSettings, chatContent?: ChatContent): Promise<boolean> {
const didSwitchToActMode = chatSettings.mode === "act"
async togglePlanActMode(modeToSwitchTo: Mode, chatContent?: ChatContent): Promise<boolean> {
const didSwitchToActMode = modeToSwitchTo === "act"
// Store mode to global state
await updateGlobalState(this.context, "mode", chatSettings.mode)
await updateGlobalState(this.context, "mode", modeToSwitchTo)
// Capture mode switch telemetry | Capture regardless of if we know the taskId
telemetryService.captureModeSwitch(this.task?.taskId ?? "0", chatSettings.mode)
telemetryService.captureModeSwitch(this.task?.taskId ?? "0", modeToSwitchTo)
// Update API handler with new mode (buildApiHandler now selects provider based on mode)
if (this.task) {
const { apiConfiguration } = await getAllExtensionState(this.context)
this.task.api = buildApiHandler({ ...apiConfiguration, taskId: this.task.taskId }, chatSettings.mode)
const apiConfiguration = this.cacheService.getApiConfiguration()
this.task.api = buildApiHandler({ ...apiConfiguration, taskId: this.task.taskId }, modeToSwitchTo)
}
// Save only non-mode properties to global storage
const { mode, ...persistentChatSettings }: { mode: string } & StoredChatSettings = chatSettings
await updateGlobalState(this.context, "chatSettings", persistentChatSettings)
await this.postStateToWebview()
if (this.task) {
this.task.chatSettings = chatSettings
this.task.updateMode(modeToSwitchTo)
if (this.task.taskState.isAwaitingPlanResponse && didSwitchToActMode) {
this.task.taskState.didRespondToPlanAskBySwitchingMode = true
// Use chatContent if provided, otherwise use default message
@@ -315,13 +305,14 @@ export class Controller {
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
// Dont send the state to the webview, the new Cline instance will send state when it's ready.
// Sending the state here sent an empty messages array to webview leading to virtuoso having to reload the entire list
}
}
async handleAuthCallback(customToken: string, provider: string | null = null) {
try {
await this.authService.handleAuthCallback(customToken, provider ? provider : "google")
await AuthService.getInstance(this).handleAuthCallback(customToken, provider ? provider : "google")
const clineProvider: ApiProvider = "cline"
@@ -329,27 +320,26 @@ export class Controller {
const { planActSeparateModelsSetting } = await getAllExtensionState(this.context)
const currentMode = await this.getCurrentMode()
// Get current API configuration from cache
const currentApiConfiguration = this.cacheService.getApiConfiguration()
const updatedConfig = { ...currentApiConfiguration }
if (planActSeparateModelsSetting) {
// Only update the current mode's provider
if (currentMode === "plan") {
await updateGlobalState(this.context, "planModeApiProvider", clineProvider)
updatedConfig.planModeApiProvider = clineProvider
} else {
await updateGlobalState(this.context, "actModeApiProvider", clineProvider)
updatedConfig.actModeApiProvider = clineProvider
}
} else {
// Update both modes to keep them in sync
await Promise.all([
updateGlobalState(this.context, "planModeApiProvider", clineProvider),
updateGlobalState(this.context, "actModeApiProvider", clineProvider),
])
updatedConfig.planModeApiProvider = clineProvider
updatedConfig.actModeApiProvider = clineProvider
}
// Get the updated API configuration (now includes the updated providers)
const { apiConfiguration } = await getAllExtensionState(this.context)
const updatedConfig = {
...apiConfiguration,
apiProvider: clineProvider,
}
// Update the API configuration through cache service
this.cacheService.setApiConfiguration(updatedConfig)
// Mark welcome view as completed since user has successfully logged in
await updateGlobalState(this.context, "welcomeViewCompleted", true)
@@ -456,7 +446,7 @@ export class Controller {
/**
* RPC variant that silently refreshes the MCP marketplace catalog and returns the result
* Unlike silentlyRefreshMcpMarketplace, this doesn't post a message to the webview
* Unlike silentlyRefreshMcpMarketplace, this doesn't send a message to the webview
* @returns MCP marketplace catalog or undefined if refresh failed
*/
async silentlyRefreshMcpMarketplaceRPC() {
@@ -468,31 +458,6 @@ export class Controller {
}
}
private async fetchMcpMarketplace(forceRefresh: boolean = false) {
try {
// Check if we have cached data
const cachedCatalog = (await getGlobalState(this.context, "mcpMarketplaceCatalog")) as
| McpMarketplaceCatalog
| undefined
if (!forceRefresh && cachedCatalog?.items) {
await sendMcpMarketplaceCatalogEvent(cachedCatalog)
return
}
const catalog = await this.fetchMcpMarketplaceFromApi(false)
if (catalog) {
await sendMcpMarketplaceCatalogEvent(catalog)
}
} catch (error) {
console.error("Failed to handle cached MCP marketplace:", error)
const errorMessage = error instanceof Error ? error.message : "Failed to handle cached MCP marketplace"
HostProvider.window.showMessage({
type: ShowMessageType.ERROR,
message: errorMessage,
})
}
}
// OpenRouter
async handleOpenRouterCallback(code: string) {
@@ -511,23 +476,22 @@ export class Controller {
const openrouter: ApiProvider = "openrouter"
const currentMode = await this.getCurrentMode()
await Promise.all([
updateGlobalState(this.context, "planModeApiProvider", openrouter),
updateGlobalState(this.context, "actModeApiProvider", openrouter),
])
await storeSecret(this.context, "openRouterApiKey", apiKey)
// Update API configuration through cache service
const currentApiConfiguration = this.cacheService.getApiConfiguration()
const updatedConfig = {
...currentApiConfiguration,
planModeApiProvider: openrouter,
actModeApiProvider: openrouter,
openRouterApiKey: apiKey,
}
this.cacheService.setApiConfiguration(updatedConfig)
await this.postStateToWebview()
if (this.task) {
// Get the updated API configuration (now includes the updated providers)
const { apiConfiguration } = await getAllExtensionState(this.context)
const updatedConfig = {
...apiConfiguration,
openRouterApiKey: apiKey,
taskId: this.task.taskId,
}
this.task.api = buildApiHandler(updatedConfig, currentMode)
this.task.api = buildApiHandler({ ...updatedConfig, taskId: this.task.taskId }, currentMode)
}
// await this.postMessageToWebview({ type: "action", action: "settingsButtonClicked" }) // bad ux if user is on welcome
// Dont send settingsButtonClicked because its bad ux if user is on welcome
}
private async ensureCacheDirectoryExists(): Promise<string> {
@@ -560,10 +524,6 @@ export class Controller {
// 'Add to Cline' context menu in editor and code action
async addSelectedCodeToChat(code: string, filePath: string, languageId: string, diagnostics?: vscode.Diagnostic[]) {
// Ensure the sidebar view is visible
await vscode.commands.executeCommand("claude-dev.SidebarProvider.focus")
await setTimeoutPromise(100)
// Post message to webview with the selected code
const fileMention = await this.getFileMentionFromPath(filePath)
@@ -573,7 +533,10 @@ export class Controller {
input += `\nProblems:\n${problemsString}`
}
await sendAddToInputEvent(input)
const lastActiveWebview = WebviewProvider.getLastActiveInstance()
if (lastActiveWebview) {
await sendAddToInputEventToClient(lastActiveWebview.getClientId(), input)
}
console.log("addSelectedCodeToChat", code, filePath, languageId)
}
@@ -583,14 +546,6 @@ export class Controller {
// Ensure the sidebar view is visible
await vscode.commands.executeCommand("claude-dev.SidebarProvider.focus")
await setTimeoutPromise(100)
// Post message to webview with the selected terminal output
// await this.postMessageToWebview({
// type: "addSelectedTerminalOutput",
// output,
// terminalName
// })
await sendAddToInputEvent(`Terminal output:\n\`\`\`\n${output}\n\`\`\``)
console.log("addSelectedTerminalOutputToChat", output, terminalName)
@@ -699,13 +654,18 @@ export class Controller {
}
async getStateToPostToWebview(): Promise<ExtensionState> {
// Get API configuration from cache for immediate access
const apiConfiguration = this.cacheService.getApiConfiguration()
const {
apiConfiguration,
lastShownAnnouncementId,
taskHistory,
autoApprovalSettings,
browserSettings,
chatSettings: storedChatSettings,
preferredLanguage,
openaiReasoningEffort,
mode,
strictPlanModeEnabled,
userInfo,
mcpMarketplaceEnabled,
mcpDisplayMode,
@@ -721,51 +681,51 @@ export class Controller {
welcomeViewCompleted,
mcpResponsesCollapsed,
terminalOutputLineLimit,
localClineRulesToggles,
localWindsurfRulesToggles,
localCursorRulesToggles,
localWorkflowToggles,
} = await getAllExtensionState(this.context)
// Get current mode using helper function
const currentMode = await this.getCurrentMode()
const currentTaskItem = this.task?.taskId ? (taskHistory || []).find((item) => item.id === this.task?.taskId) : undefined
const checkpointTrackerErrorMessage = this.task?.taskState.checkpointTrackerErrorMessage
const clineMessages = this.task?.messageStateHandler.getClineMessages() || []
// Reconstruct ChatSettings with mode from global state and stored preferences
const chatSettings: ChatSettings = {
...storedChatSettings, // Spread stored preferences (preferredLanguage, openAIReasoningEffort)
mode: currentMode, // Use mode from global state
}
const processedTaskHistory = (taskHistory || [])
.filter((item) => item.ts && item.task)
.sort((a, b) => b.ts - a.ts)
.slice(0, 100) // for now we're only getting the latest 100 tasks, but a better solution here is to only pass in 3 for recent task history, and then get the full task history on demand when going to the task history view (maybe with pagination?)
const localClineRulesToggles =
((await getWorkspaceState(this.context, "localClineRulesToggles")) as ClineRulesToggles) || {}
const localWindsurfRulesToggles =
((await getWorkspaceState(this.context, "localWindsurfRulesToggles")) as ClineRulesToggles) || {}
const localCursorRulesToggles =
((await getWorkspaceState(this.context, "localCursorRulesToggles")) as ClineRulesToggles) || {}
const localWorkflowToggles = ((await getWorkspaceState(this.context, "workflowToggles")) as ClineRulesToggles) || {}
const latestAnnouncementId = getLatestAnnouncementId(this.context)
const shouldShowAnnouncement = lastShownAnnouncementId !== latestAnnouncementId
const platform = process.platform as Platform
const distinctId = PostHogClientProvider.getInstance().distinctId
const version = this.context.extension?.packageJSON?.version ?? ""
const uriScheme = vscode.env.uriScheme
return {
version: this.context.extension?.packageJSON?.version ?? "",
version,
apiConfiguration,
uriScheme: vscode.env.uriScheme,
currentTaskItem: this.task?.taskId ? (taskHistory || []).find((item) => item.id === this.task?.taskId) : undefined,
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)
.slice(0, 100), // for now we're only getting the latest 100 tasks, but a better solution here is to only pass in 3 for recent task history, and then get the full task history on demand when going to the task history view (maybe with pagination?)
shouldShowAnnouncement: lastShownAnnouncementId !== this.latestAnnouncementId,
platform: process.platform as Platform,
uriScheme,
currentTaskItem,
checkpointTrackerErrorMessage,
clineMessages,
taskHistory: processedTaskHistory,
shouldShowAnnouncement,
platform,
autoApprovalSettings,
browserSettings,
chatSettings,
preferredLanguage,
openaiReasoningEffort,
mode,
strictPlanModeEnabled,
userInfo,
mcpMarketplaceEnabled,
mcpDisplayMode,
telemetrySetting,
planActSeparateModelsSetting,
enableCheckpointsSetting: enableCheckpointsSetting ?? true,
distinctId: telemetryService.distinctId,
distinctId,
globalClineRulesToggles: globalClineRulesToggles || {},
localClineRulesToggles: localClineRulesToggles || {},
localWindsurfRulesToggles: localWindsurfRulesToggles || {},
@@ -840,18 +800,4 @@ export class Controller {
await updateGlobalState(this.context, "taskHistory", history)
return history
}
// private async clearState() {
// this.context.workspaceState.keys().forEach((key) => {
// this.context.workspaceState.update(key, undefined)
// })
// this.context.globalState.keys().forEach((key) => {
// this.context.globalState.update(key, undefined)
// })
// this.context.secrets.delete("apiKey")
// }
// secrets
// dev
}
+3 -3
View File
@@ -66,9 +66,9 @@ export async function downloadMcp(controller: Controller, request: StringRequest
- Once installed, demonstrate the server's capabilities by using one of its tools.
Here is the project's README to help you get started:\n\n${mcpDetails.readmeContent}\n${mcpDetails.llmsInstallationContent}`
const { chatSettings } = await controller.getStateToPostToWebview()
if (chatSettings.mode === "plan") {
await controller.togglePlanActModeWithChatSettings({ mode: "act" })
const { mode } = await controller.getStateToPostToWebview()
if (mode === "plan") {
await controller.togglePlanActMode("act")
}
// Initialize task and show chat view
@@ -0,0 +1,234 @@
import { Controller } from ".."
import { EmptyRequest } from "@shared/proto/cline/common"
import { OpenRouterCompatibleModelInfo, OpenRouterModelInfo } from "@shared/proto/cline/models"
import { getAllExtensionState } from "../../storage/state"
import { basetenModels } from "../../../shared/api"
import axios from "axios"
import path from "path"
import fs from "fs/promises"
import { fileExistsAtPath } from "@utils/fs"
import { GlobalFileNames } from "@core/storage/disk"
/**
* Refreshes the Baseten models and returns the updated model list
* @param controller The controller instance
* @param request Empty request object
* @returns Response containing the Baseten models
*/
export async function refreshBasetenModels(
controller: Controller,
request: EmptyRequest,
): Promise<OpenRouterCompatibleModelInfo> {
console.log("=== refreshBasetenModels called ===")
const basetenModelsFilePath = path.join(await ensureCacheDirectoryExists(controller), GlobalFileNames.basetenModels)
// Get the Baseten API key from the controller's state
const { apiConfiguration } = await getAllExtensionState(controller.context)
const basetenApiKey = apiConfiguration?.basetenApiKey
let models: Record<string, Partial<OpenRouterModelInfo>> = {}
try {
if (!basetenApiKey) {
console.log("No Baseten API key found, using static models as fallback")
// Don't throw an error, just use static models
for (const [modelId, modelInfo] of Object.entries(basetenModels)) {
models[modelId] = {
maxTokens: modelInfo.maxTokens,
contextWindow: modelInfo.contextWindow,
supportsImages: modelInfo.supportsImages,
supportsPromptCache: modelInfo.supportsPromptCache,
inputPrice: modelInfo.inputPrice,
outputPrice: modelInfo.outputPrice,
cacheWritesPrice: (modelInfo as any).cacheWritesPrice || 0,
cacheReadsPrice: (modelInfo as any).cacheReadsPrice || 0,
description: (modelInfo as any).description || `${modelId} model`,
}
}
} else {
// Ensure the API key is properly formatted
const cleanApiKey = basetenApiKey.trim()
if (!cleanApiKey) {
throw new Error("Invalid Baseten API key format")
}
console.log("Fetching Baseten models with API key:", cleanApiKey.substring(0, 10) + "...")
const response = await axios.get("https://inference.baseten.co/v1/models", {
headers: {
Authorization: `Bearer ${cleanApiKey}`,
"Content-Type": "application/json",
"User-Agent": "Cline-VSCode-Extension",
},
timeout: 10000, // 10 second timeout
})
if (response.data?.data) {
const rawModels = response.data.data
for (const rawModel of rawModels) {
// Filter out non-chat models and validate model capabilities
if (!isValidChatModel(rawModel)) {
continue
}
// Only include models that are listed in the static basetenModels
if (!(rawModel.id in basetenModels)) {
console.log(`Skipping model ${rawModel.id} - not in static basetenModels list`)
continue
}
// Check if we have static pricing information for this model
const staticModelInfo = basetenModels[rawModel.id as keyof typeof basetenModels]
const modelInfo: Partial<OpenRouterModelInfo> = {
maxTokens: staticModelInfo?.maxTokens || 8192,
contextWindow: staticModelInfo?.contextWindow || 8192,
supportsImages: staticModelInfo?.supportsImages || false,
supportsPromptCache: staticModelInfo?.supportsPromptCache || false,
inputPrice: staticModelInfo?.inputPrice || 0,
outputPrice: staticModelInfo?.outputPrice || 0,
cacheWritesPrice: staticModelInfo?.cacheWritesPrice || 0,
cacheReadsPrice: staticModelInfo?.cacheReadsPrice || 0,
description: generateModelDescription(rawModel, staticModelInfo),
}
models[rawModel.id] = modelInfo
}
} else {
console.error("Invalid response from Baseten API")
}
await fs.writeFile(basetenModelsFilePath, JSON.stringify(models))
console.log("Baseten models fetched and saved:", Object.keys(models))
}
} catch (error) {
console.error("Error fetching Baseten models:", error)
// Provide more specific error messages
let errorMessage = "Unknown error occurred"
if (axios.isAxiosError(error)) {
if (error.response?.status === 401) {
errorMessage = "Invalid Baseten API key. Please check your API key in settings."
} else if (error.response?.status === 403) {
errorMessage = "Access forbidden. Please verify your Baseten API key has the correct permissions."
} else if (error.response?.status === 429) {
errorMessage = "Rate limit exceeded. Please try again later."
} else if (error.code === "ECONNABORTED") {
errorMessage = "Request timeout. Please check your internet connection."
} else {
errorMessage = `API request failed: ${error.response?.status || error.code || "Unknown error"}`
}
} else if (error instanceof Error) {
errorMessage = error.message
}
console.error("Baseten API Error:", errorMessage)
// If we failed to fetch models, try to read cached models first
const cachedModels = await readBasetenModels(controller)
if (cachedModels && Object.keys(cachedModels).length > 0) {
console.log("Using cached Baseten models")
// Filter cached models to only include those in static basetenModels
for (const [modelId, modelInfo] of Object.entries(cachedModels)) {
if (modelId in basetenModels) {
models[modelId] = modelInfo
}
}
} else {
// Fall back to static models from shared/api.ts
console.log("Using static Baseten models as fallback")
for (const [modelId, modelInfo] of Object.entries(basetenModels)) {
models[modelId] = {
maxTokens: modelInfo.maxTokens,
contextWindow: modelInfo.contextWindow,
supportsImages: modelInfo.supportsImages,
supportsPromptCache: modelInfo.supportsPromptCache,
inputPrice: modelInfo.inputPrice,
outputPrice: modelInfo.outputPrice,
cacheWritesPrice: (modelInfo as any).cacheWritesPrice || 0,
cacheReadsPrice: (modelInfo as any).cacheReadsPrice || 0,
description: (modelInfo as any).description || `${modelId} model`,
}
}
}
}
// Convert the Record<string, Partial<OpenRouterModelInfo>> to Record<string, OpenRouterModelInfo>
// by filling in any missing required fields with defaults
const typedModels: Record<string, OpenRouterModelInfo> = {}
for (const [key, model] of Object.entries(models)) {
typedModels[key] = {
maxTokens: model.maxTokens ?? 8192,
contextWindow: model.contextWindow ?? 8192,
supportsImages: model.supportsImages ?? false,
supportsPromptCache: model.supportsPromptCache ?? false,
inputPrice: model.inputPrice ?? 0,
outputPrice: model.outputPrice ?? 0,
cacheWritesPrice: model.cacheWritesPrice ?? 0,
cacheReadsPrice: model.cacheReadsPrice ?? 0,
description: model.description ?? "",
tiers: model.tiers ?? [],
}
}
return OpenRouterCompatibleModelInfo.create({ models: typedModels })
}
/**
* Ensures the cache directory exists and returns its path
*/
async function ensureCacheDirectoryExists(controller: Controller): Promise<string> {
const cacheDir = path.join(controller.context.globalStorageUri.fsPath, "cache")
await fs.mkdir(cacheDir, { recursive: true })
return cacheDir
}
/**
* Reads cached Baseten models from disk
*/
async function readBasetenModels(controller: Controller): Promise<Record<string, Partial<OpenRouterModelInfo>> | undefined> {
const basetenModelsFilePath = path.join(await ensureCacheDirectoryExists(controller), GlobalFileNames.basetenModels)
const fileExists = await fileExistsAtPath(basetenModelsFilePath)
if (fileExists) {
try {
const fileContents = await fs.readFile(basetenModelsFilePath, "utf8")
return JSON.parse(fileContents)
} catch (error) {
console.error("Error reading cached Baseten models:", error)
return undefined
}
}
return undefined
}
/**
* Validates if a model is suitable for chat completions
*/
function isValidChatModel(rawModel: any): boolean {
// Filter out non-chat models (whisper, TTS, guard models, etc.)
if (rawModel.id.includes("whisper") || rawModel.id.includes("tts") || rawModel.id.includes("embedding")) {
return false
}
// Check if model supports chat completions
if (rawModel.object === "model" && rawModel.id) {
return true
}
return false
}
/**
* Generates a descriptive name for the model
*/
function generateModelDescription(rawModel: any, staticModelInfo?: any): string {
// Use static description if available
if (staticModelInfo?.description) {
return staticModelInfo.description
}
// Generate description based on model characteristics
const modelId = rawModel.id
const ownedBy = rawModel.owned_by || "Unknown"
return `${ownedBy} model: ${modelId}`
}
@@ -8,6 +8,7 @@ import path from "path"
import fs from "fs/promises"
import { fileExistsAtPath } from "@utils/fs"
import { GlobalFileNames } from "@core/storage/disk"
import { telemetryService } from "@/services/posthog/PostHogClientProvider"
/**
* Refreshes the Groq models and returns the updated model list
@@ -111,7 +112,13 @@ export async function refreshGroqModels(controller: Controller, request: EmptyRe
errorMessage = error.message
}
console.error("Groq API Error:", errorMessage)
telemetryService.captureProviderApiError({
taskId: controller.task?.taskId || "",
ulid: controller.task?.ulid || "",
errorMessage,
errorStatus: error.status,
model: "groq",
})
// If we failed to fetch models, try to read cached models first
const cachedModels = await readGroqModels(controller)
@@ -181,7 +188,7 @@ async function readGroqModels(controller: Controller): Promise<Record<string, Pa
*/
function isValidChatModel(rawModel: any): boolean {
// Check if model is active (if the property exists)
if (rawModel.hasOwnProperty("active") && !rawModel.active) {
if (Object.hasOwn(rawModel, "active") && !rawModel.active) {
return false
}
// Filter out non-chat models (whisper, TTS, guard models, etc.)
@@ -49,7 +49,6 @@ export async function refreshOpenRouterModels(
switch (rawModel.id) {
case "anthropic/claude-sonnet-4":
case "anthropic/claude-opus-4":
case "anthropic/claude-3-7-sonnet":
case "anthropic/claude-3-7-sonnet:beta":
case "anthropic/claude-3.7-sonnet":
@@ -62,6 +61,12 @@ export async function refreshOpenRouterModels(
modelInfo.cacheWritesPrice = 3.75
modelInfo.cacheReadsPrice = 0.3
break
case "anthropic/claude-opus-4.1":
case "anthropic/claude-opus-4":
modelInfo.supportsPromptCache = true
modelInfo.cacheWritesPrice = 18.75
modelInfo.cacheReadsPrice = 1.5
break
case "anthropic/claude-3.5-sonnet-20240620":
case "anthropic/claude-3.5-sonnet-20240620:beta":
modelInfo.supportsPromptCache = true
@@ -1,7 +1,6 @@
import type { Controller } from "../index"
import { Empty } from "@shared/proto/cline/common"
import { UpdateApiConfigurationRequest } from "@shared/proto/cline/models"
import { updateApiConfiguration } from "../../storage/state"
import { buildApiHandler } from "@api/index"
import { convertProtoToApiConfiguration } from "@shared/proto-conversions/models/api-configuration-conversion"
@@ -25,7 +24,7 @@ export async function updateApiConfigurationProto(
const appApiConfiguration = convertProtoToApiConfiguration(request.apiConfiguration)
// Update the API configuration in storage
await updateApiConfiguration(controller.context, appApiConfiguration)
controller.cacheService.setApiConfiguration(appApiConfiguration)
// Update the task's API handler if there's an active task
if (controller.task) {
+2 -2
View File
@@ -19,13 +19,13 @@ export async function resetState(controller: Controller, request: ResetStateRequ
type: ShowMessageType.INFORMATION,
message: "Resetting global state...",
})
await resetGlobalState(controller.context)
await resetGlobalState(controller)
} else {
HostProvider.window.showMessage({
type: ShowMessageType.INFORMATION,
message: "Resetting workspace state...",
})
await resetWorkspaceState(controller.context)
await resetWorkspaceState(controller)
}
if (controller.task) {
@@ -1,4 +1,4 @@
import { telemetryService } from "@/services/posthog/telemetry/TelemetryService"
import { telemetryService } from "@services/posthog/PostHogClientProvider"
import { Controller } from ".."
import { Empty, StringRequest } from "@shared/proto/cline/common"
import { updateGlobalState } from "@/core/storage/state"
@@ -16,11 +16,7 @@ export async function toggleFavoriteModel(controller: Controller, request: Strin
}
const modelId = request.value
const { apiConfiguration } = await controller.getStateToPostToWebview()
if (!apiConfiguration) {
throw new Error("API configuration not found")
}
const apiConfiguration = controller.cacheService.getApiConfiguration()
const favoritedModelIds = apiConfiguration.favoritedModelIds || []
@@ -29,7 +25,12 @@ export async function toggleFavoriteModel(controller: Controller, request: Strin
? favoritedModelIds.filter((id) => id !== modelId)
: [...favoritedModelIds, modelId]
await updateGlobalState(controller.context, "favoritedModelIds", updatedFavorites)
// Update the complete API configuration through cache service
const updatedApiConfiguration = {
...apiConfiguration,
favoritedModelIds: updatedFavorites,
}
controller.cacheService.setApiConfiguration(updatedApiConfiguration)
// Capture telemetry for model favorite toggle
const isFavorited = !favoritedModelIds.includes(modelId)
@@ -1,34 +0,0 @@
import { Controller } from ".."
import { Boolean } from "@shared/proto/cline/common"
import { TogglePlanActModeRequest } from "@shared/proto/cline/state"
import {
convertProtoChatContentToChatContent,
convertProtoChatSettingsToChatSettings,
} from "@shared/proto-conversions/state/chat-settings-conversion"
/**
* Toggles between Plan and Act modes
* @param controller The controller instance
* @param request The request containing the chat settings and optional chat content
* @returns An empty response
*/
export async function togglePlanActMode(controller: Controller, request: TogglePlanActModeRequest): Promise<Boolean> {
try {
if (!request.chatSettings) {
throw new Error("Chat settings are required")
}
const chatSettings = convertProtoChatSettingsToChatSettings(request.chatSettings)
const chatContent = request.chatContent ? convertProtoChatContentToChatContent(request.chatContent) : undefined
// Call the existing controller implementation
const sentMessage = await controller.togglePlanActModeWithChatSettings(chatSettings, chatContent)
return Boolean.create({
value: sentMessage,
})
} catch (error) {
console.error("Failed to toggle Plan/Act mode:", error)
throw error
}
}
@@ -0,0 +1,34 @@
import { Controller } from ".."
import { Boolean } from "@shared/proto/cline/common"
import { TogglePlanActModeRequest, PlanActMode } from "@shared/proto/cline/state"
import { Mode } from "@shared/storage/types"
/**
* Toggles between Plan and Act modes
* @param controller The controller instance
* @param request The request containing the chat settings and optional chat content
* @returns An empty response
*/
export async function togglePlanActModeProto(controller: Controller, request: TogglePlanActModeRequest): Promise<Boolean> {
try {
let mode: Mode
if (request.mode === PlanActMode.PLAN) {
mode = "plan"
} else if (request.mode === PlanActMode.ACT) {
mode = "act"
} else {
throw new Error(`Invalid mode value: ${request.mode}`)
}
const chatContent = request.chatContent
// Call the existing controller implementation
const sentMessage = await controller.togglePlanActMode(mode, chatContent)
return Boolean.create({
value: sentMessage,
})
} catch (error) {
console.error("Failed to toggle Plan/Act mode:", error)
throw error
}
}
+29 -18
View File
@@ -1,11 +1,10 @@
import { Controller } from ".."
import { Empty } from "@shared/proto/cline/common"
import { UpdateSettingsRequest } from "@shared/proto/cline/state"
import { updateApiConfiguration } from "../../storage/state"
import { PlanActMode, UpdateSettingsRequest } from "@shared/proto/cline/state"
import { buildApiHandler } from "../../../api"
import { convertProtoApiConfigurationToApiConfiguration } from "../../../shared/proto-conversions/state/settings-conversion"
import { convertProtoChatSettingsToChatSettings } from "../../../shared/proto-conversions/state/chat-settings-conversion"
import { TelemetrySetting } from "@/shared/TelemetrySetting"
import { OpenaiReasoningEffort } from "@/shared/storage/types"
/**
* Updates multiple extension settings in a single request
@@ -18,7 +17,7 @@ export async function updateSettings(controller: Controller, request: UpdateSett
// Update API configuration
if (request.apiConfiguration) {
const apiConfiguration = convertProtoApiConfigurationToApiConfiguration(request.apiConfiguration)
await updateApiConfiguration(controller.context, apiConfiguration)
controller.cacheService.setApiConfiguration(apiConfiguration)
if (controller.task) {
const currentMode = await controller.getCurrentMode()
@@ -56,22 +55,26 @@ export async function updateSettings(controller: Controller, request: UpdateSett
await controller.context.globalState.update("mcpDisplayMode", request.mcpDisplayMode)
}
// Update chat settings
if (request.chatSettings) {
const chatSettings = convertProtoChatSettingsToChatSettings(request.chatSettings)
// Store mode to global state
if (chatSettings.mode !== undefined) {
await controller.context.globalState.update("mode", chatSettings.mode)
}
// Store chat settings (excluding mode) to global state
const { mode, ...globalChatSettings } = chatSettings
await controller.context.globalState.update("chatSettings", globalChatSettings)
if (request.mode !== undefined) {
const mode = request.mode === PlanActMode.PLAN ? "plan" : "act"
if (controller.task) {
controller.task.chatSettings = chatSettings
controller.task.updateMode(mode)
}
await controller.context.globalState.update("mode", request.mode)
}
if (request.openaiReasoningEffort !== undefined) {
if (controller.task) {
controller.task.openaiReasoningEffort = request.openaiReasoningEffort as OpenaiReasoningEffort
}
await controller.context.globalState.update("openaiReasoningEffort", request.openaiReasoningEffort)
}
if (request.preferredLanguage !== undefined) {
if (controller.task) {
controller.task.preferredLanguage = request.preferredLanguage
}
await controller.context.globalState.update("preferredLanguage", request.preferredLanguage)
}
// Update terminal timeout setting
@@ -89,6 +92,14 @@ export async function updateSettings(controller: Controller, request: UpdateSett
await controller.context.globalState.update("terminalOutputLineLimit", Number(request.terminalOutputLineLimit))
}
// Update strict plan mode setting
if (request.strictPlanModeEnabled !== undefined) {
if (controller.task) {
controller.task.updateStrictPlanMode(request.strictPlanModeEnabled)
}
await controller.context.globalState.update("strictPlanModeEnabled", request.strictPlanModeEnabled)
}
// Post updated state to webview
await controller.postStateToWebview()
@@ -1,18 +1,18 @@
import { Controller } from "../index"
import * as proto from "@/shared/proto"
import { UpdateTerminalConnectionTimeoutRequest, UpdateTerminalConnectionTimeoutResponse } from "@shared/proto/cline/state"
import { updateGlobalState } from "../../storage/state"
export async function updateTerminalConnectionTimeout(
controller: Controller,
request: proto.cline.Int64Request,
): Promise<proto.cline.Int64> {
const timeoutValue = request.value
request: UpdateTerminalConnectionTimeoutRequest,
): Promise<UpdateTerminalConnectionTimeoutResponse> {
const timeoutMs = request.timeoutMs
// Update the terminal connection timeout setting in the state
await updateGlobalState(controller.context, "shellIntegrationTimeout", timeoutValue)
await updateGlobalState(controller.context, "shellIntegrationTimeout", timeoutMs)
// Broadcast state update to all webviews
await controller.postStateToWebview()
return proto.cline.Int64.create({ value: timeoutValue })
return { timeoutMs }
}
@@ -30,7 +30,7 @@ export async function deleteTasksWithIds(controller: Controller, request: String
options: { modal: true, items: ["Delete"] },
})
if (userChoice === undefined) {
if (userChoice.selectedOption !== "Delete") {
return Empty.create()
}
+1 -1
View File
@@ -1,6 +1,6 @@
import { Controller } from ".."
import { Empty, StringRequest } from "@shared/proto/cline/common"
import { telemetryService } from "@/services/posthog/telemetry/TelemetryService"
import { telemetryService } from "@services/posthog/PostHogClientProvider"
/**
* Handles task feedback submission (thumbs up/down)
+63 -9
View File
@@ -4,11 +4,12 @@ import { EmptyRequest, Empty } from "@shared/proto/cline/common"
import { getAllExtensionState, getGlobalState, updateGlobalState } from "../../storage/state"
import { sendOpenRouterModelsEvent } from "../models/subscribeToOpenRouterModels"
import { sendMcpMarketplaceCatalogEvent } from "../mcp/subscribeToMcpMarketplaceCatalog"
import { telemetryService } from "@/services/posthog/telemetry/TelemetryService"
import { telemetryService } from "@/services/posthog/PostHogClientProvider"
import { OpenRouterCompatibleModelInfo } from "@shared/proto/cline/models"
import { McpMarketplaceCatalog } from "@shared/mcp"
import { refreshOpenRouterModels } from "../models/refreshOpenRouterModels"
import { refreshGroqModels } from "../models/refreshGroqModels"
import { refreshBasetenModels } from "../models/refreshBasetenModels"
/**
* Initialize webview when it launches
@@ -32,7 +33,8 @@ export async function initializeWebview(controller: Controller, request: EmptyRe
refreshOpenRouterModels(controller, EmptyRequest.create()).then(async (response) => {
if (response && response.models) {
// Update model info in state (this needs to be done here since we don't want to update state while settings is open, and we may refresh models there)
const { apiConfiguration, planActSeparateModelsSetting } = await getAllExtensionState(controller.context)
const apiConfiguration = controller.cacheService.getApiConfiguration()
const { planActSeparateModelsSetting } = await getAllExtensionState(controller.context)
const currentMode = await controller.getCurrentMode()
if (planActSeparateModelsSetting) {
@@ -42,26 +44,32 @@ export async function initializeWebview(controller: Controller, request: EmptyRe
const modelId = apiConfiguration[modelIdField]
if (modelId && response.models[modelId]) {
await updateGlobalState(controller.context, modelInfoField, response.models[modelId])
const updatedConfig = {
...apiConfiguration,
[modelInfoField]: response.models[modelId],
}
controller.cacheService.setApiConfiguration(updatedConfig)
await controller.postStateToWebview()
}
} else {
// Shared models: update both plan and act modes
const planModelId = apiConfiguration.planModeOpenRouterModelId
const actModelId = apiConfiguration.actModeOpenRouterModelId
const updatedConfig = { ...apiConfiguration }
// Update plan mode model info if we have a model ID
if (planModelId && response.models[planModelId]) {
await updateGlobalState(controller.context, "planModeOpenRouterModelInfo", response.models[planModelId])
updatedConfig.planModeOpenRouterModelInfo = response.models[planModelId]
}
// Update act mode model info if we have a model ID
if (actModelId && response.models[actModelId]) {
await updateGlobalState(controller.context, "actModeOpenRouterModelInfo", response.models[actModelId])
updatedConfig.actModeOpenRouterModelInfo = response.models[actModelId]
}
// Post state update if we updated any model info
if ((planModelId && response.models[planModelId]) || (actModelId && response.models[actModelId])) {
controller.cacheService.setApiConfiguration(updatedConfig)
await controller.postStateToWebview()
}
}
@@ -71,7 +79,8 @@ export async function initializeWebview(controller: Controller, request: EmptyRe
refreshGroqModels(controller, EmptyRequest.create()).then(async (response) => {
if (response && response.models) {
// Update model info in state for Groq (this needs to be done here since we don't want to update state while settings is open, and we may refresh models there)
const { apiConfiguration, planActSeparateModelsSetting } = await getAllExtensionState(controller.context)
const apiConfiguration = controller.cacheService.getApiConfiguration()
const { planActSeparateModelsSetting } = await getAllExtensionState(controller.context)
const currentMode = await controller.getCurrentMode()
if (planActSeparateModelsSetting) {
@@ -81,22 +90,67 @@ export async function initializeWebview(controller: Controller, request: EmptyRe
const modelId = apiConfiguration[modelIdField]
if (modelId && response.models[modelId]) {
await updateGlobalState(controller.context, modelInfoField, response.models[modelId])
const updatedConfig = {
...apiConfiguration,
[modelInfoField]: response.models[modelId],
}
controller.cacheService.setApiConfiguration(updatedConfig)
await controller.postStateToWebview()
}
} else {
// Shared models: update both plan and act modes
const planModelId = apiConfiguration.planModeGroqModelId
const actModelId = apiConfiguration.actModeGroqModelId
const updatedConfig = { ...apiConfiguration }
// Update plan mode model info if we have a model ID
if (planModelId && response.models[planModelId]) {
await updateGlobalState(controller.context, "planModeGroqModelInfo", response.models[planModelId])
updatedConfig.planModeGroqModelInfo = response.models[planModelId]
}
// Update act mode model info if we have a model ID
if (actModelId && response.models[actModelId]) {
await updateGlobalState(controller.context, "actModeGroqModelInfo", response.models[actModelId])
updatedConfig.actModeGroqModelInfo = response.models[actModelId]
}
// Post state update if we updated any model info
if ((planModelId && response.models[planModelId]) || (actModelId && response.models[actModelId])) {
controller.cacheService.setApiConfiguration(updatedConfig)
await controller.postStateToWebview()
}
}
}
})
refreshBasetenModels(controller, EmptyRequest.create()).then(async (response) => {
if (response && response.models) {
// Update model info in state for Baseten (this needs to be done here since we don't want to update state while settings is open, and we may refresh models there)
const { apiConfiguration, planActSeparateModelsSetting } = await getAllExtensionState(controller.context)
const currentMode = await controller.getCurrentMode()
if (planActSeparateModelsSetting) {
// Separate models: update only current mode
const modelIdField = currentMode === "plan" ? "planModeBasetenModelId" : "actModeBasetenModelId"
const modelInfoField = currentMode === "plan" ? "planModeBasetenModelInfo" : "actModeBasetenModelInfo"
const modelId = apiConfiguration[modelIdField]
if (modelId && response.models[modelId]) {
await updateGlobalState(controller.context, modelInfoField, response.models[modelId])
await controller.postStateToWebview()
}
} else {
// Shared models: update both plan and act modes
const planModelId = apiConfiguration.planModeBasetenModelId
const actModelId = apiConfiguration.actModeBasetenModelId
// Update plan mode model info if we have a model ID
if (planModelId && response.models[planModelId]) {
await updateGlobalState(controller.context, "planModeBasetenModelInfo", response.models[planModelId])
}
// Update act mode model info if we have a model ID
if (actModelId && response.models[actModelId]) {
await updateGlobalState(controller.context, "actModeBasetenModelInfo", response.models[actModelId])
}
// Post state update if we updated any model info
@@ -2,6 +2,7 @@ import type { EmptyRequest } from "@shared/proto/cline/common"
import { Boolean } from "@shared/proto/cline/common"
import type { Controller } from "../index"
import { updateGlobalState } from "../../storage/state"
import { getLatestAnnouncementId } from "@/utils/announcements"
/**
* Marks the current announcement as shown
@@ -12,8 +13,9 @@ import { updateGlobalState } from "../../storage/state"
*/
export async function onDidShowAnnouncement(controller: Controller, _request: EmptyRequest): Promise<Boolean> {
try {
const latestAnnouncementId = getLatestAnnouncementId(controller.context)
// Update the lastShownAnnouncementId to the current latestAnnouncementId
await updateGlobalState(controller.context, "lastShownAnnouncementId", controller.latestAnnouncementId)
await updateGlobalState(controller.context, "lastShownAnnouncementId", latestAnnouncementId)
return Boolean.create({ value: false })
} catch (error) {
console.error("Failed to acknowledge announcement:", error)
+1 -1
View File
@@ -2,7 +2,7 @@ import * as vscode from "vscode"
import type { Controller } from "../index"
import type { EmptyRequest } from "@shared/proto/cline/common"
import { Empty } from "@shared/proto/cline/common"
import { telemetryService } from "../../../services/posthog/telemetry/TelemetryService"
import { telemetryService } from "@services/posthog/PostHogClientProvider"
/**
* Opens the Cline walkthrough in VSCode
@@ -1,33 +1,42 @@
import { Controller } from "../index"
import { EmptyRequest } from "@shared/proto/cline/common"
import { String as ProtoString } from "@shared/proto/cline/common"
import { StreamingResponseHandler, getRequestRegistry } from "../grpc-handler"
import type { String as ProtoString, StringRequest } from "@shared/proto/cline/common"
import { getRequestRegistry, type StreamingResponseHandler } from "../grpc-handler"
import type { Controller } from "../index"
// Keep track of active addToInput subscriptions
const activeAddToInputSubscriptions = new Set<StreamingResponseHandler<ProtoString>>()
// Map client IDs to their subscription handlers for targeted sending
const addToInputSubscriptions = new Map<string, StreamingResponseHandler<ProtoString>>()
/**
* Subscribe to addToInput events
* @param controller The controller instance
* @param request The empty request
* @param request The request containing the client ID
* @param responseStream The streaming response handler
* @param requestId The ID of the request (passed by the gRPC handler)
*/
export async function subscribeToAddToInput(
_controller: Controller,
_request: EmptyRequest,
request: StringRequest,
responseStream: StreamingResponseHandler<ProtoString>,
requestId?: string,
): Promise<void> {
console.log("[DEBUG] set up addToInput subscription")
const clientId = request.value
if (!clientId) {
throw new Error("Client ID is required for addToInput subscription")
}
// Add this subscription to the active subscriptions
console.log("[DEBUG] set up addToInput subscription for client:", clientId)
// Add this subscription to both the general set and the client-specific map
activeAddToInputSubscriptions.add(responseStream)
addToInputSubscriptions.set(clientId, responseStream)
// Register cleanup when the connection is closed
const cleanup = () => {
activeAddToInputSubscriptions.delete(responseStream)
console.log("[DEBUG] Cleaned up addToInput subscription")
addToInputSubscriptions.delete(clientId)
console.log("[DEBUG] Cleaned up addToInput subscription for client:", clientId)
}
// Register the cleanup function with the request registry if we have a requestId
@@ -61,3 +70,33 @@ export async function sendAddToInputEvent(text: string): Promise<void> {
await Promise.all(promises)
}
/**
* Send an addToInput event to a specific webview by client ID
* @param clientId The ID of the client to send the event to
* @param text The text to add to the input
*/
export async function sendAddToInputEventToClient(clientId: string, text: string): Promise<void> {
const responseStream = addToInputSubscriptions.get(clientId)
if (!responseStream) {
console.warn(`No addToInput subscription found for client ID: ${clientId}`)
return
}
try {
const event: ProtoString = {
value: text,
}
await responseStream(
event,
false, // Not the last message
)
console.log("[DEBUG] sending addToInput event to client", clientId, ":", text.length, "chars")
} catch (error) {
console.error(`Error sending addToInput event to client ${clientId}:`, error)
// Remove the subscription if there was an error
addToInputSubscriptions.delete(clientId)
// Also remove from the general set
activeAddToInputSubscriptions.delete(responseStream)
}
}
+415
View File
@@ -0,0 +1,415 @@
import { HostProvider } from "@/hosts/host-provider"
import { setVscodeHostProviderMock } from "@/test/host-provider-test-utils"
import { FileContextTracker } from "@core/context/context-tracking/FileContextTracker"
import * as extractTextModule from "@integrations/misc/extract-text"
import * as terminalModule from "@integrations/terminal/get-latest-output"
import { UrlContentFetcher } from "@services/browser/UrlContentFetcher"
import * as gitModule from "@utils/git"
import { expect } from "chai"
import * as fs from "fs"
import * as isBinaryFileModule from "isbinaryfile"
import * as path from "path"
import * as sinon from "sinon"
import { parseMentions } from "../index"
describe("parseMentions", () => {
let sandbox: sinon.SinonSandbox
let urlContentFetcherStub: sinon.SinonStubbedInstance<UrlContentFetcher>
let fileContextTrackerStub: sinon.SinonStubbedInstance<FileContextTracker>
let fsStatStub: sinon.SinonStub
let fsReaddirStub: sinon.SinonStub
let extractTextStub: sinon.SinonStub
let isBinaryFileStub: sinon.SinonStub
let getLatestTerminalOutputStub: sinon.SinonStub
let getWorkingStateStub: sinon.SinonStub
let getCommitInfoStub: sinon.SinonStub
let showMessageStub: sinon.SinonStub
const cwd = "/test/project"
beforeEach(() => {
sandbox = sinon.createSandbox()
setVscodeHostProviderMock()
// Create stubs for dependencies
urlContentFetcherStub = {
launchBrowser: sandbox.stub().resolves(),
closeBrowser: sandbox.stub().resolves(),
urlToMarkdown: sandbox.stub().resolves("# Example Website\n\nContent here"),
} as any
fileContextTrackerStub = {
trackFileContext: sandbox.stub().resolves(),
} as any
// Stub file system operations using fs.promises
fsStatStub = sandbox.stub(fs.promises, "stat")
fsReaddirStub = sandbox.stub(fs.promises, "readdir")
// Stub other modules
extractTextStub = sandbox.stub(extractTextModule, "extractTextFromFile")
isBinaryFileStub = sandbox.stub(isBinaryFileModule, "isBinaryFile")
getLatestTerminalOutputStub = sandbox.stub(terminalModule, "getLatestTerminalOutput")
getWorkingStateStub = sandbox.stub(gitModule, "getWorkingState")
getCommitInfoStub = sandbox.stub(gitModule, "getCommitInfo")
showMessageStub = sandbox.stub(HostProvider.window, "showMessage")
})
afterEach(() => {
sandbox.restore()
})
describe("File mentions", () => {
it("should handle simple file mention", async () => {
const text = "Check @/src/index.ts for details"
fsStatStub.resolves({ isFile: () => true, isDirectory: () => false })
isBinaryFileStub.resolves(false)
extractTextStub.resolves("console.log('Hello World');")
const result = await parseMentions(text, cwd, urlContentFetcherStub, fileContextTrackerStub)
const expectedOutput = `Check 'src/index.ts' (see below for file content) for details
<file_content path="src/index.ts">
console.log('Hello World');
</file_content>`
expect(result).to.equal(expectedOutput)
expect(fileContextTrackerStub.trackFileContext.calledWith("src/index.ts", "file_mentioned")).to.be.true
})
it("should handle quoted file paths with spaces", async () => {
const text = 'Open @"/path with spaces/file.txt"'
fsStatStub.resolves({ isFile: () => true, isDirectory: () => false })
isBinaryFileStub.resolves(false)
extractTextStub.resolves("console.log('Hello World');")
const result = await parseMentions(text, cwd, urlContentFetcherStub)
const expectedOutput = `Open 'path with spaces/file.txt' (see below for file content)
<file_content path="path with spaces/file.txt">
console.log('Hello World');
</file_content>`
expect(result).to.equal(expectedOutput)
})
it("should handle binary files", async () => {
const text = "Check @/image.png"
fsStatStub.resolves({ isFile: () => true, isDirectory: () => false })
isBinaryFileStub.resolves(true)
const result = await parseMentions(text, cwd, urlContentFetcherStub)
const expectedOutput = `Check 'image.png' (see below for file content)
<file_content path="image.png">
(Binary file, unable to display content)
</file_content>`
expect(result).to.equal(expectedOutput)
})
it("should handle file read errors", async () => {
const text = "Check @/missing.txt"
fsStatStub.rejects(new Error("ENOENT: no such file or directory"))
const result = await parseMentions(text, cwd, urlContentFetcherStub)
const expectedOutput = `Check 'missing.txt' (see below for file content)
<file_content path="missing.txt">
Error fetching content: Failed to access path "missing.txt": ENOENT: no such file or directory
</file_content>`
expect(result).to.equal(expectedOutput)
})
})
describe("Folder mentions", () => {
it("should handle folder mention", async () => {
const text = "Look in @/src/ folder"
fsStatStub.resolves({ isFile: () => false, isDirectory: () => true })
fsReaddirStub.resolves([
{ name: "index.ts", isFile: () => true, isDirectory: () => false },
{ name: "utils", isFile: () => false, isDirectory: () => true },
{ name: "README.md", isFile: () => true, isDirectory: () => false },
])
// Set up file content stubs
isBinaryFileStub.resolves(false)
extractTextStub.withArgs(path.resolve(cwd, "src/index.ts")).resolves("export const main = () => {};")
extractTextStub.withArgs(path.resolve(cwd, "src/README.md")).resolves("# Source Code")
const result = await parseMentions(text, cwd, urlContentFetcherStub)
const expectedOutput = `Look in 'src/' (see below for folder content) folder
<folder_content path="src/">
├── index.ts
├── utils/
└── README.md
<file_content path="src/index.ts">
export const main = () => {};
</file_content>
<file_content path="src/README.md">
# Source Code
</file_content>
</folder_content>`
expect(result).to.equal(expectedOutput)
})
})
describe("URL mentions", () => {
it("should handle URL mention", async () => {
const text = "Visit @https://example.com for info"
const result = await parseMentions(text, cwd, urlContentFetcherStub)
const expectedOutput = `Visit 'https://example.com' (see below for site content) for info
<url_content url="https://example.com">
# Example Website
Content here
</url_content>`
expect(result).to.equal(expectedOutput)
expect(urlContentFetcherStub.launchBrowser.called).to.be.true
expect(urlContentFetcherStub.urlToMarkdown.calledWith("https://example.com")).to.be.true
expect(urlContentFetcherStub.closeBrowser.called).to.be.true
})
it("should handle browser launch errors", async () => {
const text = "Visit @https://example.com"
urlContentFetcherStub.launchBrowser.rejects(new Error("Browser launch failed"))
const result = await parseMentions(text, cwd, urlContentFetcherStub)
const expectedOutput = `Visit 'https://example.com' (see below for site content)
<url_content url="https://example.com">
Error fetching content: Browser launch failed
</url_content>`
expect(result).to.equal(expectedOutput)
expect(showMessageStub.called).to.be.true
})
it("should handle URL fetch errors", async () => {
const text = "Visit @https://example.com"
urlContentFetcherStub.urlToMarkdown.rejects(new Error("Network error"))
const result = await parseMentions(text, cwd, urlContentFetcherStub)
const expectedOutput = `Visit 'https://example.com' (see below for site content)
<url_content url="https://example.com">
Error fetching content: Network error
</url_content>`
expect(result).to.equal(expectedOutput)
expect(showMessageStub.called).to.be.true
})
})
describe("Special mentions", () => {
it("should handle @terminal mention", async () => {
const text = "See @terminal output"
getLatestTerminalOutputStub.resolves("$ npm test\nAll tests passed!")
const result = await parseMentions(text, cwd, urlContentFetcherStub)
const expectedOutput = `See Terminal Output (see below for output) output
<terminal_output>
$ npm test
All tests passed!
</terminal_output>`
expect(result).to.equal(expectedOutput)
})
it("should handle @git-changes mention", async () => {
const text = "Review @git-changes"
getWorkingStateStub.resolves("M src/index.ts\nA src/new-file.ts")
const result = await parseMentions(text, cwd, urlContentFetcherStub)
const expectedOutput = `Review Working directory changes (see below for details)
<git_working_state>
M src/index.ts
A src/new-file.ts
</git_working_state>`
expect(result).to.equal(expectedOutput)
})
it("should handle git commit hash mention", async () => {
const text = "See commit @abcdef1234567890"
getCommitInfoStub.resolves("commit abcdef1234567890\nAuthor: Test\nDate: 2024-01-01\n\nInitial commit")
const result = await parseMentions(text, cwd, urlContentFetcherStub)
const expectedOutput = `See commit Git commit 'abcdef1234567890' (see below for commit info)
<git_commit hash="abcdef1234567890">
commit abcdef1234567890
Author: Test
Date: 2024-01-01
Initial commit
</git_commit>`
expect(result).to.equal(expectedOutput)
})
})
describe("Multiple mentions", () => {
it("should handle multiple mentions in order", async () => {
const text = "Check @/file1.txt and @/file2.txt"
fsStatStub.resolves({ isFile: () => true, isDirectory: () => false })
isBinaryFileStub.resolves(false)
extractTextStub.withArgs(path.resolve(cwd, "file1.txt")).resolves("Content 1")
extractTextStub.withArgs(path.resolve(cwd, "file2.txt")).resolves("Content 2")
const result = await parseMentions(text, cwd, urlContentFetcherStub)
const expectedOutput = `Check 'file1.txt' (see below for file content) and 'file2.txt' (see below for file content)
<file_content path="file1.txt">
Content 1
</file_content>
<file_content path="file2.txt">
Content 2
</file_content>`
expect(result).to.equal(expectedOutput)
})
it("should handle duplicate mentions only once", async () => {
const text = "Check @/file.txt and again @/file.txt"
fsStatStub.resolves({ isFile: () => true, isDirectory: () => false })
isBinaryFileStub.resolves(false)
extractTextStub.resolves("Content")
const result = await parseMentions(text, cwd, urlContentFetcherStub)
const expectedOutput = `Check 'file.txt' (see below for file content) and again 'file.txt' (see below for file content)
<file_content path="file.txt">
Content
</file_content>`
expect(result).to.equal(expectedOutput)
})
it("should handle mixed mention types", async () => {
const text = "Check @/file.txt, and @https://example.com"
fsStatStub.resolves({ isFile: () => true, isDirectory: () => false })
isBinaryFileStub.resolves(false)
extractTextStub.resolves("File content")
const result = await parseMentions(text, cwd, urlContentFetcherStub)
const expectedOutput = `Check 'file.txt' (see below for file content), and 'https://example.com' (see below for site content)
<file_content path="file.txt">
File content
</file_content>
<url_content url="https://example.com">
# Example Website
Content here
</url_content>`
expect(result).to.equal(expectedOutput)
})
})
describe("Error handling", () => {
it("should handle errors for each mention type gracefully", async () => {
const text = "@/error.txt @terminal @git-changes @abc1234567"
fsStatStub.rejects(new Error("File error"))
getLatestTerminalOutputStub.rejects(new Error("Terminal error"))
getWorkingStateStub.rejects(new Error("Git state error"))
getCommitInfoStub.rejects(new Error("Commit error"))
const result = await parseMentions(text, cwd, urlContentFetcherStub)
const expectedOutput = `'error.txt' (see below for file content) Terminal Output (see below for output) Working directory changes (see below for details) Git commit 'abc1234567' (see below for commit info)
<file_content path="error.txt">
Error fetching content: Failed to access path "error.txt": File error
</file_content>
<terminal_output>
Error fetching terminal output: Terminal error
</terminal_output>
<git_working_state>
Error fetching working state: Git state error
</git_working_state>
<git_commit hash="abc1234567">
Error fetching commit info: Commit error
</git_commit>`
expect(result).to.equal(expectedOutput)
})
})
describe("Edge cases", () => {
it("should handle text with no mentions", async () => {
const text = "This is plain text without any mentions"
const result = await parseMentions(text, cwd, urlContentFetcherStub)
expect(result).to.equal(text)
})
it("should handle empty text", async () => {
const result = await parseMentions("", cwd, urlContentFetcherStub)
expect(result).to.equal("")
})
it("should handle mentions with trailing punctuation", async () => {
const text = "Check @/file.txt!"
fsStatStub.resolves({ isFile: () => true, isDirectory: () => false })
isBinaryFileStub.resolves(false)
extractTextStub.resolves("Content")
const result = await parseMentions(text, cwd, urlContentFetcherStub)
const expectedOutput = `Check 'file.txt' (see below for file content)!
<file_content path="file.txt">
Content
</file_content>`
expect(result).to.equal(expectedOutput)
})
})
})
+21 -14
View File
@@ -6,7 +6,7 @@ import { mentionRegexGlobal } from "@shared/context-mentions"
import fs from "fs/promises"
import { extractTextFromFile } from "@integrations/misc/extract-text"
import { isBinaryFile } from "isbinaryfile"
import { diagnosticsToProblemsString } from "@integrations/diagnostics"
import { getWorkspaceProblemsString } from "@/integrations/diagnostics"
import { getLatestTerminalOutput } from "@integrations/terminal/get-latest-output"
import { getCommitInfo } from "@utils/git"
import { getWorkingState } from "@utils/git"
@@ -14,7 +14,7 @@ import { FileContextTracker } from "../context/context-tracking/FileContextTrack
import { getCwd } from "@/utils/path"
import { openExternal } from "@utils/env"
import { HostProvider } from "@/hosts/host-provider"
import { ShowMessageRequest, ShowMessageType } from "@/shared/proto/host/window"
import { ShowMessageType } from "@/shared/proto/host/window"
export async function openMention(mention?: string): Promise<void> {
if (!mention) {
@@ -26,8 +26,8 @@ export async function openMention(mention?: string): Promise<void> {
return
}
if (mention.startsWith("/")) {
const relPath = mention.slice(1)
if (isFileMention(mention)) {
const relPath = getFilePathFromMention(mention)
const absPath = path.resolve(cwd, relPath)
if (mention.endsWith("/")) {
vscode.commands.executeCommand("revealInExplorer", vscode.Uri.file(absPath))
@@ -54,8 +54,8 @@ export async function parseMentions(
mentions.add(mention)
if (mention.startsWith("http")) {
return `'${mention}' (see below for site content)`
} else if (mention.startsWith("/")) {
const mentionPath = mention.slice(1) // Remove the leading '/'
} else if (isFileMention(mention)) {
const mentionPath = getFilePathFromMention(mention)
return mentionPath.endsWith("/")
? `'${mentionPath}' (see below for folder content)`
: `'${mentionPath}' (see below for file content)`
@@ -106,8 +106,8 @@ export async function parseMentions(
}
}
parsedText += `\n\n<url_content url="${mention}">\n${result}\n</url_content>`
} else if (mention.startsWith("/")) {
const mentionPath = mention.slice(1)
} else if (isFileMention(mention)) {
const mentionPath = getFilePathFromMention(mention)
try {
const content = await getFileOrFolderContent(mentionPath, cwd)
if (mention.endsWith("/")) {
@@ -225,10 +225,17 @@ async function getFileOrFolderContent(mentionPath: string, cwd: string): Promise
}
async function getWorkspaceProblems(): Promise<string> {
const diagnostics = vscode.languages.getDiagnostics()
const result = diagnosticsToProblemsString(diagnostics, [vscode.DiagnosticSeverity.Error, vscode.DiagnosticSeverity.Warning])
if (!result) {
return "No errors or warnings detected."
}
return result
return await getWorkspaceProblemsString()
}
function isFileMention(mention: string): boolean {
return mention.startsWith("/") || mention.startsWith('"/')
}
function getFilePathFromMention(mention: string): string {
// Remove quotes
const match = mention.match(/^"(.*)"$/)
const filePath = match ? match[1] : mention
// Remove leading slash
return filePath.slice(1)
}
@@ -337,7 +337,6 @@ You accomplish a given task iteratively, breaking it down into clear steps and w
accessMcpResourceToolDefinition,
loadMcpDocumentationTool,
newTaskToolDefinition,
editToolDefinition,
]
if (supportsBrowserUse) {
tools.push(browserActionTool)
+5 -3
View File
@@ -268,14 +268,15 @@ Usage:
</new_task>
## plan_mode_respond
Description: Respond to the user's inquiry in an effort to plan a solution to the user's task. This tool should ONLY be used when you have already explored the relevant files and are ready to present a concrete plan. DO NOT use this tool to announce what files you're going to read - just read them first. This tool is only available in PLAN MODE. The environment_details will specify the current mode, if it is not PLAN_MODE then you should not use this tool. For example, if the user's task is to create a website, you may start by asking some clarifying questions with the ask_followup_question tool if their message was vague, explore the codebase, read files, then present a detailed plan for how you will accomplish the task given the context, and perhaps engage in a back and forth to finalize the details before the user switches you to ACT_MODE to implement the solution.
CRITICAL: You must complete your information gathering (reading files, exploring the codebase) BEFORE using this tool. The user expects to see a well thought-out plan based on actual analysis, not intentions.
Description: Respond to the user's inquiry in an effort to plan a solution to the user's task. This tool should ONLY be used when you have already explored the relevant files and are ready to present a concrete plan. DO NOT use this tool to announce what files you're going to read - just read them first. This tool is only available in PLAN MODE. The environment_details will specify the current mode; if it is not PLAN_MODE then you should not use this tool.
However, if while writing your response you realize you actually need to do more exploration before providing a complete plan, you can add the optional needs_more_exploration parameter to indicate this. This allows you to acknowledge that you should have done more exploration first, and signals that your next message will use exploration tools instead.
Parameters:
- response: (required) The response to provide to the user. Do not try to use tools in this parameter, this is simply a chat response. (You MUST use the response parameter, do not simply place the response text directly within <plan_mode_respond> tags.)
- needs_more_exploration: (optional) Set to true if while formulating your response that you found you need to do more exploration with tools, for example reading files. (Remember, you can explore the project with tools like read_file in PLAN MODE without the user having to toggle to ACT MODE.) Defaults to false if not specified.
Usage:
<plan_mode_respond>
<response>Your response here</response>
<needs_more_exploration>true or false (optional, but you MUST set to true if in <response> you need to read files or use other exploration tools)</needs_more_exploration>
</plan_mode_respond>
## load_mcp_documentation
@@ -619,6 +620,7 @@ RULES
- Be sure to consider the type of project (e.g. Python, JavaScript, web application) when determining the appropriate structure and files to include. Also consider what files may be most relevant to accomplishing the task, for example looking at a project's manifest file would help you understand the project's dependencies, which you could incorporate into any code you write.
- When making changes to code, always consider the context in which the code is being used. Ensure that your changes are compatible with the existing codebase and that they follow the project's coding standards and best practices.
- When you want to modify a file, use the replace_in_file or write_to_file tool directly with the desired changes. You do not need to display the changes before using the tool.
- Use Markdown **only where semantically correct** (e.g., \`inline code\`, \`\`\`code fences\`\`\`, lists, tables). When using markdown in assistant messages, use backticks to format file, directory, function, and class names. Use \( and \) for inline math, \[ and \] for block math.
- Do not ask for more information than necessary. Use the tools provided to accomplish the user's request efficiently and effectively. When you've completed your task, you must use the attempt_completion tool to present the result to the user. The user may provide feedback, which you can use to make improvements and try again.
- You are only allowed to ask the user questions using the ask_followup_question tool. Use this tool only when you need additional details to complete a task, and be sure to use a clear and concise question that will help you move forward with the task. However if you can use the available tools to avoid having to ask the user questions, you should do so. For example, if the user mentions a file that may be in an outside directory like the Desktop, you should use the list_files tool to list the files in the Desktop and check if the file they are talking about is there, rather than asking the user to provide the file path themselves.
- When the user is being vague, you should be proactive about asking clarifying questions using the ask_followup_question tool to ensure you understand their request. However, if you can infer the user's intent based on the context and available tools, you should proceed without asking unnecessary questions
+1 -1
View File
@@ -2,7 +2,7 @@ import { Anthropic } from "@anthropic-ai/sdk"
import * as diff from "diff"
import * as path from "path"
import { ClineIgnoreController, LOCK_TEXT_SYMBOL } from "../ignore/ClineIgnoreController"
import { Mode } from "@/shared/ChatSettings"
import { Mode } from "@/shared/storage/types"
export const formatResponse = {
duplicateFileReadNotice: () =>
+11 -10
View File
@@ -5,7 +5,7 @@ import { McpHub } from "@services/mcp/McpHub"
import { BrowserSettings } from "@shared/BrowserSettings"
import { SYSTEM_PROMPT_CLAUDE4_EXPERIMENTAL } from "@core/prompts/model_prompts/claude4-experimental"
import { SYSTEM_PROMPT_CLAUDE4 } from "@core/prompts/model_prompts/claude4"
import { USE_EXPERIMENTAL_CLAUDE4_FEATURES } from "@core/task/index";
import { USE_EXPERIMENTAL_CLAUDE4_FEATURES } from "@core/task/index"
export const SYSTEM_PROMPT = async (
cwd: string,
@@ -14,14 +14,13 @@ export const SYSTEM_PROMPT = async (
browserSettings: BrowserSettings,
isNextGenModel: boolean = false,
) => {
if (isNextGenModel && USE_EXPERIMENTAL_CLAUDE4_FEATURES) {
return SYSTEM_PROMPT_CLAUDE4_EXPERIMENTAL(cwd, supportsBrowserUse, mcpHub, browserSettings)
}
if (isNextGenModel) {
return SYSTEM_PROMPT_CLAUDE4(cwd, supportsBrowserUse, mcpHub, browserSettings)
}
if (isNextGenModel) {
return SYSTEM_PROMPT_CLAUDE4(cwd, supportsBrowserUse, mcpHub, browserSettings)
}
return `You are Cline, a highly skilled software engineer with extensive knowledge in many programming languages, frameworks, design patterns, and best practices.
@@ -264,12 +263,15 @@ Usage:
</new_task>
## plan_mode_respond
Description: Respond to the user's inquiry in an effort to plan a solution to the user's task. This tool should be used when you need to provide a response to a question or statement from the user about how you plan to accomplish the task. This tool is only available in PLAN MODE. The environment_details will specify the current mode, if it is not PLAN MODE then you should not use this tool. Depending on the user's message, you may ask questions to get clarification about the user's request, architect a solution to the task, and to brainstorm ideas with the user. For example, if the user's task is to create a website, you may start by asking some clarifying questions, then present a detailed plan for how you will accomplish the task given the context, and perhaps engage in a back and forth to finalize the details before the user switches you to ACT MODE to implement the solution.
Description: Respond to the user's inquiry in an effort to plan a solution to the user's task. This tool should ONLY be used when you have already explored the relevant files and are ready to present a concrete plan. DO NOT use this tool to announce what files you're going to read - just read them first. This tool is only available in PLAN MODE. The environment_details will specify the current mode; if it is not PLAN_MODE then you should not use this tool.
However, if while writing your response you realize you actually need to do more exploration before providing a complete plan, you can add the optional needs_more_exploration parameter to indicate this. This allows you to acknowledge that you should have done more exploration first, and signals that your next message will use exploration tools instead.
Parameters:
- response: (required) The response to provide to the user. Do not try to use tools in this parameter, this is simply a chat response. (You MUST use the response parameter, do not simply place the response text directly within <plan_mode_respond> tags.)
- needs_more_exploration: (optional) Set to true if while formulating your response that you found you need to do more exploration with tools, for example reading files. (Remember, you can explore the project with tools like read_file in PLAN MODE without the user having to toggle to ACT MODE.) Defaults to false if not specified.
Usage:
<plan_mode_respond>
<response>Your response here</response>
<needs_more_exploration>true or false (optional, but you MUST set to true if in <response> you need to read files or use other exploration tools)</needs_more_exploration>
</plan_mode_respond>
## load_mcp_documentation
@@ -554,7 +556,7 @@ You have access to two tools for working with files: **write_to_file** and **rep
By thoughtfully selecting between write_to_file and replace_in_file, you can make your file editing process smoother, safer, and more efficient.
====
ACT MODE V.S. PLAN MODE
In each user message, the environment_details will specify the current mode. There are two modes:
@@ -568,8 +570,8 @@ In each user message, the environment_details will specify the current mode. The
## What is PLAN MODE?
- While you are usually in ACT MODE, the user may switch to PLAN MODE in order to have a back and forth with you to plan how to best accomplish the task.
- When starting in PLAN MODE, depending on the user's request, you may need to do some information gathering e.g. using read_file or search_files to get more context about the task. You may also ask the user clarifying questions to get a better understanding of the task.
- Once you've gained more context about the user's request, you should architect a detailed plan for how you will accomplish the task.
- When starting in PLAN MODE, depending on the user's request, you may need to do some information gathering e.g. using read_file or search_files to get more context about the task. You may also ask the user clarifying questions with ask_followup_question to get a better understanding of the task.
- Once you've gained more context about the user's request, you should architect a detailed plan for how you will accomplish the task. Present the plan to the user using the plan_mode_respond tool.
- Then you might ask the user if they are pleased with this plan, or if they would like to make any changes. Think of this as a brainstorming session where you can discuss the task and plan the best way to accomplish it.
- Finally once it seems like you've reached a good plan, ask the user to switch you back to ACT MODE to implement the solution.
@@ -650,7 +652,6 @@ You accomplish a given task iteratively, breaking it down into clear steps and w
5. The user may provide feedback, which you can use to make improvements and try again. But DO NOT continue in pointless back and forth conversations, i.e. don't end your responses with questions or offers for further assistance.`
}
export function addUserInstructions(
globalClineRulesFileInstructions?: string,
localClineRulesFileInstructions?: string,
+965
View File
@@ -0,0 +1,965 @@
import { ApiConfiguration } from "@shared/api"
import { updateGlobalState, updateWorkspaceState, getAllExtensionState, storeSecret } from "./state"
import { SecretKey, GlobalStateKey, LocalStateKey } from "./state-keys"
import { CACHE_SERVICE_NOT_INITIALIZED } from "./error-messages"
import type { ExtensionContext } from "vscode"
/**
* Interface for persistence error event data
*/
export interface PersistenceErrorEvent {
error: Error
}
/**
* In-memory cache service for fast state access
* Provides immediate reads/writes with async disk persistence
*/
export class CacheService {
private globalStateCache: Map<GlobalStateKey, any> = new Map()
private secretsCache: Map<SecretKey, string | undefined> = new Map()
private workspaceStateCache: Map<LocalStateKey, any> = new Map()
private context: ExtensionContext
private isInitialized = false
// Debounced persistence state
private pendingGlobalState = new Set<GlobalStateKey>()
private pendingSecrets = new Set<SecretKey>()
private pendingWorkspaceState = new Set<LocalStateKey>()
private persistenceTimeout: NodeJS.Timeout | null = null
private readonly PERSISTENCE_DELAY_MS = 500
// Callback for persistence errors
onPersistenceError?: (event: PersistenceErrorEvent) => void
constructor(context: ExtensionContext) {
this.context = context
}
/**
* Initialize the cache by loading data from disk
*/
async initialize(): Promise<void> {
try {
// Load API configuration and populate cache with component keys
const { apiConfiguration } = await getAllExtensionState(this.context)
if (apiConfiguration) {
// Populate the caches with the API configuration component keys
// Use populate method to avoid triggering persistence during initialization
this.populateApiConfigurationCache(apiConfiguration)
}
this.isInitialized = true
console.log("CacheService initialized successfully")
} catch (error) {
console.error("Failed to initialize CacheService:", error)
throw error
}
}
/**
* Set method for global state keys - updates cache immediately and schedules debounced persistence
*/
setGlobalState<T>(key: GlobalStateKey, value: T): void {
if (!this.isInitialized) {
throw new Error(CACHE_SERVICE_NOT_INITIALIZED)
}
// Update cache immediately for instant access
this.globalStateCache.set(key, value)
// Add to pending persistence set and schedule debounced write
this.pendingGlobalState.add(key)
this.scheduleDebouncedPersistence()
}
/**
* Batch set method for global state keys - updates cache immediately and schedules debounced persistence
*/
setGlobalStateBatch(updates: Partial<Record<GlobalStateKey, any>>): void {
if (!this.isInitialized) {
throw new Error(CACHE_SERVICE_NOT_INITIALIZED)
}
// Update cache immediately for all keys
Object.entries(updates).forEach(([key, value]) => {
this.globalStateCache.set(key as GlobalStateKey, value)
this.pendingGlobalState.add(key as GlobalStateKey)
})
// Schedule debounced persistence
this.scheduleDebouncedPersistence()
}
/**
* Set method for secret keys - updates cache immediately and schedules debounced persistence
*/
setSecret(key: SecretKey, value: string | undefined): void {
if (!this.isInitialized) {
throw new Error(CACHE_SERVICE_NOT_INITIALIZED)
}
// Update cache immediately for instant access
this.secretsCache.set(key, value)
// Add to pending persistence set and schedule debounced write
this.pendingSecrets.add(key)
this.scheduleDebouncedPersistence()
}
/**
* Batch set method for secret keys - updates cache immediately and schedules debounced persistence
*/
setSecretsBatch(updates: Partial<Record<SecretKey, string | undefined>>): void {
if (!this.isInitialized) {
throw new Error(CACHE_SERVICE_NOT_INITIALIZED)
}
// Update cache immediately for all keys
Object.entries(updates).forEach(([key, value]) => {
this.secretsCache.set(key as SecretKey, value)
this.pendingSecrets.add(key as SecretKey)
})
// Schedule debounced persistence
this.scheduleDebouncedPersistence()
}
/**
* Set method for workspace state keys - updates cache immediately and schedules debounced persistence
*/
setWorkspaceState<T>(key: LocalStateKey, value: T): void {
if (!this.isInitialized) {
throw new Error(CACHE_SERVICE_NOT_INITIALIZED)
}
// Update cache immediately for instant access
this.workspaceStateCache.set(key, value)
// Add to pending persistence set and schedule debounced write
this.pendingWorkspaceState.add(key)
this.scheduleDebouncedPersistence()
}
/**
* Batch set method for workspace state keys - updates cache immediately and schedules debounced persistence
*/
setWorkspaceStateBatch(updates: Partial<Record<LocalStateKey, any>>): void {
if (!this.isInitialized) {
throw new Error(CACHE_SERVICE_NOT_INITIALIZED)
}
// Update cache immediately for all keys
Object.entries(updates).forEach(([key, value]) => {
this.workspaceStateCache.set(key as LocalStateKey, value)
this.pendingWorkspaceState.add(key as LocalStateKey)
})
// Schedule debounced persistence
this.scheduleDebouncedPersistence()
}
/**
* Convenience method for getting API configuration
* Ensures cache is initialized if not already done
*/
getApiConfiguration(): ApiConfiguration {
if (!this.isInitialized) {
throw new Error(CACHE_SERVICE_NOT_INITIALIZED)
}
// Construct API configuration from cached component keys
return this.constructApiConfigurationFromCache()
}
/**
* Convenience method for setting API configuration
*/
setApiConfiguration(apiConfiguration: ApiConfiguration): void {
if (!this.isInitialized) {
throw new Error(CACHE_SERVICE_NOT_INITIALIZED)
}
const {
apiKey,
openRouterApiKey,
awsAccessKey,
awsSecretKey,
awsSessionToken,
awsRegion,
awsUseCrossRegionInference,
awsBedrockUsePromptCache,
awsBedrockEndpoint,
awsBedrockApiKey,
awsProfile,
awsUseProfile,
awsAuthentication,
vertexProjectId,
vertexRegion,
openAiBaseUrl,
openAiApiKey,
openAiHeaders,
ollamaBaseUrl,
ollamaApiKey,
ollamaApiOptionsCtxNum,
lmStudioBaseUrl,
anthropicBaseUrl,
geminiApiKey,
geminiBaseUrl,
openAiNativeApiKey,
deepSeekApiKey,
requestyApiKey,
togetherApiKey,
qwenApiKey,
doubaoApiKey,
mistralApiKey,
azureApiVersion,
openRouterProviderSorting,
liteLlmBaseUrl,
liteLlmApiKey,
liteLlmUsePromptCache,
qwenApiLine,
moonshotApiLine,
asksageApiKey,
asksageApiUrl,
xaiApiKey,
clineAccountId,
sambanovaApiKey,
cerebrasApiKey,
groqApiKey,
moonshotApiKey,
nebiusApiKey,
favoritedModelIds,
fireworksApiKey,
fireworksModelMaxCompletionTokens,
fireworksModelMaxTokens,
sapAiCoreClientId,
sapAiCoreClientSecret,
sapAiCoreBaseUrl,
sapAiCoreTokenUrl,
sapAiResourceGroup,
claudeCodePath,
basetenApiKey,
huggingFaceApiKey,
requestTimeoutMs,
// Plan mode configurations
planModeApiProvider,
planModeApiModelId,
planModeThinkingBudgetTokens,
planModeReasoningEffort,
planModeVsCodeLmModelSelector,
planModeAwsBedrockCustomSelected,
planModeAwsBedrockCustomModelBaseId,
planModeOpenRouterModelId,
planModeOpenRouterModelInfo,
planModeOpenAiModelId,
planModeOpenAiModelInfo,
planModeOllamaModelId,
planModeLmStudioModelId,
planModeLiteLlmModelId,
planModeLiteLlmModelInfo,
planModeRequestyModelId,
planModeRequestyModelInfo,
planModeTogetherModelId,
planModeFireworksModelId,
planModeSapAiCoreModelId,
planModeGroqModelId,
planModeGroqModelInfo,
planModeBasetenModelId,
planModeBasetenModelInfo,
planModeHuggingFaceModelId,
planModeHuggingFaceModelInfo,
// Act mode configurations
actModeApiProvider,
actModeApiModelId,
actModeThinkingBudgetTokens,
actModeReasoningEffort,
actModeVsCodeLmModelSelector,
actModeAwsBedrockCustomSelected,
actModeAwsBedrockCustomModelBaseId,
actModeOpenRouterModelId,
actModeOpenRouterModelInfo,
actModeOpenAiModelId,
actModeOpenAiModelInfo,
actModeOllamaModelId,
actModeLmStudioModelId,
actModeLiteLlmModelId,
actModeLiteLlmModelInfo,
actModeRequestyModelId,
actModeRequestyModelInfo,
actModeTogetherModelId,
actModeFireworksModelId,
actModeSapAiCoreModelId,
actModeGroqModelId,
actModeGroqModelInfo,
actModeBasetenModelId,
actModeBasetenModelInfo,
actModeHuggingFaceModelId,
actModeHuggingFaceModelInfo,
} = apiConfiguration
// Batch update global state keys
this.setGlobalStateBatch({
// Plan mode configuration updates
planModeApiProvider,
planModeApiModelId,
planModeThinkingBudgetTokens,
planModeReasoningEffort,
planModeVsCodeLmModelSelector,
planModeAwsBedrockCustomSelected,
planModeAwsBedrockCustomModelBaseId,
planModeOpenRouterModelId,
planModeOpenRouterModelInfo,
planModeOpenAiModelId,
planModeOpenAiModelInfo,
planModeOllamaModelId,
planModeLmStudioModelId,
planModeLiteLlmModelId,
planModeLiteLlmModelInfo,
planModeRequestyModelId,
planModeRequestyModelInfo,
planModeTogetherModelId,
planModeFireworksModelId,
planModeSapAiCoreModelId,
planModeGroqModelId,
planModeGroqModelInfo,
planModeBasetenModelId,
planModeBasetenModelInfo,
planModeHuggingFaceModelId,
planModeHuggingFaceModelInfo,
// Act mode configuration updates
actModeApiProvider,
actModeApiModelId,
actModeThinkingBudgetTokens,
actModeReasoningEffort,
actModeVsCodeLmModelSelector,
actModeAwsBedrockCustomSelected,
actModeAwsBedrockCustomModelBaseId,
actModeOpenRouterModelId,
actModeOpenRouterModelInfo,
actModeOpenAiModelId,
actModeOpenAiModelInfo,
actModeOllamaModelId,
actModeLmStudioModelId,
actModeLiteLlmModelId,
actModeLiteLlmModelInfo,
actModeRequestyModelId,
actModeRequestyModelInfo,
actModeTogetherModelId,
actModeFireworksModelId,
actModeSapAiCoreModelId,
actModeGroqModelId,
actModeGroqModelInfo,
actModeBasetenModelId,
actModeBasetenModelInfo,
actModeHuggingFaceModelId,
actModeHuggingFaceModelInfo,
// Global state updates
awsRegion,
awsUseCrossRegionInference,
awsBedrockUsePromptCache,
awsBedrockEndpoint,
awsProfile,
awsUseProfile,
awsAuthentication,
vertexProjectId,
vertexRegion,
openAiBaseUrl,
openAiHeaders,
ollamaBaseUrl,
ollamaApiOptionsCtxNum,
lmStudioBaseUrl,
anthropicBaseUrl,
geminiBaseUrl,
azureApiVersion,
openRouterProviderSorting,
liteLlmBaseUrl,
liteLlmUsePromptCache,
qwenApiLine,
moonshotApiLine,
asksageApiUrl,
favoritedModelIds,
requestTimeoutMs,
fireworksModelMaxCompletionTokens,
fireworksModelMaxTokens,
sapAiCoreBaseUrl,
sapAiCoreTokenUrl,
sapAiResourceGroup,
claudeCodePath,
})
// Batch update secrets
this.setSecretsBatch({
apiKey,
openRouterApiKey,
clineAccountId,
awsAccessKey,
awsSecretKey,
awsSessionToken,
awsBedrockApiKey,
openAiApiKey,
ollamaApiKey,
geminiApiKey,
openAiNativeApiKey,
deepSeekApiKey,
requestyApiKey,
togetherApiKey,
qwenApiKey,
doubaoApiKey,
mistralApiKey,
liteLlmApiKey,
fireworksApiKey,
asksageApiKey,
xaiApiKey,
sambanovaApiKey,
cerebrasApiKey,
groqApiKey,
moonshotApiKey,
nebiusApiKey,
sapAiCoreClientId,
sapAiCoreClientSecret,
basetenApiKey,
huggingFaceApiKey,
})
}
/**
* Get method for global state keys - reads from in-memory cache
*/
getGlobalStateKey<T>(key: GlobalStateKey): T | undefined {
if (!this.isInitialized) {
throw new Error(CACHE_SERVICE_NOT_INITIALIZED)
}
return this.globalStateCache.get(key) as T | undefined
}
/**
* Get method for secret keys - reads from in-memory cache
*/
getSecretKey(key: SecretKey): string | undefined {
if (!this.isInitialized) {
throw new Error(CACHE_SERVICE_NOT_INITIALIZED)
}
return this.secretsCache.get(key)
}
/**
* Get method for workspace state keys - reads from in-memory cache
*/
getWorkspaceStateKey<T>(key: LocalStateKey): T | undefined {
if (!this.isInitialized) {
throw new Error(CACHE_SERVICE_NOT_INITIALIZED)
}
return this.workspaceStateCache.get(key) as T | undefined
}
/**
* Reinitialize the cache service by clearing all state and reloading from disk
* Used for error recovery when write operations fail
*/
async reInitialize(): Promise<void> {
// Clear all cached data and pending state
this.dispose()
// Reinitialize from disk
await this.initialize()
}
/**
* Dispose of the cache service
*/
private dispose(): void {
if (this.persistenceTimeout) {
clearTimeout(this.persistenceTimeout)
this.persistenceTimeout = null
}
this.pendingGlobalState.clear()
this.pendingSecrets.clear()
this.pendingWorkspaceState.clear()
this.globalStateCache.clear()
this.secretsCache.clear()
this.workspaceStateCache.clear()
this.isInitialized = false
}
/**
* Schedule debounced persistence - simple timeout-based persistence
*/
private scheduleDebouncedPersistence(): void {
// Clear existing timeout if one is pending
if (this.persistenceTimeout) {
clearTimeout(this.persistenceTimeout)
}
// Schedule a new timeout to persist pending changes
this.persistenceTimeout = setTimeout(async () => {
try {
await Promise.all([
this.persistGlobalStateBatch(this.pendingGlobalState),
this.persistSecretsBatch(this.pendingSecrets),
this.persistWorkspaceStateBatch(this.pendingWorkspaceState),
])
// Clear pending sets on successful persistence
this.pendingGlobalState.clear()
this.pendingSecrets.clear()
this.pendingWorkspaceState.clear()
this.persistenceTimeout = null
} catch (error) {
console.error("Failed to persist pending changes:", error)
this.persistenceTimeout = null
// Call persistence error callback for error recovery
this.onPersistenceError?.({ error: error as Error })
}
}, this.PERSISTENCE_DELAY_MS)
}
/**
* Private method to batch persist global state keys with Promise.all
*/
private async persistGlobalStateBatch(keys: Set<GlobalStateKey>): Promise<void> {
try {
await Promise.all(
Array.from(keys).map((key) => {
const value = this.globalStateCache.get(key)
return this.context.globalState.update(key, value)
}),
)
} catch (error) {
console.error("Failed to persist global state batch:", error)
throw error
}
}
/**
* Private method to batch persist secrets with Promise.all
*/
private async persistSecretsBatch(keys: Set<SecretKey>): Promise<void> {
try {
await Promise.all(
Array.from(keys).map((key) => {
const value = this.secretsCache.get(key)
if (value) {
return this.context.secrets.store(key, value)
} else {
return this.context.secrets.delete(key)
}
}),
)
} catch (error) {
console.error("Failed to persist secrets batch:", error)
throw error
}
}
/**
* Private method to batch persist workspace state keys with Promise.all
*/
private async persistWorkspaceStateBatch(keys: Set<LocalStateKey>): Promise<void> {
try {
await Promise.all(
Array.from(keys).map((key) => {
const value = this.workspaceStateCache.get(key)
return this.context.workspaceState.update(key, value)
}),
)
} catch (error) {
console.error("Failed to persist workspace state batch:", error)
throw error
}
}
/**
* Private method to populate API configuration cache without triggering persistence
* Used during initialization
*/
private populateApiConfigurationCache(apiConfiguration: ApiConfiguration): void {
const {
apiKey,
openRouterApiKey,
awsAccessKey,
awsSecretKey,
awsSessionToken,
awsRegion,
awsUseCrossRegionInference,
awsBedrockUsePromptCache,
awsBedrockEndpoint,
awsBedrockApiKey,
awsProfile,
awsUseProfile,
awsAuthentication,
vertexProjectId,
vertexRegion,
openAiBaseUrl,
openAiApiKey,
openAiHeaders,
ollamaBaseUrl,
ollamaApiKey,
ollamaApiOptionsCtxNum,
lmStudioBaseUrl,
anthropicBaseUrl,
geminiApiKey,
geminiBaseUrl,
openAiNativeApiKey,
deepSeekApiKey,
requestyApiKey,
togetherApiKey,
qwenApiKey,
doubaoApiKey,
mistralApiKey,
azureApiVersion,
openRouterProviderSorting,
liteLlmBaseUrl,
liteLlmApiKey,
liteLlmUsePromptCache,
qwenApiLine,
moonshotApiLine,
asksageApiKey,
asksageApiUrl,
xaiApiKey,
clineAccountId,
sambanovaApiKey,
cerebrasApiKey,
groqApiKey,
basetenApiKey,
moonshotApiKey,
nebiusApiKey,
favoritedModelIds,
fireworksApiKey,
fireworksModelMaxCompletionTokens,
fireworksModelMaxTokens,
sapAiCoreClientId,
sapAiCoreClientSecret,
sapAiCoreBaseUrl,
sapAiCoreTokenUrl,
sapAiResourceGroup,
claudeCodePath,
huggingFaceApiKey,
requestTimeoutMs,
// Plan mode configurations
planModeApiProvider,
planModeApiModelId,
planModeThinkingBudgetTokens,
planModeReasoningEffort,
planModeVsCodeLmModelSelector,
planModeAwsBedrockCustomSelected,
planModeAwsBedrockCustomModelBaseId,
planModeOpenRouterModelId,
planModeOpenRouterModelInfo,
planModeOpenAiModelId,
planModeOpenAiModelInfo,
planModeOllamaModelId,
planModeLmStudioModelId,
planModeLiteLlmModelId,
planModeLiteLlmModelInfo,
planModeRequestyModelId,
planModeRequestyModelInfo,
planModeTogetherModelId,
planModeFireworksModelId,
planModeSapAiCoreModelId,
planModeGroqModelId,
planModeGroqModelInfo,
planModeBasetenModelId,
planModeBasetenModelInfo,
planModeHuggingFaceModelId,
planModeHuggingFaceModelInfo,
// Act mode configurations
actModeApiProvider,
actModeApiModelId,
actModeThinkingBudgetTokens,
actModeReasoningEffort,
actModeVsCodeLmModelSelector,
actModeAwsBedrockCustomSelected,
actModeAwsBedrockCustomModelBaseId,
actModeOpenRouterModelId,
actModeOpenRouterModelInfo,
actModeOpenAiModelId,
actModeOpenAiModelInfo,
actModeOllamaModelId,
actModeLmStudioModelId,
actModeLiteLlmModelId,
actModeLiteLlmModelInfo,
actModeRequestyModelId,
actModeRequestyModelInfo,
actModeTogetherModelId,
actModeFireworksModelId,
actModeSapAiCoreModelId,
actModeGroqModelId,
actModeGroqModelInfo,
actModeBasetenModelId,
actModeBasetenModelInfo,
actModeHuggingFaceModelId,
actModeHuggingFaceModelInfo,
} = apiConfiguration
// Directly populate global state cache without triggering persistence
const globalStateUpdates = {
// Plan mode configuration updates
planModeApiProvider,
planModeApiModelId,
planModeThinkingBudgetTokens,
planModeReasoningEffort,
planModeVsCodeLmModelSelector,
planModeAwsBedrockCustomSelected,
planModeAwsBedrockCustomModelBaseId,
planModeOpenRouterModelId,
planModeOpenRouterModelInfo,
planModeOpenAiModelId,
planModeOpenAiModelInfo,
planModeOllamaModelId,
planModeLmStudioModelId,
planModeLiteLlmModelId,
planModeLiteLlmModelInfo,
planModeRequestyModelId,
planModeRequestyModelInfo,
planModeTogetherModelId,
planModeFireworksModelId,
planModeSapAiCoreModelId,
planModeGroqModelId,
planModeGroqModelInfo,
planModeBasetenModelId,
planModeBasetenModelInfo,
planModeHuggingFaceModelId,
planModeHuggingFaceModelInfo,
// Act mode configuration updates
actModeApiProvider,
actModeApiModelId,
actModeThinkingBudgetTokens,
actModeReasoningEffort,
actModeVsCodeLmModelSelector,
actModeAwsBedrockCustomSelected,
actModeAwsBedrockCustomModelBaseId,
actModeOpenRouterModelId,
actModeOpenRouterModelInfo,
actModeOpenAiModelId,
actModeOpenAiModelInfo,
actModeOllamaModelId,
actModeLmStudioModelId,
actModeLiteLlmModelId,
actModeLiteLlmModelInfo,
actModeRequestyModelId,
actModeRequestyModelInfo,
actModeTogetherModelId,
actModeFireworksModelId,
actModeSapAiCoreModelId,
actModeGroqModelId,
actModeGroqModelInfo,
actModeBasetenModelId,
actModeBasetenModelInfo,
actModeHuggingFaceModelId,
actModeHuggingFaceModelInfo,
// Global state updates
awsRegion,
awsUseCrossRegionInference,
awsBedrockUsePromptCache,
awsBedrockEndpoint,
awsProfile,
awsUseProfile,
awsAuthentication,
vertexProjectId,
vertexRegion,
openAiBaseUrl,
openAiHeaders,
ollamaBaseUrl,
ollamaApiOptionsCtxNum,
lmStudioBaseUrl,
anthropicBaseUrl,
geminiBaseUrl,
azureApiVersion,
openRouterProviderSorting,
liteLlmBaseUrl,
liteLlmUsePromptCache,
qwenApiLine,
moonshotApiLine,
asksageApiUrl,
favoritedModelIds,
requestTimeoutMs,
fireworksModelMaxCompletionTokens,
fireworksModelMaxTokens,
sapAiCoreBaseUrl,
sapAiCoreTokenUrl,
sapAiResourceGroup,
claudeCodePath,
}
// Populate global state cache directly
Object.entries(globalStateUpdates).forEach(([key, value]) => {
this.globalStateCache.set(key as GlobalStateKey, value)
})
// Directly populate secrets cache without triggering persistence
const secretsUpdates = {
apiKey,
openRouterApiKey,
clineAccountId,
awsAccessKey,
awsSecretKey,
awsSessionToken,
awsBedrockApiKey,
openAiApiKey,
ollamaApiKey,
geminiApiKey,
openAiNativeApiKey,
deepSeekApiKey,
requestyApiKey,
togetherApiKey,
qwenApiKey,
doubaoApiKey,
mistralApiKey,
liteLlmApiKey,
fireworksApiKey,
asksageApiKey,
xaiApiKey,
sambanovaApiKey,
cerebrasApiKey,
groqApiKey,
basetenApiKey,
moonshotApiKey,
nebiusApiKey,
sapAiCoreClientId,
sapAiCoreClientSecret,
huggingFaceApiKey,
}
// Populate secrets cache directly
Object.entries(secretsUpdates).forEach(([key, value]) => {
this.secretsCache.set(key as SecretKey, value)
})
}
/**
* Construct API configuration from cached component keys
*/
private constructApiConfigurationFromCache(): ApiConfiguration {
return {
// Secrets
apiKey: this.secretsCache.get("apiKey"),
openRouterApiKey: this.secretsCache.get("openRouterApiKey"),
clineAccountId: this.secretsCache.get("clineAccountId"),
awsAccessKey: this.secretsCache.get("awsAccessKey"),
awsSecretKey: this.secretsCache.get("awsSecretKey"),
awsSessionToken: this.secretsCache.get("awsSessionToken"),
awsBedrockApiKey: this.secretsCache.get("awsBedrockApiKey"),
openAiApiKey: this.secretsCache.get("openAiApiKey"),
ollamaApiKey: this.secretsCache.get("ollamaApiKey"),
geminiApiKey: this.secretsCache.get("geminiApiKey"),
openAiNativeApiKey: this.secretsCache.get("openAiNativeApiKey"),
deepSeekApiKey: this.secretsCache.get("deepSeekApiKey"),
requestyApiKey: this.secretsCache.get("requestyApiKey"),
togetherApiKey: this.secretsCache.get("togetherApiKey"),
qwenApiKey: this.secretsCache.get("qwenApiKey"),
doubaoApiKey: this.secretsCache.get("doubaoApiKey"),
mistralApiKey: this.secretsCache.get("mistralApiKey"),
liteLlmApiKey: this.secretsCache.get("liteLlmApiKey"),
fireworksApiKey: this.secretsCache.get("fireworksApiKey"),
asksageApiKey: this.secretsCache.get("asksageApiKey"),
xaiApiKey: this.secretsCache.get("xaiApiKey"),
sambanovaApiKey: this.secretsCache.get("sambanovaApiKey"),
cerebrasApiKey: this.secretsCache.get("cerebrasApiKey"),
groqApiKey: this.secretsCache.get("groqApiKey"),
basetenApiKey: this.secretsCache.get("basetenApiKey"),
moonshotApiKey: this.secretsCache.get("moonshotApiKey"),
nebiusApiKey: this.secretsCache.get("nebiusApiKey"),
sapAiCoreClientId: this.secretsCache.get("sapAiCoreClientId"),
sapAiCoreClientSecret: this.secretsCache.get("sapAiCoreClientSecret"),
huggingFaceApiKey: this.secretsCache.get("huggingFaceApiKey"),
// Global state
awsRegion: this.globalStateCache.get("awsRegion"),
awsUseCrossRegionInference: this.globalStateCache.get("awsUseCrossRegionInference"),
awsBedrockUsePromptCache: this.globalStateCache.get("awsBedrockUsePromptCache"),
awsBedrockEndpoint: this.globalStateCache.get("awsBedrockEndpoint"),
awsProfile: this.globalStateCache.get("awsProfile"),
awsUseProfile: this.globalStateCache.get("awsUseProfile"),
awsAuthentication: this.globalStateCache.get("awsAuthentication"),
vertexProjectId: this.globalStateCache.get("vertexProjectId"),
vertexRegion: this.globalStateCache.get("vertexRegion"),
openAiBaseUrl: this.globalStateCache.get("openAiBaseUrl"),
openAiHeaders: this.globalStateCache.get("openAiHeaders") || {},
ollamaBaseUrl: this.globalStateCache.get("ollamaBaseUrl"),
ollamaApiOptionsCtxNum: this.globalStateCache.get("ollamaApiOptionsCtxNum"),
lmStudioBaseUrl: this.globalStateCache.get("lmStudioBaseUrl"),
anthropicBaseUrl: this.globalStateCache.get("anthropicBaseUrl"),
geminiBaseUrl: this.globalStateCache.get("geminiBaseUrl"),
azureApiVersion: this.globalStateCache.get("azureApiVersion"),
openRouterProviderSorting: this.globalStateCache.get("openRouterProviderSorting"),
liteLlmBaseUrl: this.globalStateCache.get("liteLlmBaseUrl"),
liteLlmUsePromptCache: this.globalStateCache.get("liteLlmUsePromptCache"),
qwenApiLine: this.globalStateCache.get("qwenApiLine"),
moonshotApiLine: this.globalStateCache.get("moonshotApiLine"),
asksageApiUrl: this.globalStateCache.get("asksageApiUrl"),
favoritedModelIds: this.globalStateCache.get("favoritedModelIds"),
requestTimeoutMs: this.globalStateCache.get("requestTimeoutMs"),
fireworksModelMaxCompletionTokens: this.globalStateCache.get("fireworksModelMaxCompletionTokens"),
fireworksModelMaxTokens: this.globalStateCache.get("fireworksModelMaxTokens"),
sapAiCoreBaseUrl: this.globalStateCache.get("sapAiCoreBaseUrl"),
sapAiCoreTokenUrl: this.globalStateCache.get("sapAiCoreTokenUrl"),
sapAiResourceGroup: this.globalStateCache.get("sapAiResourceGroup"),
claudeCodePath: this.globalStateCache.get("claudeCodePath"),
// Plan mode configurations
planModeApiProvider: this.globalStateCache.get("planModeApiProvider"),
planModeApiModelId: this.globalStateCache.get("planModeApiModelId"),
planModeThinkingBudgetTokens: this.globalStateCache.get("planModeThinkingBudgetTokens"),
planModeReasoningEffort: this.globalStateCache.get("planModeReasoningEffort"),
planModeVsCodeLmModelSelector: this.globalStateCache.get("planModeVsCodeLmModelSelector"),
planModeAwsBedrockCustomSelected: this.globalStateCache.get("planModeAwsBedrockCustomSelected"),
planModeAwsBedrockCustomModelBaseId: this.globalStateCache.get("planModeAwsBedrockCustomModelBaseId"),
planModeOpenRouterModelId: this.globalStateCache.get("planModeOpenRouterModelId"),
planModeOpenRouterModelInfo: this.globalStateCache.get("planModeOpenRouterModelInfo"),
planModeOpenAiModelId: this.globalStateCache.get("planModeOpenAiModelId"),
planModeOpenAiModelInfo: this.globalStateCache.get("planModeOpenAiModelInfo"),
planModeOllamaModelId: this.globalStateCache.get("planModeOllamaModelId"),
planModeLmStudioModelId: this.globalStateCache.get("planModeLmStudioModelId"),
planModeLiteLlmModelId: this.globalStateCache.get("planModeLiteLlmModelId"),
planModeLiteLlmModelInfo: this.globalStateCache.get("planModeLiteLlmModelInfo"),
planModeRequestyModelId: this.globalStateCache.get("planModeRequestyModelId"),
planModeRequestyModelInfo: this.globalStateCache.get("planModeRequestyModelInfo"),
planModeTogetherModelId: this.globalStateCache.get("planModeTogetherModelId"),
planModeFireworksModelId: this.globalStateCache.get("planModeFireworksModelId"),
planModeSapAiCoreModelId: this.globalStateCache.get("planModeSapAiCoreModelId"),
planModeGroqModelId: this.globalStateCache.get("planModeGroqModelId"),
planModeGroqModelInfo: this.globalStateCache.get("planModeGroqModelInfo"),
planModeBasetenModelId: this.globalStateCache.get("planModeBasetenModelId"),
planModeBasetenModelInfo: this.globalStateCache.get("planModeBasetenModelInfo"),
planModeHuggingFaceModelId: this.globalStateCache.get("planModeHuggingFaceModelId"),
planModeHuggingFaceModelInfo: this.globalStateCache.get("planModeHuggingFaceModelInfo"),
// Act mode configurations
actModeApiProvider: this.globalStateCache.get("actModeApiProvider"),
actModeApiModelId: this.globalStateCache.get("actModeApiModelId"),
actModeThinkingBudgetTokens: this.globalStateCache.get("actModeThinkingBudgetTokens"),
actModeReasoningEffort: this.globalStateCache.get("actModeReasoningEffort"),
actModeVsCodeLmModelSelector: this.globalStateCache.get("actModeVsCodeLmModelSelector"),
actModeAwsBedrockCustomSelected: this.globalStateCache.get("actModeAwsBedrockCustomSelected"),
actModeAwsBedrockCustomModelBaseId: this.globalStateCache.get("actModeAwsBedrockCustomModelBaseId"),
actModeOpenRouterModelId: this.globalStateCache.get("actModeOpenRouterModelId"),
actModeOpenRouterModelInfo: this.globalStateCache.get("actModeOpenRouterModelInfo"),
actModeOpenAiModelId: this.globalStateCache.get("actModeOpenAiModelId"),
actModeOpenAiModelInfo: this.globalStateCache.get("actModeOpenAiModelInfo"),
actModeOllamaModelId: this.globalStateCache.get("actModeOllamaModelId"),
actModeLmStudioModelId: this.globalStateCache.get("actModeLmStudioModelId"),
actModeLiteLlmModelId: this.globalStateCache.get("actModeLiteLlmModelId"),
actModeLiteLlmModelInfo: this.globalStateCache.get("actModeLiteLlmModelInfo"),
actModeRequestyModelId: this.globalStateCache.get("actModeRequestyModelId"),
actModeRequestyModelInfo: this.globalStateCache.get("actModeRequestyModelInfo"),
actModeTogetherModelId: this.globalStateCache.get("actModeTogetherModelId"),
actModeFireworksModelId: this.globalStateCache.get("actModeFireworksModelId"),
actModeSapAiCoreModelId: this.globalStateCache.get("actModeSapAiCoreModelId"),
actModeGroqModelId: this.globalStateCache.get("actModeGroqModelId"),
actModeGroqModelInfo: this.globalStateCache.get("actModeGroqModelInfo"),
actModeBasetenModelId: this.globalStateCache.get("actModeBasetenModelId"),
actModeBasetenModelInfo: this.globalStateCache.get("actModeBasetenModelInfo"),
actModeHuggingFaceModelId: this.globalStateCache.get("actModeHuggingFaceModelId"),
actModeHuggingFaceModelInfo: this.globalStateCache.get("actModeHuggingFaceModelInfo"),
} as ApiConfiguration
}
}
+1
View File
@@ -14,6 +14,7 @@ export const GlobalFileNames = {
uiMessages: "ui_messages.json",
openRouterModels: "openrouter_models.json",
groqModels: "groq_models.json",
basetenModels: "baseten_models.json",
mcpSettings: "cline_mcp_settings.json",
clineRules: ".clinerules",
workflows: ".clinerules/workflows",
+1
View File
@@ -0,0 +1 @@
export const CACHE_SERVICE_NOT_INITIALIZED = "CacheService must be initialized before attempting to access state."
+14 -1
View File
@@ -7,6 +7,7 @@ export type SecretKey =
| "awsSessionToken"
| "awsBedrockApiKey"
| "openAiApiKey"
| "ollamaApiKey"
| "geminiApiKey"
| "openAiNativeApiKey"
| "deepSeekApiKey"
@@ -28,6 +29,8 @@ export type SecretKey =
| "sapAiCoreClientId"
| "sapAiCoreClientSecret"
| "groqApiKey"
| "huaweiCloudMaasApiKey"
| "basetenApiKey"
export type GlobalStateKey =
| "awsRegion"
@@ -82,8 +85,10 @@ export type GlobalStateKey =
| "sapAiCoreBaseUrl"
| "sapAiResourceGroup"
| "claudeCodePath"
| "strictPlanModeEnabled"
// Settings around plan/act and ephemeral model configuration
| "chatSettings"
| "preferredLanguage"
| "openaiReasoningEffort"
| "mode"
// Plan mode configurations
| "planModeApiProvider"
@@ -108,8 +113,12 @@ export type GlobalStateKey =
| "planModeSapAiCoreModelId"
| "planModeGroqModelId"
| "planModeGroqModelInfo"
| "planModeBasetenModelId"
| "planModeBasetenModelInfo"
| "planModeHuggingFaceModelId"
| "planModeHuggingFaceModelInfo"
| "planModeHuaweiCloudMaasModelId"
| "planModeHuaweiCloudMaasModelInfo"
// Act mode configurations
| "actModeApiProvider"
| "actModeApiModelId"
@@ -133,7 +142,11 @@ export type GlobalStateKey =
| "actModeSapAiCoreModelId"
| "actModeGroqModelId"
| "actModeGroqModelInfo"
| "actModeBasetenModelId"
| "actModeBasetenModelInfo"
| "actModeHuggingFaceModelId"
| "actModeHuggingFaceModelInfo"
| "actModeHuaweiCloudMaasModelId"
| "actModeHuaweiCloudMaasModelInfo"
export type LocalStateKey = "localClineRulesToggles" | "localCursorRulesToggles" | "localWindsurfRulesToggles" | "workflowToggles"
+1 -39
View File
@@ -3,7 +3,6 @@ import { ensureRulesDirectoryExists } from "./disk"
import fs from "fs/promises"
import path from "path"
import { updateGlobalState, getAllExtensionState, getGlobalState } from "./state"
import { GlobalStateKey } from "./state-keys"
export async function migrateWorkspaceToGlobalStorage(context: vscode.ExtensionContext) {
// Keys to migrate from workspace storage back to global storage
@@ -13,7 +12,6 @@ export async function migrateWorkspaceToGlobalStorage(context: vscode.ExtensionC
"apiModelId",
"thinkingBudgetTokens",
"reasoningEffort",
"chatSettings",
"vsCodeLmModelSelector",
// Provider-specific model keys
@@ -136,43 +134,6 @@ export async function migrateCustomInstructionsToGlobalRules(context: vscode.Ext
}
}
export async function migrateModeFromWorkspaceStorageToControllerState(context: vscode.ExtensionContext) {
try {
// Check legacy workspace storage (use raw methods since chatSettings is now global)
const workspaceChatSettings = (await context.workspaceState.get("chatSettings")) as any
if (workspaceChatSettings && typeof workspaceChatSettings === "object" && "mode" in workspaceChatSettings) {
console.log("Cleaning up mode from legacy workspace storage...")
// Remove mode property from chatSettings
const { mode, ...cleanedChatSettings } = workspaceChatSettings
// Save cleaned chatSettings back to workspace storage (will be migrated later)
await context.workspaceState.update("chatSettings", cleanedChatSettings)
console.log("Successfully removed mode from legacy workspace storage chatSettings")
}
// Also check global storage for any mode cleanup needed
const globalChatSettings = (await context.globalState.get("chatSettings")) as any
if (globalChatSettings && typeof globalChatSettings === "object" && "mode" in globalChatSettings) {
console.log("Cleaning up mode from global storage...")
// Remove mode property from chatSettings
const { mode, ...cleanedChatSettings } = globalChatSettings
// Save cleaned chatSettings back to global storage
await updateGlobalState(context, "chatSettings", cleanedChatSettings)
console.log("Successfully removed mode from global storage chatSettings")
}
} catch (error) {
console.error("Failed to cleanup mode from storage:", error)
// Continue execution - migration failure shouldn't break extension startup
}
}
export async function migrateLegacyApiConfigurationToModeSpecific(context: vscode.ExtensionContext) {
try {
// Check if migration is needed - if planModeApiProvider already exists, skip migration
@@ -563,6 +524,7 @@ export async function migrateWelcomeViewCompleted(context: vscode.ExtensionConte
config.awsRegion,
config.vertexProjectId,
config.openAiApiKey,
config.ollamaApiKey,
config.planModeOllamaModelId,
config.planModeLmStudioModelId,
config.actModeOllamaModelId,
+69 -261
View File
@@ -1,5 +1,5 @@
import * as vscode from "vscode"
import { DEFAULT_CHAT_SETTINGS, Mode } from "@shared/ChatSettings"
import { Mode, OpenaiReasoningEffort } from "@shared/storage/types"
import { DEFAULT_BROWSER_SETTINGS } from "@shared/BrowserSettings"
import { DEFAULT_AUTO_APPROVAL_SETTINGS } from "@shared/AutoApprovalSettings"
import { GlobalStateKey, LocalStateKey, SecretKey } from "./state-keys"
@@ -7,12 +7,12 @@ import { ApiConfiguration, ApiProvider, BedrockModelId, ModelInfo } from "@share
import { HistoryItem } from "@shared/HistoryItem"
import { AutoApprovalSettings } from "@shared/AutoApprovalSettings"
import { BrowserSettings } from "@shared/BrowserSettings"
import { StoredChatSettings } from "@shared/ChatSettings"
import { TelemetrySetting } from "@shared/TelemetrySetting"
import { UserInfo } from "@shared/UserInfo"
import { ClineRulesToggles } from "@shared/cline-rules"
import { DEFAULT_MCP_DISPLAY_MODE, McpDisplayMode } from "@shared/McpDisplayMode"
import { migrateEnableCheckpointsSetting, migrateMcpMarketplaceEnableSetting } from "./state-migrations"
import { Controller } from "../controller"
/*
Storage
https://dev.to/kompotkot/how-to-use-secretstorage-in-your-vscode-extensions-2hco
@@ -110,7 +110,6 @@ export async function getWorkspaceState(context: vscode.ExtensionContext, key: L
}
export async function getAllExtensionState(context: vscode.ExtensionContext) {
const firstBatchStart = performance.now()
const [
isNewUser,
welcomeViewCompleted,
@@ -134,6 +133,7 @@ export async function getAllExtensionState(context: vscode.ExtensionContext) {
openAiApiKey,
openAiHeaders,
ollamaBaseUrl,
ollamaApiKey,
ollamaApiOptionsCtxNum,
lmStudioBaseUrl,
anthropicBaseUrl,
@@ -168,6 +168,7 @@ export async function getAllExtensionState(context: vscode.ExtensionContext) {
sambanovaApiKey,
cerebrasApiKey,
groqApiKey,
basetenApiKey,
moonshotApiKey,
nebiusApiKey,
huggingFaceApiKey,
@@ -190,6 +191,7 @@ export async function getAllExtensionState(context: vscode.ExtensionContext) {
sapAiCoreTokenUrl,
sapAiResourceGroup,
claudeCodePath,
huaweiCloudMaasApiKey,
] = await Promise.all([
getGlobalState(context, "isNewUser") as Promise<boolean | undefined>,
getGlobalState(context, "welcomeViewCompleted") as Promise<boolean | undefined>,
@@ -213,6 +215,7 @@ export async function getAllExtensionState(context: vscode.ExtensionContext) {
getSecret(context, "openAiApiKey") as Promise<string | undefined>,
getGlobalState(context, "openAiHeaders") as Promise<Record<string, string> | undefined>,
getGlobalState(context, "ollamaBaseUrl") as Promise<string | undefined>,
getSecret(context, "ollamaApiKey") as Promise<string | undefined>,
getGlobalState(context, "ollamaApiOptionsCtxNum") as Promise<string | undefined>,
getGlobalState(context, "lmStudioBaseUrl") as Promise<string | undefined>,
getGlobalState(context, "anthropicBaseUrl") as Promise<string | undefined>,
@@ -247,6 +250,7 @@ export async function getAllExtensionState(context: vscode.ExtensionContext) {
getSecret(context, "sambanovaApiKey") as Promise<string | undefined>,
getSecret(context, "cerebrasApiKey") as Promise<string | undefined>,
getSecret(context, "groqApiKey") as Promise<string | undefined>,
getSecret(context, "basetenApiKey") as Promise<string | undefined>,
getSecret(context, "moonshotApiKey") as Promise<string | undefined>,
getSecret(context, "nebiusApiKey") as Promise<string | undefined>,
getSecret(context, "huggingFaceApiKey") as Promise<string | undefined>,
@@ -269,14 +273,21 @@ export async function getAllExtensionState(context: vscode.ExtensionContext) {
getGlobalState(context, "sapAiCoreTokenUrl") as Promise<string | undefined>,
getGlobalState(context, "sapAiResourceGroup") as Promise<string | undefined>,
getGlobalState(context, "claudeCodePath") as Promise<string | undefined>,
getSecret(context, "huaweiCloudMaasApiKey") as Promise<string | undefined>,
])
const localClineRulesToggles = (await getWorkspaceState(context, "localClineRulesToggles")) as ClineRulesToggles
const [localClineRulesToggles, localWindsurfRulesToggles, localCursorRulesToggles, localWorkflowToggles] = await Promise.all([
getWorkspaceState(context, "localClineRulesToggles") as Promise<ClineRulesToggles | undefined>,
getWorkspaceState(context, "localWindsurfRulesToggles") as Promise<ClineRulesToggles | undefined>,
getWorkspaceState(context, "localCursorRulesToggles") as Promise<ClineRulesToggles | undefined>,
getWorkspaceState(context, "workflowToggles") as Promise<ClineRulesToggles | undefined>,
])
const secondBatchStart = performance.now()
const [
chatSettings,
currentMode,
preferredLanguage,
openaiReasoningEffort,
mode,
strictPlanModeEnabled,
// Plan mode configurations
planModeApiProvider,
planModeApiModelId,
@@ -300,8 +311,12 @@ export async function getAllExtensionState(context: vscode.ExtensionContext) {
planModeSapAiCoreModelId,
planModeGroqModelId,
planModeGroqModelInfo,
planModeBasetenModelId,
planModeBasetenModelInfo,
planModeHuggingFaceModelId,
planModeHuggingFaceModelInfo,
planModeHuaweiCloudMaasModelId,
planModeHuaweiCloudMaasModelInfo,
// Act mode configurations
actModeApiProvider,
actModeApiModelId,
@@ -325,11 +340,17 @@ export async function getAllExtensionState(context: vscode.ExtensionContext) {
actModeSapAiCoreModelId,
actModeGroqModelId,
actModeGroqModelInfo,
actModeBasetenModelId,
actModeBasetenModelInfo,
actModeHuggingFaceModelId,
actModeHuggingFaceModelInfo,
actModeHuaweiCloudMaasModelId,
actModeHuaweiCloudMaasModelInfo,
] = await Promise.all([
getGlobalState(context, "chatSettings") as Promise<StoredChatSettings | undefined>,
getGlobalState(context, "preferredLanguage") as Promise<string | undefined>,
getGlobalState(context, "openaiReasoningEffort") as Promise<OpenaiReasoningEffort | undefined>,
getGlobalState(context, "mode") as Promise<Mode | undefined>,
getGlobalState(context, "strictPlanModeEnabled") as Promise<boolean | undefined>,
// Plan mode configurations
getGlobalState(context, "planModeApiProvider") as Promise<ApiProvider | undefined>,
getGlobalState(context, "planModeApiModelId") as Promise<string | undefined>,
@@ -353,8 +374,12 @@ export async function getAllExtensionState(context: vscode.ExtensionContext) {
getGlobalState(context, "planModeSapAiCoreModelId") as Promise<string | undefined>,
getGlobalState(context, "planModeGroqModelId") as Promise<string | undefined>,
getGlobalState(context, "planModeGroqModelInfo") as Promise<ModelInfo | undefined>,
getGlobalState(context, "planModeBasetenModelId") as Promise<string | undefined>,
getGlobalState(context, "planModeBasetenModelInfo") as Promise<ModelInfo | undefined>,
getGlobalState(context, "planModeHuggingFaceModelId") as Promise<string | undefined>,
getGlobalState(context, "planModeHuggingFaceModelInfo") as Promise<ModelInfo | undefined>,
getGlobalState(context, "planModeHuaweiCloudMaasModelId") as Promise<string | undefined>,
getGlobalState(context, "planModeHuaweiCloudMaasModelInfo") as Promise<ModelInfo | undefined>,
// Act mode configurations
getGlobalState(context, "actModeApiProvider") as Promise<ApiProvider | undefined>,
getGlobalState(context, "actModeApiModelId") as Promise<string | undefined>,
@@ -378,11 +403,14 @@ export async function getAllExtensionState(context: vscode.ExtensionContext) {
getGlobalState(context, "actModeSapAiCoreModelId") as Promise<string | undefined>,
getGlobalState(context, "actModeGroqModelId") as Promise<string | undefined>,
getGlobalState(context, "actModeGroqModelInfo") as Promise<ModelInfo | undefined>,
getGlobalState(context, "actModeBasetenModelId") as Promise<string | undefined>,
getGlobalState(context, "actModeBasetenModelInfo") as Promise<ModelInfo | undefined>,
getGlobalState(context, "actModeHuggingFaceModelId") as Promise<string | undefined>,
getGlobalState(context, "actModeHuggingFaceModelInfo") as Promise<ModelInfo | undefined>,
getGlobalState(context, "actModeHuaweiCloudMaasModelId") as Promise<string | undefined>,
getGlobalState(context, "actModeHuaweiCloudMaasModelInfo") as Promise<ModelInfo | undefined>,
])
const processingStart = performance.now()
let apiProvider: ApiProvider
if (planModeApiProvider) {
apiProvider = planModeApiProvider
@@ -442,6 +470,7 @@ export async function getAllExtensionState(context: vscode.ExtensionContext) {
openAiApiKey,
openAiHeaders: openAiHeaders || {},
ollamaBaseUrl,
ollamaApiKey,
ollamaApiOptionsCtxNum,
lmStudioBaseUrl,
anthropicBaseUrl,
@@ -470,6 +499,7 @@ export async function getAllExtensionState(context: vscode.ExtensionContext) {
sambanovaApiKey,
cerebrasApiKey,
groqApiKey,
basetenApiKey,
moonshotApiKey,
nebiusApiKey,
favoritedModelIds,
@@ -480,6 +510,7 @@ export async function getAllExtensionState(context: vscode.ExtensionContext) {
sapAiCoreTokenUrl,
sapAiResourceGroup,
huggingFaceApiKey,
huaweiCloudMaasApiKey,
// Plan mode configurations
planModeApiProvider: planModeApiProvider || apiProvider,
planModeApiModelId,
@@ -503,8 +534,12 @@ export async function getAllExtensionState(context: vscode.ExtensionContext) {
planModeSapAiCoreModelId,
planModeGroqModelId,
planModeGroqModelInfo,
planModeBasetenModelId,
planModeBasetenModelInfo,
planModeHuggingFaceModelId,
planModeHuggingFaceModelInfo,
planModeHuaweiCloudMaasModelId,
planModeHuaweiCloudMaasModelInfo,
// Act mode configurations
actModeApiProvider: actModeApiProvider || apiProvider,
actModeApiModelId,
@@ -528,8 +563,12 @@ export async function getAllExtensionState(context: vscode.ExtensionContext) {
actModeSapAiCoreModelId,
actModeGroqModelId,
actModeGroqModelInfo,
actModeBasetenModelId,
actModeBasetenModelInfo,
actModeHuggingFaceModelId,
actModeHuggingFaceModelInfo,
actModeHuaweiCloudMaasModelId,
actModeHuaweiCloudMaasModelInfo,
},
isNewUser: isNewUser ?? true,
welcomeViewCompleted,
@@ -537,13 +576,11 @@ export async function getAllExtensionState(context: vscode.ExtensionContext) {
taskHistory,
autoApprovalSettings: autoApprovalSettings || DEFAULT_AUTO_APPROVAL_SETTINGS, // default value can be 0 or empty string
globalClineRulesToggles: globalClineRulesToggles || {},
localClineRulesToggles: localClineRulesToggles || {},
browserSettings: { ...DEFAULT_BROWSER_SETTINGS, ...browserSettings }, // this will ensure that older versions of browserSettings (e.g. before remoteBrowserEnabled was added) are merged with the default values (false for remoteBrowserEnabled)
chatSettings: {
...DEFAULT_CHAT_SETTINGS, // Apply defaults first
...(chatSettings || {}), // Spread fetched global chatSettings, which includes preferredLanguage, and openAIReasoningEffort
mode: currentMode || "act", // Merge mode from global state
},
preferredLanguage: preferredLanguage || "English",
openaiReasoningEffort: (openaiReasoningEffort as OpenaiReasoningEffort) || "medium",
mode: mode || "act",
strictPlanModeEnabled: strictPlanModeEnabled ?? false,
userInfo,
mcpMarketplaceEnabled: mcpMarketplaceEnabled,
mcpDisplayMode: mcpDisplayMode ?? DEFAULT_MCP_DISPLAY_MODE,
@@ -556,256 +593,25 @@ export async function getAllExtensionState(context: vscode.ExtensionContext) {
terminalOutputLineLimit: terminalOutputLineLimit ?? 500,
defaultTerminalProfile: defaultTerminalProfile ?? "default",
globalWorkflowToggles: globalWorkflowToggles || {},
localClineRulesToggles: localClineRulesToggles || {},
localWindsurfRulesToggles: localWindsurfRulesToggles || {},
localCursorRulesToggles: localCursorRulesToggles || {},
localWorkflowToggles: localWorkflowToggles || {},
}
}
export async function updateApiConfiguration(context: vscode.ExtensionContext, apiConfiguration: ApiConfiguration) {
const {
apiKey,
openRouterApiKey,
awsAccessKey,
awsSecretKey,
awsSessionToken,
awsRegion,
awsUseCrossRegionInference,
awsBedrockUsePromptCache,
awsBedrockEndpoint,
awsBedrockApiKey,
awsProfile,
awsUseProfile,
awsAuthentication,
vertexProjectId,
vertexRegion,
openAiBaseUrl,
openAiApiKey,
openAiHeaders,
ollamaBaseUrl,
ollamaApiOptionsCtxNum,
lmStudioBaseUrl,
anthropicBaseUrl,
geminiApiKey,
geminiBaseUrl,
openAiNativeApiKey,
deepSeekApiKey,
requestyApiKey,
togetherApiKey,
qwenApiKey,
doubaoApiKey,
mistralApiKey,
azureApiVersion,
openRouterProviderSorting,
liteLlmBaseUrl,
liteLlmApiKey,
liteLlmUsePromptCache,
qwenApiLine,
moonshotApiLine,
asksageApiKey,
asksageApiUrl,
xaiApiKey,
clineAccountId,
sambanovaApiKey,
cerebrasApiKey,
groqApiKey,
moonshotApiKey,
nebiusApiKey,
favoritedModelIds,
fireworksApiKey,
fireworksModelMaxCompletionTokens,
fireworksModelMaxTokens,
sapAiCoreClientId,
sapAiCoreClientSecret,
sapAiCoreBaseUrl,
sapAiCoreTokenUrl,
sapAiResourceGroup,
claudeCodePath,
huggingFaceApiKey,
// Plan mode configurations
planModeApiProvider,
planModeApiModelId,
planModeThinkingBudgetTokens,
planModeReasoningEffort,
planModeVsCodeLmModelSelector,
planModeAwsBedrockCustomSelected,
planModeAwsBedrockCustomModelBaseId,
planModeOpenRouterModelId,
planModeOpenRouterModelInfo,
planModeOpenAiModelId,
planModeOpenAiModelInfo,
planModeOllamaModelId,
planModeLmStudioModelId,
planModeLiteLlmModelId,
planModeLiteLlmModelInfo,
planModeRequestyModelId,
planModeRequestyModelInfo,
planModeTogetherModelId,
planModeFireworksModelId,
planModeSapAiCoreModelId,
planModeGroqModelId,
planModeGroqModelInfo,
planModeHuggingFaceModelId,
planModeHuggingFaceModelInfo,
// Act mode configurations
actModeApiProvider,
actModeApiModelId,
actModeThinkingBudgetTokens,
actModeReasoningEffort,
actModeVsCodeLmModelSelector,
actModeAwsBedrockCustomSelected,
actModeAwsBedrockCustomModelBaseId,
actModeOpenRouterModelId,
actModeOpenRouterModelInfo,
actModeOpenAiModelId,
actModeOpenAiModelInfo,
actModeOllamaModelId,
actModeLmStudioModelId,
actModeLiteLlmModelId,
actModeLiteLlmModelInfo,
actModeRequestyModelId,
actModeRequestyModelInfo,
actModeTogetherModelId,
actModeFireworksModelId,
actModeSapAiCoreModelId,
actModeGroqModelId,
actModeGroqModelInfo,
actModeHuggingFaceModelId,
actModeHuggingFaceModelInfo,
} = apiConfiguration
export async function resetWorkspaceState(controller: Controller) {
const context = controller.context
await Promise.all(context.workspaceState.keys().map((key) => controller.context.workspaceState.update(key, undefined)))
// OPTIMIZED: Batch all global state updates into 2 operations instead of 47
const batchedGlobalUpdates = {
// Plan mode configuration updates
planModeApiProvider,
planModeApiModelId,
planModeThinkingBudgetTokens,
planModeReasoningEffort,
planModeVsCodeLmModelSelector,
planModeAwsBedrockCustomSelected,
planModeAwsBedrockCustomModelBaseId,
planModeOpenRouterModelId,
planModeOpenRouterModelInfo,
planModeOpenAiModelId,
planModeOpenAiModelInfo,
planModeOllamaModelId,
planModeLmStudioModelId,
planModeLiteLlmModelId,
planModeLiteLlmModelInfo,
planModeRequestyModelId,
planModeRequestyModelInfo,
planModeTogetherModelId,
planModeFireworksModelId,
planModeSapAiCoreModelId,
planModeGroqModelId,
planModeGroqModelInfo,
planModeHuggingFaceModelId,
planModeHuggingFaceModelInfo,
// Act mode configuration updates
actModeApiProvider,
actModeApiModelId,
actModeThinkingBudgetTokens,
actModeReasoningEffort,
actModeVsCodeLmModelSelector,
actModeAwsBedrockCustomSelected,
actModeAwsBedrockCustomModelBaseId,
actModeOpenRouterModelId,
actModeOpenRouterModelInfo,
actModeOpenAiModelId,
actModeOpenAiModelInfo,
actModeOllamaModelId,
actModeLmStudioModelId,
actModeLiteLlmModelId,
actModeLiteLlmModelInfo,
actModeRequestyModelId,
actModeRequestyModelInfo,
actModeTogetherModelId,
actModeFireworksModelId,
actModeSapAiCoreModelId,
actModeGroqModelId,
actModeGroqModelInfo,
actModeHuggingFaceModelId,
actModeHuggingFaceModelInfo,
// Global state updates (27 keys)
awsRegion,
awsUseCrossRegionInference,
awsBedrockUsePromptCache,
awsBedrockEndpoint,
awsProfile,
awsUseProfile,
awsAuthentication,
vertexProjectId,
vertexRegion,
openAiBaseUrl,
openAiHeaders: openAiHeaders || {},
ollamaBaseUrl,
ollamaApiOptionsCtxNum,
lmStudioBaseUrl,
anthropicBaseUrl,
geminiBaseUrl,
azureApiVersion,
openRouterProviderSorting,
liteLlmBaseUrl,
liteLlmUsePromptCache,
qwenApiLine,
moonshotApiLine,
asksageApiUrl,
favoritedModelIds,
requestTimeoutMs: apiConfiguration.requestTimeoutMs,
fireworksModelMaxCompletionTokens,
fireworksModelMaxTokens,
sapAiCoreBaseUrl,
sapAiCoreTokenUrl,
sapAiResourceGroup,
claudeCodePath,
}
// OPTIMIZED: Batch all secret updates into 1 operation instead of 23
const batchedSecretUpdates = {
apiKey,
openRouterApiKey,
clineAccountId,
awsAccessKey,
awsSecretKey,
awsSessionToken,
awsBedrockApiKey,
openAiApiKey,
geminiApiKey,
openAiNativeApiKey,
deepSeekApiKey,
requestyApiKey,
togetherApiKey,
qwenApiKey,
doubaoApiKey,
mistralApiKey,
liteLlmApiKey,
fireworksApiKey,
asksageApiKey,
xaiApiKey,
sambanovaApiKey,
cerebrasApiKey,
groqApiKey,
moonshotApiKey,
nebiusApiKey,
sapAiCoreClientId,
sapAiCoreClientSecret,
huggingFaceApiKey,
}
// Execute batched operations in parallel for maximum performance
await Promise.all([updateGlobalStateBatch(context, batchedGlobalUpdates), updateSecretsBatch(context, batchedSecretUpdates)])
await controller.cacheService.reInitialize()
}
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) {
export async function resetGlobalState(controller: Controller) {
// TODO: Reset all workspace states?
for (const key of context.globalState.keys()) {
await context.globalState.update(key, undefined)
}
const context = controller.context
await Promise.all(context.globalState.keys().map((key) => context.globalState.update(key, undefined)))
const secretKeys: SecretKey[] = [
"apiKey",
"openRouterApiKey",
@@ -814,6 +620,7 @@ export async function resetGlobalState(context: vscode.ExtensionContext) {
"awsSessionToken",
"awsBedrockApiKey",
"openAiApiKey",
"ollamaApiKey",
"geminiApiKey",
"openAiNativeApiKey",
"deepSeekApiKey",
@@ -830,11 +637,12 @@ export async function resetGlobalState(context: vscode.ExtensionContext) {
"sambanovaApiKey",
"cerebrasApiKey",
"groqApiKey",
"basetenApiKey",
"moonshotApiKey",
"nebiusApiKey",
"huggingFaceApiKey",
"huaweiCloudMaasApiKey",
]
for (const key of secretKeys) {
await storeSecret(context, key, undefined)
}
await Promise.all(secretKeys.map((key) => storeSecret(context, key, undefined)))
await controller.cacheService.reInitialize()
}
+77 -19
View File
@@ -1,6 +1,6 @@
import { showSystemNotification } from "@/integrations/notifications"
import { listFiles } from "@/services/glob/list-files"
import { telemetryService } from "@/services/posthog/telemetry/TelemetryService"
import { telemetryService } from "@/services/posthog/PostHogClientProvider"
import { regexSearchFiles } from "@/services/ripgrep"
import { parseSourceCodeForDefinitionsTopLevel } from "@/services/tree-sitter"
import { findLast, findLastIndex, parsePartialArrayString } from "@/shared/array"
@@ -32,9 +32,16 @@ import {
COMPLETION_RESULT_CHANGES_FLAG,
} from "@shared/ExtensionMessage"
import { ClineAskResponse } from "@shared/WebviewMessage"
import { extractFileContent, FileContentResult } from "@integrations/misc/extract-file-content"
import { COMMAND_REQ_APP_STRING } from "@shared/combineCommandSequences"
import { fileExistsAtPath } from "@utils/fs"
import { isClaude4ModelFamily, isGemini2dot5ModelFamily } from "@utils/model-utils"
import {
isClaude4ModelFamily,
isGemini2dot5ModelFamily,
isGrok4ModelFamily,
modelDoesntSupportWebp,
isNextGenModelFamily,
} from "@utils/model-utils"
import { fixModelHtmlEscaping, removeInvalidChars } from "@utils/string"
import { setTimeout as setTimeoutPromise } from "node:timers/promises"
import os from "os"
@@ -49,12 +56,12 @@ import { ContextManager } from "../context/context-management/ContextManager"
import { loadMcpDocumentation } from "../prompts/loadMcpDocumentation"
import { formatResponse } from "../prompts/responses"
import { ensureTaskDirectoryExists } from "../storage/disk"
import { getGlobalState, getWorkspaceState } from "../storage/state"
import { CacheService } from "../storage/CacheService"
import { TaskState } from "./TaskState"
import { MessageStateHandler } from "./message-state"
import { AutoApprove } from "./tools/autoApprove"
import { showNotificationForApprovalIfAutoApprovalEnabled } from "./utils"
import { ChatSettings } from "@/shared/ChatSettings"
import { Mode } from "@shared/storage/types"
export class ToolExecutor {
private autoApprover: AutoApprove
@@ -85,13 +92,16 @@ export class ToolExecutor {
private clineIgnoreController: ClineIgnoreController,
private workspaceTracker: WorkspaceTracker,
private contextManager: ContextManager,
private cacheService: CacheService,
// Configuration & Settings
private autoApprovalSettings: AutoApprovalSettings,
private browserSettings: BrowserSettings,
private cwd: string,
private taskId: string,
private chatSettings: ChatSettings,
private ulid: string,
private mode: Mode,
private strictPlanModeEnabled: boolean,
// Callbacks to the Task (Entity)
private say: (
@@ -105,7 +115,12 @@ export class ToolExecutor {
type: ClineAsk,
text?: string,
partial?: boolean,
) => Promise<{ response: ClineAskResponse; text?: string; images?: string[]; files?: string[] }>,
) => Promise<{
response: ClineAskResponse
text?: string
images?: string[]
files?: string[]
}>,
private saveCheckpoint: (isAttemptCompletionMessage?: boolean) => Promise<void>,
private sayAndCreateMissingParamError: (toolName: ToolUseName, paramName: string, relPath?: string) => Promise<any>,
private removeLastPartialMessageIfExistsWithType: (type: "ask" | "say", askOrSay: ClineAsk | ClineSay) => Promise<void>,
@@ -122,8 +137,24 @@ export class ToolExecutor {
this.autoApprover.updateSettings(settings)
}
/**
* Defines the tools which should be restricted in plan mode
*/
private isPlanModeToolRestricted(toolName: ToolUseName): boolean {
const planModeRestrictedTools: ToolUseName[] = ["write_to_file", "replace_in_file"]
return planModeRestrictedTools.includes(toolName)
}
public updateMode(mode: Mode): void {
this.mode = mode
}
public updateStrictPlanModeEnabled(strictPlanModeEnabled: boolean): void {
this.strictPlanModeEnabled = strictPlanModeEnabled
}
private pushToolResult = (content: ToolResponse, block: ToolUse) => {
const isNextGenModel = isClaude4ModelFamily(this.api) || isGemini2dot5ModelFamily(this.api)
const isNextGenModel = isNextGenModelFamily(this.api)
if (typeof content === "string") {
const resultText = content || "(tool did not return anything)"
@@ -434,6 +465,15 @@ export class ToolExecutor {
return
}
// Logic for plan-model tool call restrictions
if (this.strictPlanModeEnabled && this.mode === "plan" && block.name && this.isPlanModeToolRestricted(block.name)) {
const errorMessage = `Tool '${block.name}' is not available in PLAN MODE. This tool is restricted to ACT MODE for file modifications. Only use tools available for PLAN MODE when in that mode.`
await this.say("error", errorMessage)
this.pushToolResult(formatResponse.toolError(errorMessage), block)
await this.saveCheckpoint()
return
}
if (block.name !== "browser_action") {
await this.browserSession.closeBrowser()
}
@@ -443,7 +483,7 @@ export class ToolExecutor {
case "write_to_file":
case "replace_in_file": {
const relPath: string | undefined = block.params.path
let content: string | undefined = block.params.content // for write_to_file
const content: string | undefined = block.params.content // for write_to_file
let diff: string | undefined = block.params.diff // for replace_in_file
if (!relPath || (!content && !diff)) {
// checking for content/diff ensures relPath is complete
@@ -488,7 +528,7 @@ export class ToolExecutor {
const currentFullJson = block.params.diff
// Check if we should use streaming (e.g., for specific models)
const isNextGenModel = isClaude4ModelFamily(this.api) || isGemini2dot5ModelFamily(this.api)
const isNextGenModel = isNextGenModelFamily(this.api)
// Going through claude family of models
if (isNextGenModel && USE_EXPERIMENTAL_CLAUDE4_FEATURES && currentFullJson) {
const streamingResult = await this.handleStreamingJsonReplacement(block, relPath, currentFullJson)
@@ -839,12 +879,18 @@ export class ToolExecutor {
telemetryService.captureToolUsage(this.taskId, block.name, this.api.getModel().id, false, true)
}
// now execute the tool like normal
const content = await extractTextFromFile(absolutePath)
const supportsImages = this.api.getModel().info.supportsImages ?? false
const result = await extractFileContent(absolutePath, supportsImages)
// Track file read operation
await this.fileContextTracker.trackFileContext(relPath, "read_tool")
this.pushToolResult(content, block)
this.pushToolResult(result.text, block)
if (result.imageBlock) {
this.taskState.userMessageContent.push(result.imageBlock)
}
await this.saveCheckpoint()
break
}
@@ -1175,7 +1221,9 @@ export class ToolExecutor {
// Re-make browserSession to make sure latest settings apply
if (this.context) {
await this.browserSession.dispose()
this.browserSession = new BrowserSession(this.context, this.browserSettings)
const useWebp = this.api ? !modelDoesntSupportWebp(this.api) : true
this.browserSession = new BrowserSession(this.context, this.browserSettings, useWebp)
} else {
console.warn("no controller context available for browserSession")
}
@@ -1919,11 +1967,9 @@ export class ToolExecutor {
const clineVersion =
vscode.extensions.getExtension("saoudrizwan.claude-dev")?.packageJSON.version || "Unknown"
const systemInfo = `VSCode: ${vscode.version}, Node.js: ${process.version}, Architecture: ${os.arch()}`
const currentMode = this.chatSettings.mode
const apiProvider =
currentMode === "plan"
? await getGlobalState(this.context, "planModeApiProvider")
: await getGlobalState(this.context, "actModeApiProvider")
const currentMode = this.mode
const apiConfig = this.cacheService.getApiConfiguration()
const apiProvider = currentMode === "plan" ? apiConfig.planModeApiProvider : apiConfig.actModeApiProvider
const providerAndModel = `${apiProvider} / ${this.api.getModel().id}`
// Ask user for confirmation
@@ -2099,6 +2145,7 @@ export class ToolExecutor {
case "plan_mode_respond": {
const response: string | undefined = block.params.response
const optionsRaw: string | undefined = block.params.options
const needsMoreExploration: boolean = block.params.needs_more_exploration === "true"
const sharedMessage = {
response: this.removeClosingTag(block, "response", response),
options: parsePartialArrayString(this.removeClosingTag(block, "options", optionsRaw)),
@@ -2123,6 +2170,17 @@ export class ToolExecutor {
// })
// }
// The plan_mode_respond tool tends to run into this issue where the model realizes mid-tool call that it should have called another tool before calling plan_mode_respond. And it ends the plan_mode_respond tool call with 'Proceeding to reading files...' which doesn't do anything because we restrict to 1 tool call per message. As an escape hatch for the model, we provide it the optionality to tack on a parameter at the end of its response `needs_more_exploration`, which will allow the loop to continue.
if (needsMoreExploration) {
this.pushToolResult(
formatResponse.toolResult(
`[You have indicated that you need more exploration. Proceed with calling tools to continue the planning process.]`,
),
block,
)
break
}
// Store the number of options for telemetry
const options = parsePartialArrayString(optionsRaw || "[]")
@@ -2301,7 +2359,7 @@ export class ToolExecutor {
await this.say("completion_result", result, undefined, undefined, false)
await this.saveCheckpoint(true)
await addNewChangesFlagToLastCompletionResultMessage()
telemetryService.captureTaskCompleted(this.taskId)
telemetryService.captureTaskCompleted(this.taskId, this.ulid)
} else {
// we already sent a command message, meaning the complete completion message has also been sent
await this.saveCheckpoint(true)
@@ -2326,7 +2384,7 @@ export class ToolExecutor {
await this.say("completion_result", result, undefined, undefined, false)
await this.saveCheckpoint(true)
await addNewChangesFlagToLastCompletionResultMessage()
telemetryService.captureTaskCompleted(this.taskId)
telemetryService.captureTaskCompleted(this.taskId, this.ulid)
}
// we already sent completion_result says, an empty string asks relinquishes control over button and field
+106 -69
View File
@@ -14,12 +14,11 @@ import { BrowserSession } from "@services/browser/BrowserSession"
import { UrlContentFetcher } from "@services/browser/UrlContentFetcher"
import { listFiles } from "@services/glob/list-files"
import { Logger } from "@services/logging/Logger"
import { telemetryService } from "@services/posthog/telemetry/TelemetryService"
import { telemetryService } from "@services/posthog/PostHogClientProvider"
import { ApiConfiguration } from "@shared/api"
import { findLast, findLastIndex } from "@shared/array"
import { AutoApprovalSettings } from "@shared/AutoApprovalSettings"
import { BrowserSettings } from "@shared/BrowserSettings"
import { ChatSettings } from "@shared/ChatSettings"
import { combineApiRequests } from "@shared/combineApiRequests"
import { combineCommandSequences } from "@shared/combineCommandSequences"
import { ClineApiReqCancelReason, ClineApiReqInfo, ClineAsk, ClineMessage, ClineSay } from "@shared/ExtensionMessage"
@@ -36,10 +35,11 @@ import pTimeout from "p-timeout"
import pWaitFor from "p-wait-for"
import * as path from "path"
import * as vscode from "vscode"
import { ulid } from "ulid"
import { HostProvider } from "@/hosts/host-provider"
import { ClineErrorType } from "@/services/error/ClineError"
import { ErrorService } from "@/services/error/ErrorService"
import { errorService } from "@/services/posthog/PostHogClientProvider"
import { parseAssistantMessageV2, parseAssistantMessageV3, ToolUseName } from "@core/assistant-message"
import {
checkIsAnthropicContextWindowError,
@@ -78,7 +78,7 @@ import { processFilesIntoText } from "@integrations/misc/extract-text"
import WorkspaceTracker from "@integrations/workspace/WorkspaceTracker"
import { McpHub } from "@services/mcp/McpHub"
import { convertClineMessageToProto } from "@shared/proto-conversions/cline-message"
import { isClaude4ModelFamily, isGemini2dot5ModelFamily } from "@utils/model-utils"
import { isClaude4ModelFamily, isGemini2dot5ModelFamily, isGrok4ModelFamily, isNextGenModelFamily } from "@utils/model-utils"
import { isInTestMode } from "../../services/test/TestMode"
import { ensureLocalClineDirExists } from "../context/instructions/user-instructions/rule-helpers"
import { refreshWorkflowToggles } from "../context/instructions/user-instructions/workflows"
@@ -86,6 +86,8 @@ import { MessageStateHandler } from "./message-state"
import { TaskState } from "./TaskState"
import { ToolExecutor } from "./ToolExecutor"
import { updateApiReqMsg } from "./utils"
import { CacheService } from "../storage/CacheService"
import { Mode, OpenaiReasoningEffort } from "@shared/storage/types"
import { ShowMessageType } from "@/shared/proto/index.host"
export const USE_EXPERIMENTAL_CLAUDE4_FEATURES = false
@@ -96,6 +98,7 @@ type UserContent = Array<Anthropic.ContentBlockParam>
export class Task {
// Core task variables
readonly taskId: string
readonly ulid: string
private taskIsFavorited?: boolean
private cwd: string
@@ -130,10 +133,15 @@ export class Task {
private reinitExistingTaskFromId: (taskId: string) => Promise<void>
private cancelTask: () => Promise<void>
// Cache service
private cacheService: CacheService
// User chat state
autoApprovalSettings: AutoApprovalSettings
browserSettings: BrowserSettings
chatSettings: ChatSettings
preferredLanguage: string
openaiReasoningEffort: OpenaiReasoningEffort
mode: Mode
// Message and conversation state
messageStateHandler: MessageStateHandler
@@ -148,13 +156,17 @@ export class Task {
apiConfiguration: ApiConfiguration,
autoApprovalSettings: AutoApprovalSettings,
browserSettings: BrowserSettings,
chatSettings: ChatSettings,
preferredLanguage: string,
openaiReasoningEffort: OpenaiReasoningEffort,
mode: Mode,
strictPlanModeEnabled: boolean,
shellIntegrationTimeout: number,
terminalReuseEnabled: boolean,
terminalOutputLineLimit: number,
defaultTerminalProfile: string,
enableCheckpointsSetting: boolean,
cwd: string,
cacheService: CacheService,
task?: string,
images?: string[],
files?: string[],
@@ -193,9 +205,12 @@ export class Task {
this.diffViewProvider = HostProvider.get().createDiffViewProvider()
this.autoApprovalSettings = autoApprovalSettings
this.browserSettings = browserSettings
this.chatSettings = chatSettings
this.preferredLanguage = preferredLanguage
this.openaiReasoningEffort = openaiReasoningEffort
this.mode = mode
this.enableCheckpoints = enableCheckpointsSetting
this.cwd = cwd
this.cacheService = cacheService
// Set up MCP notification callback for real-time notifications
this.mcpHub.setNotificationCallback(async (serverName: string, level: string, message: string) => {
@@ -206,6 +221,7 @@ export class Task {
// Initialize taskId first
if (historyItem) {
this.taskId = historyItem.id
this.ulid = historyItem.ulid ?? ulid()
this.taskIsFavorited = historyItem.isFavorited
this.taskState.conversationHistoryDeletedRange = historyItem.conversationHistoryDeletedRange
if (historyItem.checkpointTrackerErrorMessage) {
@@ -213,6 +229,7 @@ export class Task {
}
} else if (task || images || files) {
this.taskId = Date.now().toString()
this.ulid = ulid()
} else {
throw new Error("Either historyItem or task/images must be provided")
}
@@ -220,6 +237,7 @@ export class Task {
this.messageStateHandler = new MessageStateHandler({
context,
taskId: this.taskId,
ulid: this.ulid,
taskState: this.taskState,
taskIsFavorited: this.taskIsFavorited,
updateTaskHistory: this.updateTaskHistory,
@@ -230,7 +248,7 @@ export class Task {
this.modelContextTracker = new ModelContextTracker(context, this.taskId)
// Prepare effective API configuration
let effectiveApiConfiguration: ApiConfiguration = {
const effectiveApiConfiguration: ApiConfiguration = {
...apiConfiguration,
taskId: this.taskId,
onRetryAttempt: async (attempt: number, maxRetries: number, delay: number, error: any) => {
@@ -267,19 +285,18 @@ export class Task {
},
}
const currentProvider =
chatSettings.mode === "plan" ? apiConfiguration.planModeApiProvider : apiConfiguration.actModeApiProvider
const currentProvider = this.mode === "plan" ? apiConfiguration.planModeApiProvider : apiConfiguration.actModeApiProvider
if (currentProvider === "openai" || currentProvider === "openai-native") {
if (chatSettings.mode === "plan") {
effectiveApiConfiguration.planModeReasoningEffort = chatSettings.openAIReasoningEffort
if (this.mode === "plan") {
effectiveApiConfiguration.planModeReasoningEffort = this.openaiReasoningEffort
} else {
effectiveApiConfiguration.actModeReasoningEffort = chatSettings.openAIReasoningEffort
effectiveApiConfiguration.actModeReasoningEffort = this.openaiReasoningEffort
}
}
// Now that taskId is initialized, we can build the API handler
this.api = buildApiHandler(effectiveApiConfiguration, chatSettings.mode)
this.api = buildApiHandler(effectiveApiConfiguration, this.mode)
// Set taskId on browserSession for telemetry tracking
this.browserSession.setTaskId(this.taskId)
@@ -294,10 +311,10 @@ export class Task {
// initialize telemetry
if (historyItem) {
// Open task from history
telemetryService.captureTaskRestarted(this.taskId, currentProvider)
telemetryService.captureTaskRestarted(this.taskId, this.ulid, currentProvider)
} else {
// New task started
telemetryService.captureTaskCreated(this.taskId, currentProvider)
telemetryService.captureTaskCreated(this.taskId, this.ulid, currentProvider)
}
this.toolExecutor = new ToolExecutor(
@@ -313,11 +330,14 @@ export class Task {
this.clineIgnoreController,
this.workspaceTracker,
this.contextManager,
this.cacheService,
this.autoApprovalSettings,
this.browserSettings,
cwd,
this.taskId,
this.chatSettings,
this.ulid,
this.mode,
strictPlanModeEnabled,
this.say.bind(this),
this.ask.bind(this),
this.saveCheckpoint.bind(this),
@@ -328,6 +348,15 @@ export class Task {
)
}
public updateMode(mode: Mode): void {
this.mode = mode
this.toolExecutor.updateMode(mode)
}
public updateStrictPlanMode(strictPlanModeEnabled: boolean): void {
this.toolExecutor.updateStrictPlanModeEnabled(strictPlanModeEnabled)
}
// While a task is ref'd by a controller, it will always have access to the extension context
// This error is thrown if the controller derefs the task after e.g., aborting the task
private getContext(): vscode.ExtensionContext {
@@ -451,7 +480,7 @@ export class Task {
if (!didWorkspaceRestoreFail) {
switch (restoreType) {
case "task":
case "taskAndWorkspace":
case "taskAndWorkspace": {
this.taskState.conversationHistoryDeletedRange = message.conversationHistoryDeletedRange
const apiConversationHistory = this.messageStateHandler.getApiConversationHistory()
const newConversationHistory = apiConversationHistory.slice(0, (message.conversationHistoryIndex || 0) + 2) // +1 since this index corresponds to the last user message, and another +1 since slice end index is exclusive
@@ -494,6 +523,7 @@ export class Task {
} satisfies ClineApiReqInfo),
)
break
}
case "workspace":
break
}
@@ -1020,9 +1050,9 @@ export class Task {
this.taskState.isInitialized = true
let imageBlocks: Anthropic.ImageBlockParam[] = formatResponse.imageBlocks(images)
const imageBlocks: Anthropic.ImageBlockParam[] = formatResponse.imageBlocks(images)
let userContent: UserContent = [
const userContent: UserContent = [
{
type: "text",
text: `<task>\n${task}\n</task>`,
@@ -1149,7 +1179,7 @@ export class Task {
throw new Error("Unexpected: No existing API conversation history")
}
let newUserContent: UserContent = [...modifiedOldUserContent]
const newUserContent: UserContent = [...modifiedOldUserContent]
const agoText = (() => {
const timestamp = lastClineMessage?.ts ?? Date.now()
@@ -1178,7 +1208,7 @@ export class Task {
const hasPendingFileContextWarnings = pendingContextWarning && pendingContextWarning.length > 0
const [taskResumptionMessage, userResponseMessage] = formatResponse.taskResumption(
this.chatSettings?.mode === "plan" ? "plan" : "act",
this.mode === "plan" ? "plan" : "act",
agoText,
this.cwd,
wasRecent,
@@ -1605,7 +1635,7 @@ export class Task {
// grouping command_output messages despite any gaps anyways)
await setTimeoutPromise(50)
let result = this.terminalManager.processOutput(outputLines)
const result = this.terminalManager.processOutput(outputLines)
if (userFeedback) {
await this.say("user_feedback", userFeedback.text, userFeedback.images, userFeedback.files)
@@ -1654,28 +1684,21 @@ export class Task {
}
}
private async migratePreferredLanguageToolSetting(): Promise<void> {
const config = vscode.workspace.getConfiguration("cline")
const preferredLanguage = config.get<LanguageDisplay>("preferredLanguage")
if (preferredLanguage !== undefined) {
this.chatSettings.preferredLanguage = preferredLanguage
// Remove from VSCode configuration
await config.update("preferredLanguage", undefined, true)
}
}
private async getCurrentProviderInfo(): Promise<{ modelId: string; providerId: string }> {
private async getCurrentProviderInfo(): Promise<{
modelId: string
providerId: string
}> {
const modelId = this.api.getModel()?.id
const providerId =
this.chatSettings.mode === "plan"
? ((await getGlobalState(this.getContext(), "planModeApiProvider")) as string)
: ((await getGlobalState(this.getContext(), "actModeApiProvider")) as string)
const apiConfig = this.cacheService.getApiConfiguration()
const providerId = (this.mode === "plan" ? apiConfig.planModeApiProvider : apiConfig.actModeApiProvider) as string
return { modelId, providerId }
}
async *attemptApiRequest(previousApiReqIndex: number): ApiStream {
// Wait for MCP servers to be connected before generating system prompt
await pWaitFor(() => this.mcpHub.isConnecting !== true, { timeout: 10_000 }).catch(() => {
await pWaitFor(() => this.mcpHub.isConnecting !== true, {
timeout: 10_000,
}).catch(() => {
console.error("MCP servers failed to connect in time")
})
@@ -1687,11 +1710,10 @@ export class Task {
const supportsBrowserUse = modelSupportsBrowserUse && !disableBrowserTool // only enable browser use if the model supports it and the user hasn't disabled it
const isNextGenModel = isClaude4ModelFamily(this.api) || isGemini2dot5ModelFamily(this.api)
const isNextGenModel = isNextGenModelFamily(this.api)
let systemPrompt = await SYSTEM_PROMPT(this.cwd, supportsBrowserUse, this.mcpHub, this.browserSettings, isNextGenModel)
await this.migratePreferredLanguageToolSetting()
const preferredLanguage = getLanguageKey(this.chatSettings.preferredLanguage as LanguageDisplay)
const preferredLanguage = getLanguageKey(this.preferredLanguage as LanguageDisplay)
const preferredLanguageInstructions =
preferredLanguage && preferredLanguage !== DEFAULT_LANGUAGE_SETTINGS
? `# Preferred Language\n\nSpeak in ${preferredLanguage}.`
@@ -1752,7 +1774,7 @@ export class Task {
// saves task history item which we use to keep track of conversation history deleted range
}
let stream = this.api.createMessage(systemPrompt, contextManagementMetadata.truncatedConversationHistory)
const stream = this.api.createMessage(systemPrompt, contextManagementMetadata.truncatedConversationHistory)
const iterator = stream[Symbol.asyncIterator]()
@@ -1768,17 +1790,12 @@ export class Task {
const isOpenRouterContextWindowError = checkIsOpenRouterContextWindowError(error) && isOpenRouter
const isAnthropicContextWindowError = checkIsAnthropicContextWindowError(error) && isAnthropic
const { modelId, providerId } = await this.getCurrentProviderInfo()
const clineError = ErrorService.toClineError(error, modelId, providerId)
const clineError = errorService.toClineError(error, modelId, providerId)
// Capture provider failure telemetry using clineError
// TODO: Move into ErrorService
telemetryService.captureProviderApiError({
taskId: this.taskId,
model: modelInfo.id,
errorMessage: clineError.message,
errorStatus: clineError._error?.status,
requestId: clineError._error?.request_id,
})
// TODO: Move into errorService
errorService.logMessage(clineError.message)
errorService.logException(clineError)
if (isAnthropic && isAnthropicContextWindowError && !this.taskState.didAutomaticallyRetryFailedApiRequest) {
this.taskState.conversationHistoryDeletedRange = this.contextManager.getNextTruncationRange(
@@ -1857,12 +1874,24 @@ export class Task {
throw new Error("API request failed")
}
// Do not retry automatically again if currently unauthenticated
if (clineError.isErrorType(ClineErrorType.Auth)) {
return
// Clear streamingFailedMessage when user manually retries
const manualRetryApiReqIndex = findLastIndex(
this.messageStateHandler.getClineMessages(),
(m) => m.say === "api_req_started",
)
if (manualRetryApiReqIndex !== -1) {
const clineMessages = this.messageStateHandler.getClineMessages()
const currentApiReqInfo: ClineApiReqInfo = JSON.parse(clineMessages[manualRetryApiReqIndex].text || "{}")
delete currentApiReqInfo.streamingFailedMessage
await this.messageStateHandler.updateClineMessage(manualRetryApiReqIndex, {
text: JSON.stringify(currentApiReqInfo),
})
}
await this.say("api_req_retried")
// Reset the automatic retry flag so the request can proceed
this.taskState.didAutomaticallyRetryFailedApiRequest = false
}
// delegate generator output from the recursive call
yield* this.attemptApiRequest(previousApiReqIndex)
@@ -2000,7 +2029,7 @@ export class Task {
const { modelId, providerId } = await this.getCurrentProviderInfo()
if (providerId && modelId) {
try {
await this.modelContextTracker.recordModelUsage(providerId, modelId, this.chatSettings.mode)
await this.modelContextTracker.recordModelUsage(providerId, modelId, this.mode)
} catch {}
}
@@ -2204,7 +2233,7 @@ export class Task {
content: userContent,
})
telemetryService.captureConversationTurnEvent(this.taskId, providerId, modelId, "user")
telemetryService.captureConversationTurnEvent(this.taskId, this.ulid, providerId, modelId, "user")
// since we sent off a placeholder api_req_started message to update the webview while waiting to actually start the API request (to load potential details for example), we need to update the text of that message
const lastApiReqIndex = findLastIndex(this.messageStateHandler.getClineMessages(), (m) => m.say === "api_req_started")
@@ -2269,13 +2298,20 @@ export class Task {
})
await this.messageStateHandler.saveClineMessagesAndUpdateHistory()
telemetryService.captureConversationTurnEvent(this.taskId, providerId, this.api.getModel().id, "assistant", {
tokensIn: inputTokens,
tokensOut: outputTokens,
cacheWriteTokens,
cacheReadTokens,
totalCost,
})
telemetryService.captureConversationTurnEvent(
this.taskId,
this.ulid,
providerId,
this.api.getModel().id,
"assistant",
{
tokensIn: inputTokens,
tokensOut: outputTokens,
cacheWriteTokens,
cacheReadTokens,
totalCost,
},
)
// signals to provider that it can retrieve the saved messages from disk, as abortTask can not be awaited on in nature
this.taskState.didFinishAbortingStream = true
@@ -2321,7 +2357,7 @@ export class Task {
await this.say("reasoning", reasoningMessage, undefined, undefined, true)
}
break
case "text":
case "text": {
if (reasoningMessage && assistantMessage.length === 0) {
// complete reasoning message
await this.say("reasoning", reasoningMessage, undefined, undefined, false)
@@ -2329,7 +2365,7 @@ export class Task {
assistantMessage += chunk.text
// parse raw assistant message into content blocks
const prevLength = this.taskState.assistantMessageContent.length
const isNextGenModel = isClaude4ModelFamily(this.api) || isGemini2dot5ModelFamily(this.api)
const isNextGenModel = isNextGenModelFamily(this.api)
if (isNextGenModel && USE_EXPERIMENTAL_CLAUDE4_FEATURES) {
this.taskState.assistantMessageContent = parseAssistantMessageV3(assistantMessage)
} else {
@@ -2342,6 +2378,7 @@ export class Task {
// present content to user
this.presentAssistantMessage()
break
}
}
if (this.taskState.abort) {
@@ -2372,7 +2409,7 @@ export class Task {
// abandoned happens when extension is no longer waiting for the cline instance to finish aborting (error is thrown here when any function in the for loop throws due to this.abort)
if (!this.taskState.abandoned) {
this.abortTask() // if the stream failed, there's various states the task could be in (i.e. could have streamed some tools the user may have executed), so we just resort to replicating a cancel task
const clineError = ErrorService.toClineError(error, this.api.getModel().id)
const clineError = errorService.toClineError(error, this.api.getModel().id)
const errorMessage = clineError.serialize()
await abortStream("streaming_failed", errorMessage)
@@ -2443,7 +2480,7 @@ export class Task {
// need to save assistant responses to file before proceeding to tool use since user can exit at any moment and we wouldn't be able to save the assistant's response
let didEndLoop = false
if (assistantMessage.length > 0) {
telemetryService.captureConversationTurnEvent(this.taskId, providerId, modelId, "assistant", {
telemetryService.captureConversationTurnEvent(this.taskId, this.ulid, providerId, modelId, "assistant", {
tokensIn: inputTokens,
tokensOut: outputTokens,
cacheWriteTokens,
@@ -2771,7 +2808,7 @@ export class Task {
details += `\n${lastApiReqTotalTokens.toLocaleString()} / ${(contextWindow / 1000).toLocaleString()}K tokens used (${usagePercentage}%)`
details += "\n\n# Current Mode"
if (this.chatSettings.mode === "plan") {
if (this.mode === "plan") {
details += "\nPLAN MODE\n" + formatResponse.planModeInstructions()
} else {
details += "\nACT MODE"
+4
View File
@@ -17,6 +17,7 @@ import { getCwd, getDesktopDir } from "@/utils/path"
interface MessageStateHandlerParams {
context: vscode.ExtensionContext
taskId: string
ulid: string
taskIsFavorited?: boolean
updateTaskHistory: (historyItem: HistoryItem) => Promise<HistoryItem[]>
taskState: TaskState
@@ -32,11 +33,13 @@ export class MessageStateHandler {
private updateTaskHistory: (historyItem: HistoryItem) => Promise<HistoryItem[]>
private context: vscode.ExtensionContext
private taskId: string
private ulid: string
private taskState: TaskState
constructor(params: MessageStateHandlerParams) {
this.context = params.context
this.taskId = params.taskId
this.ulid = params.ulid
this.taskState = params.taskState
this.taskIsFavorited = params.taskIsFavorited ?? false
this.updateTaskHistory = params.updateTaskHistory
@@ -89,6 +92,7 @@ export class MessageStateHandler {
const cwd = await getCwd(getDesktopDir())
await this.updateTaskHistory({
id: this.taskId,
ulid: this.ulid,
ts: lastRelevantMessage.ts,
task: taskMessage.text ?? "",
tokensIn: apiMetrics.totalTokensIn,
+50 -37
View File
@@ -11,26 +11,30 @@ import { v4 as uuidv4 } from "uuid"
import { Uri } from "vscode"
import { ExtensionMessage } from "@/shared/ExtensionMessage"
import { HostProvider } from "@/hosts/host-provider"
import { ShowMessageRequest, ShowMessageType } from "@/shared/proto/host/window"
import { ShowMessageType } from "@/shared/proto/host/window"
export abstract class WebviewProvider {
public static readonly sideBarId = "claude-dev.SidebarProvider" // used in package.json as the view's id. This value cannot be changed due to how vscode caches views based on their id, and updating the id would break existing instances of the extension.
public static readonly tabPanelId = "claude-dev.TabPanelProvider"
private static activeInstances: Set<WebviewProvider> = new Set()
private static clientIdMap = new Map<WebviewProvider, string>()
protected disposables: vscode.Disposable[] = []
controller: Controller
private clientId: string
private static lastActiveControllerId: string | null = null
constructor(
readonly context: vscode.ExtensionContext,
protected readonly outputChannel: vscode.OutputChannel,
private readonly providerType: WebviewProviderType,
) {
WebviewProvider.activeInstances.add(this)
this.clientId = uuidv4()
WebviewProvider.clientIdMap.set(this, this.clientId)
this.controller = new Controller(context, outputChannel, (message) => this.postMessageToWebview(message), this.clientId)
// Create controller with cache service
this.controller = new Controller(context, this.clientId)
WebviewProvider.setLastActiveControllerId(this.controller.id)
}
// Add a method to get the client ID
@@ -44,12 +48,6 @@ export abstract class WebviewProvider {
}
async dispose() {
while (this.disposables.length) {
const x = this.disposables.pop()
if (x) {
x.dispose()
}
}
await this.controller.dispose()
WebviewProvider.activeInstances.delete(this)
// Remove from client ID map
@@ -57,40 +55,62 @@ export abstract class WebviewProvider {
}
public static getVisibleInstance(): WebviewProvider | undefined {
return findLast(Array.from(this.activeInstances), (instance) => instance.isVisible() === true)
return findLast(Array.from(WebviewProvider.activeInstances), (instance) => instance.isVisible() === true)
}
public static getActiveInstance(): WebviewProvider | undefined {
return Array.from(this.activeInstances).find((instance) => {
if (
instance.getWebview() &&
instance.getWebview().viewType === "claude-dev.TabPanelProvider" &&
"active" in instance.getWebview()
) {
return instance.getWebview().active === true
return Array.from(WebviewProvider.activeInstances).find((instance) => {
const webview = instance.getWebview()
if (webview && webview.viewType === "claude-dev.TabPanelProvider" && "active" in webview) {
return webview.active === true
}
return false
})
}
public static getAllInstances(): WebviewProvider[] {
return Array.from(this.activeInstances)
return Array.from(WebviewProvider.activeInstances)
}
public static getSidebarInstance() {
return Array.from(this.activeInstances).find(
(instance) => instance.getWebview() && "onDidChangeVisibility" in instance.getWebview(),
return Array.from(WebviewProvider.activeInstances).find(
(instance) => instance.providerType === WebviewProviderType.SIDEBAR,
)
}
public static getTabInstances(): WebviewProvider[] {
return Array.from(this.activeInstances).filter(
(instance) => instance.getWebview() && "onDidChangeViewState" in instance.getWebview(),
)
return Array.from(WebviewProvider.activeInstances).filter((instance) => instance.providerType === WebviewProviderType.TAB)
}
public static getLastActiveInstance(): WebviewProvider | undefined {
const lastActiveId = WebviewProvider.getLastActiveControllerId()
if (!lastActiveId) {
return undefined
}
return Array.from(WebviewProvider.activeInstances).find((instance) => instance.controller.id === lastActiveId)
}
/**
* Gets the last active controller ID with performance optimization
* @returns The last active controller ID or null
*/
public static getLastActiveControllerId(): string | null {
return WebviewProvider.lastActiveControllerId || WebviewProvider.getSidebarInstance()?.controller.id || null
}
/**
* Sets the last active controller ID with validation and performance optimization
* @param controllerId The controller ID to set as last active
*/
public static setLastActiveControllerId(controllerId: string | null): void {
// Only update if the value is actually different to avoid unnecessary operations
if (WebviewProvider.lastActiveControllerId !== controllerId) {
WebviewProvider.lastActiveControllerId = controllerId
}
}
public static async disposeAllInstances() {
const instances = Array.from(this.activeInstances)
const instances = Array.from(WebviewProvider.activeInstances)
for (const instance of instances) {
await instance.dispose()
}
@@ -104,20 +124,12 @@ export abstract class WebviewProvider {
*/
abstract resolveWebviewView(webviewView: vscode.WebviewView | vscode.WebviewPanel): Promise<void>
/**
* Sends a message from the extension to the webview.
*
* @param message - The message to send to the webview
* @returns A thenable that resolves to a boolean indicating success, or undefined if the webview is not available
*/
abstract postMessageToWebview(message: ExtensionMessage): Thenable<boolean> | undefined
/**
* Gets the current webview instance.
*
* @returns The webview instance (WebviewView, WebviewPanel, or similar)
*/
abstract getWebview(): any
abstract getWebview(): vscode.WebviewPanel | vscode.WebviewView | undefined
/**
* Converts a local URI to a webview URI that can be used within the webview.
@@ -217,6 +229,7 @@ export abstract class WebviewProvider {
window.clineClientId = "${this.clientId}";
</script>
<script type="module" nonce="${nonce}" src="${scriptUri}"></script>
<script src="http://localhost:8097"></script>
</body>
</html>
`
@@ -262,8 +275,8 @@ export abstract class WebviewProvider {
try {
await axios.get(`http://${localServerUrl}`)
} catch (error) {
// Only show the error message if not in development mode.
if (!process.env.IS_DEV) {
// Only show the error message when in development mode.
if (process.env.IS_DEV) {
HostProvider.window.showMessage({
type: ShowMessageType.ERROR,
message:
@@ -304,7 +317,7 @@ export abstract class WebviewProvider {
<!DOCTYPE html>
<html lang="en">
<head>
<script src="http://localhost:8097"></script>
${process.env.IS_DEV ? '<script src="http://localhost:8097"></script>' : ""}
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,shrink-to-fit=no">
<meta http-equiv="Content-Security-Policy" content="${csp.join("; ")}">
+10 -12
View File
@@ -1,50 +1,48 @@
import * as vscode from "vscode"
import { Controller } from "@core/controller"
import { ClineAPI } from "./cline"
import { getGlobalState } from "@core/storage/state"
import { sendChatButtonClickedEvent } from "@core/controller/ui/subscribeToChatButtonClicked"
import { WebviewProviderType as WebviewProviderTypeEnum } from "@shared/proto/cline/ui"
import { HostProvider } from "@/hosts/host-provider"
export function createClineAPI(outputChannel: vscode.OutputChannel, sidebarController: Controller): ClineAPI {
export function createClineAPI(sidebarController: Controller): ClineAPI {
const api: ClineAPI = {
startNewTask: async (task?: string, images?: string[]) => {
outputChannel.appendLine("Starting new task")
HostProvider.get().logToChannel("Starting new task")
await sidebarController.clearTask()
await sidebarController.postStateToWebview()
await sendChatButtonClickedEvent(sidebarController.id)
await sidebarController.initTask(task, images)
outputChannel.appendLine(
HostProvider.get().logToChannel(
`Task started with message: ${task ? `"${task}"` : "undefined"} and ${images?.length || 0} image(s)`,
)
},
sendMessage: async (message?: string, images?: string[]) => {
outputChannel.appendLine(
HostProvider.get().logToChannel(
`Sending message: ${message ? `"${message}"` : "undefined"} with ${images?.length || 0} image(s)`,
)
if (sidebarController.task) {
await sidebarController.task.handleWebviewAskResponse("messageResponse", message || "", images || [])
} else {
outputChannel.appendLine("No active task to send message to")
HostProvider.get().logToChannel("No active task to send message to")
}
},
pressPrimaryButton: async () => {
outputChannel.appendLine("Pressing primary button")
HostProvider.get().logToChannel("Pressing primary button")
if (sidebarController.task) {
await sidebarController.task.handleWebviewAskResponse("yesButtonClicked", "", [])
} else {
outputChannel.appendLine("No active task to press button for")
HostProvider.get().logToChannel("No active task to press button for")
}
},
pressSecondaryButton: async () => {
outputChannel.appendLine("Pressing secondary button")
HostProvider.get().logToChannel("Pressing secondary button")
if (sidebarController.task) {
await sidebarController.task.handleWebviewAskResponse("noButtonClicked", "", [])
} else {
outputChannel.appendLine("No active task to press button for")
HostProvider.get().logToChannel("No active task to press button for")
}
},
}
+125 -176
View File
@@ -1,45 +1,44 @@
// The module 'vscode' contains the VS Code extensibility API
// Import the module and reference it with the alias vscode in your code below
import { setTimeout as setTimeoutPromise } from "node:timers/promises"
import * as vscode from "vscode"
import pWaitFor from "p-wait-for"
import { Logger } from "./services/logging/Logger"
import { createClineAPI } from "./exports"
import "./utils/path" // necessary to have access to String.prototype.toPosix
import { DIFF_VIEW_URI_SCHEME } from "@hosts/vscode/VscodeDiffViewProvider"
import assert from "node:assert"
import { posthogClientProvider } from "./services/posthog/PostHogClientProvider"
import { WebviewProvider } from "./core/webview"
import { sendMcpButtonClickedEvent } from "./core/controller/ui/subscribeToMcpButtonClicked"
import { sendChatButtonClickedEvent } from "./core/controller/ui/subscribeToChatButtonClicked"
import { ErrorService } from "./services/error/ErrorService"
import { initializeTestMode, cleanupTestMode } from "./services/test/TestMode"
import { telemetryService } from "./services/posthog/telemetry/TelemetryService"
import { sendSettingsButtonClickedEvent } from "./core/controller/ui/subscribeToSettingsButtonClicked"
import { v4 as uuidv4 } from "uuid"
import { WebviewProviderType as WebviewProviderTypeEnum } from "@shared/proto/cline/ui"
import { WebviewProviderType } from "./shared/webview/types"
import { sendHistoryButtonClickedEvent } from "./core/controller/ui/subscribeToHistoryButtonClicked"
import { sendAccountButtonClickedEvent } from "./core/controller/ui/subscribeToAccountButtonClicked"
import {
migrateWorkspaceToGlobalStorage,
migrateCustomInstructionsToGlobalRules,
migrateModeFromWorkspaceStorageToControllerState,
migrateWelcomeViewCompleted,
migrateLegacyApiConfigurationToModeSpecific,
} from "./core/storage/state-migrations"
import { sendFocusChatInputEvent } from "./core/controller/ui/subscribeToFocusChatInput"
import { FileContextTracker } from "./core/context/context-tracking/FileContextTracker"
import { vscodeHostBridgeClient } from "@/hosts/vscode/hostbridge/client/host-grpc-client"
import { VscodeWebviewProvider } from "./hosts/vscode/VscodeWebviewProvider"
import { ExtensionContext } from "vscode"
import { AuthService } from "./services/auth/AuthService"
import { writeTextToClipboard, readTextFromClipboard } from "@/utils/env"
import { VscodeDiffViewProvider } from "./hosts/vscode/VscodeDiffViewProvider"
import assert from "node:assert"
import { setTimeout as setTimeoutPromise } from "node:timers/promises"
import { DIFF_VIEW_URI_SCHEME } from "@hosts/vscode/VscodeDiffViewProvider"
import { WebviewProviderType as WebviewProviderTypeEnum } from "@shared/proto/cline/ui"
import pWaitFor from "p-wait-for"
import * as vscode from "vscode"
import { sendAccountButtonClickedEvent } from "./core/controller/ui/subscribeToAccountButtonClicked"
import { sendChatButtonClickedEvent } from "./core/controller/ui/subscribeToChatButtonClicked"
import { sendHistoryButtonClickedEvent } from "./core/controller/ui/subscribeToHistoryButtonClicked"
import { sendMcpButtonClickedEvent } from "./core/controller/ui/subscribeToMcpButtonClicked"
import { sendSettingsButtonClickedEvent } from "./core/controller/ui/subscribeToSettingsButtonClicked"
import {
migrateCustomInstructionsToGlobalRules,
migrateWelcomeViewCompleted,
migrateWorkspaceToGlobalStorage,
} from "./core/storage/state-migrations"
import { WebviewProvider } from "./core/webview"
import { createClineAPI } from "./exports"
import { Logger } from "./services/logging/Logger"
import { PostHogClientProvider } from "./services/posthog/PostHogClientProvider"
import { cleanupTestMode, initializeTestMode } from "./services/test/TestMode"
import { WebviewProviderType } from "./shared/webview/types"
import "./utils/path" // necessary to have access to String.prototype.toPosix
import type { ExtensionContext } from "vscode"
import { HostProvider } from "@/hosts/host-provider"
import { ShowMessageType } from "./shared/proto/host/window"
import { vscodeHostBridgeClient } from "@/hosts/vscode/hostbridge/client/host-grpc-client"
import { readTextFromClipboard, writeTextToClipboard } from "@/utils/env"
import { FileContextTracker } from "./core/context/context-tracking/FileContextTracker"
import { sendFocusChatInputEvent } from "./core/controller/ui/subscribeToFocusChatInput"
import { VscodeDiffViewProvider } from "./hosts/vscode/VscodeDiffViewProvider"
import { VscodeWebviewProvider } from "./hosts/vscode/VscodeWebviewProvider"
import { GitCommitGenerator } from "./integrations/git/commit-message-generator"
import { AuthService } from "./services/auth/AuthService"
import { telemetryService } from "./services/posthog/PostHogClientProvider"
import { ShowMessageType } from "./shared/proto/host/window"
import { SharedUriHandler } from "./services/uri/SharedUriHandler"
import { getLatestAnnouncementId } from "./utils/announcements"
/*
Built using https://github.com/microsoft/vscode-webview-ui-toolkit
@@ -49,35 +48,26 @@ https://github.com/microsoft/vscode-webview-ui-toolkit-samples/tree/main/framewo
*/
let outputChannel: vscode.OutputChannel
// This method is called when your extension is activated
// Your extension is activated the very first time the command is executed
export async function activate(context: vscode.ExtensionContext) {
outputChannel = vscode.window.createOutputChannel("Cline")
context.subscriptions.push(outputChannel)
ErrorService.initialize()
Logger.initialize(outputChannel)
Logger.log("Cline extension activated")
maybeSetupHostProviders(context)
// Initialize PostHog client provider
const distinctId = context.globalState.get<string>("cline.distinctId")
PostHogClientProvider.getInstance(distinctId)
Logger.log("Cline extension activated")
// Migrate custom instructions to global Cline rules (one-time cleanup)
await migrateCustomInstructionsToGlobalRules(context)
// Migrate mode from workspace storage to controller state (one-time cleanup)
await migrateModeFromWorkspaceStorageToControllerState(context)
// Migrate welcomeViewCompleted setting based on existing API keys (one-time cleanup)
await migrateWelcomeViewCompleted(context)
// Migrate workspace storage values back to global storage (reverting previous migration)
await migrateWorkspaceToGlobalStorage(context)
// Migrate legacy API configuration to mode-specific keys (one-time migration)
await migrateLegacyApiConfigurationToModeSpecific(context)
// Clean up orphaned file context warnings (startup cleanup)
await FileContextTracker.cleanupOrphanedWarnings(context)
@@ -105,7 +95,7 @@ export async function activate(context: vscode.ExtensionContext) {
// Use the same condition as announcements: focus when there's a new announcement to show
const lastShownAnnouncementId = context.globalState.get<string>("lastShownAnnouncementId")
const latestAnnouncementId = context.extension?.packageJSON?.version?.split(".").slice(0, 2).join(".") ?? ""
const latestAnnouncementId = getLatestAnnouncementId(context)
if (lastShownAnnouncementId !== latestAnnouncementId) {
// Focus Cline when there's a new announcement to show (major/minor updates or fresh installs)
@@ -114,7 +104,10 @@ export async function activate(context: vscode.ExtensionContext) {
: `Welcome to Cline v${currentVersion}`
await vscode.commands.executeCommand("claude-dev.SidebarProvider.focus")
await new Promise((resolve) => setTimeout(resolve, 200))
HostProvider.window.showMessage({ type: ShowMessageType.INFORMATION, message })
HostProvider.window.showMessage({
type: ShowMessageType.INFORMATION,
message,
})
}
// Always update the main version tracker for the next launch.
await context.globalState.update("clineVersion", currentVersion)
@@ -124,15 +117,7 @@ export async function activate(context: vscode.ExtensionContext) {
console.error(`Error during post-update actions: ${errorMessage}, Stack trace: ${error.stack}`)
}
// backup id in case vscMachineID doesn't work
let installId = context.globalState.get<string>("installId")
if (!installId) {
installId = uuidv4()
await context.globalState.update("installId", installId)
}
telemetryService.captureExtensionActivated(installId)
telemetryService.captureExtensionActivated()
context.subscriptions.push(
vscode.commands.registerCommand("cline.plusButtonClicked", async (webview: any) => {
@@ -218,6 +203,7 @@ export async function activate(context: vscode.ExtensionContext) {
// Lock the editor group so clicking on files doesn't open them over the panel
await setTimeoutPromise(100)
await vscode.commands.executeCommand("workbench.action.lockEditorGroup")
return tabWebview
}
context.subscriptions.push(vscode.commands.registerCommand("cline.popoutButtonClicked", openClineInNewTab))
@@ -279,44 +265,10 @@ export async function activate(context: vscode.ExtensionContext) {
})()
context.subscriptions.push(vscode.workspace.registerTextDocumentContentProvider(DIFF_VIEW_URI_SCHEME, diffContentProvider))
// URI Handler
const handleUri = async (uri: vscode.Uri) => {
console.log("URI Handler called with:", {
path: uri.path,
query: uri.query,
scheme: uri.scheme,
})
const path = uri.path
const query = new URLSearchParams(uri.query.replace(/\+/g, "%2B"))
const visibleWebview = WebviewProvider.getVisibleInstance()
if (!visibleWebview) {
return
}
switch (path) {
case "/openrouter": {
const code = query.get("code")
if (code) {
await visibleWebview?.controller.handleOpenRouterCallback(code)
}
break
}
case "/auth": {
console.log("Auth callback received:", uri.toString())
const token = query.get("idToken")
const provider = query.get("provider")
console.log("Auth callback received:", { provider })
if (token) {
await visibleWebview?.controller.handleAuthCallback(token, provider)
// await authService.handleAuthCallback(token)
}
break
}
default:
break
const success = await SharedUriHandler.handleUri(uri)
if (!success) {
console.warn("Extension URI handler: Failed to process URI:", uri.toString())
}
}
context.subscriptions.push(vscode.window.registerUriHandler({ handleUri }))
@@ -338,12 +290,16 @@ export async function activate(context: vscode.ExtensionContext) {
context.subscriptions.push(
vscode.commands.registerCommand("cline.addToChat", async (range?: vscode.Range, diagnostics?: vscode.Diagnostic[]) => {
await vscode.commands.executeCommand("cline.focusChatInput") // Ensure Cline is visible and input focused
await pWaitFor(() => !!WebviewProvider.getVisibleInstance())
const activeWebview = WebviewProvider.getLastActiveInstance()
const clientId = activeWebview?.getClientId()
await pWaitFor(() => !!activeWebview)
const editor = vscode.window.activeTextEditor
if (!editor) {
if (!editor || !clientId) {
return
}
await sendFocusChatInputEvent(clientId)
// Use provided range if available, otherwise use current selection
// (vscode command passes an argument in the first param by default, so we need to ensure it's a Range object)
const textRange = range instanceof vscode.Range ? range : editor.selection
@@ -357,14 +313,13 @@ export async function activate(context: vscode.ExtensionContext) {
const filePath = editor.document.uri.fsPath
const languageId = editor.document.languageId
const visibleWebview = WebviewProvider.getVisibleInstance()
await visibleWebview?.controller.addSelectedCodeToChat(
await activeWebview?.controller.addSelectedCodeToChat(
selectedText,
filePath,
languageId,
Array.isArray(diagnostics) ? diagnostics : undefined,
)
telemetryService.captureButtonClick("codeAction_addToChat", visibleWebview?.controller.task?.taskId)
telemetryService.captureButtonClick("codeAction_addToChat", activeWebview?.controller.task?.taskId)
}),
)
@@ -383,7 +338,7 @@ export async function activate(context: vscode.ExtensionContext) {
await vscode.commands.executeCommand("workbench.action.terminal.copySelection")
// Get copied content
let terminalContents = (await readTextFromClipboard()).trim()
const terminalContents = (await readTextFromClipboard()).trim()
// Restore original clipboard content
await writeTextToClipboard(tempCopyBuffer)
@@ -525,8 +480,8 @@ export async function activate(context: vscode.ExtensionContext) {
vscode.commands.registerCommand("cline.fixWithCline", async (range: vscode.Range, diagnostics: vscode.Diagnostic[]) => {
// Add this line to focus the chat input first
await vscode.commands.executeCommand("cline.focusChatInput")
// Wait for a webview instance to become visible after focusing
await pWaitFor(() => !!WebviewProvider.getVisibleInstance())
// Wait for a webview instance to become available after focusing
await pWaitFor(() => !!WebviewProvider.getLastActiveInstance())
const editor = vscode.window.activeTextEditor
if (!editor) {
return
@@ -536,17 +491,17 @@ export async function activate(context: vscode.ExtensionContext) {
const filePath = editor.document.uri.fsPath
const languageId = editor.document.languageId
// Send to sidebar provider with diagnostics
const visibleWebview = WebviewProvider.getVisibleInstance()
await visibleWebview?.controller.fixWithCline(selectedText, filePath, languageId, diagnostics)
telemetryService.captureButtonClick("codeAction_fixWithCline", visibleWebview?.controller.task?.taskId)
// Send to last active instance with diagnostics
const activeWebview = WebviewProvider.getLastActiveInstance()
await activeWebview?.controller.fixWithCline(selectedText, filePath, languageId, diagnostics)
telemetryService.captureButtonClick("codeAction_fixWithCline", activeWebview?.controller.task?.taskId)
}),
)
context.subscriptions.push(
vscode.commands.registerCommand("cline.explainCode", async (range: vscode.Range) => {
await vscode.commands.executeCommand("cline.focusChatInput") // Ensure Cline is visible and input focused
await pWaitFor(() => !!WebviewProvider.getVisibleInstance())
await pWaitFor(() => !!WebviewProvider.getLastActiveInstance())
const editor = vscode.window.activeTextEditor
if (!editor) {
return
@@ -560,18 +515,18 @@ export async function activate(context: vscode.ExtensionContext) {
return
}
const filePath = editor.document.uri.fsPath
const visibleWebview = WebviewProvider.getVisibleInstance()
const fileMention = visibleWebview?.controller.getFileMentionFromPath(filePath) || filePath
const activeWebview = WebviewProvider.getLastActiveInstance()
const fileMention = activeWebview?.controller.getFileMentionFromPath(filePath) || filePath
const prompt = `Explain the following code from ${fileMention}:\n\`\`\`${editor.document.languageId}\n${selectedText}\n\`\`\``
await visibleWebview?.controller.initTask(prompt)
telemetryService.captureButtonClick("codeAction_explainCode", visibleWebview?.controller.task?.taskId)
await activeWebview?.controller.initTask(prompt)
telemetryService.captureButtonClick("codeAction_explainCode", activeWebview?.controller.task?.taskId)
}),
)
context.subscriptions.push(
vscode.commands.registerCommand("cline.improveCode", async (range: vscode.Range) => {
await vscode.commands.executeCommand("cline.focusChatInput") // Ensure Cline is visible and input focused
await pWaitFor(() => !!WebviewProvider.getVisibleInstance())
await pWaitFor(() => !!WebviewProvider.getLastActiveInstance())
const editor = vscode.window.activeTextEditor
if (!editor) {
return
@@ -585,73 +540,62 @@ export async function activate(context: vscode.ExtensionContext) {
return
}
const filePath = editor.document.uri.fsPath
const visibleWebview = WebviewProvider.getVisibleInstance()
const fileMention = visibleWebview?.controller.getFileMentionFromPath(filePath) || filePath
const activeWebview = WebviewProvider.getLastActiveInstance()
const fileMention = activeWebview?.controller.getFileMentionFromPath(filePath) || filePath
const prompt = `Improve the following code from ${fileMention} (e.g., suggest refactorings, optimizations, or better practices):\n\`\`\`${editor.document.languageId}\n${selectedText}\n\`\`\``
await visibleWebview?.controller.initTask(prompt)
telemetryService.captureButtonClick("codeAction_improveCode", visibleWebview?.controller.task?.taskId)
await activeWebview?.controller.initTask(prompt)
telemetryService.captureButtonClick("codeAction_improveCode", activeWebview?.controller.task?.taskId)
}),
)
// Register the focusChatInput command handler
context.subscriptions.push(
vscode.commands.registerCommand("cline.focusChatInput", async () => {
let activeWebviewProvider: WebviewProvider | undefined = WebviewProvider.getVisibleInstance()
// Fast path: check for existing active instance
let activeWebview = WebviewProvider.getLastActiveInstance()
// If a tab is visible and active, ensure it's fully revealed (might be redundant but safe)
if (activeWebviewProvider?.getWebview() && activeWebviewProvider.getWebview().hasOwnProperty("reveal")) {
const panelView = activeWebviewProvider.getWebview() as vscode.WebviewPanel
panelView.reveal(panelView.viewColumn)
} else if (!activeWebviewProvider) {
// No webview is currently visible, try to activate the sidebar
await vscode.commands.executeCommand("claude-dev.SidebarProvider.focus")
await new Promise((resolve) => setTimeout(resolve, 200)) // Allow time for focus
activeWebviewProvider = WebviewProvider.getSidebarInstance()
if (!activeWebviewProvider) {
// Sidebar didn't become active (might be closed or not in current view container)
// Check for existing tab panels
const tabInstances = WebviewProvider.getTabInstances()
if (tabInstances.length > 0) {
const potentialTabInstance = tabInstances[tabInstances.length - 1] // Get the most recent one
if (potentialTabInstance.getWebview() && potentialTabInstance.getWebview().hasOwnProperty("reveal")) {
const panelView = potentialTabInstance.getWebview() as vscode.WebviewPanel
panelView.reveal(panelView.viewColumn)
activeWebviewProvider = potentialTabInstance
}
if (activeWebview) {
// Instance exists - just reveal and focus it
const webview = activeWebview.getWebview()
if (webview) {
if (webview && "reveal" in webview) {
webview.reveal()
} else if ("show" in webview) {
webview.show()
}
}
} else {
// No active instance - need to find or create one
WebviewProvider.setLastActiveControllerId(null)
if (!activeWebviewProvider) {
// No existing Cline view found at all, open a new tab
await vscode.commands.executeCommand("cline.openInNewTab")
// After openInNewTab, a new webview is created. We need to get this new instance.
// It might take a moment for it to register.
await pWaitFor(
() => {
const visibleInstance = WebviewProvider.getVisibleInstance()
// Ensure a boolean is returned
return !!(visibleInstance?.getWebview() && visibleInstance.getWebview().hasOwnProperty("reveal"))
},
{ timeout: 2000 },
)
activeWebviewProvider = WebviewProvider.getVisibleInstance()
// Check for existing tab instances first (cheaper than focusing sidebar)
const tabInstances = WebviewProvider.getTabInstances()
if (tabInstances.length > 0) {
activeWebview = tabInstances[tabInstances.length - 1]
} else {
// Try to focus sidebar
await vscode.commands.executeCommand("claude-dev.SidebarProvider.focus")
// Small delay for focus to complete
await new Promise((resolve) => setTimeout(resolve, 200))
// Last resort: create new tab
activeWebview = WebviewProvider.getSidebarInstance() || (await openClineInNewTab())
}
}
// At this point, activeWebviewProvider should be the one we want to send the message to.
// It could still be undefined if opening a new tab failed or timed out.
if (activeWebviewProvider) {
// Use the gRPC streaming method instead of postMessageToWebview
const clientId = activeWebviewProvider.getClientId()
sendFocusChatInputEvent(clientId)
} else {
// Send focus event
const clientId = activeWebview?.getClientId()
if (!clientId) {
console.error("FocusChatInput: Could not find or activate a Cline webview to focus.")
HostProvider.window.showMessage({
type: ShowMessageType.ERROR,
message: "Could not activate Cline view. Please try opening it manually from the Activity Bar.",
})
return
}
telemetryService.captureButtonClick("command_focusChatInput", activeWebviewProvider?.controller.task?.taskId)
sendFocusChatInputEvent(clientId)
telemetryService.captureButtonClick("command_focusChatInput", activeWebview.controller?.task?.taskId)
}),
)
@@ -678,7 +622,10 @@ export async function activate(context: vscode.ExtensionContext) {
if (event.key === "clineAccountId") {
// Check if the secret was removed (logout) or added/updated (login)
const secretValue = await context.secrets.get("clineAccountId")
const authService = AuthService.getInstance(context)
const activeWebviewProvider = WebviewProvider.getVisibleInstance()
const controller = activeWebviewProvider?.controller
const authService = AuthService.getInstance(controller)
if (secretValue) {
// Secret was added or updated - restore auth info (login from another window)
authService?.restoreRefreshTokenAndRetrieveAuthInfo()
@@ -690,30 +637,32 @@ export async function activate(context: vscode.ExtensionContext) {
}),
)
return createClineAPI(outputChannel, sidebarWebview.controller)
return createClineAPI(sidebarWebview.controller)
}
function maybeSetupHostProviders(context: ExtensionContext) {
if (!HostProvider.isInitialized()) {
console.log("Setting up vscode host providers...")
const createWebview = function (type: WebviewProviderType) {
return new VscodeWebviewProvider(context, outputChannel, type)
}
const createDiffView = function () {
return new VscodeDiffViewProvider()
}
HostProvider.initialize(createWebview, createDiffView, vscodeHostBridgeClient)
const createWebview = (type: WebviewProviderType) => new VscodeWebviewProvider(context, type)
const createDiffView = () => new VscodeDiffViewProvider()
const outputChannel = vscode.window.createOutputChannel("Cline")
context.subscriptions.push(outputChannel)
const getCallbackUri = async () => `${vscode.env.uriScheme || "vscode"}://saoudrizwan.claude-dev`
HostProvider.initialize(createWebview, createDiffView, vscodeHostBridgeClient, outputChannel.appendLine, getCallbackUri)
}
}
// This method is called when your extension is deactivated
export async function deactivate() {
PostHogClientProvider.getInstance().dispose()
// Dispose all webview instances
await WebviewProvider.disposeAllInstances()
// Clean up test mode
cleanupTestMode()
await posthogClientProvider.shutdown()
Logger.log("Cline extension deactivated")
}
+270
View File
@@ -0,0 +1,270 @@
import type { IncomingMessage, Server, ServerResponse } from "node:http"
import http from "node:http"
import type { AddressInfo } from "node:net"
import { openExternal } from "@/utils/env"
import { SharedUriHandler } from "@/services/uri/SharedUriHandler"
const SERVER_TIMEOUT = 10 * 60 * 1000 // 10 minutes
/**
* Handles OAuth authentication flow by creating a local server to receive tokens.
*/
export class AuthHandler {
private static instance: AuthHandler | null = null
private port = 0
private server: Server | null = null
private serverCreationPromise: Promise<void> | null = null
private timeoutId: NodeJS.Timeout | null = null
private enabled: boolean = false
private constructor() {}
/**
* Gets the singleton instance of AuthHandler
* @returns The singleton AuthHandler instance
*/
public static getInstance(): AuthHandler {
if (!AuthHandler.instance) {
AuthHandler.instance = new AuthHandler()
}
return AuthHandler.instance
}
public setEnabled(enabled: boolean): void {
this.enabled = enabled
}
public async getCallbackUri(): Promise<string> {
if (!this.enabled) {
throw Error("AuthHandler was not enabled")
}
if (!this.server) {
// If server creation is already in progress, wait for it
if (this.serverCreationPromise) {
await this.serverCreationPromise
} else {
// Start server creation and track the promise
this.serverCreationPromise = this.createServer()
await this.serverCreationPromise
}
} else {
this.updateTimeout()
}
return `http://127.0.0.1:${this.port}`
}
private async createServer(): Promise<void> {
return new Promise((resolve, reject) => {
try {
const server = http.createServer(this.handleRequest.bind(this))
// Use callback to ensure server is ready before getting address
server.listen(0, "127.0.0.1", () => {
const address = server.address()
if (!address) {
console.error("AuthHandler: Failed to get server address")
this.server = null
this.port = 0
this.serverCreationPromise = null
reject(new Error("Failed to get server address"))
return
}
// Get the assigned port and set up the server
this.port = (address as AddressInfo).port
this.server = server
console.log("AuthHandler: Server started on port", this.port)
this.updateTimeout()
this.serverCreationPromise = null
resolve()
})
server.on("error", (error) => {
console.error("AuthHandler: Server error", error)
this.server = null
this.port = 0
this.serverCreationPromise = null
reject(error)
})
} catch (error) {
console.error("AuthHandler: Failed to create server", error)
this.server = null
this.port = 0
this.serverCreationPromise = null
reject(error)
}
})
}
private updateTimeout(): void {
if (this.timeoutId) {
clearTimeout(this.timeoutId)
}
this.timeoutId = setTimeout(() => this.stop(), SERVER_TIMEOUT)
}
private async handleRequest(req: IncomingMessage, res: ServerResponse): Promise<void> {
console.log("AuthHandler: Received request", req.url)
if (!req.url) {
this.sendResponse(res, 404, "text/plain", "Not found")
return
}
try {
// Convert HTTP URL to vscode.Uri and use shared handler directly
const fullUrl = `http://127.0.0.1:${this.port}${req.url}`
const uri = SharedUriHandler.convertHttpUrlToUri(fullUrl)
// Use SharedUriHandler directly - it handles all validation and processing
const success = await SharedUriHandler.handleUri(uri)
if (success) {
this.sendResponse(res, 200, "text/html", TOKEN_REQUEST_VIEW)
} else {
this.sendResponse(res, 400, "text/plain", "Bad request")
}
} catch (error) {
console.error("AuthHandler: Error processing request", error)
this.sendResponse(res, 400, "text/plain", "Bad request")
} finally {
// Stop the server after handling any request (success or failure)
this.stop()
}
}
private sendResponse(res: ServerResponse, status: number, type: string, content: string): void {
res.writeHead(status, { "Content-Type": type })
res.end(content)
}
private async openBrowser(callbackUrl: URL): Promise<void> {
await openExternal(callbackUrl.toString())
}
public stop(): void {
if (this.timeoutId) {
clearTimeout(this.timeoutId)
this.timeoutId = null
}
if (this.server) {
this.server.close()
this.server = null
}
this.serverCreationPromise = null
this.port = 0
}
public dispose(): void {
this.stop()
}
}
const TOKEN_REQUEST_VIEW = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Cline - Authentication Success</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=Azeret+Mono:wght@300;400;700&display=swap');
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Azeret Mono', monospace;
background-color: #ffffff;
color: #333333;
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
line-height: 1.25;
}
.container {
text-align: center;
padding: 32px;
background-color: #f8f8f8;
border: 1px solid #e1e1e1;
border-radius: 6px;
max-width: 480px;
width: 90%;
}
.checkmark {
width: 48px;
height: 48px;
border-radius: 50%;
background-color: #73c991;
margin: 0 auto 24px;
display: flex;
align-items: center;
justify-content: center;
}
.checkmark::after {
content: '✓';
font-size: 24px;
color: #ffffff;
font-weight: bold;
}
h1 {
font-size: 1.5rem;
margin-bottom: 16px;
font-weight: 400;
color: #333333;
}
p {
font-size: 0.875rem;
line-height: 1.5;
margin-bottom: 24px;
color: #666666;
}
.countdown {
font-size: 0.8125rem;
color: #666666;
background-color: #ffffff;
border: 1px solid #d1d1d1;
padding: 8px 16px;
border-radius: 4px;
display: inline-block;
}
@media (max-width: 480px) {
.container {
padding: 24px 16px;
}
h1 {
font-size: 1.25rem;
}
p {
font-size: 0.8125rem;
}
}
</style>
</head>
<body>
<div class="container">
<div class="checkmark"></div>
<h1>Authentication Successful</h1>
<p>Your authentication token has been securely sent back to your IDE. You can now return to your development environment to continue working.</p>
<div class="countdown">Feel free to close this window and continue in your IDE</div>
</div>
</body>
</html>`
+22 -2
View File
@@ -1,5 +1,6 @@
import { HostProvider } from "@/hosts/host-provider"
import { DiffViewProvider } from "@/integrations/editor/DiffViewProvider"
import { DiagnosticSeverity } from "@/shared/proto/host/workspace"
import { status } from "@grpc/grpc-js"
export class ExternalDiffViewProvider extends DiffViewProvider {
@@ -78,8 +79,27 @@ export class ExternalDiffViewProvider extends DiffViewProvider {
}
protected override async getNewDiagnosticProblems(): Promise<string> {
console.log(`Called ExternalDiffViewProvider.getNewDiagnosticProblems() stub`)
return ""
// Get diagnostics using the HostBridge workspace service
const response = await HostProvider.workspace.getDiagnostics({})
if (response.fileDiagnostics.length === 0) {
return ""
}
let result = ""
for (const fileDiagnostics of response.fileDiagnostics) {
const errors = fileDiagnostics.diagnostics.filter((d) => d.severity === DiagnosticSeverity.DIAGNOSTIC_ERROR)
if (errors.length > 0) {
result += `\n\n${fileDiagnostics.filePath}`
for (const diagnostic of errors) {
const line = (diagnostic.range?.start?.line || 0) + 1 // Proto lines are 0-indexed
const source = diagnostic.source ? `${diagnostic.source} ` : ""
result += `\n- [${source}Error] Line ${line}: ${diagnostic.message}`
}
}
}
return result.trim()
}
protected override async closeDiffView(): Promise<void> {
+3 -7
View File
@@ -8,8 +8,8 @@ export class ExternalWebviewProvider extends WebviewProvider {
// This hostname cannot be changed without updating the external webview handler.
private RESOURCE_HOSTNAME: string = "internal.resources"
constructor(context: vscode.ExtensionContext, outputChannel: vscode.OutputChannel, providerType: WebviewProviderType) {
super(context, outputChannel, providerType)
constructor(context: vscode.ExtensionContext, providerType: WebviewProviderType) {
super(context, providerType)
}
override getWebviewUri(uri: URI) {
@@ -21,15 +21,11 @@ export class ExternalWebviewProvider extends WebviewProvider {
override getCspSource() {
return `'self' https://${this.RESOURCE_HOSTNAME}`
}
override postMessageToWebview(message: ExtensionMessage) {
console.log(`postMessageToWebview: ${message}`)
return undefined
}
override isVisible() {
return true
}
override getWebview() {
return {}
return undefined
}
override resolveWebviewView(_: any): Promise<void> {
+22 -2
View File
@@ -23,26 +23,44 @@ export class HostProvider {
createDiffViewProvider: DiffViewProviderCreator
hostBridge: HostBridgeClientProvider
// Logs to a user-visible output channel.
logToChannel: LogToChannel
// Returns a callback URI that will redirect to Cline.
getCallbackUri: () => Promise<string>
// Private constructor to enforce singleton pattern
private constructor(
createWebviewProvider: WebviewProviderCreator,
createDiffViewProvider: DiffViewProviderCreator,
hostBridge: HostBridgeClientProvider,
logToChannel: LogToChannel,
getCallbackUri: () => Promise<string>,
) {
this.createWebviewProvider = createWebviewProvider
this.createDiffViewProvider = createDiffViewProvider
this.hostBridge = hostBridge
this.logToChannel = logToChannel
this.getCallbackUri = getCallbackUri
}
public static initialize(
webviewProviderCreator: WebviewProviderCreator,
diffViewProviderCreator: DiffViewProviderCreator,
hostBridgeProvider: HostBridgeClientProvider,
logToChannel: LogToChannel,
getCallbackUri: () => Promise<string>,
): HostProvider {
if (HostProvider.instance) {
throw new Error("Host providers have already been initialized.")
}
HostProvider.instance = new HostProvider(webviewProviderCreator, diffViewProviderCreator, hostBridgeProvider)
HostProvider.instance = new HostProvider(
webviewProviderCreator,
diffViewProviderCreator,
hostBridgeProvider,
logToChannel,
getCallbackUri,
)
return HostProvider.instance
}
@@ -51,7 +69,7 @@ export class HostProvider {
*/
public static get(): HostProvider {
if (!HostProvider.instance) {
throw new Error("HostProvider not initialized. Call HostProvider.initialize() first.")
throw new Error("HostProvider not setup. Call HostProvider.initialize() first.")
}
return HostProvider.instance
}
@@ -99,3 +117,5 @@ export type WebviewProviderCreator = (providerType: WebviewProviderType) => Webv
* A function that creates DiffViewProvider instances
*/
export type DiffViewProviderCreator = () => DiffViewProvider
export type LogToChannel = (message: string) => void
+14 -4
View File
@@ -3,7 +3,7 @@ import * as path from "path"
import * as vscode from "vscode"
import { DecorationController } from "@/hosts/vscode/DecorationController"
import { DiffViewProvider } from "@integrations/editor/DiffViewProvider"
import { diagnosticsToProblemsString, getNewDiagnostics } from "@/integrations/diagnostics"
import { diagnosticsToProblemsString, getNewDiagnostics } from "./diagnostics"
export const DIFF_VIEW_URI_SCHEME = "cline-diff"
@@ -30,7 +30,11 @@ export class VscodeDiffViewProvider extends DiffViewProvider {
.filter((tab) => tab.input instanceof vscode.TabInputText && arePathsEqual(tab.input.uri.fsPath, this.absolutePath))
for (const tab of tabs) {
if (!tab.isDirty) {
await vscode.window.tabGroups.close(tab)
try {
await vscode.window.tabGroups.close(tab)
} catch (error) {
console.warn("Tab close retry failed:", error.message)
}
}
this.documentWasOpen = true
}
@@ -64,7 +68,9 @@ export class VscodeDiffViewProvider extends DiffViewProvider {
})
vscode.commands.executeCommand(
"vscode.diff",
vscode.Uri.parse(`${DIFF_VIEW_URI_SCHEME}:${fileName}`).with({
vscode.Uri.from({
scheme: DIFF_VIEW_URI_SCHEME,
path: fileName,
query: Buffer.from(this.originalContent ?? "").toString("base64"),
}),
uri,
@@ -187,7 +193,11 @@ export class VscodeDiffViewProvider extends DiffViewProvider {
for (const tab of tabs) {
// trying to close dirty views results in save popup
if (!tab.isDirty) {
await vscode.window.tabGroups.close(tab)
try {
await vscode.window.tabGroups.close(tab)
} catch (error) {
console.warn("Tab close retry failed:", error.message)
}
}
}
}
+88 -41
View File
@@ -1,11 +1,14 @@
import { ExtensionMessage } from "@/shared/ExtensionMessage"
import { WebviewProviderType } from "@/shared/webview/types"
import { sendThemeEvent } from "@core/controller/ui/subscribeToTheme"
import { getTheme } from "@integrations/theme/getTheme"
import * as vscode from "vscode"
import { Uri } from "vscode"
import { WebviewProvider } from "@core/webview"
import { sendDidBecomeVisibleEvent } from "@core/controller/ui/subscribeToDidBecomeVisible"
import { sendThemeEvent } from "@core/controller/ui/subscribeToTheme"
import { WebviewProvider } from "@core/webview"
import { getTheme } from "@integrations/theme/getTheme"
import type { Uri } from "vscode"
import * as vscode from "vscode"
import { HostProvider } from "@/hosts/host-provider"
import type { ExtensionMessage } from "@/shared/ExtensionMessage"
import type { WebviewProviderType } from "@/shared/webview/types"
import { WebviewMessage } from "@/shared/WebviewMessage"
import { handleGrpcRequest, handleGrpcRequestCancel } from "@/core/controller/grpc-handler"
/*
https://github.com/microsoft/vscode-webview-ui-toolkit-samples/blob/main/default/weather-webview/src/providers/WeatherViewProvider.ts
@@ -13,10 +16,11 @@ https://github.com/KumarVariable/vscode-extension-sidebar-html/blob/master/src/c
*/
export class VscodeWebviewProvider extends WebviewProvider implements vscode.WebviewViewProvider {
public webview?: vscode.WebviewView | vscode.WebviewPanel
private webview?: vscode.WebviewView | vscode.WebviewPanel
private disposables: vscode.Disposable[] = []
constructor(context: vscode.ExtensionContext, outputChannel: vscode.OutputChannel, providerType: WebviewProviderType) {
super(context, outputChannel, providerType)
constructor(context: vscode.ExtensionContext, providerType: WebviewProviderType) {
super(context, providerType)
}
override getWebviewUri(uri: Uri) {
@@ -31,9 +35,6 @@ export class VscodeWebviewProvider extends WebviewProvider implements vscode.Web
}
return this.webview.webview.cspSource
}
override postMessageToWebview(message: ExtensionMessage) {
return this.webview?.webview.postMessage(message)
}
override isVisible() {
return this.webview?.visible || false
}
@@ -68,8 +69,10 @@ export class VscodeWebviewProvider extends WebviewProvider implements vscode.Web
// WebviewView and WebviewPanel have all the same properties except for this visibility listener
// panel
webviewView.onDidChangeViewState(
async () => {
if (this.webview?.visible) {
async (e) => {
if (e?.webviewPanel?.visible && e.webviewPanel?.active) {
WebviewProvider.setLastActiveControllerId(this.controller.id)
// Only send the event if the webview is active (focused)
await sendDidBecomeVisibleEvent(this.controller.id)
}
},
@@ -81,6 +84,7 @@ export class VscodeWebviewProvider extends WebviewProvider implements vscode.Web
webviewView.onDidChangeVisibility(
async () => {
if (this.webview?.visible) {
WebviewProvider.setLastActiveControllerId(this.controller.id)
await sendDidBecomeVisibleEvent(this.controller.id)
}
},
@@ -93,41 +97,40 @@ export class VscodeWebviewProvider extends WebviewProvider implements vscode.Web
// This happens when the user closes the view or when the view is closed programmatically
webviewView.onDidDispose(
async () => {
if (WebviewProvider.getLastActiveControllerId() === this.controller.id) {
WebviewProvider.setLastActiveControllerId(null)
}
await this.dispose()
},
null,
this.disposables,
)
// // if the extension is starting a new session, clear previous task state
// this.clearTask()
{
// Listen for configuration changes
vscode.workspace.onDidChangeConfiguration(
async (e) => {
if (e && e.affectsConfiguration("workbench.colorTheme")) {
// Send theme update via gRPC subscription
const theme = await getTheme()
if (theme) {
await sendThemeEvent(JSON.stringify(theme))
}
// Listen for configuration changes
vscode.workspace.onDidChangeConfiguration(
async (e) => {
if (e && e.affectsConfiguration("workbench.colorTheme")) {
// Send theme update via gRPC subscription
const theme = await getTheme()
if (theme) {
await sendThemeEvent(JSON.stringify(theme))
}
if (e && e.affectsConfiguration("cline.mcpMarketplace.enabled")) {
// Update state when marketplace tab setting changes
await this.controller.postStateToWebview()
}
},
null,
this.disposables,
)
}
if (e && e.affectsConfiguration("cline.mcpMarketplace.enabled")) {
// Update state when marketplace tab setting changes
await this.controller.postStateToWebview()
}
},
null,
this.disposables,
)
// if the extension is starting a new session, clear previous task state
this.controller.clearTask()
// if the extension is starting a new session, clear previous task state
this.controller.clearTask()
this.outputChannel.appendLine("Webview view resolved")
HostProvider.get().logToChannel("Webview view resolved")
// Title setting logic removed to allow VSCode to use the container title primarily.
}
// Title setting logic removed to allow VSCode to use the container title primarily.
}
/**
@@ -157,17 +160,61 @@ export class VscodeWebviewProvider extends WebviewProvider implements vscode.Web
private setWebviewMessageListener(webview: vscode.Webview) {
webview.onDidReceiveMessage(
(message) => {
this.controller.handleWebviewMessage(message)
this.handleWebviewMessage(message)
},
null,
this.disposables,
)
}
/**
* Sets up an event listener to listen for messages passed from the webview context and
* executes code based on the message that is received.
*
* @param webview A reference to the extension webview
*/
async handleWebviewMessage(message: WebviewMessage) {
const postMessageToWebview = (response: ExtensionMessage) => this.postMessageToWebview(response)
switch (message.type) {
case "grpc_request": {
if (message.grpc_request) {
await handleGrpcRequest(this.controller, postMessageToWebview, message.grpc_request)
}
break
}
case "grpc_request_cancel": {
if (message.grpc_request_cancel) {
await handleGrpcRequestCancel(postMessageToWebview, message.grpc_request_cancel)
}
break
}
default: {
console.error("Received unhandled WebviewMessage type:", JSON.stringify(message))
}
}
}
/**
* Sends a message from the extension to the webview.
*
* @param message - The message to send to the webview
* @returns A thenable that resolves to a boolean indicating success, or undefined if the webview is not available
*/
private async postMessageToWebview(message: ExtensionMessage): Promise<boolean | undefined> {
return this.webview?.webview.postMessage(message)
}
override async dispose() {
if (this.webview && "dispose" in this.webview) {
this.webview.dispose()
}
while (this.disposables.length) {
const x = this.disposables.pop()
if (x) {
x.dispose()
}
}
super.dispose()
}
}
+109
View File
@@ -0,0 +1,109 @@
import * as vscode from "vscode"
import * as path from "path"
import deepEqual from "fast-deep-equal"
import { getCwd } from "@/utils/path"
export function getNewDiagnostics(
oldDiagnostics: [vscode.Uri, vscode.Diagnostic[]][],
newDiagnostics: [vscode.Uri, vscode.Diagnostic[]][],
): [vscode.Uri, vscode.Diagnostic[]][] {
const newProblems: [vscode.Uri, vscode.Diagnostic[]][] = []
const oldMap = new Map(oldDiagnostics)
for (const [uri, newDiags] of newDiagnostics) {
const oldDiags = oldMap.get(uri) || []
const newProblemsForUri = newDiags.filter((newDiag) => !oldDiags.some((oldDiag) => deepEqual(oldDiag, newDiag)))
if (newProblemsForUri.length > 0) {
newProblems.push([uri, newProblemsForUri])
}
}
return newProblems
}
// Usage:
// const oldDiagnostics = // ... your old diagnostics array
// const newDiagnostics = // ... your new diagnostics array
// const newProblems = getNewDiagnostics(oldDiagnostics, newDiagnostics);
// Example usage with mocks:
//
// // Mock old diagnostics
// const oldDiagnostics: [vscode.Uri, vscode.Diagnostic[]][] = [
// [vscode.Uri.file("/path/to/file1.ts"), [
// new vscode.Diagnostic(new vscode.Range(0, 0, 0, 10), "Old error in file1", vscode.DiagnosticSeverity.Error)
// ]],
// [vscode.Uri.file("/path/to/file2.ts"), [
// new vscode.Diagnostic(new vscode.Range(5, 5, 5, 15), "Old warning in file2", vscode.DiagnosticSeverity.Warning)
// ]]
// ];
//
// // Mock new diagnostics
// const newDiagnostics: [vscode.Uri, vscode.Diagnostic[]][] = [
// [vscode.Uri.file("/path/to/file1.ts"), [
// new vscode.Diagnostic(new vscode.Range(0, 0, 0, 10), "Old error in file1", vscode.DiagnosticSeverity.Error),
// new vscode.Diagnostic(new vscode.Range(2, 2, 2, 12), "New error in file1", vscode.DiagnosticSeverity.Error)
// ]],
// [vscode.Uri.file("/path/to/file2.ts"), [
// new vscode.Diagnostic(new vscode.Range(5, 5, 5, 15), "Old warning in file2", vscode.DiagnosticSeverity.Warning)
// ]],
// [vscode.Uri.file("/path/to/file3.ts"), [
// new vscode.Diagnostic(new vscode.Range(1, 1, 1, 11), "New error in file3", vscode.DiagnosticSeverity.Error)
// ]]
// ];
//
// const newProblems = getNewDiagnostics(oldDiagnostics, newDiagnostics);
//
// console.log("New problems:");
// for (const [uri, diagnostics] of newProblems) {
// console.log(`File: ${uri.fsPath}`);
// for (const diagnostic of diagnostics) {
// console.log(`- ${diagnostic.message} (${diagnostic.range.start.line}:${diagnostic.range.start.character})`);
// }
// }
//
// // Expected output:
// // New problems:
// // File: /path/to/file1.ts
// // - New error in file1 (2:2)
// // File: /path/to/file3.ts
// // - New error in file3 (1:1)
// will return empty string if no problems with the given severity are found
export async function diagnosticsToProblemsString(
diagnostics: [vscode.Uri, vscode.Diagnostic[]][],
severities: vscode.DiagnosticSeverity[],
): Promise<string> {
const cwd = await getCwd()
let result = ""
for (const [uri, fileDiagnostics] of diagnostics) {
const problems = fileDiagnostics.filter((d) => severities.includes(d.severity))
if (problems.length > 0) {
result += `\n\n${path.relative(cwd, uri.fsPath).toPosix()}`
for (const diagnostic of problems) {
let label: string
switch (diagnostic.severity) {
case vscode.DiagnosticSeverity.Error:
label = "Error"
break
case vscode.DiagnosticSeverity.Warning:
label = "Warning"
break
case vscode.DiagnosticSeverity.Information:
label = "Information"
break
case vscode.DiagnosticSeverity.Hint:
label = "Hint"
break
default:
label = "Diagnostic"
}
const line = diagnostic.range.start.line + 1 // VSCode lines are 0-indexed
const source = diagnostic.source ? `${diagnostic.source} ` : ""
result += `\n- [${source}${label}] Line ${line}: ${diagnostic.message}`
}
}
}
return result.trim()
}
@@ -2,6 +2,7 @@
import { describe, it, beforeEach, afterEach } from "mocha"
import { strict as assert } from "assert"
import * as vscode from "vscode"
import pWaitFor from "p-wait-for"
import { getOpenTabs } from "@/hosts/vscode/hostbridge/window/getOpenTabs"
import { GetOpenTabsRequest } from "@/shared/proto/host/window"
@@ -54,8 +55,18 @@ describe("Hostbridge - Window - getOpenTabs", () => {
await createAndOpenTestDocument(1, vscode.ViewColumn.One)
await createAndOpenTestDocument(2, vscode.ViewColumn.Two)
// Wait a bit for tabs to be fully created
await new Promise((resolve) => setTimeout(resolve, 100))
// Wait for tabs to be fully created
await pWaitFor(
async () => {
const request = GetOpenTabsRequest.create({})
const response = await getOpenTabs(request)
return response.paths.length === 2
},
{
timeout: 2000,
interval: 50,
},
)
const request = GetOpenTabsRequest.create({})
const response = await getOpenTabs(request)
@@ -74,8 +85,18 @@ describe("Hostbridge - Window - getOpenTabs", () => {
await createAndOpenTestDocument(2, vscode.ViewColumn.One)
await createAndOpenTestDocument(3, vscode.ViewColumn.One)
// Wait a bit for tabs to be fully created
await new Promise((resolve) => setTimeout(resolve, 100))
// Wait for tabs to be fully created
await pWaitFor(
async () => {
const request = GetOpenTabsRequest.create({})
const response = await getOpenTabs(request)
return response.paths.length === 3
},
{
timeout: 2000,
interval: 50,
},
)
const request = GetOpenTabsRequest.create({})
const response = await getOpenTabs(request)
@@ -0,0 +1,69 @@
import * as vscode from "vscode"
import {
GetDiagnosticsRequest,
GetDiagnosticsResponse,
FileDiagnostics,
Diagnostic,
DiagnosticRange,
DiagnosticPosition,
DiagnosticSeverity,
} from "@/shared/proto/host/workspace"
export async function getDiagnostics(request: GetDiagnosticsRequest): Promise<GetDiagnosticsResponse> {
// Get all diagnostics from VS Code
const vscodeAllDiagnostics = vscode.languages.getDiagnostics()
const fileDiagnostics: FileDiagnostics[] = []
for (const [uri, diagnostics] of vscodeAllDiagnostics) {
if (diagnostics.length > 0) {
const convertedDiagnostics: Diagnostic[] = diagnostics.map((vsDiagnostic) => {
// Convert VS Code severity to proto severity
let severity: DiagnosticSeverity
switch (vsDiagnostic.severity) {
case vscode.DiagnosticSeverity.Error:
severity = DiagnosticSeverity.DIAGNOSTIC_ERROR
break
case vscode.DiagnosticSeverity.Warning:
severity = DiagnosticSeverity.DIAGNOSTIC_WARNING
break
case vscode.DiagnosticSeverity.Information:
severity = DiagnosticSeverity.DIAGNOSTIC_INFORMATION
break
case vscode.DiagnosticSeverity.Hint:
severity = DiagnosticSeverity.DIAGNOSTIC_HINT
break
default:
severity = DiagnosticSeverity.DIAGNOSTIC_ERROR
}
return Diagnostic.create({
message: vsDiagnostic.message,
range: DiagnosticRange.create({
start: DiagnosticPosition.create({
line: vsDiagnostic.range.start.line,
character: vsDiagnostic.range.start.character,
}),
end: DiagnosticPosition.create({
line: vsDiagnostic.range.end.line,
character: vsDiagnostic.range.end.character,
}),
}),
severity: severity,
source: vsDiagnostic.source || undefined,
})
})
fileDiagnostics.push(
FileDiagnostics.create({
filePath: uri.fsPath,
diagnostics: convertedDiagnostics,
}),
)
}
}
return GetDiagnosticsResponse.create({
fileDiagnostics: fileDiagnostics,
})
}
@@ -0,0 +1,187 @@
import { describe, it, before, after, beforeEach } from "mocha"
import { expect } from "chai"
import * as vscode from "vscode"
import * as path from "path"
import * as fs from "fs/promises"
import * as os from "os"
import { saveOpenDocumentIfDirty } from "@/hosts/vscode/hostbridge/workspace/saveOpenDocumentIfDirty"
import { SaveOpenDocumentIfDirtyRequest } from "@/shared/proto/index.host"
describe("saveOpenDocumentIfDirty Integration Test", () => {
let testWorkspaceRoot: string
let testFilePath: string
let testFileUri: vscode.Uri
before(async () => {
// Use a temporary directory for tests
testWorkspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), "cline-test-"))
// Create a test file path
testFilePath = path.join(testWorkspaceRoot, "test-save-document.txt")
testFileUri = vscode.Uri.file(testFilePath)
})
after(async () => {
// Clean up: close all editors and delete test directory
await vscode.commands.executeCommand("workbench.action.closeAllEditors")
try {
await fs.rm(testWorkspaceRoot, { recursive: true, force: true })
} catch (error) {
// Directory might not exist, ignore
}
})
beforeEach(async () => {
// Close all editors before each test
await vscode.commands.executeCommand("workbench.action.closeAllEditors")
})
it("should save a dirty document and return wasSaved: true", async () => {
// Create a test file with initial content
await fs.writeFile(testFilePath, "Initial content")
// Open the document in VSCode
const document = await vscode.workspace.openTextDocument(testFileUri)
const editor = await vscode.window.showTextDocument(document)
// Make the document dirty by editing it
await editor.edit((editBuilder) => {
editBuilder.insert(new vscode.Position(0, 0), "Modified ")
})
// Verify the document is dirty
expect(document.isDirty).to.be.true
// Call saveOpenDocumentIfDirty
const request = SaveOpenDocumentIfDirtyRequest.create({
filePath: testFilePath,
})
const response = await saveOpenDocumentIfDirty(request)
// Verify the response
expect(response.wasSaved).to.be.true
// Verify the document is no longer dirty
expect(document.isDirty).to.be.false
// Verify the file content was saved
const savedContent = await fs.readFile(testFilePath, "utf-8")
expect(savedContent).to.equal("Modified Initial content")
})
it("should not save a clean document and return empty response", async () => {
// Create a test file
await fs.writeFile(testFilePath, "Clean content")
// Open the document in VSCode
const document = await vscode.workspace.openTextDocument(testFileUri)
await vscode.window.showTextDocument(document)
// Verify the document is not dirty
expect(document.isDirty).to.be.false
// Call saveOpenDocumentIfDirty
const request = SaveOpenDocumentIfDirtyRequest.create({
filePath: testFilePath,
})
const response = await saveOpenDocumentIfDirty(request)
// Verify the response
expect(response.wasSaved).to.be.undefined
// Verify the document is still not dirty
expect(document.isDirty).to.be.false
})
it("should return empty response when document is not open", async () => {
// Ensure no documents are open
await vscode.commands.executeCommand("workbench.action.closeAllEditors")
// Call saveOpenDocumentIfDirty with a non-existent file
const request = SaveOpenDocumentIfDirtyRequest.create({
filePath: path.join(testWorkspaceRoot, "non-existent-file.txt"),
})
const response = await saveOpenDocumentIfDirty(request)
// Verify the response
expect(response.wasSaved).to.be.undefined
})
it("should handle multiple open documents and save only the specified one", async () => {
// Create multiple test files
const testFile1 = path.join(testWorkspaceRoot, "test-file-1.txt")
const testFile2 = path.join(testWorkspaceRoot, "test-file-2.txt")
const testFile3 = path.join(testWorkspaceRoot, "test-file-3.txt")
await fs.writeFile(testFile1, "File 1 content")
await fs.writeFile(testFile2, "File 2 content")
await fs.writeFile(testFile3, "File 3 content")
try {
// Open all documents
const doc1 = await vscode.workspace.openTextDocument(vscode.Uri.file(testFile1))
const doc2 = await vscode.workspace.openTextDocument(vscode.Uri.file(testFile2))
const doc3 = await vscode.workspace.openTextDocument(vscode.Uri.file(testFile3))
// Edit all documents to make them dirty
const editor1 = await vscode.window.showTextDocument(doc1)
await editor1.edit((editBuilder) => {
editBuilder.insert(new vscode.Position(0, 0), "Modified ")
})
const editor2 = await vscode.window.showTextDocument(doc2)
await editor2.edit((editBuilder) => {
editBuilder.insert(new vscode.Position(0, 0), "Modified ")
})
const editor3 = await vscode.window.showTextDocument(doc3)
await editor3.edit((editBuilder) => {
editBuilder.insert(new vscode.Position(0, 0), "Modified ")
})
// Verify all documents are dirty
expect(doc1.isDirty).to.be.true
expect(doc2.isDirty).to.be.true
expect(doc3.isDirty).to.be.true
// Save only the second document
const request = SaveOpenDocumentIfDirtyRequest.create({
filePath: testFile2,
})
const response = await saveOpenDocumentIfDirty(request)
// Verify the response
expect(response.wasSaved).to.be.true
// Verify only doc2 was saved
expect(doc1.isDirty).to.be.true
expect(doc2.isDirty).to.be.false
expect(doc3.isDirty).to.be.true
// Verify the file content
const savedContent = await fs.readFile(testFile2, "utf-8")
expect(savedContent).to.equal("Modified File 2 content")
} finally {
// Clean up
await fs.unlink(testFile1).catch(() => {})
await fs.unlink(testFile2).catch(() => {})
await fs.unlink(testFile3).catch(() => {})
}
})
it("should handle empty file path gracefully", async () => {
const request = SaveOpenDocumentIfDirtyRequest.create({
filePath: "",
})
const response = await saveOpenDocumentIfDirty(request)
expect(response.wasSaved).to.be.undefined
})
it("should handle undefined file path gracefully", async () => {
const request = SaveOpenDocumentIfDirtyRequest.create({})
const response = await saveOpenDocumentIfDirty(request)
expect(response.wasSaved).to.be.undefined
})
})
@@ -1,14 +1,12 @@
import { SaveOpenDocumentIfDirtyRequest } from "@/shared/proto/index.host"
import { Empty } from "@shared/proto/cline/common"
import { SaveOpenDocumentIfDirtyRequest, SaveOpenDocumentIfDirtyResponse } from "@/shared/proto/index.host"
import * as vscode from "vscode"
import { arePathsEqual } from "@utils/path"
export async function saveOpenDocumentIfDirty(request: SaveOpenDocumentIfDirtyRequest): Promise<Empty> {
export async function saveOpenDocumentIfDirty(request: SaveOpenDocumentIfDirtyRequest): Promise<SaveOpenDocumentIfDirtyResponse> {
const existingDocument = vscode.workspace.textDocuments.find((doc) => arePathsEqual(doc.uri.fsPath, request.filePath))
if (existingDocument && existingDocument.isDirty) {
await existingDocument.save()
return { wasSaved: true }
}
return Empty.create({})
return {}
}
@@ -96,7 +96,6 @@ function getBuildArtifactPatterns(): string[] {
"node_modules/",
"obj/",
"out/",
"pkg/",
"pycache/",
"target/dependency/",
"temp/",

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