Compare commits

...

108 Commits

Author SHA1 Message Date
frostbournesb 1592a25f80 Add review CTA 2025-08-20 15:54:02 -07:00
frostbournesb 6c040ac186 Add review links to About section 2025-08-15 15:49:33 -07:00
Toshii b176772cd5 auto-condense telemetry (#5584)
* telemetry

* task state preference
2025-08-15 10:41:15 -07:00
canvrno e6b00527ed Improved git branch analysis workflow (#5602)
Co-authored-by: Kevin Bond <kevin@Mac.hsd1.ca.comcast.net>
2025-08-15 10:33:03 -07:00
canvrno 2635f5f575 Focus Chain regex moved to /shared (#5592)
Co-authored-by: Kevin Bond <kevin@Mac.hsd1.ca.comcast.net>
2025-08-15 10:14:34 -07:00
Sarah Fortune 44f370f295 Add test host bridge server and refactor proto utilities (#5600)
- Extract getPackageDefinition() from loadProtoDescriptorSet() in proto-utils
- Add int64 encoding option to handle numbers properly
- Create test-hostbridge-server.ts with mock gRPC service implementations
- Add -h flag to runclinecore.sh to start test server
- Include testing.md documentation for cline rules
2025-08-15 18:07:45 +01:00
Matthias Oßwald 8c49ce56f6 fix: Prevent non-error logs from being misclassified as errors (#5531)
This re-applies the change from #2900 which got reverted by accident during
a refactoring in #3970.
2025-08-15 10:04:13 -07:00
Sarah Fortune 08543e051e Remove unused params from the AuthService (#5596) 2025-08-15 14:31:32 +01:00
github-actions[bot] f4828d3344 3.25.2 Release Notes (#5589)
* 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-14 23:45:17 -07:00
Saoud Rizwan 385e952935 fix: OpenRouter showing cline credits error after 402 response (#5590)
* fix: OpenRouter showing cline credits error after 402 response

* Create silly-scissors-repair.md
2025-08-14 23:05:16 -07:00
Saoud Rizwan 9a2aaf0881 fix: attempt_completion showing twice in chat due to partial logic not being handled correctly (#5588)
* fix: attempt_completion showing twice in chat due to partial logic not being handled correctly

* Create orange-beds-itch.md
2025-08-14 22:35:19 -07:00
github-actions[bot] 5d65260611 v3.25.1 Release Notes (#5587)
* 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-14 22:16:58 -07:00
Saoud Rizwan 7098c1a32a fix: attempt_completion command showing twice in chat view when updating progress checklist (#5583)
* fix: attempt_completion command showing twice in chat view when updating progress checklist

* Create heavy-walls-own.md
2025-08-14 22:13:04 -07:00
Saoud Rizwan d46e672990 feat(AnthropicProvider): Add Claude Sonnet 4 variant switching functionality (#5585) 2025-08-14 22:11:31 -07:00
Saoud Rizwan 7b2dddd4a5 fix: CacheService not populating with all the expected state (#5586)
* fix: CacheService not populating with all the expected state

* Create modern-impalas-hug.md
2025-08-14 22:10:21 -07:00
watany a6e657e0d1 feat(bedrock): Adding GPT-OSS (#5412)
* feat(bedrock): adding GPT-OSS

* changeset

* update usage return + add reasoning & text for token output estimate

---------

Co-authored-by: 0xtoshii <94262432+0xToshii@users.noreply.github.com>
2025-08-14 20:18:49 -07:00
github-actions[bot] 612a67ee89 v3.25.0 Release Notes (#5547)
* changeset version bump

* Updating CHANGELOG.md format

* rebased

* changelog

* package-lock.json

---------

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-14 19:17:11 -07:00
Saoud Rizwan 383826f7f7 fix: bug where ask question tool would show 'Start new task' button (#5581) 2025-08-14 18:33:35 -07:00
Sarah Fortune b44d3c8793 Add fix, explain, and improve commands to the ProtoBus (#5574)
* Add fix, explain, and improve commands to Cline

- Rename AddToClineRequest to CommandContext for reusability
- Implement fixWithCline, explainWithCline, and improveWithCline commands
- Extract common command utilities to commandUtils.ts
- Move file mention logic to mentions module
- Update command registration and handlers in extension.ts

* Update src/core/controller/commands/addToCline.ts

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

* Update src/extension.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-15 02:21:40 +01:00
Saoud Rizwan fbc517c9e5 Add support for 200k context for claude sonnet 4 using openrouter/cline (#5569)
* Add support for 200k context for claude sonnet 4 using openrouter/cline

* Update announcement

* Create beige-singers-jog.md
2025-08-14 18:19:06 -07:00
Saoud Rizwan 3175e19bd7 Fix partial plan_mode_respond cline ask being interrupted by task_progress say (#5578) 2025-08-14 18:18:17 -07:00
Igor Tceglevskii 8e80c18c52 Fix the New Task button in Navbar (#5577) 2025-08-14 18:07:28 -07:00
Toshii b5be6f57d5 requesty (#5579)
* adding option to have custom requesty base url

fix

changeset

* finish state changes

* cleanup

---------

Co-authored-by: John Costa <john@requesty.ai>
2025-08-14 17:44:58 -07:00
canvrno 28737ac62a Enable focus chain by default (#5575)
Co-authored-by: Kevin Bond <kevin@Kevins-MacBook-Pro.local>
2025-08-14 17:35:34 -07:00
kvyb 61112aaa03 Hostbridge telemetry and startup (#5561)
* feat: Use hostbridge machine ID for posthog distinctId across hosts; VS Code only settings link in warning,, generic warning on other hosts.

* fix: block cline-core until hostbridge health is SERVING; exit on failure; initialize telemetry PostHog with hostbridge machineId;

* fix: posthog prefer host-provided UUID when running via HostBridge; fall back to VS Code's machineId, then a random UUID

* fix: add logging to waitForHostBridgeReady

* fix: log error in initialize
2025-08-15 02:36:19 +03:00
Igor Tceglevskii 922cfed632 Fix the case if the app state and the user state are inconsistent (#5549) 2025-08-14 14:26:29 -07:00
Sarah Fortune 6d7cca7d38 Allow external hosts to trigger the 'Add to Cline' action (#5548)
* Allow hosts to trigger the 'Add to Cline' action

Other platforms need a way to trigger the context menu actions and commands that are available currently in Vscode.

Add a service to the ProtoBus for this called `CommandService`, currently it just has the 'Add to Cline' action.

IDEs that are running cline with cline-core can trigger these actions and commands over gRPC.

Move the code for handling the 'Add to Cline' out of extension.ts into
`src/controller/commands/addToCline.ts`. This same handler will be used for the RPC.

Switch the handler over to use the proto Diagnostics types as it is host-agnostic.

* Fix getDiagnostics.test.ts on windows.

Use `toPosix()` on the fspath.

* Update src/extension.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-14 22:16:52 +01:00
Sarah Fortune d81fb542a9 Update logging (#5563)
Update logs for cline-core.
Save the logs from the script `runclinecore.sh` to the ~/.cline directory
2025-08-14 14:39:37 +01:00
pashpashpash a16fc09a68 bedrock docs cleanup (#5551)
* bedrock docs cleanup

* auto compact  docs

* drag and drops docs outdated
2025-08-13 20:22:21 -07:00
Ara 2ec21eda34 Adding a retry button for cases when the streaming response breaks with no text or error (#5505) 2025-08-13 16:53:27 -07:00
canvrno 88000d4f39 Feat: Focus Chain & Deep Planning (#5409)
* Focus Chain feature & Deep Planning workflow

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>

* Casing & variable  naming cleanup

* Alligned default focus chain internval values, corrected typos

* Rebase, moving focus chain settings state to cache

* Fix issue with duplicated ask responses, also renamed function

* renamed slash command

* updated docs and added focus-chain to docs sidebar

* Minor docs changes

* adding deep-planning slash command

* nit: linking

* Updated telemetry service to use ulid

---------

Co-authored-by: Kevin Bond <kevin@Kevins-MacBook-Pro.local>
Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>
Co-authored-by: pashpashpash <nik@cline.bot>
2025-08-13 16:17:45 -07:00
Sarah Fortune 6318eb5948 Add utils for working with Diagnostics (#5543)
These utils will be to build the 'Add to Cline' context menu action for external hosts. The context menu action only deals with diagnostics for a single file, so the current utils need to be adapted.

Export util functions for converting vscode diagnostics to Host Bridge diagnostics.

Export a util for converting diagnostics for a single file from `diagnostics/index.ts`.
2025-08-14 00:16:16 +01:00
Bee 0fd1c0a3aa Configure Playwright to retain videos on failure and simplify teardown (#5542)
* Close page to stop e2e test on teardown

* Configure Playwright to retain videos on failure and simplify teardown

- Enable video recording that only saves on test failures
- Remove complex cleanup logic from global teardown
- Streamline server shutdown to not block teardown process

* change build.js to build.mjs which fixes ES module load error

* speed up

---------

Co-authored-by: Brian Pierce <brian@cline.bot>
2025-08-13 14:31:49 -07:00
Toshii e0478493a2 auto compact - context management (#5520)
* base

* working state reduction & summarization flow wo duplicate calls

* stop injecting into user message

* focus on latest message

* edge case for cancelled stream post summarization tool call

* fix merge
2025-08-13 13:59:00 -07:00
github-actions[bot] 9355d3eea4 v3.24.0 Release Notes (#5472)
* 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-13 13:09:15 -07:00
Saoud Rizwan f485d0cc8f Display context window in model info (#5546) 2025-08-13 13:04:58 -07:00
Saoud Rizwan 79bda976cb Add 1m context window model variant for claude sonnet 4 (#5526)
* Add 1m context window model variant for claude sonnet 4

* Fix cost calculation for 1m tier

* Add new 1m context window announcement

* Create beige-bobcats-watch.md

* Add bedrock support for 1m context
2025-08-13 12:53:23 -07:00
celestial-vault 94acfec39f refactor: migrate FileContextTracker to chokidar (#5501)
Co-authored-by: Igor Tceglevskii <igor@cline.bot>
2025-08-13 12:40:19 -07:00
Richard Meyer 0f67508fa3 Enable browser arguments (#4871)
* feat: add arguments option to browser settings

* feat: add custom browser arguments setting for Chrome launch flags

* remove hardcoded headless argument

* re-run npm run format:fix

* minmal targeted code change vs refactor

* fix: allow browser arguments to persist

* refac: match syntax browser persistence sytanx for customArgs to match chromeExecutablePath

* fix: update browser session to append custom arguments and remove undefined check

* small update to UI description
2025-08-13 12:16:49 -07:00
CellenLee e31bc6147f feat: add kimi-k2-turbo-preview model (#5510) 2025-08-13 11:28:13 -07:00
Peter Dave Hello 8d69e63d72 Add OpenAI GPT-5 Chat(gpt-5-chat-latest) (#5494)
GPT-5 Chat points to the GPT-5 snapshot currently used in ChatGPT. Just
like the dynamic model chatgpt-4o-latest for GPT-4o series.

Reference:
- https://platform.openai.com/docs/models/gpt-5-chat-latest
2025-08-13 11:24:33 -07:00
Igor Tceglevskii 49a678b835 Use HostProvider for creating the OpenRouter auth URL (#5522) 2025-08-13 09:27:06 -07:00
Sarah Fortune 67610b1f87 Refactoring: Move the FileDiagnostics from the host package into common. (#5537)
I am going to reuse the FileDiagnostics in the ProtoBus, so move them into a
common location.

Update the imports.
2025-08-13 09:19:36 -07:00
Sarah Fortune 45f837e1e9 Fix the PATH not being set in the standalone terminal (#5536)
`cline-core` cannot depend on its environment being set up properly
by its parent process. Run the terminal commands in a login shell so
that the PATH etc. will be setup correctly.
2025-08-13 08:34:51 -07:00
Sarah Fortune d0793e51c4 Refactoring: Move vscode specific code out of the WebviewProvider (#5534) 2025-08-13 11:48:53 +01:00
Sarah Fortune 00740de01d In the log timestamp use current timezone instead of UTC (#5535)
The ISO date format logs in UTC with the timezone information, this is unneccessary,
just log the time in the current timezone.
2025-08-13 11:08:15 +01:00
celestial-vault 1ffa4085a6 Remove experimental Sonnet 4 code (#5527)
* remove sonnet 4 experimental tool definitions and prompting

* remove instances of parseV3 in evals
2025-08-12 22:32:30 -07:00
Bee 4db5581cfc disable buttons for plan_mode_respond (#5528) 2025-08-12 22:18:14 -07:00
pashpashpash 675cd1779b using ulid instead of taskid (#5524)
* using ulid instead of taskid

* protos

* Update src/services/browser/BrowserSession.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-12 21:19:28 -07:00
pashpashpash af0f0b3d7c make cline better at git (#5525)
* make cline better at git

* typo
2025-08-12 19:09:48 -07:00
Saoud Rizwan 45767b87fd Fix usage endpoint call using oauth token instead of saved refresh token (#5483)
* Fix usage endpoint call using oauth token instead of saved refresh token

* Create odd-ladybugs-punch.md

* Update src/api/providers/cline.ts

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

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-08-12 19:08:23 -07:00
Sarah Fortune 8f4c6038dd Change the host bridge RPC closeDiff to close*All*Diffs (#5521)
* Change the host bridge RPC closeDiff to closeAllDiffs

In the vscode diff view provider when the diff is closed, it
closes _all_ open diff views.

I thought in the HostBridge, we would just only be closing the
current diff, but we do need to close all the open diff view
because there can be checkpoint diffs open as well.

* Update proto/host/diff.proto

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

* Update src/integrations/editor/DiffViewProvider.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-13 01:16:42 +01:00
Peter Dave Hello 9dc021a881 Remove deprecated GPT-4.5 Preview (#5493) 2025-08-12 15:59:46 -07:00
Max Höhl 3e2bdf8b12 Set CLINE_ACTIVE environment variable for new terminals (#5367)
Resolves #5366
2025-08-12 15:44:51 -07:00
Sarah Fortune d4a99a4060 Task Refactor: Move logic for showing multi-file diffs out of Task (#5517)
* Move the logic for showing the multi-file diffs out of the Task class which >2000 lines long.

Split the logic up into functions, add tests.

Use try/finally to ensure that `sendRelinquishControlEvent` is always sent when the function returns.

* Fix warning about use of !!

* Fix tests

Remove asserts on console logs because they are not able to be stubbed properly.
Move test file to correct directory.

* Formatting
2025-08-12 23:27:53 +01:00
Auroter f4bbb45b07 fix: request_id was being incorrectly extracted from the API response… (#5504)
* fix: request_id was being incorrectly extracted from the API response -- it can always be found in the response header under X-Request-ID

* fix: leave error alone, no need to re-create it

* Update src/services/error/ClineError.ts

Co-authored-by: Bee <68532117+abeatrix@users.noreply.github.com>

---------

Co-authored-by: Bee <68532117+abeatrix@users.noreply.github.com>
2025-08-12 12:12:35 -07:00
Bee 088deebd63 Add VSCode theme colors to Tailwind config (#5516)
* Add VSCode theme colors to Tailwind config

- Add comprehensive VSCode theme color palette to Tailwind config
- Replace hardcoded VSCode CSS variables with Tailwind utility classes for HomeHeader
- Update button and text styling to use new theme-aware classes

* format
2025-08-12 10:42:45 -07:00
celestial-vault dcc744dd87 move browser settings menu to tailwind (#5507) 2025-08-12 10:20:31 -07:00
Bee 9ad8525bd0 Guard ActionButtons when no task (#5508)
Guard ActionButtons when no task; fix scroll deps/empty state

- Return null from ActionButtons if no task to avoid rendering controls without context
- Add missing setExpandedRows dependency and remove unnecessary deps to prevent stale closures and re-renders
- Hide “scroll to bottom” button when there are no messages
- Clean up unused index-tracking logic in scrollToMessage
2025-08-12 09:50:15 -07:00
Sarah Fortune 8bf6268952 Add multi-file diff to the host bridge. (#5515)
Add an RPC to the Host Bridge to open a diff for multiple files, this is
used when comparing check points or to display the changes cline has made
when it is finished editing.

Switch the file mentions unit test to an integration test because
now it is pulling vscode dependencies and they cannot be mocked
in the unit tests.
2025-08-12 09:24:16 -07:00
requesty-JohnCosta27 2081bb8dc6 fix requesty's api key url (#5498) 2025-08-11 21:56:14 -07:00
Bee ac22b63796 refactor: centralize action buttons state (#5462)
* Refactor action buttons: centralize state, remove useButtonState

- Replace useButtonState hook with centralized ButtonConfig logic in ActionButtons, mapping task/ask/tool states to button enablement and labels
- Update ActionButtons API to accept task, messages, mode; compute streaming/enablement internally; remove isStreaming prop
- Always render ActionButtons from ChatView; adjust props accordingly
- Update useIsStreaming call to pass task instead of enableButtons/primaryText
- Clean up useMessageHandlers to reset UI state consistently (input, quotes, files, images, autoscroll)
- Remove deprecated hook and align types

Why: unify and simplify button behavior across task lifecycle, reduce duplicated state/props, and make streaming/approval flows more predictable.

* clean up

* Refactor input clearing and streaming detection logic

This commit:
- Separates input clearing logic into a separate useEffect in ActionButtons
- Removes StreamingIndicator component and its useIsStreaming hook

* Revert newly added button states

Remove switch_to_act_mode button config and associated plan mode conditionals in getButtonConfig function, will do any UI change in follow-up

* simplify further

* Add test suite for button configuration logic

This commit introduces a new test file for the `buttonConfig` module, covering various scenarios such as:
- Default button configurations
- Streaming and partial message handling
- Error recovery states
- Tool approval states
- Command execution states
- Specific ask state configurations
- API request state testing

The tests ensure robust button configuration selection based on different message types and states.

* update button styles
2025-08-11 19:14:53 -07:00
Ara 44eb2cc65e Fixing context exceeded error (#5479)
* Detect OpenAI context window errors and auto-retry

- Add checkIsOpenAIContextWindowError to identify OpenAI context length issues (context_length_exceeded, 400 + context-length patterns)
- Integrate into Task: detect OpenAiHandler/OpenAiNativeHandler and handle first-chunk failures as context window overflows
- On detection, aggressively truncate history ("quarter"), persist changes, show truncation notice, wait 1s, then retry once
- Align behavior with Anthropic/OpenRouter handling to reduce failures from oversized prompts

* Fixing OpenAI context exceeded errors

* Fixing OpenAI context exceeded errors

* Fixing OpenAI context exceeded errors

* use OpenAI sdk error types

* Making errors for this generic and adding cerebras

* Making errors for this generic and adding cerebras

---------

Co-authored-by: abeatrix <beatrix@cline.bot>
2025-08-11 17:56:48 -07:00
Saoud Rizwan 2cf1d8628b Set gpt5 max tokens to 8_192 to fix context window exceeded error (#5478)
* Set gpt5 max tokens to 8_192 to fix context window exceeded error

* Create healthy-crabs-sip.md
2025-08-11 15:50:32 -07:00
celestial-vault 669e018b85 Move rest of state to cache (#5404)
* move rest of state to cacheService

* finish moving state to cache

* remove console logs

* fix types

* don't type cast

* add eslint rule banning use of direct storage apis

* fix types

* move vscode state eslint rule to separate rule since it's error and the others aren't

* fix eslint rules parsing
2025-08-11 14:46:39 -07:00
Sarah Fortune 2aa5156905 Update ExternalDiffViewProvider to return diagnostics before/after diff (#5500)
The diff view is supposed to return any new errors or warnings after the
file is edited. The ExternalDiffViewProvider was just returning *all*
the errors.

When the DiffViewProvider is being reset, reset *all* the properties.

Add unit tests for diagnostics functionality

Refactoring:
- Move diagnostics into the parent DiffViewProvider, remove duplicate implementations in VscodeDiffViewProvider and ExternalDiffViewProvider
- Move duplicated code for converting FileDiagnostics to string to `diagnosticsToProblemsString`.
- Use a single implementation of `getDiagnostics` and `diagnosticsToProblemsString` using the HostBridge protos.
2025-08-11 19:54:41 +01:00
celestial-vault 51b619e0d5 remove workspace tracker (#5346)
* remove workspace tracker

* remove console log

* fix search when clicking folder option

* create enum for searchType

* use hostbridge for active files

* use util function for relative path

* Fix into interests error where false security warning is being triggered
2025-08-11 09:51:47 -07:00
Sarah Fortune 85fb76a996 Call teardown() when cline-core is stopped. (#5496) 2025-08-11 17:29:27 +01:00
Dennise Bartlett d73a7cfd06 Fix package-lock version and update CODEOWNERS (#5490) 2025-08-11 01:43:08 -07:00
Ara 489dfbc932 Fixing Read of Workspace root by index.ts (#5482)
* Fixing Read of Workspace root by index.ts

* Fixing Read of Workspace root by index.ts
2025-08-09 20:50:50 -07:00
Bee 314c416788 remove PostHog exception autocapture (#5481)
Remove enableExceptionAutocapture option from PostHog client configuration.
2025-08-09 16:49:07 -07:00
Igor Tceglevskii 3b19c2ec95 Click from a file name in chat to editor (#5422) 2025-08-09 15:48:12 -07:00
Sarah Fortune e04cbea504 Add an endpoint to the HostBridge for integration testing (#5476)
Add an RPC to the HostBridge that returns the contents of the
webview, for use in integration tests.
2025-08-09 23:14:29 +01:00
Toshii affac119f5 stop double counting tokens (#5426)
* updated

* cleanup
2025-08-09 13:05:36 -07:00
Sarah Fortune 3847a2545c Test is still flaking, increase the timeout (#5473) 2025-08-09 16:25:02 +01:00
Sarah Fortune 15593bac2a Use the active webview in ProtoBus getWebviewHtml. (#5444) 2025-08-09 16:24:46 +01:00
Sarah Fortune 84267efb9e Don't use activate() in cline-core (#5448)
* Don't use activate() in cline-core

Have separate code paths to set up the extension and cline-core.

This means the cline-core is not running all the vscode setup and is
only using one `Controller` (the one from the WebviewProvider).

Move the shared logic into common.ts.

* Comments and logging
2025-08-09 14:49:23 +01:00
github-actions[bot] 985ce56809 v3.23.0 Release Notes (#5466)
* 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-09 02:16:07 -07:00
xiongxiong cad28c4c0c fix: calibrate input token when using anthropic models of sap ai core provider (#5469) 2025-08-09 02:13:01 -07:00
Saoud Rizwan 4a22f7dbd2 Fix plan/act hover color and act mode color (#5470)
* Fix plan/act hover color and act mode color

* Revert plan color change
2025-08-09 02:10:49 -07:00
Saoud Rizwan a430226caa Revert unnecessary terminal command issue workarounds (#5463)
* Revert terminal process logic to pre-capture where we didn't use grace periods or fast command workarounds

* Create empty-pears-fail.md
2025-08-09 02:09:40 -07:00
Bee 5885a3cc1d improve mode switch background color (#5467)
* fix: mode switch styling

Replace the use of `--vscode-toolbar-hoverBackground` which is a `-hoverBackground` that tends to be transperant or opacity change on some themes. Replace it with `-background` which uses solid color instead. See https://code.visualstudio.com/api/references/theme-color

- Update Plan/Act mode switch colors var for better visibility across themes
- Remove hover effects from switch options
- Add background classes to active switch options

* changeset added
2025-08-09 00:30:07 -07:00
Oliver Schirmer 759ef873ae Feat: Prompt Caching in SAP AI Core (#5399)
* add: caching support for bedrock (claude)

* refactor: gemini message handling to adhere closer to original implementation (and make implicit caching clear)

* remove: unused bedrock conversion functions

* fix: payload for converse stream (older claude models)
remove: caching support flag for older claude models

* add: changeset

* Update package-lock.json
2025-08-09 00:12:26 -07:00
Saoud Rizwan 782e4ff6e0 Fix credit error tests (#5465)
* Fix credit error tests

* Create odd-tables-pump.md
2025-08-08 21:17:33 -07:00
github-actions[bot] 4bb00241bf v3.22.0 Release Notes (#5424)
* 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-08 20:47:54 -07:00
Saoud Rizwan c325faf8db Fix bug where running out of credits on cline accounts would show '402 empty body' response instead of 'buy credits' component (#5464)
* fix: show credits purchase component when user runs out of credits and we receive 402 status from server

* revert unnecessary change

* Create many-adults-end.md
2025-08-08 20:43:43 -07:00
Igor Tceglevskii 20f8f9c9cf Request for requesting a current PR number for pr_review workflow (#5406) 2025-08-08 13:14:50 -07:00
Yechao LI 5be163f49d Fix safari does not support isComposing of input event (#4118)
* fix: safari does not support isComposing of input event

* formatting

---------

Co-authored-by: 0xtoshii <94262432+0xToshii@users.noreply.github.com>
2025-08-08 12:28:46 -07:00
Kevin Taylor 7843ab937a Add cerebras rate limit handling (#5408)
* Update cerebras.ts

* Create friendly-geckos-accept.md
2025-08-08 12:28:34 -07:00
tjandy98 5ed4319d21 Add support for GPT-5 models to SAP AI Core Provider (#5428)
* add gpt-5 models

Signed-off-by: tjandy98 <3953059+tjandy98@users.noreply.github.com>

* add changeset

* remove max_tokens & temperature

Signed-off-by: tjandy98 <3953059+tjandy98@users.noreply.github.com>

---------

Signed-off-by: tjandy98 <3953059+tjandy98@users.noreply.github.com>
2025-08-08 10:16:45 -07:00
Igor Tceglevskii a8971b807a Possibility to install binaries to a separate folder (#5446) 2025-08-08 15:24:05 +01:00
Sarah Fortune 51c4e0aceb Don't instantiate the AuthService at the top level of the module. (#5443)
I am working separating the initialization for the extension and cline-core
and this is causing a circular dependency.
2025-08-08 06:51:43 -07:00
Sarah Fortune 1cf62941cd Fix webview on IntelliJ (#5439)
The ExternalWebviewProvider has to return /something/ for `getWebview()` or
the rest of the code thinks that is not set up and it won't generate the HTML
for IntelliJ.

The Vscode webview panel, `resolveWebview()` and other Vscode specific parts are
planned to be moved out of the WebviewProvider and into VscodeWebviewProvider,
but that depends other changes to how the webview is initialized in extension.ts
to need to happen first.

Move the WebviewProvider out of index.ts and into a file name `WebviewProvider`,
this follows best practises.
2025-08-08 13:50:28 +01:00
Sarah Fortune cc2472f500 Sssh McpHub (#5434) 2025-08-08 11:58:14 +01:00
Ara 677e544c51 Fix Gpt 5 context window (#5414)
* Fix Gpt 5 context window

* Fix Gpt 5 context window
2025-08-08 01:04:12 -07:00
akfoster d3c8fbbf1d chore: remove unused parseAssistantmessageV1 (#5425)
* chore: remove unused parseAssistantmessageV1

* chore: add PR number to comments

* fix: include full path to PR
2025-08-07 22:21:17 -07:00
Sam 1b06633253 fix: LiteLLM provider cost calculations (#4990)
* fix: LiteLLM provider cost calculations

* fix: LiteLLM provider cost calculations

* Update src/api/providers/litellm.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-07 22:06:04 -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
github-actions[bot] 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
pashpashpash 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
Toshii 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
github-actions[bot] 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
284 changed files with 11080 additions and 24897 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
Prevent non-error logs from being misclassified as errors
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
Focus chain regex matching moved into /shared
+7 -21
View File
@@ -6,28 +6,14 @@ Analyze the current branch's changes against main to provide informed insights a
## Step 1: Gather Git Information
<important>Do not return any text or conversation other than what is necessary to run these commands</important>
**First, check the expected output size:**
```shell
(git branch --show-current && echo "=== STATUS ===" && git status --porcelain | cat && echo "=== COMMIT MESSAGES ===" && git log main..HEAD --oneline | cat && echo "=== CHANGED FILES ===" && git diff main --name-only | cat && echo "=== FULL DIFF ===" && git diff main | cat) | wc -l
```
**Run the following command to get the latest changes (bash):**
```bash
B=$(for c in main master origin/main origin/master; do git rev-parse --verify -q "$c" >/dev/null && echo "$c" && break; done); B=${B:-HEAD}; r(){ git branch --show-current; printf "=== STATUS ===\n"; git status --porcelain | cat; printf "=== COMMIT MESSAGES ===\n"; git log "$B"..HEAD --oneline | cat; printf "=== CHANGED FILES ===\n"; git diff "$B" --name-only | cat; printf "=== FULL DIFF ===\n"; git diff "$B" | cat; }; L=$(r | wc -l); if [ "$L" -gt 500 ]; then r > cline-git-analysis.temp && echo "::OUTPUT_FILE=cline-git-analysis.temp"; else r; fi
```
**If the expected line count is greater than 500 lines, use the file-based approach:**
```shell
git branch --show-current > cline-git-analysis.temp && echo "=== STATUS ===" >> cline-git-analysis.temp && git status --porcelain >> cline-git-analysis.temp && echo "=== COMMIT MESSAGES ===" >> cline-git-analysis.temp && git log main..HEAD --oneline >> cline-git-analysis.temp && echo "=== CHANGED FILES ===" >> cline-git-analysis.temp && git diff main --name-only >> cline-git-analysis.temp && echo "=== FULL DIFF ===" >> cline-git-analysis.temp && git diff main >> cline-git-analysis.temp
```
Then, read the file using the read_file tool. After you have read the file but before you proceed with subsequent steps, delete it:
```shell
rm cline-git-analysis.temp
```
**If the expected line count is 500 lines or fewer, use the direct approach:**
```shell
git branch --show-current && echo "=== STATUS ===" && git status --porcelain | cat && echo "=== COMMIT MESSAGES ===" && git log main..HEAD --oneline | cat && echo "=== CHANGED FILES ===" && git diff main --name-only | cat && echo "=== FULL DIFF ===" && git diff main | cat
```
<important>If using the direct approach, pipe outputs through `cat` to avoid interactive terminals. If the user's shell is not bash/zsh, adjust the command and chaining
syntax accordingly.</important>
```powershell
$B=$null;foreach($c in 'main','master','origin/main','origin/master'){git rev-parse --verify -q $c *> $null;if($LASTEXITCODE -eq 0){$B=$c;break}};if(-not $B){$B='HEAD'};function r([string]$b){git rev-parse --abbrev-ref HEAD; '=== STATUS ==='; git status --porcelain | cat; '=== COMMIT MESSAGES ==='; git log "$b"..HEAD --oneline | cat; '=== CHANGED FILES ==='; git diff "$b" --name-only | cat; '=== FULL DIFF ==='; git diff "$b" | cat};$out=r $B|Out-String;$lines=($out -split "`r?`n").Count;if($lines -gt 500){$out|Set-Content -NoNewline cline-git-analysis.temp; '::OUTPUT_FILE=cline-git-analysis.temp'}else{$out}
```
## Step 2: Silent, Structured Analysis Phase
- Analyze all git output without providing commentary or narration
+3
View File
@@ -219,6 +219,9 @@ EOF
## Basic PR Commands
```bash
# Get current PR number
gh pr view --json number -q .number
# List open PRs
gh pr list
+2 -1
View File
@@ -21,6 +21,7 @@
"semi": "off",
"react-hooks/exhaustive-deps": "off",
"eslint-rules/no-direct-vscode-api": "warn",
"eslint-rules/no-direct-vscode-state-api": "error",
"no-restricted-syntax": [
"error",
{
@@ -29,5 +30,5 @@
}
]
},
"ignorePatterns": ["out", "dist", "**/*.d.ts"]
"ignorePatterns": ["out", "dist", "dist-standalone", "**/*.d.ts", "node_modules"]
}
+3 -1
View File
@@ -1 +1,3 @@
* @saoudrizwan @ocasta181 @NightTrek @pashpashpash @dcbartlett @saito-sv @Garoth
/docs/
/.github/ @saoudrizwan @dcbartlett
/README.md @saoudrizwan @nickbaumann98
+1 -1
View File
@@ -61,6 +61,6 @@ old_docs/**
!assets/icons/**
# Ignore E2E build files
e2e-build.js
e2e-build.mjs
e2e.vsix
test-results/
+54
View File
@@ -1,5 +1,59 @@
# Changelog
## [3.25.2]
- Fix attempt_completion showing twice in chat due to partial logic not being handled correctly
- Fix OpenRouter showing cline credits error after 402 response
## [3.25.1]
- Fix attempt_completion command showing twice in chat view when updating progress checklist
- Fix bug where announcement banner could not be dismissed
- Add GPT-OSS models to AWS Bedrock
## [3.25.0]
- **Focus Chain:** Automatically creates and maintains todo lists as you work with Cline, breaking down complex tasks into manageable steps with real-time progress tracking
- **Auto Compact:** Intelligently manages conversation context to prevent token limit errors by automatically compacting older messages while preserving important context
- **Deep Planning:** New `/deep-planning` slash command for structured 4-step implementation planning that integrates with Focus Chain for automatic progress tracking
- Add support for 200k context window for Claude Sonnet 4 in OpenRouter and Cline providers
- Add option to configure custom base URL for Requesty provider
## [3.24.0]
- Add OpenAI GPT-5 Chat(gpt-5-chat-latest)
- Add custom browser arguments setting to allow passing flags to the Chrome executable for better headless compatibility.
- Add 1m context window model support for claude sonnet 4
- Fis the API Keys URL for Requesty
- Set gpt5 max tokens to 8_192 to fix 'context window exceeded' error
- Fix issue where fallback request to retrieve cost was not using correct auth token
- Add OpenAI context window exceeded error handling
- Calibrate input token counts when using anthropic models of sap ai core provider
## [3.23.0]
- Add caching support for Bedrock inferences using SAP AI Core and minor refactor
- Improve visibility for mode switch background color on different themes
- Fix terminal commands putting webview in blocked state
## [3.22.0]
- Implemented a retry strategy for Cerebras to handle rate limit issues due to its generation speed
- Add support for GPT-5 models to SAP AI Core Provider
- Support sending context to active webview when editor panels are opened.
- Fix bug where running out of credits on Cline accounts would show '402 empty body' response instead of 'buy credits' component
- Fix LiteLLM Proxy Provider Cost Tracking
## [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!)
+12 -4
View File
@@ -79,6 +79,8 @@
"features/drag-and-drop",
"features/plan-and-act",
"features/slash-commands/workflows",
"features/focus-chain",
"features/auto-compact",
"features/editing-messages",
{
"group": "@ Mentions",
@@ -97,7 +99,8 @@
"features/slash-commands/new-task",
"features/slash-commands/new-rule",
"features/slash-commands/smol",
"features/slash-commands/report-bug"
"features/slash-commands/report-bug",
"features/slash-commands/deep-planning"
]
},
{
@@ -146,9 +149,14 @@
"pages": [
"provider-config/anthropic",
"provider-config/claude-code",
"provider-config/aws-bedrock-with-apikey-authentication",
"provider-config/aws-bedrock-with-credentials-authentication",
"provider-config/aws-bedrock-with-profile-authentication",
{
"group": "AWS Bedrock",
"pages": [
"provider-config/aws-bedrock/api-key",
"provider-config/aws-bedrock/iam-credentials",
"provider-config/aws-bedrock/cli-profile"
]
},
"provider-config/gcp-vertex-ai",
"provider-config/litellm-and-cline-using-codestral",
"provider-config/vscode-language-model-api",
+55
View File
@@ -0,0 +1,55 @@
---
title: "Automatic Context Summarization"
sidebarTitle: "Auto Compact"
---
When your conversation approaches the model's context window limit, Cline automatically summarizes it to free up space and keep working.
## How It Works
Cline monitors token usage during your conversation. When you're getting close to the limit, he:
1. Creates a comprehensive summary of everything that's happened
2. Preserves all the technical details, code changes, and decisions
3. Replaces the conversation history with the summary
4. Continues exactly where he left off
You'll see a summarization tool call when this happens, showing the total cost like any other api call in the chat view.
## Why This Matters
Previously, Cline would truncate older messages when hitting context limits. This meant losing important context from earlier in the conversation.
Now with summarization:
- All technical decisions and code patterns are preserved
- File changes and project context remain intact
- Cline remembers everything he's done
- You can work on much larger projects without interruption
<Tip>
Context Summarization synergizes beautifully with [Focus Chain](/features/focus-chain). When Focus Chain is enabled, todo lists persist across summarizations. This means Cline can work on long-horizon tasks that span multiple context windows while staying on track with the todo list guiding him through each reset.
</Tip>
## Technical Details
The summarization happens through your configured API provider using the same model you're already using. It leverages prompt caching to minimize costs.
1. Cline uses a [summarization prompt](https://github.com/cline/cline/blob/main/src/core/prompts/contextManagement.ts) to request a summary of the conversation.
2. Once the summary is generated, Cline replaces the conversation history with a [continuation prompt](https://github.com/cline/cline/blob/main/src/core/prompts/contextManagement.ts#L69) that asks Cline to keep working and provides the summary as context.
Different models have different context window thresholds for when auto-summarization kicks in. You can see how thresholds are determined in [context-window-utils.ts](https://github.com/cline/cline/blob/main/src/core/context/context-management/context-window-utils.ts).
## Cost Considerations
Summarization leverages your existing prompt cache from the conversation, so it costs about the same as any other tool call.
Since most input tokens are already cached, you're primarily paying for the summary generation (output tokens), making it very cost-effective.
## Restoring Context with Checkpoints
You can use [checkpoints](/features/checkpoints) to restore your task state from before a summarization occurred. This means you never truly lose context - you can always roll back to previous versions of your conversation.
<Note>
Editing a message before a summarization tool call will work similarly to a checkpoint, allowing you to restore the conversation to that point.
</Note>
+1 -1
View File
@@ -11,4 +11,4 @@ Dragging and dropping workspace files into Cline will automatically create a [fi
### Supported File Types
Cline supports dragging external images from your file system, as well as files from your workspace.
Cline supports dragging external images, pdfs, csv, excel, and other text files from your file system, as well as files from your workspace.
+296
View File
@@ -0,0 +1,296 @@
---
title: "Focus Chain"
sidebarTitle: "Focus Chain"
---
Focus Chain is a task management enhancement feature in Cline that provides automatic todo list management with real-time progress tracking throughout your tasks.
This enables Cline to work on long-horizon tasks, seamlessly managing the context sent to LLMs, and keeping Cline on track across many context window resets.
<Tip>
Focus Chain works particularly well with Cline's [Deep Planning slash command](/features/slash-commands/deep-planning), providing seamless progress tracking for implementation tasks created through the [planning process](/features/plan-and-act).
</Tip>
## Key Features
### Automatic Todo List Generation
Cline analyzes your task and automatically creates a comprehensive todo list with:
- Clear, actionable items in markdown checklist format
- Logical breakdown of complex tasks into manageable steps
- Real-time updates as work progresses
### User-Editable Todo Lists
Todo lists are stored as editable markdown files:
- Direct editing through your preferred markdown editor
- Automatic detection of changes you make
- Seamless integration back into Cline's workflow
- Quick access through the edit button in the task header
### Visual Progress Tracking
The task header displays clear progress indicators:
- **Step counters** showing current progress (e.g., "3/8")
- **Completed items** clearly marked with checkmarks
- **Current work** highlighted with indicators
- **Expandable view** to see the full todo list
### Smart Reminder System
Configurable reminders ensure todo lists stay current:
- Default reminder every 6 messages (customizable 1-100)
- Automatic prompts when switching from Plan Mode to Act Mode
- User-triggered updates when todo lists are manually edited
## Getting Started
<Steps>
<Step title="Open Cline Settings">
- Click the gear icon in the Cline sidebar
- Navigate to the "Features" section
</Step>
<Step title="Enable Focus Chain">
- Check "Enable Focus Chain"
- Optionally adjust "Remind Cline Interval" (default: 6 messages)
</Step>
<Step title="Start a New Task">
- Begin a new task
- Cline will automatically start creating and managing todo lists
</Step>
</Steps>
| Setting | Default | Range | Description |
|---------|---------|-------|-------------|
| Enable Focus Chain | Disabled | On/Off | Enables enhanced task progress tracking |
| Remind Cline Interval | 6 | 1-100 messages | How often Cline updates the todo list |
## Usage Examples
#### 1. Task Initiation
When you start a new task with Focus Chain enabled:
``` markdown User Request
User: "Create a user authentication system for my React app"
Cline: [Analyzes request and creates todo list]
```
#### 2. Todo List Created
Cline creates a comprehensive plan for the task, stored in a markdown file:
```markdown Todo List Created
- [ ] Set up project structure
- [ ] Install authentication dependencies
- [ ] Create user registration component
- [ ] Implement login functionality
- [ ] Add password validation
- [ ] Set up user database schema
- [ ] Write authentication tests
- [ ] Deploy to staging environment
```
#### 3. Progress Tracking
As Cline works, the task header shows real-time progress:
```markdown Todo List Header
[3/8] Implement login functionality ⌄
```
Click to expand and see the full list:
```markdown Full Todo List
✓ Set up project structure
✓ Install authentication dependencies
✓ Create user registration component
○ Implement login functionality ← Currently working
○ Add password validation
○ Set up user database schema
○ Write authentication tests
○ Deploy to staging environment
```
#### 4. User Editing
Need to tweak the todo list? No problem.
<Steps>
<Step title="Open the todo list">
Click the edit button in the expanded todo view
</Step>
<Step title="Edit the markdown file">
A markdown file opens in your editor:
```markdown Editing Todo List
# Focus Chain Todo List for Task abc123
<!-- Edit this markdown file to update your focus chain todo list -->
<!-- Use - [ ] for incomplete items and - [x] for completed items -->
- [x] Set up project structure
- [x] Install authentication dependencies (e.g., Firebase Auth)
- [x] Create user registration component
- [ ] Implement login functionality
- [ ] Add password reset feature
- [ ] Set up protected routes
- [ ] Implement logout functionality
- [ ] Add user profile page
- [ ] Write authentication tests
- [ ] Deploy to staging environment
<!-- Save this file to update the task's todo list -->
```
</Step>
<Step title="Make your changes">
Add, remove, or reorder items as needed
</Step>
<Step title="Save the file">
Cline automatically detects and uses your updates
</Step>
</Steps>
## File Structure
### Todo List Storage
Todo lists are stored as markdown files in your task directory:
``` markdown
<VSCode Global Storage>/
tasks/
<taskId>/
focus_chain_taskid_<taskId>.md
... other task files
```
### Markdown Format
Todo files use standard markdown checklist syntax:
```markdown Example Todo Syntax
# Focus Chain Todo List for Task abc123
<!-- Edit this markdown file to update your focus chain todo list -->
<!-- Use the format: - [ ] for incomplete items and - [x] for completed items -->
- [x] Set up project structure
- [x] Install authentication dependencies
- [ ] Create user registration component
- [ ] Implement login functionality
- [ ] Add password validation
- [ ] Set up user database schema
- [ ] Write authentication tests
- [ ] Deploy to staging environment
<!-- Save this file and the todo list will be updated in the task -->
```
## Integration with Plan/Act Mode
Focus Chain works seamlessly with Cline's [Plan/Act mode](/features/plan-and-act):
- **Plan Mode**: Optional todo lists for presenting concrete steps
- **Act Mode**: Automatic todo creation when switching from Plan Mode
<Tip>
For complex projects, start in Plan Mode to discuss and refine your approach before switching to Act Mode for implementation.
</Tip>
## Best Practices
<AccordionGroup>
<Accordion title="For Effective Todo Lists">
1. **Start with Clear Requests**
- Provide detailed initial task descriptions
- Include specific requirements and constraints
- Mention any preferred technologies or approaches
2. **Review Generated Lists**
- Check that Cline's breakdown aligns with your expectations
- Verify that all important steps are included
- Ensure the order makes sense for your project
3. **Edit When Needed**
- Add missing steps you identify
- Remove unnecessary items
- Reorder steps for better workflow
- Add more specific details to general items
</Accordion>
<Accordion title="For Complex Projects">
1. **Use Plan Mode First**
- Discuss the approach before implementation
- Refine requirements through conversation
- Switch to Act Mode when ready to begin work
2. **Break Down Large Tasks**
- Split complex projects into smaller, manageable tasks
- Create separate todo lists for different components
- Focus on one major area at a time
3. **Regular Reviews**
- Check progress periodically during long tasks
- Update todo lists as requirements evolve
- Communicate changes to Cline through edits
</Accordion>
<Accordion title="For Collaboration">
1. **Share Todo Files**
- Todo markdown files can be shared with team members
- Include in version control for project documentation
- Use as basis for project planning discussions
2. **Consistent Format**
- Follow the standard markdown checklist format
- Keep item descriptions clear and actionable
- Use consistent terminology across todo lists
</Accordion>
</AccordionGroup>
## Troubleshooting
Having issues? Try these quick fixes:
<AccordionGroup>
<Accordion title="Todo list not updating?">
- Check that Focus Chain is enabled in settings
- Focus Chain may not work as well with smaller, less capable models
- Ensure file permissions are correct in the task directory
</Accordion>
<Accordion title="Can't edit todo file?">
- Verify your editor supports markdown
- Check VSCode has write permissions for the directory
</Accordion>
<Accordion title="Progress not displaying?">
- Ensure todo items use correct syntax (`- [ ]` and `- [x]`)
- Verify the markdown file is properly formatted
</Accordion>
</AccordionGroup>
Still stuck? Use the [/reportbug](/features/slash-commands/report-bug) command in Cline to get help.
## Technical Details (for the curious)
<AccordionGroup>
<Accordion title="File Monitoring">
- Real-time file watching detects changes to todo markdown files
- Automatic synchronization between file edits and UI updates
- Graceful handling of file creation, modification, and deletion
</Accordion>
<Accordion title="Progress Calculation">
- Dynamic counting of completed vs. total todo items
- Support for both `- [x]` and `- [X]` completion syntax
- Unicode symbols (✓, ○) for enhanced visual display
</Accordion>
<Accordion title="Privacy Considerations">
- Todo lists stored locally in VSCode workspace
- No todo content transmitted to external services
- Usage telemetry (can be disabled in settings)
</Accordion>
</AccordionGroup>
Focus Chain turns Cline into your personal project manager, keeping you on track and your tasks organized. Give it a try on your next project!
@@ -0,0 +1,153 @@
---
title: "Deep Planning Command"
sidebarTitle: "/deep-planning"
---
`/deep-planning` transforms Cline into a meticulous architect who investigates your codebase, asks clarifying questions, and creates a comprehensive implementation plan before writing a single line of code.
When you use `/deep-planning`, Cline follows a four-step process that mirrors how senior developers approach complex features: thorough investigation, discussion & clarification of requirements, detailed planning, and structured task creation with progress tracking.
## The Four-Step Process
### Step 1: Silent Investigation
Cline becomes a detective, silently exploring your codebase to understand its structure, patterns, and constraints. He examines source files, analyzes import patterns, discovers class hierarchies, and identifies technical debt markers. No commentary, no narration - just focused research.
During this phase, Cline runs commands like:
- Finding all class and function definitions across your codebase
- Analyzing import patterns to understand dependencies
- Discovering project structure and file organization
- Identifying TODOs and technical debt
### Step 2: Discussion and Questions
Once Cline understands your codebase, he asks targeted questions that will shape the implementation. These aren't generic questions - they're specific to your project and the feature you're building.
Questions might cover:
- Clarifying ambiguous requirements
- Choosing between equally valid implementation approaches
- Confirming assumptions about system behavior
- Understanding preferences for technical decisions
### Step 3: Implementation Plan Document
Cline creates a structured markdown document (`implementation_plan.md`) that serves as your implementation blueprint. This isn't a vague outline - it's a detailed specification with exact file paths, function signatures, and implementation order.
The plan includes eight comprehensive sections:
- **Overview**: The goal and high-level approach
- **Types**: Complete type definitions and data structures
- **Files**: Exact files to create, modify, or delete
- **Functions**: New and modified functions with signatures
- **Classes**: Class modifications and inheritance details
- **Dependencies**: Package requirements and versions
- **Testing**: Validation strategies and test requirements
- **Implementation Order**: Step-by-step execution sequence
### Step 4: Implementation Task Creation
Cline creates a new task that references the plan document and includes trackable implementation steps. The task comes with specific commands to read each section of the plan, ensuring the implementing agent (whether that's you or Cline in Act Mode) can navigate the blueprint efficiently.
<Tip>
Deep Planning works beautifully with [Focus Chain](/features/focus-chain). The implementation steps automatically become a todo list with real-time progress tracking, keeping complex projects organized and on track.
</Tip>
## Using Deep Planning
Start a deep planning session by typing `/deep-planning` followed by your feature description:
```
/deep-planning Add user authentication with JWT tokens and role-based access control
```
Cline will begin his investigation immediately. You'll see him reading files and running commands to understand your codebase. Once he's gathered enough context, he'll engage you in discussion before creating the plan.
## Example Workflow
Here's how I use `/deep-planning` for a real feature:
<Steps>
<Step title="Initiate Planning">
I type `/deep-planning implement a caching layer for API responses`
</Step>
<Step title="Silent Investigation">
Cline explores my codebase, examining:
- Current API structure and endpoints
- Existing data flow patterns
- Database queries and performance bottlenecks
- Configuration and environment setup
</Step>
<Step title="Targeted Discussion">
Cline asks me:
- "Should we use Redis or in-memory caching?"
- "What's the acceptable cache staleness for user data?"
- "Do you need cache invalidation webhooks?"
</Step>
<Step title="Plan Creation">
Cline generates `implementation_plan.md` with:
- Cache service class specifications
- Redis connection configuration
- Modified API endpoints with caching logic
- Cache key generation strategies
- TTL configurations for different data types
</Step>
<Step title="Task Generation">
Cline creates a new task with:
- Reference to the implementation plan
- Commands to read specific sections
- Trackable todo items for each implementation step
- Request to switch to Act Mode for execution
</Step>
</Steps>
## Integration with Plan/Act Mode
Deep Planning is designed to work seamlessly with [Plan/Act Mode](/features/plan-and-act):
- Use `/deep-planning` in Plan Mode for the investigation and planning phases
- The generated task requests switching to Act Mode for implementation
- Focus Chain automatically tracks progress through the implementation steps
This separation ensures planning stays focused on architecture while implementation stays focused on execution.
## Best Practices
### When to Use Deep Planning
Use `/deep-planning` for:
- Features touching multiple parts of your codebase
- Architectural changes requiring careful coordination
- Complex integrations with external services
- Refactoring efforts that need systematic execution
- Any feature where you'd normally spend time whiteboarding
### Making the Most of Investigation
Let Cline complete his investigation thoroughly. The quality of the plan directly correlates with how well he understands your codebase. If you have specific areas he should examine, mention them in your initial request.
### Reviewing the Plan
Always review `implementation_plan.md` before starting implementation. The plan is comprehensive but not immutable - you can edit it directly if needed. Think of it as a collaborative document between you and Cline.
### Tracking Progress
With Focus Chain enabled, your implementation progress displays in the task header. Each completed step gets checked off automatically as Cline works through the plan, giving you real-time visibility into complex implementations.
## Inspiration
I use `/deep-planning` whenever I'm about to build something that would normally require a design document. Recent examples from my workflow:
- **Migrating authentication systems**: Deep Planning mapped every endpoint, identified all authentication touchpoints, and created a migration plan that avoided breaking changes.
- **Adding real-time features**: The plan covered WebSocket integration, event handling, state synchronization, and fallback mechanisms for disconnections.
- **Database schema refactoring**: Cline identified all affected queries, created migration scripts, and planned the rollout to minimize downtime.
- **API versioning implementation**: The plan detailed route changes, backward compatibility layers, deprecation notices, and client migration paths.
The power of `/deep-planning` is that it forces thoughtful architecture before implementation. It's like having a senior developer review your approach before you write code, except that developer has perfect knowledge of your entire codebase.
<Note>
Deep Planning requires models with strong reasoning capabilities. It works best with the latest generation of models, like GPT-5, Claude 4, Gemini 2.5, or Grok 4. Smaller models may struggle with the comprehensive analysis required.
</Note>
For simpler tasks that don't require extensive planning, consider using [/newtask](/features/slash-commands/new-task) to create focused tasks with context, or jump straight into implementation if the path forward is clear.
@@ -55,7 +55,8 @@ Think of context like a whiteboard you and Cline share:
- Each model has a fixed size:
- Claude 3.5 Sonnet: 200,000 tokens
- DeepSeek: 64,000 tokens
- When the whiteboard is full, you need to erase (clear context) to write more
- When the whiteboard is full, Cline automatically summarizes the conversation to free up space
- [Learn about Automatic Context Summarization](/features/automatic-context-summarization)
- [How Cline manages context under the hood](https://cline.bot/blog/understanding-the-new-context-window-progress-bar-in-cline)
⚠️ **Important**: Having a large context window (like Claude's 200k tokens) doesn't mean you should fill it completely. Just like a cluttered whiteboard, too much information can make it harder to focus on what's important.
@@ -85,7 +86,28 @@ Cline provides a visual way to monitor your context window usage through a progr
- Before starting complex tasks
- When Cline seems to lose context
💡 **Tip**: Consider starting a fresh session when usage reaches 70-80% to maintain optimal performance.
💡 **Tip**: With [Automatic Context Summarization](/features/automatic-context-summarization), Cline can now handle long conversations automatically. When combined with [Focus Chain](/features/focus-chain), you can work on complex projects that span multiple context windows without losing progress.
## Automatic Context Management
Cline now includes intelligent features to manage context automatically:
### Automatic Context Summarization
When your conversation approaches the context window limit, Cline automatically:
- Creates a comprehensive summary of the conversation
- Preserves all essential technical details and decisions
- Seamlessly continues work without interruption
- Maintains full task continuity
This means you can work on larger projects without manually managing context. [Learn more about Automatic Context Summarization](/features/automatic-context-summarization).
### Focus Chain Integration
When combined with [Focus Chain](/features/focus-chain), Cline can:
- Maintain todo lists across context resets
- Track progress through multiple summarizations
- Keep working on long-horizon tasks without losing direction
## Working with Context Files
+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,6 +1,7 @@
---
title: "AWS Bedrock"
description: "Learn how to set up AWS Bedrock with Cline using credentials authentication. This guide covers AWS environment setup, regional access verification, and secure integration with the Cline VS Code extension."
title: "API Key (Simple Setup)"
sidebarTitle: "API Key"
description: "Set up AWS Bedrock with Cline using Bedrock API Keys. Simplest setup for individual developers to access frontier models."
---
### Overview
@@ -121,14 +122,14 @@ You can create a custom IAM policy with these permissions and attach it to your
### Conclusion
By following these steps, your enterprise team can securely integrate AWS Bedrock with the Cline VS Code extension to accelerate development:
By following these steps, you can quickly integrate AWS Bedrock with the Cline VS Code extension to accelerate development:
1. **Prepare Your AWS Environment:** Create or use a secure IAM role/user, attach the `AmazonBedrockLimitedAccess` policy, and ensure necessary permissions.
1. **Prepare Your AWS Environment:** Create a Bedrock API Key with the necessary permissions.
2. **Verify Region and Model Access:** Confirm that your selected region supports your required models.
3. **Configure Cline in VS Code:** Install and set up Cline with your AWS credentials and choose an appropriate model.
3. **Configure Cline in VS Code:** Install and set up Cline with your AWS API Key and choose an appropriate model.
4. **Implement Security and Monitoring:** Use best practices for IAM, network security, monitoring, and cost management.
For further details, consult the [AWS Bedrock Documentation](https://docs.aws.amazon.com/bedrock/latest/userguide/what-is-bedrock.html) and coordinate with your internal cloud team. Happy coding!
For further details, consult the [AWS Bedrock Documentation](https://docs.aws.amazon.com/bedrock/latest/userguide/what-is-bedrock.html). Happy coding!
---
@@ -1,6 +1,7 @@
---
title: "AWS Bedrock w/ Profile Authentication"
description: "Learn how to configure AWS Bedrock to use AWS Profiles for authentication with Cline, focusing on SSO/Federated roles for secure access."
title: "CLI Profile (SSO)"
sidebarTitle: "CLI Profile (SSO)"
description: "Configure AWS Bedrock to use AWS CLI profiles for authentication with Cline. Best for SSO/federated roles and secure enterprise access."
---
### Overview
@@ -1,6 +1,7 @@
---
title: "AWS Bedrock"
description: "Learn how to set up AWS Bedrock with Cline using credentials authentication. This guide covers AWS environment setup, regional access verification, and secure integration with the Cline VS Code extension."
title: "IAM Credentials"
sidebarTitle: "IAM Credentials"
description: "Set up AWS Bedrock with Cline using IAM Access Key and Secret Key credentials. Best for enterprise environments with established IAM policies."
---
### Overview
+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`
@@ -43,7 +43,6 @@ While the "OpenAI Compatible" provider type allows connecting to various endpoin
- `o1`
- `o1-preview`
- `o1-mini`
- `gpt-4.5-preview`
- `gpt-4o`
- `gpt-4o-mini`
-1
View File
@@ -26,7 +26,6 @@ Cline is compatible with a variety of OpenAI models, including but not limited t
- `o1`
- `o1-preview`
- `o1-mini`
- `gpt-4.5-preview`
- `gpt-4o`
- `gpt-4o-mini`
- 'gpt-4.1'
+2 -2
View File
@@ -10,7 +10,7 @@ Cline supports accessing models through the [Requesty](https://www.requesty.ai/)
### Getting an API Key
1. **Sign Up/Sign In:** Go to the [Requesty website](https://www.requesty.ai/) and create an account or sign in.
2. **Get API Key:** You can get an API key from the [API Management](https://app.requesty.ai/manage-api) section of your Requesty dashboard.
2. **Get API Key:** You can get an API key from the [API Management](https://app.requesty.ai/api-keys) section of your Requesty dashboard.
### Supported Models
@@ -26,7 +26,7 @@ Requesty provides access to a wide range of models. Cline will automatically fet
### Tips and Notes
- **Optimizations**: Requesty offers a range of in-flight cost optimizations to lower your costs.
- **Unified and simplified billing**: Unrestricted access to all providers and models, automatic balance top ups and more via a single [API key](https://app.requesty.ai/manage-api).
- **Unified and simplified billing**: Unrestricted access to all providers and models, automatic balance top ups and more via a single [API key](https://app.requesty.ai/api-keys).
- **Cost tracking**: Track cost per model, coding language, changed file, and more via the [Cost dashboard](https://app.requesty.ai/cost-management) or the [Requesty VS Code extension](https://marketplace.visualstudio.com/items?itemName=Requesty.requesty).
- **Stats and logs**: See your [coding stats dashboard](https://app.requesty.ai/usage-stats) or go through your [LLM interaction logs](https://app.requesty.ai/logs).
- **Fallback policies**: Keep your LLM working for you with fallback policies when providers are down.
+2 -2
View File
@@ -1,7 +1,7 @@
import fs from "node:fs"
import * as esbuild from "esbuild"
import path from "node:path"
import { fileURLToPath } from "node:url"
import * as esbuild from "esbuild"
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
@@ -170,7 +170,7 @@ const standaloneConfig = {
const e2eBuildConfig = {
...baseConfig,
entryPoints: ["src/test/e2e/utils/build.ts"],
outfile: `${destDir}/e2e-build.js`,
outfile: `${destDir}/e2e-build.mjs`,
external: ["@vscode/test-electron", "execa"],
sourcemap: false,
plugins: [aliasResolverPlugin, esbuildProblemMatcherPlugin],
@@ -0,0 +1,171 @@
const { RuleTester: StateApiRuleTester } = require("eslint")
const noDirectVscodeStateApiRule = require("../no-direct-vscode-state-api")
const stateApiRuleTester = new StateApiRuleTester({
parser: require.resolve("@typescript-eslint/parser"),
parserOptions: {
ecmaVersion: 2020,
sourceType: "module",
ecmaFeatures: {
jsx: true,
},
},
})
stateApiRuleTester.run("no-direct-vscode-state-api", noDirectVscodeStateApiRule, {
valid: [
// Should allow state APIs in CacheService.ts
{
code: `await context.globalState.update("myKey", value);`,
filename: "CacheService.ts",
},
{
code: `const value = context.globalState.get("myKey");`,
filename: "/src/core/storage/CacheService.ts",
},
{
code: `await context.secrets.store("apiKey", value);`,
filename: "CacheService.ts",
},
// Should allow state APIs in state-helpers.ts
{
code: `const value = context.globalState.get("myKey");`,
filename: "state-helpers.ts",
},
{
code: `await context.secrets.get("apiKey");`,
filename: "/src/core/storage/utils/state-helpers.ts",
},
// Should allow state APIs in state-migrations.ts
{
code: `await context.globalState.update("myKey", value);`,
filename: "state-migrations.ts",
},
{
code: `const value = context.workspaceState.get("myKey");`,
filename: "/src/core/storage/state-migrations.ts",
},
// Should allow state APIs in extension.ts
{
code: `const distinctId = context.globalState.get<string>("cline.distinctId");`,
filename: "extension.ts",
},
{
code: `await context.globalState.update("clineVersion", currentVersion);`,
filename: "/src/extension.ts",
},
{
code: `const secret = await context.secrets.get("clineAccountId");`,
filename: "extension.ts",
},
// Should allow state APIs in test files
{
code: `context.globalState.get("testKey")`,
filename: "/foo/bar.test.ts",
},
// Should allow non-state API calls
{
code: `const value = someOtherObject.globalState.get("myKey");`,
filename: "some-file.ts",
},
{
code: `await myContext.secrets.store("key", "value");`,
filename: "some-file.ts",
},
],
invalid: [
// Should disallow context.globalState.get
{
code: `const value = context.globalState.get("myKey");`,
filename: "some-file.ts",
errors: [
{
messageId: "useCacheServiceGlobalGet",
},
],
},
// Should disallow context.globalState.update
{
code: `await context.globalState.update("myKey", "myValue");`,
filename: "some-file.ts",
errors: [
{
messageId: "useCacheServiceGlobalSet",
},
],
},
// Should disallow context.workspaceState.get
{
code: `const value = context.workspaceState.get("myKey");`,
filename: "some-file.ts",
errors: [
{
messageId: "useCacheServiceWorkspaceGet",
},
],
},
// Should disallow context.workspaceState.update
{
code: `await context.workspaceState.update("myKey", "myValue");`,
filename: "some-file.ts",
errors: [
{
messageId: "useCacheServiceWorkspaceSet",
},
],
},
// Should disallow context.secrets.get
{
code: `const secret = await context.secrets.get("apiKey");`,
filename: "some-file.ts",
errors: [
{
messageId: "useCacheServiceSecretsGet",
},
],
},
// Should disallow context.secrets.store
{
code: `await context.secrets.store("apiKey", "secret-value");`,
filename: "some-file.ts",
errors: [
{
messageId: "useCacheServiceSecretsSet",
},
],
},
// Should disallow context.secrets.delete
{
code: `await context.secrets.delete("apiKey");`,
filename: "some-file.ts",
errors: [
{
messageId: "useCacheServiceSecretsSet",
},
],
},
// Should disallow chained state API calls
{
code: `const value = await context.globalState.get("key") || "default";`,
filename: "some-file.ts",
errors: [
{
messageId: "useCacheServiceGlobalGet",
},
],
},
// Should disallow state API calls in Promise.all
{
code: `await Promise.all([context.secrets.get("key1"), context.secrets.get("key2")]);`,
filename: "some-file.ts",
errors: [
{
messageId: "useCacheServiceSecretsGet",
},
{
messageId: "useCacheServiceSecretsGet",
},
],
},
],
})
+3
View File
@@ -1,15 +1,18 @@
// eslint-rules/index.js
const noDirectVscodeApi = require("./no-direct-vscode-api")
const noDirectVscodeStateApi = require("./no-direct-vscode-state-api")
module.exports = {
rules: {
"no-direct-vscode-api": noDirectVscodeApi,
"no-direct-vscode-state-api": noDirectVscodeStateApi,
},
configs: {
recommended: {
plugins: ["local"],
rules: {
"local/no-direct-vscode-api": "warn",
"local/no-direct-vscode-state-api": "error",
},
},
},
+144
View File
@@ -0,0 +1,144 @@
const { ESLintUtils } = require("@typescript-eslint/utils")
const path = require("path")
const createRule = ESLintUtils.RuleCreator((name) => `https://cline.bot/eslint-rules/${name}`)
// Configuration for context-based state APIs
const disallowedContextApis = {
"globalState.get": {
messageId: "useCacheServiceGlobalGet",
},
"globalState.update": {
messageId: "useCacheServiceGlobalSet",
},
"workspaceState.get": {
messageId: "useCacheServiceWorkspaceGet",
},
"workspaceState.update": {
messageId: "useCacheServiceWorkspaceSet",
},
"secrets.get": {
messageId: "useCacheServiceSecretsGet",
},
"secrets.store": {
messageId: "useCacheServiceSecretsSet",
},
"secrets.delete": {
messageId: "useCacheServiceSecretsSet",
},
}
module.exports = createRule({
name: "no-direct-vscode-state-api",
meta: {
type: "problem",
docs: {
description:
"Disallow direct VSCode state API usage (context.globalState, context.workspaceState, context.secrets) in favor of CacheService",
recommended: "error",
},
messages: {
useCacheServiceGlobalGet:
"Use CacheService.getGlobalStateKey() instead of context.globalState.get().\n" +
"The CacheService provides fast in-memory access with automatic persistence.\n" +
"Example: cacheService.getGlobalStateKey('myKey') instead of context.globalState.get('myKey').\n" +
"Found: {{code}}",
useCacheServiceGlobalSet:
"Use CacheService.setGlobalState() instead of context.globalState.update().\n" +
"The CacheService provides immediate updates with debounced persistence.\n" +
"Example: cacheService.setGlobalState('myKey', value) instead of context.globalState.update('myKey', value).\n" +
"Found: {{code}}",
useCacheServiceWorkspaceGet:
"Use CacheService.getWorkspaceStateKey() instead of context.workspaceState.get().\n" +
"The CacheService provides fast in-memory access with automatic persistence.\n" +
"Example: cacheService.getWorkspaceStateKey('myKey') instead of context.workspaceState.get('myKey').\n" +
"Found: {{code}}",
useCacheServiceWorkspaceSet:
"Use CacheService.setWorkspaceState() instead of context.workspaceState.update().\n" +
"The CacheService provides immediate updates with debounced persistence.\n" +
"Example: cacheService.setWorkspaceState('myKey', value) instead of context.workspaceState.update('myKey', value).\n" +
"Found: {{code}}",
useCacheServiceSecretsGet:
"Use CacheService.getSecretKey() instead of context.secrets.get().\n" +
"The CacheService provides fast in-memory access with automatic persistence.\n" +
"Example: cacheService.getSecretKey('mySecret') instead of context.secrets.get('mySecret').\n" +
"Found: {{code}}",
useCacheServiceSecretsSet:
"Use CacheService.setSecret() instead of context.secrets.store() or context.secrets.delete().\n" +
"The CacheService provides immediate updates with debounced persistence.\n" +
"Example: cacheService.setSecret('mySecret', value) instead of context.secrets.store('mySecret', value).\n" +
"For deletion, use: cacheService.setSecret('mySecret', undefined).\n" +
"Found: {{code}}",
},
schema: [],
},
defaultOptions: [],
create(context) {
function isExcluded(filename) {
// Skip checking test files
if (filename.endsWith(".test.ts")) {
return true
}
// Skip checking specific state-related files that need direct access
const basename = path.basename(filename)
if (
basename === "CacheService.ts" ||
basename === "state-helpers.ts" ||
basename === "state-migrations.ts" ||
basename === "extension.ts" ||
basename === "common.ts" // CI might report errors from this virtual file
) {
return true
}
return false
}
// Check for context-based state API calls
function checkContextStateApi(node) {
if (isExcluded(context.filename)) {
return
}
// Check if this is a member expression like context.globalState.get
if (
node.type === "MemberExpression" &&
node.object &&
node.object.type === "MemberExpression" &&
node.object.object &&
node.object.object.type === "Identifier" &&
node.object.object.name === "context"
) {
const stateType = node.object.property.name // e.g., "globalState", "workspaceState", "secrets"
const method = node.property.name // e.g., "get", "update", "store", "delete"
const apiPath = `${stateType}.${method}`
if (disallowedContextApis[apiPath]) {
// For method calls, get the whole call expression
let reportNode = node
let parentNode = context.sourceCode.getAncestors(node).pop()
if (parentNode && parentNode.type === "CallExpression" && parentNode.callee === node) {
reportNode = parentNode
}
const callText = context.sourceCode.getText(reportNode).trim()
context.report({
node: reportNode,
messageId: disallowedContextApis[apiPath].messageId,
data: {
code: callText,
},
})
}
}
}
return {
// Detect member expressions (e.g., context.globalState.get)
MemberExpression(node) {
checkContextStateApi(node)
},
}
},
})
-5
View File
@@ -1,12 +1,9 @@
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"
import {
parseAssistantMessageV1,
parseAssistantMessageV2,
parseAssistantMessageV3,
AssistantMessageContent,
} from "./parsing/parse-assistant-message-06-06-25" // "../../src/core/assistant-message"
import { constructNewFileContent as constructNewFileContent_06_06_25 } from "./diff-apply/diff-06-06-25"
@@ -18,9 +15,7 @@ type ParseAssistantMessageFn = (message: string) => AssistantMessageContent[]
type ConstructNewFileContentFn = (diff: string, original: string, strict: boolean) => Promise<string | any>
const parsingFunctions: Record<string, ParseAssistantMessageFn> = {
parseAssistantMessageV1: parseAssistantMessageV1,
parseAssistantMessageV2: parseAssistantMessageV2,
parseAssistantMessageV3: parseAssistantMessageV3,
}
const diffEditingFunctions: Record<string, ConstructNewFileContentFn> = {
@@ -70,246 +70,7 @@ export interface ToolUse {
partial: boolean
}
/**
* @description **Version 1**
* Parses an assistant message string potentially containing mixed text and tool usage blocks
* marked with XML-like tags into an array of structured content objects.
*
* This version iterates through the message character by character, building an accumulator string.
* It maintains state to track whether it's currently parsing text, a tool use block, or a specific tool parameter.
* It detects the start and end of tool uses and parameters by checking if the accumulator ends with
* the corresponding opening or closing tags.
* Special handling is included for `write_to_file` and `new_rule` tool uses to correctly parse
* the `content` parameter, which might contain the closing tag itself, by looking for the *last*
* occurrence of the closing tag.
* If the input string ends mid-tag or mid-content, the last block (text or tool use) is marked as partial.
*
* @param assistantMessage The raw string output from the assistant.
* @returns An array of `AssistantMessageContent` objects, which can be `TextContent` or `ToolUse`.
* Blocks that were not fully closed by the end of the input string will have their `partial` flag set to `true`.
*/
export function parseAssistantMessageV1(assistantMessage: string): AssistantMessageContent[] {
const contentBlocks: AssistantMessageContent[] = []
let currentTextContent: TextContent | undefined = undefined
let currentTextContentStartIndex = 0
let currentToolUse: ToolUse | undefined = undefined
let currentToolUseStartIndex = 0
let currentParamName: ToolParamName | undefined = undefined
let currentParamValueStartIndex = 0
let accumulator = ""
for (let i = 0; i < assistantMessage.length; i++) {
const char = assistantMessage[i]
accumulator += char
// --- State: Parsing a Tool Parameter ---
// there should not be a param without a tool use
if (currentToolUse && currentParamName) {
const currentParamValue = accumulator.slice(currentParamValueStartIndex)
const paramClosingTag = `</${currentParamName}>`
if (currentParamValue.endsWith(paramClosingTag)) {
// End of param value found
currentToolUse.params[currentParamName] = currentParamValue.slice(0, -paramClosingTag.length).trim()
currentParamName = undefined // Go back to parsing tool content or looking for next param
continue // Move to next character
} else {
// Partial param value is accumulating
continue // Move to next character
}
}
// --- State: Parsing a Tool Use (but not a specific parameter) ---
// no currentParamName
if (currentToolUse) {
const currentToolValue = accumulator.slice(currentToolUseStartIndex)
const toolUseClosingTag = `</${currentToolUse.name}>`
if (currentToolValue.endsWith(toolUseClosingTag)) {
// End of a tool use found
currentToolUse.partial = false
contentBlocks.push(currentToolUse)
currentToolUse = undefined // Go back to parsing text or looking for next tool
// Reset text start index in case text follows immediately
currentTextContentStartIndex = i + 1
continue // Move to next character
} else {
// Check if starting a new parameter within the current tool use
const possibleParamOpeningTags = toolParamNames.map((name) => `<${name}>`)
let foundParamStart = false
for (const paramOpeningTag of possibleParamOpeningTags) {
if (accumulator.endsWith(paramOpeningTag)) {
// Start of a new parameter found
currentParamName = paramOpeningTag.slice(1, -1) as ToolParamName
currentParamValueStartIndex = accumulator.length
foundParamStart = true
break
}
}
if (foundParamStart) {
continue // Move to next character
}
// Special case for write_to_file/new_rule content param allowing nested tags
// Check if a </content> tag appears, potentially indicating the end of the content param
// even if the main tool closing tag hasn't been seen yet.
const contentParamName: ToolParamName = "content"
if (
(currentToolUse.name === "write_to_file" || currentToolUse.name === "new_rule") &&
accumulator.endsWith(`</${contentParamName}>`)
) {
const toolContent = accumulator.slice(currentToolUseStartIndex)
const contentStartTag = `<${contentParamName}>`
const contentEndTag = `</${contentParamName}>`
const contentStartIndex = toolContent.indexOf(contentStartTag) + contentStartTag.length
// Use lastIndexOf to handle cases where </content> might appear within the content itself
const contentEndIndex = toolContent.lastIndexOf(contentEndTag)
// Ensure we found valid start/end tags and end is after start
if (
contentStartIndex !== -1 &&
contentEndIndex !== -1 &&
contentEndIndex > contentStartIndex - contentStartTag.length // Ensure end tag is after start tag begins
) {
// Check if this content param was already being parsed. If so, update it.
// If not, and we just found the closing tag, assign it.
// This handles cases where the </content> detection might fire before
// the <content> tag detection logic, or if the content is very short.
if (currentParamName === contentParamName) {
// Already parsing content, now we found the end tag
currentToolUse.params[contentParamName] = toolContent.slice(contentStartIndex, contentEndIndex).trim()
currentParamName = undefined // Finished with this param
} else if (currentParamName === undefined) {
// Not parsing a param, but found </content>. Assume it closes the content block.
currentToolUse.params[contentParamName] = toolContent.slice(contentStartIndex, contentEndIndex).trim()
// We stay in the "parsing tool use" state, looking for more params or the tool end tag.
}
}
}
// If none of the above, partial tool value is accumulating
continue // Move to next character
}
}
// --- State: Parsing Text (or looking for start of a tool use) ---
// no currentToolUse
let didStartToolUse = false
const possibleToolUseOpeningTags = toolUseNames.map((name) => `<${name}>`)
for (const toolUseOpeningTag of possibleToolUseOpeningTags) {
if (accumulator.endsWith(toolUseOpeningTag)) {
// Start of a new tool use found
const toolName = toolUseOpeningTag.slice(1, -1) as ToolUseName
currentToolUse = {
type: "tool_use",
name: toolName,
params: {},
partial: true,
}
currentToolUseStartIndex = accumulator.length
// This also indicates the end of the current text content block (if any)
if (currentTextContent) {
currentTextContent.partial = false
// Extract text content, removing the part that formed the tool opening tag
const textEndIndex = accumulator.length - toolUseOpeningTag.length
currentTextContent.content = accumulator.slice(currentTextContentStartIndex, textEndIndex).trim()
// Only add if there's actual content
if (currentTextContent.content.length > 0) {
contentBlocks.push(currentTextContent)
}
currentTextContent = undefined
} else {
// Check if there was text before this tool use started
const textEndIndex = accumulator.length - toolUseOpeningTag.length
const potentialText = accumulator.slice(currentTextContentStartIndex, textEndIndex).trim()
if (potentialText.length > 0) {
contentBlocks.push({
type: "text",
content: potentialText,
partial: false, // Ended because tool use started
})
}
}
didStartToolUse = true
break // Found tool start, stop checking for others
}
}
if (!didStartToolUse) {
// No tool use started, so it must be text content accumulating
// (or continuing after a closed tool use)
if (currentTextContent === undefined) {
// Start of a new text block
currentTextContentStartIndex = i - (accumulator.length - currentTextContentStartIndex - 1) // Adjust start index based on how much we've accumulated since the last block ended or the beginning
// If accumulator starts from 0, start index is i
if (contentBlocks.length === 0 && currentToolUse === undefined) {
currentTextContentStartIndex = accumulator.length - 1 // i
} else {
// Re-calculate based on the actual start of the current text segment
// Find the end of the last block
let lastBlockEndIndex = 0
if (contentBlocks.length > 0) {
const lastBlock = contentBlocks[contentBlocks.length - 1]
// Approximation: find where the accumulator matches the end of the message string representation of the last block. This is complex.
// Simpler: Assume text starts right after the last block ended implicitly at index i.
lastBlockEndIndex = i // Where the loop *was* when the last block finished processing
// Need a more robust way to track the end index of the *raw string* corresponding to the last block.
// Let's stick to the accumulator slice approach for simplicity in this version.
// The start index should be where the current *unmatched* text began.
let lastProcessedIndex = -1
if (contentBlocks.length > 0) {
// This requires knowing the raw string length of the previous block, which V1 doesn't explicitly track easily.
// We'll approximate based on the current accumulator and start index logic.
// The issue arises if a tool tag was just closed. accumulator contains everything up to i.
// lastBlockEndIndex should point to the character *after* the closing tag of the last block.
}
// Reset start index to the beginning of the *current* potential text block
currentTextContentStartIndex = accumulator.length - 1 // Start accumulating from the current character `i`
}
// If we just closed a tool, text starts *after* its closing tag
// The logic needs refinement here for accurate start index after a tool closure.
// Let's assume for now the start index logic inside the loop handles it via slicing.
}
currentTextContent = {
type: "text",
content: "", // Content will be filled by slicing accumulator
partial: true,
}
}
// Update text content based on the accumulator from its start index
currentTextContent.content = accumulator.slice(currentTextContentStartIndex).trimStart() // Trim start to avoid leading space if text follows tool
}
} // End of loop
// --- Finalization after loop ---
// If a tool use was open at the end
if (currentToolUse) {
// If a parameter was open within that tool use
if (currentParamName) {
// The remaining accumulator content belongs to this partial parameter
currentToolUse.params[currentParamName] = accumulator.slice(currentParamValueStartIndex).trim()
}
// Add the potentially partial tool use block
contentBlocks.push(currentToolUse)
}
// If text content was being accumulated at the end
// Note: Only one of currentToolUse or currentTextContent can be defined here,
// as starting a tool use finalizes the preceding text block.
else if (currentTextContent) {
// Update content one last time
currentTextContent.content = accumulator.slice(currentTextContentStartIndex).trim()
// Add the potentially partial text block only if it contains content
if (currentTextContent.content.length > 0) {
contentBlocks.push(currentTextContent)
}
}
return contentBlocks
}
// parseAssistantmessageV1 removed in https://github.com/cline/cline/pull/5425
/**
* @description **Version 2**
@@ -543,621 +304,3 @@ export function parseAssistantMessageV2(assistantMessage: string): AssistantMess
return contentBlocks
}
export function parseAssistantMessageV3(assistantMessage: string): AssistantMessageContent[] {
const contentBlocks: AssistantMessageContent[] = []
let currentTextContentStart = 0 // Index where the current text block started
let currentTextContent: TextContent | undefined = undefined
let currentToolUseStart = 0 // Index *after* the opening tag of the current tool use
let currentToolUse: ToolUse | undefined = undefined
let currentParamValueStart = 0 // Index *after* the opening tag of the current param
let currentParamName: ToolParamName | undefined = undefined
// Precompute tags for faster lookups
const toolUseOpenTags = new Map<string, ToolUseName>()
const toolParamOpenTags = new Map<string, ToolParamName>()
for (const name of toolUseNames) {
toolUseOpenTags.set(`<${name}>`, name)
}
for (const name of toolParamNames) {
toolParamOpenTags.set(`<${name}>`, name)
}
// Function calls format detection
const isFunctionCallsOpen = "<function_calls>"
const isFunctionCallsClose = "</function_calls>"
const isInvokeStart = '<invoke name="'
const isInvokeEnd = '">'
const isInvokeClose = "</invoke>"
const isParameterStart = '<parameter name="'
const isParameterNameEnd = '">'
const isParameterClose = "</parameter>"
// Variables for function calls parsing
let inFunctionCalls = false
let currentInvokeName = ""
let currentParameterName = ""
const len = assistantMessage.length
for (let i = 0; i < len; i++) {
const currentCharIndex = i
// --- State: Parsing Function Calls ---
// Check for opening function_calls tag
if (
!inFunctionCalls &&
currentCharIndex >= isFunctionCallsOpen.length - 1 &&
assistantMessage.startsWith(isFunctionCallsOpen, currentCharIndex - isFunctionCallsOpen.length + 1)
) {
// End current text block if one was active
if (currentTextContent) {
currentTextContent.content = assistantMessage
.slice(currentTextContentStart, currentCharIndex - isFunctionCallsOpen.length + 1)
.trim()
currentTextContent.partial = false
if (currentTextContent.content.length > 0) {
contentBlocks.push(currentTextContent)
}
currentTextContent = undefined
}
inFunctionCalls = true
continue
}
// Check for invoke start within function_calls
if (
inFunctionCalls &&
currentInvokeName === "" &&
!currentToolUse && // Don't create a new tool if we already have one
currentCharIndex >= isInvokeStart.length - 1 &&
assistantMessage.startsWith(isInvokeStart, currentCharIndex - isInvokeStart.length + 1)
) {
// Find the end of the invoke name
const nameEndPos = assistantMessage.indexOf(isInvokeEnd, currentCharIndex + 1)
if (nameEndPos !== -1) {
// Extract the invoke name
currentInvokeName = assistantMessage.slice(currentCharIndex + 1, nameEndPos)
i = nameEndPos + isInvokeEnd.length - 1 // Skip to after the '">
// If this is an LS invoke, create a list_files tool
if (currentInvokeName === "LS") {
currentToolUse = {
type: "tool_use",
name: "list_files",
params: {},
partial: true,
}
}
// If this is a Grep invoke, create a search_files tool
if (currentInvokeName === "Grep") {
currentToolUse = {
type: "tool_use",
name: "search_files",
params: {},
partial: true,
}
}
if (currentInvokeName === "Bash") {
currentToolUse = {
type: "tool_use",
name: "execute_command",
params: {},
partial: true,
}
}
if (currentInvokeName === "Read") {
currentToolUse = {
type: "tool_use",
name: "read_file",
params: {},
partial: true,
}
}
if (currentInvokeName === "Write") {
currentToolUse = {
type: "tool_use",
name: "write_to_file",
params: {},
partial: true,
}
}
if (currentInvokeName === "WebFetch") {
currentToolUse = {
type: "tool_use",
name: "web_fetch",
params: {},
partial: true,
}
}
if (currentInvokeName === "AskQuestion") {
currentToolUse = {
type: "tool_use",
name: "ask_followup_question",
params: {},
partial: true,
}
}
if (currentInvokeName === "UseMCPTool") {
currentToolUse = {
type: "tool_use",
name: "use_mcp_tool",
params: {},
partial: true,
}
}
if (currentInvokeName === "AccessMCPResource") {
currentToolUse = {
type: "tool_use",
name: "access_mcp_resource",
params: {},
partial: true,
}
}
if (currentInvokeName === "ListCodeDefinitionNames") {
currentToolUse = {
type: "tool_use",
name: "list_code_definition_names",
params: {},
partial: true,
}
}
if (currentInvokeName === "PlanModeRespond") {
currentToolUse = {
type: "tool_use",
name: "plan_mode_respond",
params: {},
partial: true,
}
}
if (currentInvokeName === "LoadMcpDocumentation") {
currentToolUse = {
type: "tool_use",
name: "load_mcp_documentation",
params: {},
partial: true,
}
}
if (currentInvokeName === "AttemptCompletion") {
currentToolUse = {
type: "tool_use",
name: "attempt_completion",
params: {},
partial: true,
}
}
if (currentInvokeName === "BrowserAction") {
currentToolUse = {
type: "tool_use",
name: "browser_action",
params: {},
partial: true,
}
}
if (currentInvokeName === "NewTask") {
currentToolUse = {
type: "tool_use",
name: "new_task",
params: {},
partial: true,
}
}
// If this is a MultiEdit invoke, create a replace_in_file tool
if (currentInvokeName === "MultiEdit") {
currentToolUse = {
type: "tool_use",
name: "replace_in_file",
params: {},
partial: true,
}
}
continue
}
}
// Check for parameter start within invoke
if (
inFunctionCalls &&
currentInvokeName !== "" &&
currentParameterName === "" &&
currentCharIndex >= isParameterStart.length - 1 &&
assistantMessage.startsWith(isParameterStart, currentCharIndex - isParameterStart.length + 1)
) {
// Find the end of the parameter name
const nameEndPos = assistantMessage.indexOf(isParameterNameEnd, currentCharIndex + 1)
if (nameEndPos !== -1) {
// Extract the parameter name
currentParameterName = assistantMessage.slice(currentCharIndex + 1, nameEndPos)
currentParamValueStart = nameEndPos + isParameterNameEnd.length
i = nameEndPos + isParameterNameEnd.length - 1 // Skip to after the '">'
continue
}
}
// Check for parameter end
if (
inFunctionCalls &&
currentInvokeName !== "" &&
currentParameterName !== "" &&
currentCharIndex >= isParameterClose.length - 1 &&
assistantMessage.startsWith(isParameterClose, currentCharIndex - isParameterClose.length + 1)
) {
// Extract parameter value
const value = assistantMessage.slice(currentParamValueStart, currentCharIndex - isParameterClose.length + 1).trim()
// Map parameter to tool params
if (currentToolUse && currentInvokeName === "LS" && currentParameterName === "path") {
currentToolUse.params["path"] = value
// Default recursive to false - only show top level
currentToolUse.params["recursive"] = "false"
}
if (currentToolUse && currentInvokeName === "Read" && currentParameterName === "file_path") {
currentToolUse.params["path"] = value
}
if (currentToolUse && currentInvokeName === "PlanModeRespond" && currentParameterName === "response") {
currentToolUse.params["response"] = value
}
if (currentToolUse && currentInvokeName === "WebFetch" && currentParameterName === "url") {
currentToolUse.params["url"] = value
}
if (currentToolUse && currentInvokeName === "ListCodeDefinitionNames" && currentParameterName === "path") {
currentToolUse.params["path"] = value
}
if (currentToolUse && currentInvokeName === "NewTask" && currentParameterName === "context") {
currentToolUse.params["context"] = value
}
// Map parameter to tool params for Grep
if (currentToolUse && currentInvokeName === "Grep") {
if (currentParameterName === "pattern") {
currentToolUse.params["regex"] = value
} else if (currentParameterName === "path") {
currentToolUse.params["path"] = value
} else if (currentParameterName === "include") {
currentToolUse.params["file_pattern"] = value
}
}
if (currentToolUse && currentInvokeName === "Bash") {
if (currentParameterName === "command") {
currentToolUse.params["command"] = value
} else if (currentParameterName === "requires_approval") {
currentToolUse.params["requires_approval"] = value === "true" ? "true" : "false"
}
}
if (currentToolUse && currentInvokeName === "Write") {
if (currentParameterName === "file_path") {
currentToolUse.params["path"] = value
} else if (currentParameterName === "content") {
currentToolUse.params["content"] = value
}
}
if (currentToolUse && currentInvokeName === "AskQuestion") {
if (currentParameterName === "question") {
currentToolUse.params["question"] = value
} else if (currentParameterName === "options") {
currentToolUse.params["options"] = value
}
}
if (currentToolUse && currentInvokeName === "UseMCPTool") {
if (currentParameterName === "server_name") {
currentToolUse.params["server_name"] = value
} else if (currentParameterName === "tool_name") {
currentToolUse.params["tool_name"] = value
} else if (currentParameterName === "arguments") {
currentToolUse.params["arguments"] = value
}
}
if (currentToolUse && currentInvokeName === "AccessMCPResource") {
if (currentParameterName === "server_name") {
currentToolUse.params["server_name"] = value
} else if (currentParameterName === "uri") {
currentToolUse.params["uri"] = value
}
}
if (currentToolUse && currentInvokeName === "AttemptCompletion") {
if (currentParameterName === "result") {
currentToolUse.params["result"] = value
}
if (currentParameterName === "command") {
currentToolUse.params["command"] = value
}
}
if (currentToolUse && currentInvokeName === "BrowserAction") {
if (currentParameterName === "action") {
currentToolUse.params["action"] = value
} else if (currentParameterName === "url") {
currentToolUse.params["url"] = value
} else if (currentParameterName === "coordinate") {
currentToolUse.params["coordinate"] = value
} else if (currentParameterName === "text") {
currentToolUse.params["text"] = value
}
}
// Map parameter to tool params for MultiEdit
if (currentToolUse && currentInvokeName === "MultiEdit") {
if (currentParameterName === "file_path") {
currentToolUse.params["path"] = value
} else if (currentParameterName === "edits") {
// Save the value to the diff parameter for replace_in_file
currentToolUse.params["diff"] = value
}
}
currentParameterName = ""
continue
}
// Check for invoke end
if (
inFunctionCalls &&
currentInvokeName !== "" &&
currentCharIndex >= isInvokeClose.length - 1 &&
assistantMessage.startsWith(isInvokeClose, currentCharIndex - isInvokeClose.length + 1)
) {
// If we have a tool use from this invoke, finalize it
if (
currentToolUse &&
(currentInvokeName === "LS" ||
currentInvokeName === "Grep" ||
currentInvokeName === "Bash" ||
currentInvokeName === "Read" ||
currentInvokeName === "Write" ||
currentInvokeName === "WebFetch" ||
currentInvokeName === "AskQuestion" ||
currentInvokeName === "UseMCPTool" ||
currentInvokeName === "AccessMCPResource" ||
currentInvokeName === "ListCodeDefinitionNames" ||
currentInvokeName === "PlanModeRespond" ||
currentInvokeName === "LoadMcpDocumentation" ||
currentInvokeName === "AttemptCompletion" ||
currentInvokeName === "BrowserAction" ||
currentInvokeName === "NewTask" ||
currentInvokeName === "MultiEdit")
) {
currentToolUse.partial = false
contentBlocks.push(currentToolUse)
currentToolUse = undefined
}
currentInvokeName = ""
continue
}
// Check for function_calls end
if (
inFunctionCalls &&
currentCharIndex >= isFunctionCallsClose.length - 1 &&
assistantMessage.startsWith(isFunctionCallsClose, currentCharIndex - isFunctionCallsClose.length + 1)
) {
inFunctionCalls = false
currentTextContentStart = currentCharIndex + 1
// Start a new text content block for any text after function_calls
currentTextContent = {
type: "text",
content: "",
partial: true,
}
continue
}
// Skip normal parsing when inside function_calls
if (inFunctionCalls) {
continue
}
// --- State: Parsing a Tool Parameter ---
if (currentToolUse && currentParamName) {
const closeTag = `</${currentParamName}>`
// Check if the string *ending* at index `i` matches the closing tag
if (
currentCharIndex >= closeTag.length - 1 &&
assistantMessage.startsWith(
closeTag,
currentCharIndex - closeTag.length + 1, // Start checking from potential start of tag
)
) {
// Found the closing tag for the parameter
const value = assistantMessage
.slice(
currentParamValueStart, // Start after the opening tag
currentCharIndex - closeTag.length + 1, // End before the closing tag
)
.trim()
currentToolUse.params[currentParamName] = value
currentParamName = undefined // Go back to parsing tool content
// We don't continue loop here, need to check for tool close or other params at index i
} else {
continue // Still inside param value, move to next char
}
}
// --- State: Parsing a Tool Use (but not a specific parameter) ---
if (currentToolUse && !currentParamName) {
// Ensure we are not inside a parameter already
// Check if starting a new parameter
let startedNewParam = false
for (const [tag, paramName] of toolParamOpenTags.entries()) {
if (currentCharIndex >= tag.length - 1 && assistantMessage.startsWith(tag, currentCharIndex - tag.length + 1)) {
currentParamName = paramName
currentParamValueStart = currentCharIndex + 1 // Value starts after the tag
startedNewParam = true
break
}
}
if (startedNewParam) {
continue // Handled start of param, move to next char
}
// Check if closing the current tool use
const toolCloseTag = `</${currentToolUse.name}>`
if (
currentCharIndex >= toolCloseTag.length - 1 &&
assistantMessage.startsWith(toolCloseTag, currentCharIndex - toolCloseTag.length + 1)
) {
// End of the tool use found
// Special handling for content params *before* finalizing the tool
const toolContentSlice = assistantMessage.slice(
currentToolUseStart, // From after the tool opening tag
currentCharIndex - toolCloseTag.length + 1, // To before the tool closing tag
)
// Check if content parameter needs special handling (write_to_file/new_rule)
// This check is important if the closing </content> tag was missed by the parameter parsing logic
// (e.g., if content is empty or parsing logic prioritizes tool close)
const contentParamName: ToolParamName = "content"
if (
currentToolUse.name === "write_to_file" /* || currentToolUse.name === "new_rule" */ &&
toolContentSlice.includes(`<${contentParamName}>`)
) {
const contentStartTag = `<${contentParamName}>`
const contentEndTag = `</${contentParamName}>`
const contentStart = toolContentSlice.indexOf(contentStartTag)
// Use lastIndexOf for robustness against nested tags
const contentEnd = toolContentSlice.lastIndexOf(contentEndTag)
if (contentStart !== -1 && contentEnd !== -1 && contentEnd > contentStart) {
const contentValue = toolContentSlice.slice(contentStart + contentStartTag.length, contentEnd).trim()
currentToolUse.params[contentParamName] = contentValue
}
}
currentToolUse.partial = false // Mark as complete
contentBlocks.push(currentToolUse)
currentToolUse = undefined // Reset state
currentTextContentStart = currentCharIndex + 1 // Potential text starts after this tag
continue // Move to next char
}
// If not starting a param and not closing the tool, continue accumulating tool content implicitly
continue
}
// --- State: Parsing Text / Looking for Tool Start ---
if (!currentToolUse) {
// Check if starting a new tool use
let startedNewTool = false
for (const [tag, toolName] of toolUseOpenTags.entries()) {
if (currentCharIndex >= tag.length - 1 && assistantMessage.startsWith(tag, currentCharIndex - tag.length + 1)) {
// End current text block if one was active
if (currentTextContent) {
currentTextContent.content = assistantMessage
.slice(
currentTextContentStart, // From where text started
currentCharIndex - tag.length + 1, // To before the tool tag starts
)
.trim()
currentTextContent.partial = false // Ended because tool started
if (currentTextContent.content.length > 0) {
contentBlocks.push(currentTextContent)
}
currentTextContent = undefined
} else {
// Check for any text between the last block and this tag
const potentialText = assistantMessage
.slice(
currentTextContentStart, // From where text *might* have started
currentCharIndex - tag.length + 1, // To before the tool tag starts
)
.trim()
if (potentialText.length > 0) {
contentBlocks.push({
type: "text",
content: potentialText,
partial: false,
})
}
}
// Start the new tool use
currentToolUse = {
type: "tool_use",
name: toolName,
params: {},
partial: true, // Assume partial until closing tag is found
}
currentToolUseStart = currentCharIndex + 1 // Tool content starts after the opening tag
startedNewTool = true
break
}
}
if (startedNewTool) {
continue // Handled start of tool, move to next char
}
// If not starting a tool, it must be text content
if (!currentTextContent) {
// Start a new text block if we aren't already in one
currentTextContentStart = currentCharIndex // Text starts at the current character
// Check if the current char is the start of potential text *immediately* after a tag
// This needs the previous state - simpler to let slicing handle it later.
// Resetting start index accurately is key.
// It should be the index *after* the last processed tag.
// The logic managing currentTextContentStart after closing tags handles this.
currentTextContent = {
type: "text",
content: "", // Will be determined by slicing at the end or when a tool starts
partial: true,
}
}
// Continue accumulating text implicitly; content is extracted later.
}
} // End of loop
// --- Finalization after loop ---
// Finalize any open parameter within an open tool use
if (currentToolUse && currentParamName) {
currentToolUse.params[currentParamName] = assistantMessage
.slice(currentParamValueStart) // From param start to end of string
.trim()
// Tool use remains partial
}
// Finalize any open tool use (which might contain the finalized partial param)
if (currentToolUse) {
// Tool use is partial because the loop finished before its closing tag
contentBlocks.push(currentToolUse)
}
// Finalize any trailing text content
// Only possible if a tool use wasn't open at the very end
else if (currentTextContent) {
currentTextContent.content = assistantMessage
.slice(currentTextContentStart) // From text start to end of string
.trim()
// Text is partial because the loop finished
if (currentTextContent.content.length > 0) {
contentBlocks.push(currentTextContent)
}
}
return contentBlocks
}
+236 -16826
View File
File diff suppressed because it is too large Load Diff
+6 -3
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.12",
"version": "3.25.2",
"icon": "assets/icons/icon.png",
"engines": {
"vscode": "^1.84.0"
@@ -362,8 +362,8 @@
"test:unit": "TS_NODE_PROJECT='./tsconfig.unit-test.json' mocha",
"test:coverage": "vscode-test --coverage",
"e2e": "playwright test -c playwright.config.ts",
"test:e2e": "playwright install && vsce package --no-dependencies --out dist/e2e.vsix && node src/test/e2e/utils/build.js && playwright test",
"test:e2e:optimal": "vsce package --no-dependencies --out dist/e2e.vsix && node src/test/e2e/utils/build.js && playwright test",
"test:e2e": "playwright install && vsce package --no-dependencies --allow-package-secrets sendgrid --out dist/e2e.vsix && node src/test/e2e/utils/build.mjs && playwright test",
"test:e2e:optimal": "vsce package --no-dependencies --allow-package-secrets sendgrid --out dist/e2e.vsix && node src/test/e2e/utils/build.mjs && playwright test",
"install:all": "npm install && cd webview-ui && npm install",
"dev:webview": "cd webview-ui && npm run dev",
"build:webview": "cd webview-ui && npm run build",
@@ -447,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",
@@ -491,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"
+5 -5
View File
@@ -6,13 +6,18 @@ const isWindow = process?.platform?.startsWith("win")
export default defineConfig({
workers: 1,
retries: 1,
forbidOnly: isCI,
testDir: "src/test/e2e",
testMatch: /.*\.test\.ts/,
timeout: isCI || isWindow ? 40000 : 20000,
expect: {
timeout: isCI || isWindow ? 5000 : 2000,
},
fullyParallel: true,
reporter: isCI ? [["github"], ["list"]] : [["list"]],
use: {
video: "retain-on-failure",
},
projects: [
{
name: "setup test environment",
@@ -20,12 +25,7 @@ export default defineConfig({
},
{
name: "e2e tests",
testMatch: /.*\.test\.ts/,
dependencies: ["setup test environment"],
},
{
name: "cleanup test environment",
testMatch: /global\.teardown\.ts/,
},
],
})
+2
View File
@@ -36,6 +36,8 @@ service AccountService {
rpc getUserOrganizations(EmptyRequest) returns (UserOrganizationsResponse);
rpc setUserOrganization(UserOrganizationUpdateRequest) returns (Empty);
rpc openrouterAuthClicked(EmptyRequest) returns (Empty);
}
message AuthStateChangedRequest {
+2
View File
@@ -42,6 +42,7 @@ message BrowserSettings {
optional bool remote_browser_enabled = 3;
optional string chrome_executable_path = 4;
optional bool disable_tool_use = 5;
optional string custom_args = 6;
}
message UpdateBrowserSettingsRequest {
@@ -51,4 +52,5 @@ message UpdateBrowserSettingsRequest {
optional bool remote_browser_enabled = 4;
optional string chrome_executable_path = 5;
optional bool disable_tool_use = 6;
optional string custom_args = 7;
}
+29
View File
@@ -0,0 +1,29 @@
syntax = "proto3";
package cline;
import "cline/common.proto";
option java_package = "bot.cline.proto";
option java_multiple_files = true;
// Service for running IDE commands, for example context menu actions,
// commands, etc.
// In contrast to the rest of the ProtoBus services, these are
// intended to be called by the IDE directly instead of through the webview,
// because they are triggered by interactions in the IDE.
service CommandsService {
rpc addToCline(CommandContext) returns (Empty);
rpc fixWithCline(CommandContext) returns (Empty);
rpc explainWithCline(CommandContext) returns (Empty);
rpc improveWithCline(CommandContext) returns (Empty);
}
message CommandContext {
// The absolute path of the current file.
optional string file_path = 1;
// The selected source text.
optional string selected_text = 2;
// The language identifier for the current file.
optional string language = 3;
// Any diagnostic problems for the current file.
repeated cline.Diagnostic diagnostics = 4;
}
+34
View File
@@ -55,6 +55,11 @@ message Boolean {
bool value = 1;
}
// the same as Boolean, but avoiding name conflicts
message BooleanResponse {
bool value = 1;
}
message StringArray {
repeated string values = 1;
}
@@ -68,3 +73,32 @@ message KeyValuePair {
string key = 1;
string value = 2;
}
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;
}
+15 -2
View File
@@ -55,8 +55,14 @@ service FileService {
// Toggles a workflow on or off
rpc toggleWorkflow(ToggleWorkflowRequest) returns (ClineRulesToggles);
// Subscribe to workspace file updates
rpc subscribeToWorkspaceUpdates(EmptyRequest) returns (stream StringArray);
// Check if file exists in the project
rpc ifFileExistsRelativePath(StringRequest) returns (BooleanResponse);
// Open a file in editor by a relative path
rpc openFileRelativePath(StringRequest) returns (Empty);
// Opens or creates a focus chain checklist markdown file for editing
rpc openFocusChainFile(StringRequest) returns (Empty);
}
// Response for refreshRules operation
@@ -87,12 +93,19 @@ message RelativePaths {
repeated string paths = 1;
}
// Enum for file search type filtering
enum FileSearchType {
FILE = 0;
FOLDER = 1;
}
// Request for file search operations
message FileSearchRequest {
Metadata metadata = 1;
string query = 2; // Search query string
optional string mentions_request_id = 3; // Optional request ID for tracking requests
optional int32 limit = 4; // Optional limit for results (default: 20)
optional FileSearchType selected_type = 5; // Optional selected type filter
}
// Result for file search operations
+34 -32
View File
@@ -25,7 +25,7 @@ service ModelsService {
rpc subscribeToOpenRouterModels(EmptyRequest) returns (stream OpenRouterCompatibleModelInfo);
// Updates API configuration
rpc updateApiConfigurationProto(UpdateApiConfigurationRequest) returns (Empty);
// Refreshes and returns Groq models
// Refreshes and returns Groq models
rpc refreshGroqModels(EmptyRequest) returns (OpenRouterCompatibleModelInfo);
// Refreshes and returns Baseten models
rpc refreshBasetenModels(EmptyRequest) returns (OpenRouterCompatibleModelInfo);
@@ -175,7 +175,7 @@ message ModelsApiConfiguration {
// Global configuration fields (not mode-specific)
optional string api_key = 1;
optional string cline_api_key = 2;
optional string task_id = 3;
optional string ulid = 3;
optional string lite_llm_base_url = 4;
optional string lite_llm_api_key = 5;
optional bool lite_llm_use_prompt_cache = 6;
@@ -205,36 +205,38 @@ message ModelsApiConfiguration {
optional string open_ai_native_api_key = 30;
optional string deep_seek_api_key = 31;
optional string requesty_api_key = 32;
optional string together_api_key = 33;
optional string fireworks_api_key = 34;
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;
optional string azure_api_version = 40;
optional string qwen_api_line = 41;
optional string nebius_api_key = 42;
optional string asksage_api_url = 43;
optional string asksage_api_key = 44;
optional string xai_api_key = 45;
optional string sambanova_api_key = 46;
optional string cerebras_api_key = 47;
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;
optional string sap_ai_core_token_url = 52;
optional string sap_ai_core_base_url = 53;
optional string moonshot_api_key = 54;
optional string moonshot_api_line = 55;
optional string aws_authentication = 56;
optional string aws_bedrock_api_key = 57;
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 requesty_base_url = 33;
optional string together_api_key = 34;
optional string fireworks_api_key = 35;
optional int32 fireworks_model_max_completion_tokens = 36;
optional int32 fireworks_model_max_tokens = 37;
optional string qwen_api_key = 38;
optional string doubao_api_key = 39;
optional string mistral_api_key = 40;
optional string azure_api_version = 41;
optional string qwen_api_line = 42;
optional string nebius_api_key = 43;
optional string asksage_api_url = 44;
optional string asksage_api_key = 45;
optional string xai_api_key = 46;
optional string sambanova_api_key = 47;
optional string cerebras_api_key = 48;
optional int32 request_timeout_ms = 49;
optional string sap_ai_core_client_id = 50;
optional string sap_ai_core_client_secret = 51;
optional string sap_ai_resource_group = 52;
optional string sap_ai_core_token_url = 53;
optional string sap_ai_core_base_url = 54;
optional string moonshot_api_key = 55;
optional string moonshot_api_line = 56;
optional string aws_authentication = 57;
optional string aws_bedrock_api_key = 58;
optional string cline_account_id = 59;
optional string groq_api_key = 60;
optional string hugging_face_api_key = 61;
optional string huawei_cloud_maas_api_key = 62;
optional string baseten_api_key = 63;
optional string ollama_api_key = 64;
// Plan mode configurations
optional ApiProvider plan_mode_api_provider = 100;
+47 -27
View File
@@ -52,6 +52,18 @@ enum PlanActMode {
ACT = 1;
}
enum OpenaiReasoningEffort {
LOW = 0;
MEDIUM = 1;
HIGH = 2;
}
enum McpDisplayMode {
RICH = 0;
PLAIN = 1;
MARKDOWN = 2;
}
message ChatContent {
optional string message = 1;
repeated string images = 2;
@@ -105,12 +117,13 @@ message UpdateSettingsRequest {
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 McpDisplayMode mcp_display_mode = 11;
optional int32 terminal_output_line_limit = 12;
optional PlanActMode mode = 13;
optional string preferred_language = 14;
optional string openai_reasoning_effort = 15;
optional OpenaiReasoningEffort openai_reasoning_effort = 15;
optional bool strict_plan_mode_enabled = 16;
optional FocusChainSettings focus_chain_settings = 17;
}
// Complete API Configuration message
@@ -118,7 +131,7 @@ message ApiConfiguration {
// Global configuration fields (not mode-specific)
optional string api_key = 1; // anthropic
optional string cline_api_key = 2;
optional string task_id = 3;
optional string ulid = 3;
optional string lite_llm_base_url = 4;
optional string lite_llm_api_key = 5;
optional bool lite_llm_use_prompt_cache = 6;
@@ -148,30 +161,32 @@ message ApiConfiguration {
optional string openai_native_api_key = 30;
optional string deep_seek_api_key = 31;
optional string requesty_api_key = 32;
optional string together_api_key = 33;
optional string fireworks_api_key = 34;
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;
optional string azure_api_version = 40;
optional string qwen_api_line = 41;
optional string nebius_api_key = 42;
optional string asksage_api_url = 43;
optional string asksage_api_key = 44;
optional string xai_api_key = 45;
optional string sambanova_api_key = 46;
optional string cerebras_api_key = 47;
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;
optional string sap_ai_core_token_url = 52;
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 requesty_base_url = 33;
optional string together_api_key = 34;
optional string fireworks_api_key = 35;
optional int32 fireworks_model_max_completion_tokens = 36;
optional int32 fireworks_model_max_tokens = 37;
optional string qwen_api_key = 38;
optional string doubao_api_key = 39;
optional string mistral_api_key = 40;
optional string azure_api_version = 41;
optional string qwen_api_line = 42;
optional string nebius_api_key = 43;
optional string asksage_api_url = 44;
optional string asksage_api_key = 45;
optional string xai_api_key = 46;
optional string sambanova_api_key = 47;
optional string cerebras_api_key = 48;
optional int32 request_timeout_ms = 49;
optional string sap_ai_core_client_id = 50;
optional string sap_ai_core_client_secret = 51;
optional string sap_ai_resource_group = 52;
optional string sap_ai_core_token_url = 53;
optional string sap_ai_core_base_url = 54;
optional string moonshot_api_key = 55;
optional string moonshot_api_line = 56;
optional string huawei_cloud_maas_api_key = 57;
optional string ollama_api_key = 58;
// Plan mode configurations
optional string plan_mode_api_provider = 100;
@@ -235,6 +250,11 @@ message UpdateTerminalConnectionTimeoutRequest {
optional int32 timeout_ms = 1;
}
message FocusChainSettings {
bool enabled = 1;
int32 remind_cline_interval = 2;
}
message UpdateTerminalConnectionTimeoutResponse {
optional int32 timeout_ms = 1;
}
+6 -1
View File
@@ -41,6 +41,7 @@ enum ClineAsk {
NEW_TASK = 13;
CONDENSE = 14;
REPORT_BUG = 15;
SUMMARIZE_TASK = 16;
}
// Enum for ClineSay types
@@ -72,6 +73,7 @@ enum ClineSay {
CHECKPOINT_CREATED = 24;
LOAD_MCP_DOCUMENTATION = 25;
INFO = 26;
TASK_PROGRESS = 27;
}
// Enum for ClineSayTool tool types
@@ -227,7 +229,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 +269,7 @@ service UiService {
// Opens a URL in the default browser
rpc openUrl(StringRequest) returns (Empty);
// Opens the Cline walkthrough
rpc openWalkthrough(EmptyRequest) returns (Empty);
}
+29 -7
View File
@@ -10,17 +10,28 @@ import "cline/common.proto";
service DiffService {
// Open the diff view/editor.
rpc openDiff(OpenDiffRequest) returns (OpenDiffResponse);
// Get the contents of the diff view.
rpc getDocumentText(GetDocumentTextRequest) returns (GetDocumentTextResponse);
// Replace a text selection in the diff.
rpc replaceText(ReplaceTextRequest) returns (ReplaceTextResponse);
rpc scrollDiff(ScrollDiffRequest) returns (ScrollDiffResponse);
// Truncate the diff document.
rpc truncateDocument(TruncateDocumentRequest) returns (TruncateDocumentResponse);
// Save the diff document.
rpc saveDocument(SaveDocumentRequest) returns (SaveDocumentResponse);
// Close the diff editor UI.
rpc closeDiff(CloseDiffRequest) returns (CloseDiffResponse);
// Close all the diff editor windows/tabs.
// Any diff editors with unsaved content should not be closed.
rpc closeAllDiffs(CloseAllDiffsRequest) returns (CloseAllDiffsResponse);
// Display a diff view comparing before/after states for multiple files.
// Content is passed as in-memory data, not read from the file system.
rpc openMultiFileDiff(OpenMultiFileDiffRequest) returns (OpenMultiFileDiffResponse);
}
message OpenDiffRequest {
@@ -70,12 +81,9 @@ message TruncateDocumentRequest {
message TruncateDocumentResponse {}
message CloseDiffRequest {
optional cline.Metadata metadata = 1;
optional string diff_id = 2;
}
message CloseAllDiffsRequest {}
message CloseDiffResponse {}
message CloseAllDiffsResponse {}
message SaveDocumentRequest {
optional cline.Metadata metadata = 1;
@@ -83,3 +91,17 @@ message SaveDocumentRequest {
}
message SaveDocumentResponse {}
message OpenMultiFileDiffRequest {
optional string title = 1;
repeated ContentDiff diffs = 2;
}
message ContentDiff {
// The absolute file path.
optional string file_path = 1;
optional string left_content = 2;
optional string right_content = 3;
}
message OpenMultiFileDiffResponse {}
+3
View File
@@ -13,4 +13,7 @@ service EnvService {
// Reads text from the system clipboard.
rpc clipboardReadText(cline.EmptyRequest) returns (cline.String);
// Returns a stable machine identifier for telemetry distinctId purposes.
rpc getMachineId(cline.EmptyRequest) returns (cline.String);
}
+17
View File
@@ -0,0 +1,17 @@
syntax = "proto3";
package host;
option java_package = "bot.cline.host.proto";
option java_multiple_files = true;
// This is for use in integration tests to get the contents of the webview.
service TestingService {
rpc getWebviewHtml(GetWebviewHtmlRequest) returns (GetWebviewHtmlResponse);
}
message GetWebviewHtmlRequest {
}
message GetWebviewHtmlResponse {
optional string html = 1;
}
+12
View File
@@ -4,6 +4,8 @@ package host;
option java_package = "bot.cline.host.proto";
option java_multiple_files = true;
import "cline/common.proto";
// Provides methods for working with workspaces/projects.
service WorkspaceService {
// Returns a list of the top level directories of the workspace.
@@ -12,6 +14,8 @@ service WorkspaceService {
// 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 {
@@ -34,3 +38,11 @@ message SaveOpenDocumentIfDirtyResponse {
// Returns true if the document was saved.
optional bool was_saved = 1;
}
message GetDiagnosticsRequest {
optional cline.Metadata metadata = 1;
}
message GetDiagnosticsResponse {
repeated cline.FileDiagnostics file_diagnostics = 1;
}
+7 -2
View File
@@ -23,9 +23,14 @@ export function getFqn(name) {
return typeNameToFQN.get(name)
}
export async function loadProtoDescriptorSet() {
export async function getPackageDefinition() {
const descriptorBuffer = await fs.readFile(DESCRIPTOR_SET)
const packageDefinition = protoLoader.loadFileDescriptorSetFromBuffer(descriptorBuffer)
const options = { longs: Number } // Encode int64 fields as numbers
return protoLoader.loadFileDescriptorSetFromBuffer(descriptorBuffer, options)
}
export async function loadProtoDescriptorSet() {
const packageDefinition = await getPackageDefinition()
return grpc.loadPackageDefinition(packageDefinition)
}
+8 -1
View File
@@ -1,10 +1,16 @@
#!/usr/bin/env bash
set -eu #x
# This installs the cline-core app to the user's home directory,
# and starts the service.
if [[ "${1:-}" == "-h" ]]; then
./scripts/test-hostbridge-server.ts &
fi
CORE_DIR=~/.cline/core
INSTALL_DIR=$CORE_DIR/0.0.1
LOG_FILE=~/.cline/cline-core-service.log
ZIP_FILE=standalone.zip
ZIP=dist-standalone/${ZIP_FILE}
@@ -18,4 +24,5 @@ cd $INSTALL_DIR
unp $ZIP_FILE > /dev/null
pkill -f cline-core.js || true
NODE_PATH=./node_modules DEV_WORKSPACE_FOLDER=/tmp/ node cline-core.js
NODE_PATH=./node_modules DEV_WORKSPACE_FOLDER=/tmp/ node cline-core.js 2>&1 | tee $LOG_FILE
+139
View File
@@ -0,0 +1,139 @@
#!/usr/bin/env npx tsx
import * as grpc from "@grpc/grpc-js"
import * as health from "grpc-health-check"
import { ReflectionService } from "@grpc/reflection"
import * as os from "os"
import { host } from "src/generated/grpc-js/index"
import { getPackageDefinition } from "./proto-utils.mjs"
export async function startTestHostBridgeServer() {
const server = new grpc.Server()
// Set up health check
const healthImpl = new health.HealthImplementation({ "": "SERVING" })
healthImpl.addToServer(server)
// Add host bridge services using the mock implementations
server.addService(host.WorkspaceServiceService, createMockService<host.WorkspaceServiceServer>("WorkspaceService"))
server.addService(host.WindowServiceService, createMockService<host.WindowServiceServer>("WindowService"))
server.addService(host.EnvServiceService, createMockService<host.EnvServiceServer>("EnvService"))
server.addService(host.DiffServiceService, createMockService<host.DiffServiceServer>("DiffService"))
server.addService(host.WatchServiceService, createMockService<host.WatchServiceServer>("WatchService"))
// Load package definition for reflection service
const packageDefinition = await getPackageDefinition()
// Filter service names to only include host services
const hostBridgeServiceNames = Object.keys(packageDefinition).filter(
(name) => name.startsWith("host.") || name.startsWith("grpc.health"),
)
const reflection = new ReflectionService(packageDefinition, {
services: hostBridgeServiceNames,
})
reflection.addToServer(server)
const bindAddress = process.env.HOST_BRIDGE_ADDRESS || `127.0.0.1:26041`
server.bindAsync(bindAddress, grpc.ServerCredentials.createInsecure(), (err) => {
if (err) {
console.error(`Failed to bind test host bridge server to ${bindAddress}:`, err)
process.exit(1)
}
server.start()
console.log(`Test HostBridge gRPC server listening on ${bindAddress}`)
})
}
/**
* Creates a mock gRPC service implementation using Proxy
* @param serviceName Name of the service for logging
* @returns A proxy that implements the service interface
*/
function createMockService<T extends grpc.UntypedServiceImplementation>(serviceName: string): T {
const handler: ProxyHandler<T> = {
get(_target, prop) {
// Return a function that handles the gRPC call
return (call: any, callback: any) => {
console.log(`Hostbridge: ${serviceName}.${String(prop)} called with:`, call.request)
// Special cases that need specific return values
switch (prop) {
case "getWorkspacePaths":
callback(null, {
paths: ["/test-workspace"],
})
return
case "getMachineId":
callback(null, {
value: "fake-machine-id-" + os.hostname(),
})
return
case "clipboardReadText":
callback(null, {
value: "",
})
return
case "getWebviewHtml":
callback(null, {
html: "<html><body>Fake Webview</body></html>",
})
return
case "showTextDocument":
callback(null, {
document_path: call.request?.path || "",
view_column: 1,
is_active: true,
})
return
case "openDiff":
callback(null, {
diff_id: "fake-diff-" + Date.now(),
})
return
case "getDocumentText":
callback(null, {
content: "",
})
return
case "getOpenTabs":
case "getVisibleTabs":
case "showOpenDialogue":
callback(null, {
paths: [],
})
return
case "getDiagnostics":
callback(null, {
file_diagnostics: [],
})
return
// For streaming methods (like subscribeToFile)
case "subscribeToFile":
// Just end the stream immediately
call.end()
return
}
// Default: return empty object for all other methods
callback(null, {})
}
},
}
return new Proxy({} as T, handler)
}
if (require.main === module) {
startTestHostBridgeServer().catch((err) => {
console.error("Failed to start test host bridge server:", err)
process.exit(1)
})
}
+8 -4
View File
@@ -98,7 +98,7 @@ function createHandlerForProvider(
mode === "plan" ? options.planModeThinkingBudgetTokens : options.actModeThinkingBudgetTokens,
geminiApiKey: options.geminiApiKey,
geminiBaseUrl: options.geminiBaseUrl,
taskId: options.taskId,
ulid: options.ulid,
})
case "openai":
return new OpenAiHandler({
@@ -113,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,
@@ -131,7 +132,7 @@ function createHandlerForProvider(
thinkingBudgetTokens:
mode === "plan" ? options.planModeThinkingBudgetTokens : options.actModeThinkingBudgetTokens,
apiModelId: mode === "plan" ? options.planModeApiModelId : options.actModeApiModelId,
taskId: options.taskId,
ulid: options.ulid,
})
case "openai-native":
return new OpenAiNativeHandler({
@@ -146,6 +147,7 @@ function createHandlerForProvider(
})
case "requesty":
return new RequestyHandler({
requestyBaseUrl: options.requestyBaseUrl,
requestyApiKey: options.requestyApiKey,
reasoningEffort: mode === "plan" ? options.planModeReasoningEffort : options.actModeReasoningEffort,
thinkingBudgetTokens:
@@ -192,7 +194,7 @@ function createHandlerForProvider(
case "cline":
return new ClineHandler({
clineAccountId: options.clineAccountId,
taskId: options.taskId,
ulid: options.ulid,
reasoningEffort: mode === "plan" ? options.planModeReasoningEffort : options.actModeReasoningEffort,
thinkingBudgetTokens:
mode === "plan" ? options.planModeThinkingBudgetTokens : options.actModeThinkingBudgetTokens,
@@ -209,7 +211,7 @@ function createHandlerForProvider(
thinkingBudgetTokens:
mode === "plan" ? options.planModeThinkingBudgetTokens : options.actModeThinkingBudgetTokens,
liteLlmUsePromptCache: options.liteLlmUsePromptCache,
taskId: options.taskId,
ulid: options.ulid,
})
case "moonshot":
return new MoonshotHandler({
@@ -273,6 +275,8 @@ function createHandlerForProvider(
sapAiResourceGroup: options.sapAiResourceGroup,
sapAiCoreBaseUrl: options.sapAiCoreBaseUrl,
apiModelId: mode === "plan" ? options.planModeApiModelId : options.actModeApiModelId,
thinkingBudgetTokens:
mode === "plan" ? options.planModeThinkingBudgetTokens : options.actModeThinkingBudgetTokens,
})
case "claude-code":
return new ClaudeCodeHandler({
+16 -22
View File
@@ -1,8 +1,8 @@
import { Anthropic } from "@anthropic-ai/sdk"
import { Stream as AnthropicStream } from "@anthropic-ai/sdk/streaming"
import { withRetry } from "../retry"
import { anthropicDefaultModelId, AnthropicModelId, anthropicModels, ApiHandlerOptions, ModelInfo } from "@shared/api"
import { anthropicDefaultModelId, AnthropicModelId, anthropicModels, CLAUDE_SONNET_4_1M_SUFFIX, ModelInfo } from "@shared/api"
import { ApiHandler } from "../index"
import { withRetry } from "../retry"
import { ApiStream } from "../transform/stream"
interface AnthropicHandlerOptions {
@@ -43,7 +43,11 @@ export class AnthropicHandler implements ApiHandler {
const model = this.getModel()
let stream: AnthropicStream<Anthropic.RawMessageStreamEvent>
const modelId = model.id
const modelId = model.id.endsWith(CLAUDE_SONNET_4_1M_SUFFIX)
? model.id.slice(0, -CLAUDE_SONNET_4_1M_SUFFIX.length)
: model.id
const enable1mContextWindow = model.id.endsWith(CLAUDE_SONNET_4_1M_SUFFIX)
const budget_tokens = this.options.thinkingBudgetTokens || 0
const reasoningOn = (modelId.includes("3-7") || modelId.includes("4-")) && budget_tokens !== 0 ? true : false
@@ -117,25 +121,15 @@ export class AnthropicHandler implements ApiHandler {
stream: true,
},
(() => {
// prompt caching: https://x.com/alexalbert__/status/1823751995901272068
// https://github.com/anthropics/anthropic-sdk-typescript?tab=readme-ov-file#default-headers
// https://github.com/anthropics/anthropic-sdk-typescript/commit/c920b77fc67bd839bfeb6716ceab9d7c9bbe7393
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":
case "claude-3-opus-20240229":
case "claude-3-haiku-20240307":
return {
headers: {
"anthropic-beta": "prompt-caching-2024-07-31",
},
}
default:
return undefined
// 1m context window beta header
if (enable1mContextWindow) {
return {
headers: {
"anthropic-beta": "context-1m-2025-08-07",
},
}
} else {
return undefined
}
})(),
)
+164 -12
View File
@@ -2,13 +2,14 @@ import { Anthropic } from "@anthropic-ai/sdk"
import { withRetry } from "../retry"
import { ApiHandler } from "../"
import { convertToR1Format } from "../transform/r1-format"
import { bedrockDefaultModelId, BedrockModelId, bedrockModels, ModelInfo } from "@shared/api"
import { bedrockDefaultModelId, BedrockModelId, bedrockModels, CLAUDE_SONNET_4_1M_SUFFIX, ModelInfo } from "@shared/api"
import { calculateApiCostOpenAI } from "../../utils/cost"
import { ApiStream } from "../transform/stream"
import { fromNodeProviderChain } from "@aws-sdk/credential-providers"
import {
BedrockRuntimeClient,
ConversationRole,
ConverseCommand,
ConverseStreamCommand,
InvokeModelWithResponseStreamCommand,
} from "@aws-sdk/client-bedrock-runtime"
@@ -117,7 +118,14 @@ export class AwsBedrockHandler implements ApiHandler {
@withRetry({ maxRetries: 4 })
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
// cross region inference requires prefixing the model id with the region
const modelId = await this.getModelId()
const rawModelId = await this.getModelId()
const modelId = rawModelId.endsWith(CLAUDE_SONNET_4_1M_SUFFIX)
? rawModelId.slice(0, -CLAUDE_SONNET_4_1M_SUFFIX.length)
: rawModelId
const enable1mContextWindow = rawModelId.endsWith(CLAUDE_SONNET_4_1M_SUFFIX)
const model = this.getModel()
// This baseModelId is used to indicate the capabilities of the model.
@@ -132,6 +140,11 @@ export class AwsBedrockHandler implements ApiHandler {
return
}
if (baseModelId.includes("openai")) {
yield* this.createOpenAIMessage(systemPrompt, messages, modelId, model)
return
}
// Check if this is a Deepseek model
if (baseModelId.includes("deepseek")) {
yield* this.createDeepseekMessage(systemPrompt, messages, modelId, model)
@@ -139,7 +152,7 @@ export class AwsBedrockHandler implements ApiHandler {
}
// Default: Use Anthropic Converse API for all Anthropic models
yield* this.createAnthropicMessage(systemPrompt, messages, modelId, model)
yield* this.createAnthropicMessage(systemPrompt, messages, modelId, model, enable1mContextWindow)
}
getModel(): { id: string; info: ModelInfo } {
@@ -743,6 +756,7 @@ export class AwsBedrockHandler implements ApiHandler {
messages: Anthropic.Messages.MessageParam[],
modelId: string,
model: { id: string; info: ModelInfo },
enable1mContextWindow: boolean,
): ApiStream {
// Format messages for Anthropic model using unified formatter
const formattedMessages = this.formatMessagesForConverseAPI(messages)
@@ -773,15 +787,18 @@ export class AwsBedrockHandler implements ApiHandler {
messages: messagesWithCache,
system: systemMessages,
inferenceConfig: this.getInferenceConfig(model.info, "anthropic"),
// Add thinking configuration as per LangChain documentation
additionalModelRequestFields: reasoningOn
? {
thinking: {
type: "enabled",
budget_tokens: budget_tokens,
},
}
: undefined,
additionalModelRequestFields: {
// Add thinking configuration as per LangChain documentation
...(reasoningOn && {
thinking: {
type: "enabled",
budget_tokens: budget_tokens,
},
}),
...(enable1mContextWindow && {
anthropic_beta: ["context-1m-2025-08-07"],
}),
},
})
// Execute the streaming request using unified handler
@@ -958,4 +975,139 @@ export class AwsBedrockHandler implements ApiHandler {
// Execute the streaming request using unified handler
yield* this.executeConverseStream(command, model.info)
}
/**
* Creates a message using OpenAI models through AWS Bedrock
* Uses non-streaming Converse API and simulates streaming for models that don't support it
*/
private async *createOpenAIMessage(
systemPrompt: string,
messages: Anthropic.Messages.MessageParam[],
modelId: string,
model: { id: string; info: ModelInfo },
): ApiStream {
// Get Bedrock client with proper credentials
const client = await this.getBedrockClient()
// Format messages for Converse API
const formattedMessages = this.formatMessagesForConverseAPI(messages)
// Prepare system message
const systemMessages = systemPrompt ? [{ text: systemPrompt }] : undefined
// Prepare the non-streaming Converse command
const command = new ConverseCommand({
modelId: modelId,
messages: formattedMessages,
system: systemMessages,
inferenceConfig: {
maxTokens: model.info.maxTokens || 8192,
temperature: 0,
},
})
try {
// Track token usage
const inputTokenEstimate = this.estimateInputTokens(systemPrompt, messages)
let outputTokens = 0
// Execute the non-streaming request
const response = await client.send(command)
// Extract the complete response text and reasoning content
let fullText = ""
let reasoningText = ""
if (response.output?.message?.content) {
for (const contentBlock of response.output.message.content) {
// Check for reasoning content first
if ("reasoningContent" in contentBlock && contentBlock.reasoningContent) {
// Handle nested reasoning structure
const reasoning = contentBlock.reasoningContent
if ("reasoningText" in reasoning && reasoning.reasoningText && "text" in reasoning.reasoningText) {
reasoningText += reasoning.reasoningText.text
}
}
// Handle regular text content
else if ("text" in contentBlock && contentBlock.text) {
fullText += contentBlock.text
}
}
}
// If we have actual usage data from the response, use it
if (response.usage) {
const actualInputTokens = response.usage.inputTokens || inputTokenEstimate
const actualOutputTokens = response.usage.outputTokens || this.estimateTokenCount(fullText + reasoningText)
outputTokens = actualOutputTokens
// Report actual usage after processing content
const actualCost = calculateApiCostOpenAI(model.info, actualInputTokens, actualOutputTokens, 0, 0)
yield {
type: "usage",
inputTokens: actualInputTokens,
outputTokens: actualOutputTokens,
totalCost: actualCost,
}
} else {
// Estimate output tokens if not provided (includes both regular text and reasoning)
outputTokens = this.estimateTokenCount(fullText + reasoningText)
}
// Yield reasoning content first if present
if (reasoningText) {
const reasoningChunkSize = 1000 // Characters per chunk
for (let i = 0; i < reasoningText.length; i += reasoningChunkSize) {
const chunk = reasoningText.slice(i, Math.min(i + reasoningChunkSize, reasoningText.length))
yield {
type: "reasoning",
reasoning: chunk,
}
}
}
// Simulate streaming by chunking the response text
if (fullText) {
const chunkSize = 1000 // Characters per chunk
for (let i = 0; i < fullText.length; i += chunkSize) {
const chunk = fullText.slice(i, Math.min(i + chunkSize, fullText.length))
yield {
type: "text",
text: chunk,
}
}
}
// Report final usage if we didn't have actual usage data earlier
if (!response.usage) {
const finalCost = calculateApiCostOpenAI(model.info, inputTokenEstimate, outputTokens, 0, 0)
yield {
type: "usage",
inputTokens: inputTokenEstimate,
outputTokens: outputTokens,
totalCost: finalCost,
}
}
} catch (error) {
console.error("Error with OpenAI model via Converse API:", error)
// Try to extract more detailed error information
let errorMessage = "Failed to process OpenAI model request"
if (error instanceof Error) {
errorMessage = error.message
// Check for specific AWS SDK errors
if ("name" in error) {
errorMessage = `${error.name}: ${error.message}`
}
}
yield {
type: "text",
text: `[ERROR] ${errorMessage}`,
}
}
}
}
+53 -2
View File
@@ -39,7 +39,11 @@ export class CerebrasHandler implements ApiHandler {
return this.client
}
@withRetry()
@withRetry({
maxRetries: 6, // More retries to be patient with rate limits
baseDelay: 5000, // Start with 5 second delay
maxDelay: 60000, // Allow up to 60 second delays to respect rate limits
})
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
const client = this.ensureClient()
@@ -170,7 +174,25 @@ export class CerebrasHandler implements ApiHandler {
}
}
}
} catch (error) {
} catch (error: any) {
// Enhanced error handling for Cerebras API
if (error?.status === 429 || error?.code === "rate_limit_exceeded") {
// Rate limit error - will be handled by retry decorator with patient backoff
const limits = this.getRateLimits()
throw new Error(`Cerebras API rate limit exceeded.`)
} else if (error?.status === 401) {
throw new Error("Cerebras API authentication failed. Please check your API key.")
} else if (error?.status === 403) {
throw new Error("Cerebras API access denied. Please check your API key permissions.")
} else if (error?.status >= 500) {
// Server errors - retryable
throw new Error(`Cerebras API server error (${error.status}): ${error.message || "Unknown server error"}`)
} else if (error?.status === 400) {
// Client errors - not retryable
throw new Error(`Cerebras API bad request: ${error.message || "Invalid request parameters"}`)
}
// Re-throw original error for other cases
throw error
}
}
@@ -193,6 +215,35 @@ export class CerebrasHandler implements ApiHandler {
}
}
/**
* Get rate limit information for the current model
*
* These limits are used for informational purposes and to calculate appropriate
* retry delays. Since Cerebras inference is extremely fast, users hit these limits
* quickly, so we need to be patient with retries to maximize usage efficiency.
*
* @returns Rate limit configuration for the model
*/
private getRateLimits(): { requestsPerMinute: number; tokensPerMinute: number } {
const modelId = this.getModel().id
switch (modelId) {
case "qwen-3-coder-480b":
case "qwen-3-coder-480b-free":
return { requestsPerMinute: 10, tokensPerMinute: 150_000 }
case "qwen-3-235b-a22b-instruct-2507":
case "qwen-3-235b-a22b-thinking-2507":
return { requestsPerMinute: 30, tokensPerMinute: 60_000 }
case "llama-3.3-70b":
case "gpt-oss-120b":
case "qwen-3-32b":
return { requestsPerMinute: 30, tokensPerMinute: 64_000 }
default:
// Default rate limits for unknown models
return { requestsPerMinute: 30, tokensPerMinute: 60_000 }
}
}
private calculateCost({ inputTokens, outputTokens }: { inputTokens: number; outputTokens: number }): number {
const model = this.getModel()
const inputPrice = model.info.inputPrice || 0
+23 -47
View File
@@ -15,7 +15,7 @@ import { CLINE_ACCOUNT_AUTH_ERROR_MESSAGE } from "@/shared/ClineAccount"
import { clineEnvConfig } from "@/config"
interface ClineHandlerOptions {
taskId?: string
ulid?: string
reasoningEffort?: string
thinkingBudgetTokens?: number
openRouterProviderSorting?: string
@@ -51,7 +51,7 @@ export class ClineHandler implements ApiHandler {
defaultHeaders: {
"HTTP-Referer": "https://cline.bot",
"X-Title": "Cline",
"X-Task-ID": this.options.taskId || "",
"X-Task-ID": this.options.ulid || "",
"X-Cline-Version": extensionVersion,
},
})
@@ -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
}
@@ -186,36 +172,26 @@ export class ClineHandler implements ApiHandler {
try {
// TODO: replace this with firebase auth
// TODO: use global API Host
const clineAccountAuthToken = await this._authService.getAuthToken()
if (!clineAccountAuthToken) {
throw new Error(CLINE_ACCOUNT_AUTH_ERROR_MESSAGE)
}
const response = await axios.get(`${this.clineAccountService.baseUrl}/generation?id=${this.lastGenerationId}`, {
headers: {
Authorization: `Bearer ${this.options.clineAccountId}`,
Authorization: `Bearer ${clineAccountAuthToken}`,
},
timeout: 15_000, // this request hangs sometimes
})
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
+5 -5
View File
@@ -20,7 +20,7 @@ interface GeminiHandlerOptions {
geminiBaseUrl?: string
thinkingBudgetTokens?: number
apiModelId?: string
taskId?: string
ulid?: string
}
/**
@@ -28,7 +28,7 @@ interface GeminiHandlerOptions {
*
* Key features:
* - One cache per task: Creates a single cache per task and reuses it for subsequent turns
* - Stable cache keys: Uses taskId as a stable identifier for caches
* - Stable cache keys: Uses ulid as a stable identifier for caches
* - Efficient cache updates: Only updates caches when there's new content to add
* - Split cost accounting: Separates immediate costs from ongoing cache storage costs
*
@@ -255,8 +255,8 @@ export class GeminiHandler implements ApiHandler {
const throughputTokensPerSecSdk =
totalDurationSdkMs > 0 && outputTokens > 0 ? outputTokens / (totalDurationSdkMs / 1000) : undefined
if (this.options.taskId) {
telemetryService.captureGeminiApiPerformance(this.options.taskId, modelId, {
if (this.options.ulid) {
telemetryService.captureGeminiApiPerformance(this.options.ulid, modelId, {
ttftSec: ttftSdkMs !== undefined ? ttftSdkMs / 1000 : undefined,
totalDurationSec: totalDurationSdkMs / 1000,
promptTokens,
@@ -269,7 +269,7 @@ export class GeminiHandler implements ApiHandler {
throughputTokensPerSec: throughputTokensPerSecSdk,
})
} else {
console.warn("GeminiHandler: taskId not available for telemetry in createMessage.")
console.warn("GeminiHandler: ulid not available for telemetry in createMessage.")
}
}
}
+128 -28
View File
@@ -13,12 +13,32 @@ interface LiteLlmHandlerOptions {
liteLlmModelInfo?: LiteLLMModelInfo
thinkingBudgetTokens?: number
liteLlmUsePromptCache?: boolean
taskId?: string
ulid?: string
}
interface LiteLlmModelInfoResponse {
data: Array<{
model_name: string
litellm_params: {
model: string
[key: string]: any
}
model_info: {
input_cost_per_token: number
output_cost_per_token: number
cache_creation_input_token_cost?: number
cache_read_input_token_cost?: number
[key: string]: any
}
}>
}
export class LiteLlmHandler implements ApiHandler {
private options: LiteLlmHandlerOptions
private client: OpenAI | undefined
private modelInfoCache: LiteLlmModelInfoResponse | undefined
private modelInfoCacheTimestamp: number = 0
private readonly modelInfoCacheTTL = 5 * 60 * 1000 // 5 minutes
constructor(options: LiteLlmHandlerOptions) {
this.options = options
@@ -41,35 +61,112 @@ export class LiteLlmHandler implements ApiHandler {
return this.client
}
async calculateCost(prompt_tokens: number, completion_tokens: number): Promise<number | undefined> {
// Reference: https://github.com/BerriAI/litellm/blob/122ee634f434014267af104814022af1d9a0882f/litellm/proxy/spend_tracking/spend_management_endpoints.py#L1473
private async fetchModelInfo(): Promise<LiteLlmModelInfoResponse | undefined> {
// Check if cache is still valid
const now = Date.now()
if (this.modelInfoCache && now - this.modelInfoCacheTimestamp < this.modelInfoCacheTTL) {
return this.modelInfoCache
}
const client = this.ensureClient()
const modelId = this.options.liteLlmModelId || liteLlmDefaultModelId
// Handle base URLs that already include /v1 to avoid double /v1/v1/
const baseUrl = client.baseURL.endsWith("/v1") ? client.baseURL : `${client.baseURL}/v1`
const url = `${baseUrl}/model/info`
try {
const response = await fetch(`${client.baseURL}/spend/calculate`, {
method: "POST",
const response = await fetch(url, {
method: "GET",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${this.options.liteLlmApiKey}`,
accept: "application/json",
"x-litellm-api-key": this.options.liteLlmApiKey || "",
},
body: JSON.stringify({
completion_response: {
model: modelId,
usage: {
prompt_tokens,
completion_tokens,
},
},
}),
})
if (response.ok) {
const data: { cost: number } = await response.json()
return data.cost
const data: LiteLlmModelInfoResponse = await response.json()
this.modelInfoCache = data
this.modelInfoCacheTimestamp = now
return data
} else {
console.error("Error calculating spend:", response.statusText)
return undefined
console.warn("Failed to fetch LiteLLM model info:", response.statusText)
// Try with Authorization header instead
const retryResponse = await fetch(url, {
method: "GET",
headers: {
accept: "application/json",
Authorization: `Bearer ${this.options.liteLlmApiKey || ""}`,
},
})
if (retryResponse.ok) {
const data: LiteLlmModelInfoResponse = await retryResponse.json()
this.modelInfoCache = data
this.modelInfoCacheTimestamp = now
return data
} else {
console.warn("Failed to fetch LiteLLM model info with Authorization header:", retryResponse.statusText)
return undefined
}
}
} catch (error) {
console.warn("Error fetching LiteLLM model info:", error)
return undefined
}
}
private async getModelCostInfo(publicModelName: string): Promise<{
inputCostPerToken: number
outputCostPerToken: number
cacheCreationCostPerToken?: number
cacheReadCostPerToken?: number
}> {
try {
const modelInfo = await this.fetchModelInfo()
if (modelInfo?.data) {
// Find the model by public name
const matchingModel = modelInfo.data.find((model) => model.model_name === publicModelName)
if (matchingModel?.model_info) {
return {
inputCostPerToken: matchingModel.model_info.input_cost_per_token || 0,
outputCostPerToken: matchingModel.model_info.output_cost_per_token || 0,
cacheCreationCostPerToken: matchingModel.model_info.cache_creation_input_token_cost,
cacheReadCostPerToken: matchingModel.model_info.cache_read_input_token_cost,
}
}
}
} catch (error) {
console.warn("Error getting LiteLLM model cost info:", error)
}
// Fallback to zero costs if we can't get the information
return {
inputCostPerToken: 0,
outputCostPerToken: 0,
}
}
async calculateCost(
prompt_tokens: number,
completion_tokens: number,
cache_creation_tokens?: number,
cache_read_tokens?: number,
): Promise<number | undefined> {
const publicModelId = this.options.liteLlmModelId || liteLlmDefaultModelId
try {
const costInfo = await this.getModelCostInfo(publicModelId)
// Calculate costs for different token types
const inputCost = Math.max(0, prompt_tokens - (cache_read_tokens || 0)) * costInfo.inputCostPerToken
const outputCost = completion_tokens * costInfo.outputCostPerToken
const cacheCreationCost = (cache_creation_tokens || 0) * (costInfo.cacheCreationCostPerToken || 0)
const cacheReadCost = (cache_read_tokens || 0) * (costInfo.cacheReadCostPerToken || 0)
const totalCost = inputCost + outputCost + cacheCreationCost + cacheReadCost
return totalCost
} catch (error) {
console.error("Error calculating spend:", error)
return undefined
@@ -133,12 +230,9 @@ export class LiteLlmHandler implements ApiHandler {
stream: true,
stream_options: { include_usage: true },
...(thinkingConfig && { thinking: thinkingConfig }), // Add thinking configuration when applicable
...(this.options.taskId && { litellm_session_id: `cline-${this.options.taskId}` }), // Add session ID for LiteLLM tracking
...(this.options.ulid && { litellm_session_id: `cline-${this.options.ulid}` }), // Add session ID for LiteLLM tracking
})
const inputCost = (await this.calculateCost(1e6, 0)) || 0
const outputCost = (await this.calculateCost(0, 1e6)) || 0
for await (const chunk of stream) {
const delta = chunk.choices[0]?.delta
@@ -165,9 +259,6 @@ export class LiteLlmHandler implements ApiHandler {
// Handle token usage information
if (chunk.usage) {
const totalCost =
(inputCost * chunk.usage.prompt_tokens) / 1e6 + (outputCost * chunk.usage.completion_tokens) / 1e6
// Extract cache-related information if available
// Need to use type assertion since these properties are not in the standard OpenAI types
const usage = chunk.usage as {
@@ -182,6 +273,15 @@ export class LiteLlmHandler implements ApiHandler {
const cacheWriteTokens = usage.cache_creation_input_tokens || usage.prompt_cache_miss_tokens || 0
const cacheReadTokens = usage.cache_read_input_tokens || usage.prompt_cache_hit_tokens || 0
// Calculate cost using the actual token usage including cache tokens
const totalCost =
(await this.calculateCost(
usage.prompt_tokens || 0,
usage.completion_tokens || 0,
cacheWriteTokens > 0 ? cacheWriteTokens : undefined,
cacheReadTokens > 0 ? cacheReadTokens : undefined,
)) || 0
yield {
type: "usage",
inputTokens: usage.prompt_tokens || 0,
+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
+6 -2
View File
@@ -8,6 +8,7 @@ import { calculateApiCostOpenAI } from "@utils/cost"
import { ApiStream } from "@api/transform/stream"
interface RequestyHandlerOptions {
requestyBaseUrl?: string
requestyApiKey?: string
reasoningEffort?: string
thinkingBudgetTokens?: number
@@ -40,7 +41,7 @@ export class RequestyHandler implements ApiHandler {
}
try {
this.client = new OpenAI({
baseURL: "https://router.requesty.ai/v1",
baseURL: this.options.requestyBaseUrl || "https://router.requesty.ai/v1",
apiKey: this.options.requestyApiKey,
defaultHeaders: {
"HTTP-Referer": "https://cline.bot",
@@ -74,7 +75,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
: {}
+374 -145
View File
@@ -5,6 +5,11 @@ import { ApiHandler } from "../"
import { ModelInfo, sapAiCoreDefaultModelId, SapAiCoreModelId, sapAiCoreModels } from "../../shared/api"
import { convertToOpenAiMessages } from "../transform/openai-format"
import { ApiStream } from "../transform/stream"
import {
type Message as BedrockMessage,
type ContentBlock as BedrockContentBlock,
ConversationRole as BedrockConversationRole,
} from "@aws-sdk/client-bedrock-runtime"
interface SapAiCoreHandlerOptions {
sapAiCoreClientId?: string
@@ -13,6 +18,7 @@ interface SapAiCoreHandlerOptions {
sapAiResourceGroup?: string
sapAiCoreBaseUrl?: string
apiModelId?: string
thinkingBudgetTokens?: number
}
interface Deployment {
@@ -27,6 +33,307 @@ interface Token {
token_type: string
expires_at: number
}
// Bedrock namespace containing caching-related functions
namespace Bedrock {
// Define cache point type for AWS Bedrock
interface CachePointContentBlock {
cachePoint: {
type: "default"
}
}
// Define types for supported content types
type SupportedContentType = "text" | "image" | "thinking"
interface ContentItem {
type: SupportedContentType
text?: string
source?: {
data: string | Buffer | Uint8Array
media_type?: string
}
}
/**
* Prepares system messages with optional caching support
*/
export function prepareSystemMessages(systemPrompt: string, enableCaching: boolean): any[] | undefined {
if (!systemPrompt) {
return undefined
}
if (enableCaching) {
return [{ text: systemPrompt }, { cachePoint: { type: "default" } }]
}
return [{ text: systemPrompt }]
}
/**
* Applies cache control to messages for prompt caching using AWS Bedrock's cachePoint system
* AWS Bedrock uses cachePoint objects instead of Anthropic's cache_control approach
*/
export function applyCacheControlToMessages(
messages: BedrockMessage[],
lastUserMsgIndex: number,
secondLastMsgUserIndex: number,
): BedrockMessage[] {
return messages.map((message, index) => {
// Add cachePoint to the last user message and second-to-last user message
if (index === lastUserMsgIndex || index === secondLastMsgUserIndex) {
// Clone the message to avoid modifying the original
const messageWithCache = { ...message }
if (messageWithCache.content && Array.isArray(messageWithCache.content)) {
// Add cachePoint to the end of the content array
messageWithCache.content = [
...messageWithCache.content,
{
cachePoint: {
type: "default",
},
} as CachePointContentBlock, // Properly typed cache point for AWS SDK
]
}
return messageWithCache
}
return message
})
}
/**
* Formats messages for models using the Converse API specification
* Used by both Anthropic and Nova models to avoid code duplication
*/
export function formatMessagesForConverseAPI(messages: Anthropic.Messages.MessageParam[]): BedrockMessage[] {
return messages.map((message) => {
// Determine role (user or assistant)
const role = message.role === "user" ? BedrockConversationRole.USER : BedrockConversationRole.ASSISTANT
// Process content based on type
let content: BedrockContentBlock[] = []
if (typeof message.content === "string") {
// Simple text content
content = [{ text: message.content }]
} else if (Array.isArray(message.content)) {
// Convert Anthropic content format to Converse API content format
const processedContent = message.content
.map((item) => {
// Text content
if (item.type === "text") {
return { text: item.text }
}
// Image content
if (item.type === "image") {
return processImageContent(item)
}
// Log unsupported content types for debugging
console.warn(`Unsupported content type: ${(item as ContentItem).type}`)
return null
})
.filter((item): item is BedrockContentBlock => item !== null)
content = processedContent
}
// Return formatted message
return {
role,
content,
}
})
}
/**
* Processes image content with proper error handling and user notification
*/
function processImageContent(item: any): BedrockContentBlock | null {
let imageData: Uint8Array
let format: "png" | "jpeg" | "gif" | "webp" = "jpeg" // default format
// Extract format from media_type if available
if (item.source.media_type) {
// Extract format from media_type (e.g., "image/jpeg" -> "jpeg")
const formatMatch = item.source.media_type.match(/image\/(\w+)/)
if (formatMatch && formatMatch[1]) {
const extractedFormat = formatMatch[1]
// Ensure format is one of the allowed values
if (["png", "jpeg", "gif", "webp"].includes(extractedFormat)) {
format = extractedFormat as "png" | "jpeg" | "gif" | "webp"
}
}
}
// Get image data with improved error handling
try {
if (typeof item.source.data === "string") {
// Handle base64 encoded data
const base64Data = item.source.data.replace(/^data:image\/\w+;base64,/, "")
imageData = new Uint8Array(Buffer.from(base64Data, "base64"))
} else if (item.source.data && typeof item.source.data === "object") {
// Try to convert to Uint8Array
imageData = new Uint8Array(Buffer.from(item.source.data as Buffer | Uint8Array))
} else {
throw new Error("Unsupported image data format")
}
return {
image: {
format,
source: {
bytes: imageData,
},
},
}
} catch (error) {
console.error("Failed to process image content:", error)
// Return a text content indicating the error instead of null
// This ensures users are aware of the issue
return {
text: `[ERROR: Failed to process image - ${error instanceof Error ? error.message : "Unknown error"}]`,
}
}
}
}
// Gemini namespace containing caching-related functions and types
namespace Gemini {
/**
* Process Gemini streaming response with enhanced thinking content support and caching awareness
*/
export function processStreamChunk(data: any): {
text?: string
reasoning?: string
usageMetadata?: {
promptTokenCount?: number
candidatesTokenCount?: number
thoughtsTokenCount?: number
cachedContentTokenCount?: number
}
} {
const result: ReturnType<typeof processStreamChunk> = {}
// Handle thinking content from Gemini's response
const candidateForThoughts = data?.candidates?.[0]
const partsForThoughts = candidateForThoughts?.content?.parts
let thoughts = ""
if (partsForThoughts) {
for (const part of partsForThoughts) {
const { thought, text } = part
if (thought && text) {
thoughts += text + "\n"
}
}
}
if (thoughts.trim() !== "") {
result.reasoning = thoughts.trim()
}
// Handle regular text content
if (data.text) {
result.text = data.text
}
// Handle content parts for non-thought text
if (data.candidates && data.candidates[0]?.content?.parts) {
let nonThoughtText = ""
for (const part of data.candidates[0].content.parts) {
if (part.text && !part.thought) {
nonThoughtText += part.text
}
}
if (nonThoughtText && !result.text) {
result.text = nonThoughtText
}
}
// Handle usage metadata with caching support
if (data.usageMetadata) {
result.usageMetadata = {
promptTokenCount: data.usageMetadata.promptTokenCount,
candidatesTokenCount: data.usageMetadata.candidatesTokenCount,
thoughtsTokenCount: data.usageMetadata.thoughtsTokenCount,
cachedContentTokenCount: data.usageMetadata.cachedContentTokenCount,
}
}
return result
}
function convertAnthropicMessageToGemini(message: Anthropic.Messages.MessageParam) {
const role = message.role === "assistant" ? "model" : "user"
const parts = []
if (typeof message.content === "string") {
parts.push({ text: message.content })
} else if (Array.isArray(message.content)) {
for (const block of message.content) {
if (block.type === "text") {
parts.push({ text: block.text })
} else if (block.type === "image") {
parts.push({
inlineData: {
mimeType: block.source.media_type,
data: block.source.data,
},
})
}
}
}
return { role, parts }
}
/**
* Prepare Gemini request payload with thinking configuration and implicit caching support
*/
export function prepareRequestPayload(
systemPrompt: string,
messages: Anthropic.Messages.MessageParam[],
model: { id: SapAiCoreModelId; info: ModelInfo },
thinkingBudgetTokens?: number,
): any {
const contents = messages.map(convertAnthropicMessageToGemini)
const payload = {
contents,
systemInstruction: {
parts: [
{
text: systemPrompt,
},
],
},
generationConfig: {
maxOutputTokens: model.info.maxTokens,
temperature: 0.0,
},
}
// Add thinking config if the model supports it and budget is provided
const thinkingBudget = thinkingBudgetTokens ?? 0
const maxBudget = model.info.thinkingConfig?.maxBudget ?? 0
if (thinkingBudget > 0 && model.info.thinkingConfig) {
// Add thinking configuration to the payload
;(payload as any).thinkingConfig = {
thinkingBudget: thinkingBudget,
includeThoughts: true,
}
}
return payload
}
}
export class SapAiCoreHandler implements ApiHandler {
private options: SapAiCoreHandlerOptions
private token?: Token
@@ -142,7 +449,20 @@ export class SapAiCoreHandler implements ApiHandler {
"anthropic--claude-3-opus",
]
const openAIModels = ["gpt-4o", "gpt-4", "gpt-4o-mini", "o1", "gpt-4.1", "gpt-4.1-nano", "o3-mini", "o3", "o4-mini"]
const openAIModels = [
"gpt-4o",
"gpt-4",
"gpt-4o-mini",
"o1",
"gpt-4.1",
"gpt-4.1-nano",
"gpt-5",
"gpt-5-nano",
"gpt-5-mini",
"o3-mini",
"o3",
"o4-mini",
]
const geminiModels = ["gemini-2.5-flash", "gemini-2.5-pro"]
@@ -151,21 +471,47 @@ export class SapAiCoreHandler implements ApiHandler {
if (anthropicModels.includes(model.id)) {
url = `${this.options.sapAiCoreBaseUrl}/v2/inference/deployments/${deploymentId}/invoke-with-response-stream`
// Format messages for Converse API. Note that the Invoke API has
// the same format for messages as the Converse API.
const formattedMessages = Bedrock.formatMessagesForConverseAPI(messages)
// Get message indices for caching
const userMsgIndices = messages.reduce(
(acc, msg, index) => (msg.role === "user" ? [...acc, index] : acc),
[] as number[],
)
const lastUserMsgIndex = userMsgIndices[userMsgIndices.length - 1] ?? -1
const secondLastMsgUserIndex = userMsgIndices[userMsgIndices.length - 2] ?? -1
if (
model.id === "anthropic--claude-4-sonnet" ||
model.id === "anthropic--claude-4-opus" ||
model.id === "anthropic--claude-3.7-sonnet"
) {
// Use converse-stream endpoint with caching support
url = `${this.options.sapAiCoreBaseUrl}/v2/inference/deployments/${deploymentId}/converse-stream`
// Apply caching controls to messages (enabled by default)
const messagesWithCache = Bedrock.applyCacheControlToMessages(
formattedMessages,
lastUserMsgIndex,
secondLastMsgUserIndex,
)
// Prepare system message with caching support (enabled by default)
const systemMessages = Bedrock.prepareSystemMessages(systemPrompt, true)
payload = {
inferenceConfig: {
maxTokens: model.info.maxTokens,
temperature: 0.0,
},
system: systemPrompt ? [{ text: systemPrompt }] : undefined,
messages: this.formatAnthropicMessages(messages),
system: systemMessages,
messages: messagesWithCache,
}
} else {
// Use invoke-with-response-stream endpoint
// TODO: add caching support using Anthropic-native cache_control blocks
payload = {
max_tokens: model.info.maxTokens,
system: systemPrompt,
@@ -191,7 +537,7 @@ export class SapAiCoreHandler implements ApiHandler {
stream_options: { include_usage: true },
}
if (["o1", "o3-mini", "o3", "o4-mini"].includes(model.id)) {
if (["o1", "o3-mini", "o3", "o4-mini", "gpt-5", "gpt-5-nano", "gpt-5-mini"].includes(model.id)) {
delete payload.max_tokens
delete payload.temperature
}
@@ -202,7 +548,7 @@ export class SapAiCoreHandler implements ApiHandler {
}
} else if (geminiModels.includes(model.id)) {
url = `${this.options.sapAiCoreBaseUrl}/v2/inference/deployments/${deploymentId}/models/${model.id}:streamGenerateContent`
payload = this.convertToGeminiFormat(systemPrompt, messages)
payload = Gemini.prepareRequestPayload(systemPrompt, messages, model, this.options.thinkingBudgetTokens)
} else {
throw new Error(`Unsupported model: ${model.id}`)
}
@@ -359,9 +705,17 @@ export class SapAiCoreHandler implements ApiHandler {
// Handle metadata (token usage)
if (data.metadata?.usage) {
const inputTokens = data.metadata.usage.inputTokens || 0
let inputTokens = data.metadata.usage.inputTokens || 0
const outputTokens = data.metadata.usage.outputTokens || 0
// calibrate input token
const totalTokens = data.metadata.usage.totalTokens || 0
const cacheReadInputTokens = data.metadata.usage.cacheReadInputTokens || 0
const cacheWriteOutputTokens = data.metadata.usage.cacheWriteOutputTokens || 0
if (inputTokens + outputTokens + cacheReadInputTokens + cacheWriteOutputTokens !== totalTokens) {
inputTokens = totalTokens - outputTokens - cacheReadInputTokens - cacheWriteOutputTokens
}
yield {
type: "usage",
inputTokens,
@@ -493,50 +847,31 @@ export class SapAiCoreHandler implements ApiHandler {
const jsonData = line.slice(6)
try {
const data = JSON.parse(jsonData)
const candidateForThoughts = data?.candidates?.[0]
const partsForThoughts = candidateForThoughts?.content?.parts
let thoughts = ""
if (partsForThoughts) {
for (const part of partsForThoughts) {
const { thought, text } = part
if (thought && text) {
thoughts += text + "\n"
}
}
}
// Use Gemini namespace to process the chunk
const processed = Gemini.processStreamChunk(data)
if (thoughts.trim() !== "") {
// Yield reasoning if present
if (processed.reasoning) {
yield {
type: "reasoning",
reasoning: thoughts.trim(),
reasoning: processed.reasoning,
}
}
if (data.text) {
// Yield text if present
if (processed.text) {
yield {
type: "text",
text: data.text,
text: processed.text,
}
}
if (data.candidates && data.candidates[0]?.content?.parts) {
for (const part of data.candidates[0].content.parts) {
if (part.text && !part.thought) {
// Only non-thought text
yield {
type: "text",
text: part.text,
}
}
}
}
if (data.usageMetadata) {
promptTokens = data.usageMetadata.promptTokenCount ?? promptTokens
outputTokens = data.usageMetadata.candidatesTokenCount ?? outputTokens
thoughtsTokenCount = data.usageMetadata.thoughtsTokenCount ?? thoughtsTokenCount
cacheReadTokens = data.usageMetadata.cachedContentTokenCount ?? cacheReadTokens
if (processed.usageMetadata) {
promptTokens = processed.usageMetadata.promptTokenCount ?? promptTokens
outputTokens = processed.usageMetadata.candidatesTokenCount ?? outputTokens
thoughtsTokenCount = processed.usageMetadata.thoughtsTokenCount ?? thoughtsTokenCount
cacheReadTokens = processed.usageMetadata.cachedContentTokenCount ?? cacheReadTokens
yield {
type: "usage",
@@ -544,6 +879,7 @@ export class SapAiCoreHandler implements ApiHandler {
outputTokens,
thoughtsTokenCount,
cacheReadTokens,
cacheWriteTokens: 0,
}
}
} catch (error) {
@@ -581,111 +917,4 @@ export class SapAiCoreHandler implements ApiHandler {
}
return { id: sapAiCoreDefaultModelId, info: sapAiCoreModels[sapAiCoreDefaultModelId] }
}
private getValidImageFormat(mediaType: string): string {
const format = mediaType.split("/")[1]?.toLowerCase()
const validFormats = ["png", "jpeg", "gif", "webp"]
if (validFormats.includes(format)) {
return format
}
throw new Error(`Unsupported image format: ${format}`)
}
private convertToGeminiFormat(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]) {
const contents = messages.map(this.convertAnthropicMessageToGemini)
const payload = {
contents,
systemInstruction: {
parts: [
{
text: systemPrompt,
},
],
},
generationConfig: {
maxOutputTokens: this.getModel().info.maxTokens,
temperature: 0.0,
},
}
return payload
}
private convertAnthropicMessageToGemini(message: Anthropic.Messages.MessageParam) {
const role = message.role === "assistant" ? "model" : "user"
const parts = []
if (typeof message.content === "string") {
parts.push({ text: message.content })
} else if (Array.isArray(message.content)) {
for (const block of message.content) {
if (block.type === "text") {
parts.push({ text: block.text })
} else if (block.type === "image") {
parts.push({
inlineData: {
mimeType: block.source.media_type,
data: block.source.data,
},
})
}
}
}
return { role, parts }
}
private formatAnthropicMessages(messages: Anthropic.Messages.MessageParam[]): any[] {
return messages.map((m) => {
const contentBlocks: any[] = []
if (typeof m.content === "string") {
contentBlocks.push({ text: m.content })
} else if (Array.isArray(m.content)) {
for (const block of m.content) {
if (block.type === "text") {
if (!block.text) {
throw new Error('Text block is missing the "text" field.')
}
contentBlocks.push({ text: block.text })
} else if (block.type === "image") {
if (!block.source) {
throw new Error('Image block is missing the "source" field.')
}
const { type, media_type, data } = block.source
if (!type || !media_type || !data) {
throw new Error('Image source must have "type", "media_type", and "data" fields.')
}
if (type !== "base64") {
throw new Error(`Unsupported image source type: ${type}. Only "base64" is supported.`)
}
const format = this.getValidImageFormat(media_type)
contentBlocks.push({
image: {
format,
source: {
bytes: data,
},
},
})
} else {
throw new Error(`Unsupported content block type: ${block.type}`)
}
}
} else {
throw new Error("Unsupported content format.")
}
return {
role: m.role,
content: contentBlocks,
}
})
}
}
+2 -1
View File
@@ -13,7 +13,7 @@ interface VertexHandlerOptions {
thinkingBudgetTokens?: number
geminiApiKey?: string
geminiBaseUrl?: string
taskId?: string
ulid?: string
}
export class VertexHandler implements ApiHandler {
@@ -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":
+12 -1
View File
@@ -1,4 +1,4 @@
import { ModelInfo } from "@shared/api"
import { CLAUDE_SONNET_4_1M_SUFFIX, ModelInfo, openRouterClaudeSonnet41mModelId } from "@shared/api"
import { convertToOpenAiMessages } from "@api/transform/openai-format"
import { convertToR1Format } from "@api/transform/r1-format"
import { Anthropic } from "@anthropic-ai/sdk"
@@ -19,11 +19,18 @@ export async function createOpenRouterStream(
...convertToOpenAiMessages(messages),
]
const isClaudeSonnet41m = model.id === openRouterClaudeSonnet41mModelId
if (isClaudeSonnet41m) {
// remove the custom :1m suffix, to create the model id openrouter API expects
model.id = model.id.slice(0, -CLAUDE_SONNET_4_1M_SUFFIX.length)
}
// prompt caching: https://openrouter.ai/docs/prompt-caching
// this was initially specifically for claude models (some models may 'support prompt caching' automatically without this)
// 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 +89,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 +125,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":
@@ -161,6 +170,8 @@ export async function createOpenRouterStream(
...(isKimiK2
? { provider: { order: ["groq", "together", "baseten", "parasail", "novita", "deepinfra"], allow_fallbacks: false } }
: {}),
// limit providers to only those that support the 1m context window
...(isClaudeSonnet41m ? { provider: { order: ["anthropic", "amazon-bedrock"], allow_fallbacks: false } } : {}),
})
return stream
+102
View File
@@ -0,0 +1,102 @@
import * as vscode from "vscode"
import {
migrateCustomInstructionsToGlobalRules,
migrateWelcomeViewCompleted,
migrateWorkspaceToGlobalStorage,
} from "./core/storage/state-migrations"
import { WebviewProvider } from "./core/webview"
import { Logger } from "./services/logging/Logger"
import { PostHogClientProvider } from "./services/posthog/PostHogClientProvider"
import { EmptyRequest } from "./shared/proto/cline/common"
import { WebviewProviderType } from "./shared/webview/types"
import "./utils/path" // necessary to have access to String.prototype.toPosix
import { HostProvider } from "@/hosts/host-provider"
import { FileContextTracker } from "./core/context/context-tracking/FileContextTracker"
import { telemetryService } from "./services/posthog/PostHogClientProvider"
import { ShowMessageType } from "./shared/proto/host/window"
import { getLatestAnnouncementId } from "./utils/announcements"
/**
* Performs intialization for Cline that is common to all platforms.
*
* @param context
* @returns The webview provider
*/
export async function initialize(context: vscode.ExtensionContext): Promise<WebviewProvider> {
// Initialize PostHog client provider
let distinctId = context.globalState.get<string>("cline.distinctId")
if (!distinctId) {
try {
const response = await HostProvider.env.getMachineId(EmptyRequest.create({}))
distinctId = response.value
} catch (e) {
Logger.warn(`Failed to get machine ID: ${e instanceof Error ? e.message : String(e)}`)
// PostHogProvider will fall back to uuid
}
}
PostHogClientProvider.getInstance(distinctId)
// Migrate custom instructions to global Cline rules (one-time cleanup)
await migrateCustomInstructionsToGlobalRules(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)
// Clean up orphaned file context warnings (startup cleanup)
await FileContextTracker.cleanupOrphanedWarnings(context)
const sidebarWebview = HostProvider.get().createWebviewProvider(WebviewProviderType.SIDEBAR)
await showVersionUpdateAnnouncement(context)
telemetryService.captureExtensionActivated()
return sidebarWebview
}
async function showVersionUpdateAnnouncement(context: vscode.ExtensionContext) {
// Version checking for autoupdate notification
const currentVersion = context.extension.packageJSON.version
const previousVersion = context.globalState.get<string>("clineVersion")
// Perform post-update actions if necessary
try {
if (!previousVersion || currentVersion !== previousVersion) {
Logger.log(`Cline version changed: ${previousVersion} -> ${currentVersion}. First run or update detected.`)
// Use the same condition as announcements: focus when there's a new announcement to show
const lastShownAnnouncementId = context.globalState.get<string>("lastShownAnnouncementId")
const latestAnnouncementId = getLatestAnnouncementId(context)
if (lastShownAnnouncementId !== latestAnnouncementId) {
// Focus Cline when there's a new announcement to show (major/minor updates or fresh installs)
const message = previousVersion
? `Cline has been updated to v${currentVersion}`
: `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,
})
}
// Always update the main version tracker for the next launch.
await context.globalState.update("clineVersion", currentVersion)
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error)
console.error(`Error during post-update actions: ${errorMessage}, Stack trace: ${error.stack}`)
}
}
/**
* Performs cleanup when Cline is deactivated that is common to all platforms.
*/
export async function tearDown(): Promise<void> {
PostHogClientProvider.getInstance().dispose()
// Dispose all webview instances
await WebviewProvider.disposeAllInstances()
}
-145
View File
@@ -1,145 +0,0 @@
import { JSONParser } from "@streamparser/json"
import * as fs from "fs"
import * as path from "path"
import * as os from "os"
// Fallback type definition based on the error message: "Property 'value' is optional in type 'ParsedElementInfo'"
type ParsedElementInfo = {
value?: any
key?: string | number
parent?: any
stack?: any[]
}
export interface ReplacementItem {
old_string: string
new_string: string
}
export interface ChangeLocation {
startLine: number
endLine: number
startChar: number
endChar: number
}
export class StreamingJsonReplacer {
private currentFileContent: string
private parser: JSONParser
private onContentUpdated: (newContent: string, isFinalItem: boolean, changeLocation?: ChangeLocation) => void
private onErrorCallback: (error: Error) => void
private itemsProcessed: number = 0
private successfullyParsedItems: ReplacementItem[] = []
constructor(
initialContent: string,
onContentUpdatedCallback: (newContent: string, isFinalItem: boolean, changeLocation?: ChangeLocation) => void,
onErrorCallback: (error: Error) => void,
) {
// Initialize log file path
const timestamp = new Date().toISOString().replace(/[:.]/g, "-")
this.currentFileContent = initialContent
this.onContentUpdated = onContentUpdatedCallback
this.onErrorCallback = onErrorCallback
this.parser = new JSONParser({ paths: ["$.*"] })
this.parser.onValue = (parsedElementInfo: ParsedElementInfo) => {
const { value } = parsedElementInfo // Destructure to get value, which might be undefined
// This callback is triggered for each item matched by '$.replacements.*'
if (value && typeof value === "object" && "old_string" in value && "new_string" in value) {
const item = value as ReplacementItem // Value here is confirmed to be an object
if (typeof item.old_string === "string" && typeof item.new_string === "string") {
this.successfullyParsedItems.push(item) // Store the structurally valid item
if (this.currentFileContent.includes(item.old_string)) {
// Calculate the change location before making the replacement
const changeLocation = this.calculateChangeLocation(item.old_string, item.new_string)
const beforeLength = this.currentFileContent.length
this.currentFileContent = this.currentFileContent.replace(item.old_string, item.new_string)
const afterLength = this.currentFileContent.length
this.itemsProcessed++
// Notify that an item has been processed. The `isFinalItem` argument here is tricky
// as we don't know from the parser alone if this is the *absolute* last item
// until the stream ends. The caller (Task.ts) will manage the final update.
// For now, we'll pass `false` and let Task.ts handle the final diff view update.
this.onContentUpdated(this.currentFileContent, false, changeLocation)
} else {
const snippet = item.old_string.length > 50 ? item.old_string.substring(0, 47) + "..." : item.old_string
const error = new Error(`Streaming Replacement failed: 'old_string' not found. Snippet: "${snippet}"`)
this.onErrorCallback(error) // Call our own error callback
}
} else {
const error = new Error(`Invalid item structure in replacements stream: ${JSON.stringify(item)}`)
this.onErrorCallback(error) // Call our own error callback
}
}
}
this.parser.onError = (err: Error) => {
// Propagate the error to the caller via the callback
this.onErrorCallback(err)
// Note: The @streamparser/json library might throw synchronously on write if onError is not set,
// or if it re-throws. We'll ensure Task.ts wraps write/end in try-catch.
}
}
public write(jsonChunk: string): void {
try {
// Errors during write will be caught by the parser's onError or thrown.
this.parser.write(jsonChunk)
} catch (error) {
throw error
}
}
public getCurrentContent(): string {
return this.currentFileContent
}
public getSuccessfullyParsedItems(): ReplacementItem[] {
return [...this.successfullyParsedItems] // Return a copy
}
private calculateChangeLocation(oldStr: string, newStr: string): ChangeLocation {
// Find the index where the old string starts
const startIndex = this.currentFileContent.indexOf(oldStr)
if (startIndex === -1) {
// This shouldn't happen since we already checked includes(), but just in case
return { startLine: 0, endLine: 0, startChar: 0, endChar: 0 }
}
// Calculate line numbers by counting newlines before the start index
const contentBeforeStart = this.currentFileContent.substring(0, startIndex)
const startLine = (contentBeforeStart.match(/\n/g) || []).length
// Calculate the end index after replacement
const endIndex = startIndex + oldStr.length
const contentBeforeEnd = this.currentFileContent.substring(0, endIndex)
const endLine = (contentBeforeEnd.match(/\n/g) || []).length
// Calculate character positions within their respective lines
const lastNewlineBeforeStart = contentBeforeStart.lastIndexOf("\n")
const startChar = lastNewlineBeforeStart === -1 ? startIndex : startIndex - lastNewlineBeforeStart - 1
const lastNewlineBeforeEnd = contentBeforeEnd.lastIndexOf("\n")
const endChar = lastNewlineBeforeEnd === -1 ? endIndex : endIndex - lastNewlineBeforeEnd - 1
const result = {
startLine,
endLine,
startChar,
endChar,
}
return result
}
}
+4 -1
View File
@@ -1,6 +1,6 @@
export type AssistantMessageContent = TextContent | ToolUse
export { parseAssistantMessageV1, parseAssistantMessageV2, parseAssistantMessageV3 } from "./parse-assistant-message"
export { parseAssistantMessageV2 } from "./parse-assistant-message"
export interface TextContent {
type: "text"
@@ -25,6 +25,7 @@ export const toolUseNames = [
"attempt_completion",
"new_task",
"condense",
"summarize_task",
"report_bug",
"new_rule",
"web_fetch",
@@ -60,6 +61,8 @@ export const toolParamNames = [
"steps_to_reproduce",
"api_request_output",
"additional_context",
"needs_more_exploration",
"task_progress",
] as const
export type ToolParamName = (typeof toolParamNames)[number]
@@ -1,245 +1,6 @@
import { AssistantMessageContent, TextContent, ToolUse, ToolParamName, toolParamNames, toolUseNames, ToolUseName } from "." // Assuming types are defined in index.ts or a similar file
/**
* @description **Version 1**
* Parses an assistant message string potentially containing mixed text and tool usage blocks
* marked with XML-like tags into an array of structured content objects.
*
* This version iterates through the message character by character, building an accumulator string.
* It maintains state to track whether it's currently parsing text, a tool use block, or a specific tool parameter.
* It detects the start and end of tool uses and parameters by checking if the accumulator ends with
* the corresponding opening or closing tags.
* Special handling is included for `write_to_file` and `new_rule` tool uses to correctly parse
* the `content` parameter, which might contain the closing tag itself, by looking for the *last*
* occurrence of the closing tag.
* If the input string ends mid-tag or mid-content, the last block (text or tool use) is marked as partial.
*
* @param assistantMessage The raw string output from the assistant.
* @returns An array of `AssistantMessageContent` objects, which can be `TextContent` or `ToolUse`.
* Blocks that were not fully closed by the end of the input string will have their `partial` flag set to `true`.
*/
export function parseAssistantMessageV1(assistantMessage: string): AssistantMessageContent[] {
const contentBlocks: AssistantMessageContent[] = []
let currentTextContent: TextContent | undefined = undefined
let currentTextContentStartIndex = 0
let currentToolUse: ToolUse | undefined = undefined
let currentToolUseStartIndex = 0
let currentParamName: ToolParamName | undefined = undefined
let currentParamValueStartIndex = 0
let accumulator = ""
for (let i = 0; i < assistantMessage.length; i++) {
const char = assistantMessage[i]
accumulator += char
// --- State: Parsing a Tool Parameter ---
// there should not be a param without a tool use
if (currentToolUse && currentParamName) {
const currentParamValue = accumulator.slice(currentParamValueStartIndex)
const paramClosingTag = `</${currentParamName}>`
if (currentParamValue.endsWith(paramClosingTag)) {
// End of param value found
currentToolUse.params[currentParamName] = currentParamValue.slice(0, -paramClosingTag.length).trim()
currentParamName = undefined // Go back to parsing tool content or looking for next param
continue // Move to next character
} else {
// Partial param value is accumulating
continue // Move to next character
}
}
// --- State: Parsing a Tool Use (but not a specific parameter) ---
// no currentParamName
if (currentToolUse) {
const currentToolValue = accumulator.slice(currentToolUseStartIndex)
const toolUseClosingTag = `</${currentToolUse.name}>`
if (currentToolValue.endsWith(toolUseClosingTag)) {
// End of a tool use found
currentToolUse.partial = false
contentBlocks.push(currentToolUse)
currentToolUse = undefined // Go back to parsing text or looking for next tool
// Reset text start index in case text follows immediately
currentTextContentStartIndex = i + 1
continue // Move to next character
} else {
// Check if starting a new parameter within the current tool use
const possibleParamOpeningTags = toolParamNames.map((name) => `<${name}>`)
let foundParamStart = false
for (const paramOpeningTag of possibleParamOpeningTags) {
if (accumulator.endsWith(paramOpeningTag)) {
// Start of a new parameter found
currentParamName = paramOpeningTag.slice(1, -1) as ToolParamName
currentParamValueStartIndex = accumulator.length
foundParamStart = true
break
}
}
if (foundParamStart) {
continue // Move to next character
}
// Special case for write_to_file/new_rule content param allowing nested tags
// Check if a </content> tag appears, potentially indicating the end of the content param
// even if the main tool closing tag hasn't been seen yet.
const contentParamName: ToolParamName = "content"
if (
(currentToolUse.name === "write_to_file" || currentToolUse.name === "new_rule") &&
accumulator.endsWith(`</${contentParamName}>`)
) {
const toolContent = accumulator.slice(currentToolUseStartIndex)
const contentStartTag = `<${contentParamName}>`
const contentEndTag = `</${contentParamName}>`
const contentStartIndex = toolContent.indexOf(contentStartTag) + contentStartTag.length
// Use lastIndexOf to handle cases where </content> might appear within the content itself
const contentEndIndex = toolContent.lastIndexOf(contentEndTag)
// Ensure we found valid start/end tags and end is after start
if (
contentStartIndex !== -1 &&
contentEndIndex !== -1 &&
contentEndIndex > contentStartIndex - contentStartTag.length // Ensure end tag is after start tag begins
) {
// Check if this content param was already being parsed. If so, update it.
// If not, and we just found the closing tag, assign it.
// This handles cases where the </content> detection might fire before
// the <content> tag detection logic, or if the content is very short.
if (currentParamName === contentParamName) {
// Already parsing content, now we found the end tag
currentToolUse.params[contentParamName] = toolContent.slice(contentStartIndex, contentEndIndex).trim()
currentParamName = undefined // Finished with this param
} else if (currentParamName === undefined) {
// Not parsing a param, but found </content>. Assume it closes the content block.
currentToolUse.params[contentParamName] = toolContent.slice(contentStartIndex, contentEndIndex).trim()
// We stay in the "parsing tool use" state, looking for more params or the tool end tag.
}
}
}
// If none of the above, partial tool value is accumulating
continue // Move to next character
}
}
// --- State: Parsing Text (or looking for start of a tool use) ---
// no currentToolUse
let didStartToolUse = false
const possibleToolUseOpeningTags = toolUseNames.map((name) => `<${name}>`)
for (const toolUseOpeningTag of possibleToolUseOpeningTags) {
if (accumulator.endsWith(toolUseOpeningTag)) {
// Start of a new tool use found
const toolName = toolUseOpeningTag.slice(1, -1) as ToolUseName
currentToolUse = {
type: "tool_use",
name: toolName,
params: {},
partial: true,
}
currentToolUseStartIndex = accumulator.length
// This also indicates the end of the current text content block (if any)
if (currentTextContent) {
currentTextContent.partial = false
// Extract text content, removing the part that formed the tool opening tag
const textEndIndex = accumulator.length - toolUseOpeningTag.length
currentTextContent.content = accumulator.slice(currentTextContentStartIndex, textEndIndex).trim()
// Only add if there's actual content
if (currentTextContent.content.length > 0) {
contentBlocks.push(currentTextContent)
}
currentTextContent = undefined
} else {
// Check if there was text before this tool use started
const textEndIndex = accumulator.length - toolUseOpeningTag.length
const potentialText = accumulator.slice(currentTextContentStartIndex, textEndIndex).trim()
if (potentialText.length > 0) {
contentBlocks.push({
type: "text",
content: potentialText,
partial: false, // Ended because tool use started
})
}
}
didStartToolUse = true
break // Found tool start, stop checking for others
}
}
if (!didStartToolUse) {
// No tool use started, so it must be text content accumulating
// (or continuing after a closed tool use)
if (currentTextContent === undefined) {
// Start of a new text block
currentTextContentStartIndex = i - (accumulator.length - currentTextContentStartIndex - 1) // Adjust start index based on how much we've accumulated since the last block ended or the beginning
// If accumulator starts from 0, start index is i
if (contentBlocks.length === 0 && currentToolUse === undefined) {
currentTextContentStartIndex = accumulator.length - 1 // i
} else {
// Re-calculate based on the actual start of the current text segment
// Find the end of the last block
let lastBlockEndIndex = 0
if (contentBlocks.length > 0) {
const lastBlock = contentBlocks[contentBlocks.length - 1]
// Approximation: find where the accumulator matches the end of the message string representation of the last block. This is complex.
// Simpler: Assume text starts right after the last block ended implicitly at index i.
lastBlockEndIndex = i // Where the loop *was* when the last block finished processing
// Need a more robust way to track the end index of the *raw string* corresponding to the last block.
// Let's stick to the accumulator slice approach for simplicity in this version.
// The start index should be where the current *unmatched* text began.
let lastProcessedIndex = -1
if (contentBlocks.length > 0) {
// This requires knowing the raw string length of the previous block, which V1 doesn't explicitly track easily.
// We'll approximate based on the current accumulator and start index logic.
// The issue arises if a tool tag was just closed. accumulator contains everything up to i.
// lastBlockEndIndex should point to the character *after* the closing tag of the last block.
}
// Reset start index to the beginning of the *current* potential text block
currentTextContentStartIndex = accumulator.length - 1 // Start accumulating from the current character `i`
}
// If we just closed a tool, text starts *after* its closing tag
// The logic needs refinement here for accurate start index after a tool closure.
// Let's assume for now the start index logic inside the loop handles it via slicing.
}
currentTextContent = {
type: "text",
content: "", // Content will be filled by slicing accumulator
partial: true,
}
}
// Update text content based on the accumulator from its start index
currentTextContent.content = accumulator.slice(currentTextContentStartIndex).trimStart() // Trim start to avoid leading space if text follows tool
}
} // End of loop
// --- Finalization after loop ---
// If a tool use was open at the end
if (currentToolUse) {
// If a parameter was open within that tool use
if (currentParamName) {
// The remaining accumulator content belongs to this partial parameter
currentToolUse.params[currentParamName] = accumulator.slice(currentParamValueStartIndex).trim()
}
// Add the potentially partial tool use block
contentBlocks.push(currentToolUse)
}
// If text content was being accumulated at the end
// Note: Only one of currentToolUse or currentTextContent can be defined here,
// as starting a tool use finalizes the preceding text block.
else if (currentTextContent) {
// Update content one last time
currentTextContent.content = accumulator.slice(currentTextContentStartIndex).trim()
// Add the potentially partial text block only if it contains content
if (currentTextContent.content.length > 0) {
contentBlocks.push(currentTextContent)
}
}
return contentBlocks
}
// parseAssistantmessageV1 removed in https://github.com/cline/cline/pull/5425
/**
* @description **Version 2**
@@ -473,621 +234,3 @@ export function parseAssistantMessageV2(assistantMessage: string): AssistantMess
return contentBlocks
}
export function parseAssistantMessageV3(assistantMessage: string): AssistantMessageContent[] {
const contentBlocks: AssistantMessageContent[] = []
let currentTextContentStart = 0 // Index where the current text block started
let currentTextContent: TextContent | undefined = undefined
let currentToolUseStart = 0 // Index *after* the opening tag of the current tool use
let currentToolUse: ToolUse | undefined = undefined
let currentParamValueStart = 0 // Index *after* the opening tag of the current param
let currentParamName: ToolParamName | undefined = undefined
// Precompute tags for faster lookups
const toolUseOpenTags = new Map<string, ToolUseName>()
const toolParamOpenTags = new Map<string, ToolParamName>()
for (const name of toolUseNames) {
toolUseOpenTags.set(`<${name}>`, name)
}
for (const name of toolParamNames) {
toolParamOpenTags.set(`<${name}>`, name)
}
// Function calls format detection
const isFunctionCallsOpen = "<function_calls>"
const isFunctionCallsClose = "</function_calls>"
const isInvokeStart = '<invoke name="'
const isInvokeEnd = '">'
const isInvokeClose = "</invoke>"
const isParameterStart = '<parameter name="'
const isParameterNameEnd = '">'
const isParameterClose = "</parameter>"
// Variables for function calls parsing
let inFunctionCalls = false
let currentInvokeName = ""
let currentParameterName = ""
const len = assistantMessage.length
for (let i = 0; i < len; i++) {
const currentCharIndex = i
// --- State: Parsing Function Calls ---
// Check for opening function_calls tag
if (
!inFunctionCalls &&
currentCharIndex >= isFunctionCallsOpen.length - 1 &&
assistantMessage.startsWith(isFunctionCallsOpen, currentCharIndex - isFunctionCallsOpen.length + 1)
) {
// End current text block if one was active
if (currentTextContent) {
currentTextContent.content = assistantMessage
.slice(currentTextContentStart, currentCharIndex - isFunctionCallsOpen.length + 1)
.trim()
currentTextContent.partial = false
if (currentTextContent.content.length > 0) {
contentBlocks.push(currentTextContent)
}
currentTextContent = undefined
}
inFunctionCalls = true
continue
}
// Check for invoke start within function_calls
if (
inFunctionCalls &&
currentInvokeName === "" &&
!currentToolUse && // Don't create a new tool if we already have one
currentCharIndex >= isInvokeStart.length - 1 &&
assistantMessage.startsWith(isInvokeStart, currentCharIndex - isInvokeStart.length + 1)
) {
// Find the end of the invoke name
const nameEndPos = assistantMessage.indexOf(isInvokeEnd, currentCharIndex + 1)
if (nameEndPos !== -1) {
// Extract the invoke name
currentInvokeName = assistantMessage.slice(currentCharIndex + 1, nameEndPos)
i = nameEndPos + isInvokeEnd.length - 1 // Skip to after the '">
// If this is an LS invoke, create a list_files tool
if (currentInvokeName === "LS") {
currentToolUse = {
type: "tool_use",
name: "list_files",
params: {},
partial: true,
}
}
// If this is a Grep invoke, create a search_files tool
if (currentInvokeName === "Grep") {
currentToolUse = {
type: "tool_use",
name: "search_files",
params: {},
partial: true,
}
}
if (currentInvokeName === "Bash") {
currentToolUse = {
type: "tool_use",
name: "execute_command",
params: {},
partial: true,
}
}
if (currentInvokeName === "Read") {
currentToolUse = {
type: "tool_use",
name: "read_file",
params: {},
partial: true,
}
}
if (currentInvokeName === "Write") {
currentToolUse = {
type: "tool_use",
name: "write_to_file",
params: {},
partial: true,
}
}
if (currentInvokeName === "WebFetch") {
currentToolUse = {
type: "tool_use",
name: "web_fetch",
params: {},
partial: true,
}
}
if (currentInvokeName === "AskQuestion") {
currentToolUse = {
type: "tool_use",
name: "ask_followup_question",
params: {},
partial: true,
}
}
if (currentInvokeName === "UseMCPTool") {
currentToolUse = {
type: "tool_use",
name: "use_mcp_tool",
params: {},
partial: true,
}
}
if (currentInvokeName === "AccessMCPResource") {
currentToolUse = {
type: "tool_use",
name: "access_mcp_resource",
params: {},
partial: true,
}
}
if (currentInvokeName === "ListCodeDefinitionNames") {
currentToolUse = {
type: "tool_use",
name: "list_code_definition_names",
params: {},
partial: true,
}
}
if (currentInvokeName === "PlanModeRespond") {
currentToolUse = {
type: "tool_use",
name: "plan_mode_respond",
params: {},
partial: true,
}
}
if (currentInvokeName === "LoadMcpDocumentation") {
currentToolUse = {
type: "tool_use",
name: "load_mcp_documentation",
params: {},
partial: true,
}
}
if (currentInvokeName === "AttemptCompletion") {
currentToolUse = {
type: "tool_use",
name: "attempt_completion",
params: {},
partial: true,
}
}
if (currentInvokeName === "BrowserAction") {
currentToolUse = {
type: "tool_use",
name: "browser_action",
params: {},
partial: true,
}
}
if (currentInvokeName === "NewTask") {
currentToolUse = {
type: "tool_use",
name: "new_task",
params: {},
partial: true,
}
}
// If this is a MultiEdit invoke, create a replace_in_file tool
if (currentInvokeName === "MultiEdit") {
currentToolUse = {
type: "tool_use",
name: "replace_in_file",
params: {},
partial: true,
}
}
continue
}
}
// Check for parameter start within invoke
if (
inFunctionCalls &&
currentInvokeName !== "" &&
currentParameterName === "" &&
currentCharIndex >= isParameterStart.length - 1 &&
assistantMessage.startsWith(isParameterStart, currentCharIndex - isParameterStart.length + 1)
) {
// Find the end of the parameter name
const nameEndPos = assistantMessage.indexOf(isParameterNameEnd, currentCharIndex + 1)
if (nameEndPos !== -1) {
// Extract the parameter name
currentParameterName = assistantMessage.slice(currentCharIndex + 1, nameEndPos)
currentParamValueStart = nameEndPos + isParameterNameEnd.length
i = nameEndPos + isParameterNameEnd.length - 1 // Skip to after the '">'
continue
}
}
// Check for parameter end
if (
inFunctionCalls &&
currentInvokeName !== "" &&
currentParameterName !== "" &&
currentCharIndex >= isParameterClose.length - 1 &&
assistantMessage.startsWith(isParameterClose, currentCharIndex - isParameterClose.length + 1)
) {
// Extract parameter value
const value = assistantMessage.slice(currentParamValueStart, currentCharIndex - isParameterClose.length + 1).trim()
// Map parameter to tool params
if (currentToolUse && currentInvokeName === "LS" && currentParameterName === "path") {
currentToolUse.params["path"] = value
// Default recursive to false - only show top level
currentToolUse.params["recursive"] = "false"
}
if (currentToolUse && currentInvokeName === "Read" && currentParameterName === "file_path") {
currentToolUse.params["path"] = value
}
if (currentToolUse && currentInvokeName === "PlanModeRespond" && currentParameterName === "response") {
currentToolUse.params["response"] = value
}
if (currentToolUse && currentInvokeName === "WebFetch" && currentParameterName === "url") {
currentToolUse.params["url"] = value
}
if (currentToolUse && currentInvokeName === "ListCodeDefinitionNames" && currentParameterName === "path") {
currentToolUse.params["path"] = value
}
if (currentToolUse && currentInvokeName === "NewTask" && currentParameterName === "context") {
currentToolUse.params["context"] = value
}
// Map parameter to tool params for Grep
if (currentToolUse && currentInvokeName === "Grep") {
if (currentParameterName === "pattern") {
currentToolUse.params["regex"] = value
} else if (currentParameterName === "path") {
currentToolUse.params["path"] = value
} else if (currentParameterName === "include") {
currentToolUse.params["file_pattern"] = value
}
}
if (currentToolUse && currentInvokeName === "Bash") {
if (currentParameterName === "command") {
currentToolUse.params["command"] = value
} else if (currentParameterName === "requires_approval") {
currentToolUse.params["requires_approval"] = value === "true" ? "true" : "false"
}
}
if (currentToolUse && currentInvokeName === "Write") {
if (currentParameterName === "file_path") {
currentToolUse.params["path"] = value
} else if (currentParameterName === "content") {
currentToolUse.params["content"] = value
}
}
if (currentToolUse && currentInvokeName === "AskQuestion") {
if (currentParameterName === "question") {
currentToolUse.params["question"] = value
} else if (currentParameterName === "options") {
currentToolUse.params["options"] = value
}
}
if (currentToolUse && currentInvokeName === "UseMCPTool") {
if (currentParameterName === "server_name") {
currentToolUse.params["server_name"] = value
} else if (currentParameterName === "tool_name") {
currentToolUse.params["tool_name"] = value
} else if (currentParameterName === "arguments") {
currentToolUse.params["arguments"] = value
}
}
if (currentToolUse && currentInvokeName === "AccessMCPResource") {
if (currentParameterName === "server_name") {
currentToolUse.params["server_name"] = value
} else if (currentParameterName === "uri") {
currentToolUse.params["uri"] = value
}
}
if (currentToolUse && currentInvokeName === "AttemptCompletion") {
if (currentParameterName === "result") {
currentToolUse.params["result"] = value
}
if (currentParameterName === "command") {
currentToolUse.params["command"] = value
}
}
if (currentToolUse && currentInvokeName === "BrowserAction") {
if (currentParameterName === "action") {
currentToolUse.params["action"] = value
} else if (currentParameterName === "url") {
currentToolUse.params["url"] = value
} else if (currentParameterName === "coordinate") {
currentToolUse.params["coordinate"] = value
} else if (currentParameterName === "text") {
currentToolUse.params["text"] = value
}
}
// Map parameter to tool params for MultiEdit
if (currentToolUse && currentInvokeName === "MultiEdit") {
if (currentParameterName === "file_path") {
currentToolUse.params["path"] = value
} else if (currentParameterName === "edits") {
// Save the value to the diff parameter for replace_in_file
currentToolUse.params["diff"] = value
}
}
currentParameterName = ""
continue
}
// Check for invoke end
if (
inFunctionCalls &&
currentInvokeName !== "" &&
currentCharIndex >= isInvokeClose.length - 1 &&
assistantMessage.startsWith(isInvokeClose, currentCharIndex - isInvokeClose.length + 1)
) {
// If we have a tool use from this invoke, finalize it
if (
currentToolUse &&
(currentInvokeName === "LS" ||
currentInvokeName === "Grep" ||
currentInvokeName === "Bash" ||
currentInvokeName === "Read" ||
currentInvokeName === "Write" ||
currentInvokeName === "WebFetch" ||
currentInvokeName === "AskQuestion" ||
currentInvokeName === "UseMCPTool" ||
currentInvokeName === "AccessMCPResource" ||
currentInvokeName === "ListCodeDefinitionNames" ||
currentInvokeName === "PlanModeRespond" ||
currentInvokeName === "LoadMcpDocumentation" ||
currentInvokeName === "AttemptCompletion" ||
currentInvokeName === "BrowserAction" ||
currentInvokeName === "NewTask" ||
currentInvokeName === "MultiEdit")
) {
currentToolUse.partial = false
contentBlocks.push(currentToolUse)
currentToolUse = undefined
}
currentInvokeName = ""
continue
}
// Check for function_calls end
if (
inFunctionCalls &&
currentCharIndex >= isFunctionCallsClose.length - 1 &&
assistantMessage.startsWith(isFunctionCallsClose, currentCharIndex - isFunctionCallsClose.length + 1)
) {
inFunctionCalls = false
currentTextContentStart = currentCharIndex + 1
// Start a new text content block for any text after function_calls
currentTextContent = {
type: "text",
content: "",
partial: true,
}
continue
}
// Skip normal parsing when inside function_calls
if (inFunctionCalls) {
continue
}
// --- State: Parsing a Tool Parameter ---
if (currentToolUse && currentParamName) {
const closeTag = `</${currentParamName}>`
// Check if the string *ending* at index `i` matches the closing tag
if (
currentCharIndex >= closeTag.length - 1 &&
assistantMessage.startsWith(
closeTag,
currentCharIndex - closeTag.length + 1, // Start checking from potential start of tag
)
) {
// Found the closing tag for the parameter
const value = assistantMessage
.slice(
currentParamValueStart, // Start after the opening tag
currentCharIndex - closeTag.length + 1, // End before the closing tag
)
.trim()
currentToolUse.params[currentParamName] = value
currentParamName = undefined // Go back to parsing tool content
// We don't continue loop here, need to check for tool close or other params at index i
} else {
continue // Still inside param value, move to next char
}
}
// --- State: Parsing a Tool Use (but not a specific parameter) ---
if (currentToolUse && !currentParamName) {
// Ensure we are not inside a parameter already
// Check if starting a new parameter
let startedNewParam = false
for (const [tag, paramName] of toolParamOpenTags.entries()) {
if (currentCharIndex >= tag.length - 1 && assistantMessage.startsWith(tag, currentCharIndex - tag.length + 1)) {
currentParamName = paramName
currentParamValueStart = currentCharIndex + 1 // Value starts after the tag
startedNewParam = true
break
}
}
if (startedNewParam) {
continue // Handled start of param, move to next char
}
// Check if closing the current tool use
const toolCloseTag = `</${currentToolUse.name}>`
if (
currentCharIndex >= toolCloseTag.length - 1 &&
assistantMessage.startsWith(toolCloseTag, currentCharIndex - toolCloseTag.length + 1)
) {
// End of the tool use found
// Special handling for content params *before* finalizing the tool
const toolContentSlice = assistantMessage.slice(
currentToolUseStart, // From after the tool opening tag
currentCharIndex - toolCloseTag.length + 1, // To before the tool closing tag
)
// Check if content parameter needs special handling (write_to_file/new_rule)
// This check is important if the closing </content> tag was missed by the parameter parsing logic
// (e.g., if content is empty or parsing logic prioritizes tool close)
const contentParamName: ToolParamName = "content"
if (
currentToolUse.name === "write_to_file" /* || currentToolUse.name === "new_rule" */ &&
toolContentSlice.includes(`<${contentParamName}>`)
) {
const contentStartTag = `<${contentParamName}>`
const contentEndTag = `</${contentParamName}>`
const contentStart = toolContentSlice.indexOf(contentStartTag)
// Use lastIndexOf for robustness against nested tags
const contentEnd = toolContentSlice.lastIndexOf(contentEndTag)
if (contentStart !== -1 && contentEnd !== -1 && contentEnd > contentStart) {
const contentValue = toolContentSlice.slice(contentStart + contentStartTag.length, contentEnd).trim()
currentToolUse.params[contentParamName] = contentValue
}
}
currentToolUse.partial = false // Mark as complete
contentBlocks.push(currentToolUse)
currentToolUse = undefined // Reset state
currentTextContentStart = currentCharIndex + 1 // Potential text starts after this tag
continue // Move to next char
}
// If not starting a param and not closing the tool, continue accumulating tool content implicitly
continue
}
// --- State: Parsing Text / Looking for Tool Start ---
if (!currentToolUse) {
// Check if starting a new tool use
let startedNewTool = false
for (const [tag, toolName] of toolUseOpenTags.entries()) {
if (currentCharIndex >= tag.length - 1 && assistantMessage.startsWith(tag, currentCharIndex - tag.length + 1)) {
// End current text block if one was active
if (currentTextContent) {
currentTextContent.content = assistantMessage
.slice(
currentTextContentStart, // From where text started
currentCharIndex - tag.length + 1, // To before the tool tag starts
)
.trim()
currentTextContent.partial = false // Ended because tool started
if (currentTextContent.content.length > 0) {
contentBlocks.push(currentTextContent)
}
currentTextContent = undefined
} else {
// Check for any text between the last block and this tag
const potentialText = assistantMessage
.slice(
currentTextContentStart, // From where text *might* have started
currentCharIndex - tag.length + 1, // To before the tool tag starts
)
.trim()
if (potentialText.length > 0) {
contentBlocks.push({
type: "text",
content: potentialText,
partial: false,
})
}
}
// Start the new tool use
currentToolUse = {
type: "tool_use",
name: toolName,
params: {},
partial: true, // Assume partial until closing tag is found
}
currentToolUseStart = currentCharIndex + 1 // Tool content starts after the opening tag
startedNewTool = true
break
}
}
if (startedNewTool) {
continue // Handled start of tool, move to next char
}
// If not starting a tool, it must be text content
if (!currentTextContent) {
// Start a new text block if we aren't already in one
currentTextContentStart = currentCharIndex // Text starts at the current character
// Check if the current char is the start of potential text *immediately* after a tag
// This needs the previous state - simpler to let slicing handle it later.
// Resetting start index accurately is key.
// It should be the index *after* the last processed tag.
// The logic managing currentTextContentStart after closing tags handles this.
currentTextContent = {
type: "text",
content: "", // Will be determined by slicing at the end or when a tool starts
partial: true,
}
}
// Continue accumulating text implicitly; content is extracted later.
}
} // End of loop
// --- Finalization after loop ---
// Finalize any open parameter within an open tool use
if (currentToolUse && currentParamName) {
currentToolUse.params[currentParamName] = assistantMessage
.slice(currentParamValueStart) // From param start to end of string
.trim()
// Tool use remains partial
}
// Finalize any open tool use (which might contain the finalized partial param)
if (currentToolUse) {
// Tool use is partial because the loop finished before its closing tag
contentBlocks.push(currentToolUse)
}
// Finalize any trailing text content
// Only possible if a tool use wasn't open at the very end
else if (currentTextContent) {
currentTextContent.content = assistantMessage
.slice(currentTextContentStart) // From text start to end of string
.trim()
// Text is partial because the loop finished
if (currentTextContent.content.length > 0) {
contentBlocks.push(currentTextContent)
}
}
return contentBlocks
}
@@ -106,7 +106,71 @@ export class ContextManager {
}
/**
* primary entry point for getting up to date context & truncating when required
* Determine whether we should compact context window, based on token counts
*/
shouldCompactContextWindow(clineMessages: ClineMessage[], api: ApiHandler, previousApiReqIndex: number): boolean {
if (previousApiReqIndex >= 0) {
const previousRequest = clineMessages[previousApiReqIndex]
if (previousRequest && previousRequest.text) {
const { tokensIn, tokensOut, cacheWrites, cacheReads }: ClineApiReqInfo = JSON.parse(previousRequest.text)
const totalTokens = (tokensIn || 0) + (tokensOut || 0) + (cacheWrites || 0) + (cacheReads || 0)
const { maxAllowedSize } = getContextWindowInfo(api)
return totalTokens >= maxAllowedSize
}
}
return false
}
/**
* Get telemetry data for context management decisions
* Returns the token counts and context window info that drove summarization
*/
getContextTelemetryData(
clineMessages: ClineMessage[],
api: ApiHandler,
triggerIndex?: number,
): {
tokensUsed: number
maxContextWindow: number
} | null {
// Use provided triggerIndex or fallback to automatic detection
let targetIndex
if (triggerIndex !== undefined) {
targetIndex = triggerIndex
} else {
// Find all API request indices
const apiReqIndices = clineMessages
.map((msg, index) => (msg.say === "api_req_started" ? index : -1))
.filter((index) => index !== -1)
// We want the second-to-last API request (the one that caused summarization)
targetIndex = apiReqIndices.length >= 2 ? apiReqIndices[apiReqIndices.length - 2] : -1
}
if (targetIndex >= 0) {
const targetRequest = clineMessages[targetIndex]
if (targetRequest && targetRequest.text) {
try {
const { tokensIn, tokensOut, cacheWrites, cacheReads }: ClineApiReqInfo = JSON.parse(targetRequest.text)
const tokensUsed = (tokensIn || 0) + (tokensOut || 0) + (cacheWrites || 0) + (cacheReads || 0)
const { contextWindow } = getContextWindowInfo(api)
return {
tokensUsed,
maxContextWindow: contextWindow,
}
} catch (error) {
console.error("Error parsing API request info for context telemetry:", error)
}
}
}
return null
}
/**
* primary entry point for getting up to date context
*/
async getNewContextMessagesAndMetadata(
apiConversationHistory: Anthropic.Messages.MessageParam[],
@@ -118,63 +182,6 @@ export class ContextManager {
) {
let updatedConversationHistoryDeletedRange = false
// If the previous API request's total token usage is close to the context window, truncate the conversation history to free up space for the new request
if (previousApiReqIndex >= 0) {
const previousRequest = clineMessages[previousApiReqIndex]
if (previousRequest && previousRequest.text) {
const timestamp = previousRequest.ts
const { tokensIn, tokensOut, cacheWrites, cacheReads }: ClineApiReqInfo = JSON.parse(previousRequest.text)
const totalTokens = (tokensIn || 0) + (tokensOut || 0) + (cacheWrites || 0) + (cacheReads || 0)
const { maxAllowedSize } = getContextWindowInfo(api)
// This is the most reliable way to know when we're close to hitting the context window.
if (totalTokens >= maxAllowedSize) {
// Since the user may switch between models with different context windows, truncating half may not be enough (ie if switching from claude 200k to deepseek 64k, half truncation will only remove 100k tokens, but we need to remove much more)
// So if totalTokens/2 is greater than maxAllowedSize, we truncate 3/4 instead of 1/2
const keep = totalTokens / 2 > maxAllowedSize ? "quarter" : "half"
// we later check how many chars we trim to determine if we should still truncate history
let [anyContextUpdates, uniqueFileReadIndices] = this.applyContextOptimizations(
apiConversationHistory,
conversationHistoryDeletedRange ? conversationHistoryDeletedRange[1] + 1 : 2,
timestamp,
)
let needToTruncate = true
if (anyContextUpdates) {
// determine whether we've saved enough chars to not truncate
const charactersSavedPercentage = this.calculateContextOptimizationMetrics(
apiConversationHistory,
conversationHistoryDeletedRange,
uniqueFileReadIndices,
)
if (charactersSavedPercentage >= 0.3) {
needToTruncate = false
}
}
if (needToTruncate) {
// go ahead with truncation
anyContextUpdates = this.applyStandardContextTruncationNoticeChange(timestamp) || anyContextUpdates
// NOTE: it's okay that we overwriteConversationHistory in resume task since we're only ever removing the last user message and not anything in the middle which would affect this range
conversationHistoryDeletedRange = this.getNextTruncationRange(
apiConversationHistory,
conversationHistoryDeletedRange,
keep,
)
updatedConversationHistoryDeletedRange = true
}
// if we alter the context history, save the updated version to disk
if (anyContextUpdates) {
await this.saveContextHistory(taskDirectory)
}
}
}
}
const truncatedConversationHistory = this.getAndAlterTruncatedMessages(
apiConversationHistory,
conversationHistoryDeletedRange,
@@ -1,15 +1,68 @@
export function checkIsOpenRouterContextWindowError(error: any): boolean {
import LengthFinishReasonError, { APIError } from "openai"
export function checkContextWindowExceededError(error: unknown): boolean {
return (
checkIsOpenAIContextWindowError(error) ||
checkIsOpenRouterContextWindowError(error) ||
checkIsAnthropicContextWindowError(error) ||
checkIsCerebrasContextWindowError(error)
)
}
function checkIsOpenRouterContextWindowError(error: any): boolean {
try {
return error.code === 400 && error.message?.includes("context length")
} catch (e: unknown) {
const status = error?.status ?? error?.code ?? error?.error?.status ?? error?.response?.status
const message: string = String(error?.message || error?.error?.message || "")
// Known OpenAI/OpenRouter-style signal (code 400 and message includes "context length")
const CONTEXT_ERROR_PATTERNS = [
/\bcontext\s*(?:length|window)\b/i,
/\bmaximum\s*context\b/i,
/\b(?:input\s*)?tokens?\s*exceed/i,
/\btoo\s*many\s*tokens?\b/i,
] as const
return String(status) === "400" && CONTEXT_ERROR_PATTERNS.some((pattern) => pattern.test(message))
} catch {
return false
}
}
export function checkIsAnthropicContextWindowError(response: any): boolean {
// Docs: https://platform.openai.com/docs/guides/error-codes/api-errors
function checkIsOpenAIContextWindowError(error: unknown): boolean {
try {
return response?.error?.error?.type === "invalid_request_error"
} catch (e: unknown) {
if (error instanceof LengthFinishReasonError) {
return true
}
const KNOWN_CONTEXT_ERROR_SUBSTRINGS = ["token", "context length"] as const
return (
Boolean(error) &&
error instanceof APIError &&
error.code?.toString() === "400" &&
KNOWN_CONTEXT_ERROR_SUBSTRINGS.some((substring) => error.message.includes(substring))
)
} catch {
return false
}
}
function checkIsAnthropicContextWindowError(response: any): boolean {
try {
return response?.error?.error?.type === "invalid_request_error"
} catch {
return false
}
}
function checkIsCerebrasContextWindowError(response: any): boolean {
try {
const status = response?.status ?? response?.code ?? response?.error?.status ?? response?.response?.status
const message: string = String(response?.message || response?.error?.message || "")
return String(status) === "400" && message.includes("Please reduce the length of the messages or completion")
} catch {
return false
}
}
@@ -6,14 +6,17 @@ import { afterEach, beforeEach, describe, it } from "mocha"
import * as path from "path"
import * as sinon from "sinon"
import * as vscode from "vscode"
import chokidar from "chokidar"
import type { FileMetadataEntry, TaskMetadata } from "./ContextTrackerTypes"
import { FileContextTracker } from "./FileContextTracker"
import { Controller } from "@/core/controller"
describe("FileContextTracker", () => {
let sandbox: sinon.SinonSandbox
let mockContext: vscode.ExtensionContext
let mockController: Controller
let mockWorkspace: sinon.SinonStub
let mockFileSystemWatcher: any
let chokidarWatchStub: sinon.SinonStub
let tracker: FileContextTracker
let taskId: string
let mockTaskMetadata: TaskMetadata
@@ -32,21 +35,21 @@ describe("FileContextTracker", () => {
} as vscode.WorkspaceFolder,
])
// Mock file system watcher
// Mock chokidar file watcher
mockFileSystemWatcher = {
dispose: sandbox.stub(),
onDidChange: sandbox.stub().returns({ dispose: () => {} }),
close: sandbox.stub().resolves(),
on: sandbox.stub(),
}
// Return the watcher itself for chaining
mockFileSystemWatcher.on.returns(mockFileSystemWatcher)
// Use a function replacement instead of a direct stub
vscode.workspace.createFileSystemWatcher = function () {
return mockFileSystemWatcher
}
// Stub chokidar.watch to return our mock watcher
chokidarWatchStub = sandbox.stub(chokidar, "watch").returns(mockFileSystemWatcher as any)
// Mock controller and context
mockContext = {
globalStorageUri: { fsPath: "/mock/storage" },
} as unknown as vscode.ExtensionContext
mockController = {
context: { globalStorageUri: { fsPath: "/mock/storage" } } as vscode.ExtensionContext,
} as unknown as Controller
// Mock disk module functions
mockTaskMetadata = { files_in_context: [], model_usage: [] }
@@ -57,7 +60,7 @@ describe("FileContextTracker", () => {
// Create tracker instance
taskId = "test-task-id"
tracker = new FileContextTracker(mockContext, taskId)
tracker = new FileContextTracker(mockController, taskId)
})
afterEach(() => {
@@ -186,17 +189,13 @@ describe("FileContextTracker", () => {
it("should setup a file watcher for tracked files", async () => {
const filePath = "src/test-file.ts"
// Create a spy to track if createFileSystemWatcher was called
const createWatcherSpy = sinon.spy(vscode.workspace, "createFileSystemWatcher")
await tracker.trackFileContext(filePath, "read_tool")
// Verify createFileSystemWatcher was called
expect(createWatcherSpy.called).to.be.true
createWatcherSpy.restore()
// Verify chokidar.watch was called
expect(chokidarWatchStub.called).to.be.true
// Verify onDidChange was called to set up the change listener
expect(mockFileSystemWatcher.onDidChange.called).to.be.true
// Verify change listener was set up
expect(mockFileSystemWatcher.on.called).to.be.true
})
it("should track user edits when file watcher detects changes", async () => {
@@ -212,8 +211,8 @@ describe("FileContextTracker", () => {
// Create a spy on trackFileContext to verify it's called with the right parameters
const trackFileContextSpy = sandbox.spy(tracker, "trackFileContext")
// Get the callback that was registered with onDidChange
const callback = mockFileSystemWatcher.onDidChange.firstCall.args[0]
// Get the callback that was registered with chokidar "change" event
const callback = mockFileSystemWatcher.on.firstCall.args[1]
// Directly call the callback to simulate a file change event
callback(vscode.Uri.file(path.resolve("/mock/workspace", filePath)))
@@ -242,8 +241,8 @@ describe("FileContextTracker", () => {
// Create a spy on trackFileContext to verify it's not called
const trackFileContextSpy = sandbox.spy(tracker, "trackFileContext")
// Get the callback that was registered with onDidChange
const callback = mockFileSystemWatcher.onDidChange.firstCall.args[0]
// Get the callback that was registered with chokidar "change" event
const callback = mockFileSystemWatcher.on.firstCall.args[1]
// Directly call the callback to simulate a file change event
callback(vscode.Uri.file(path.resolve("/mock/workspace", filePath)))
@@ -263,9 +262,9 @@ describe("FileContextTracker", () => {
await tracker.trackFileContext(filePath, "read_tool")
// Call dispose
tracker.dispose()
await tracker.dispose()
// Verify the watcher was disposed
expect(mockFileSystemWatcher.dispose.called).to.be.true
// Verify the watcher was closed
expect(mockFileSystemWatcher.close.called).to.be.true
})
})
@@ -1,12 +1,12 @@
import * as path from "path"
import * as vscode from "vscode"
import chokidar, { FSWatcher } from "chokidar"
import { getTaskMetadata, saveTaskMetadata } from "@core/storage/disk"
import { getWorkspaceState, updateWorkspaceState } from "@core/storage/state"
import { getGlobalState } from "@core/storage/state"
import type { FileMetadataEntry } from "./ContextTrackerTypes"
import type { ClineMessage } from "@shared/ExtensionMessage"
import { HostProvider } from "@/hosts/host-provider"
import { getCwd } from "@/utils/path"
import { HistoryItem } from "@/shared/HistoryItem"
import { Controller } from "@/core/controller"
// This class is responsible for tracking file operations that may result in stale context.
// If a user modifies a file outside of Cline, the context may become stale and need to be updated.
@@ -23,16 +23,16 @@ If a file is modified outside of Cline, we detect and track this change to preve
This is used when restoring a task (non-git "checkpoint" restore), and mid-task.
*/
export class FileContextTracker {
private context: vscode.ExtensionContext
private controller: Controller
readonly taskId: string
// File tracking and watching
private fileWatchers = new Map<string, vscode.FileSystemWatcher>()
private fileWatchers = new Map<string, FSWatcher>()
private recentlyModifiedFiles = new Set<string>()
private recentlyEditedByCline = new Set<string>()
constructor(context: vscode.ExtensionContext, taskId: string) {
this.context = context
constructor(controller: Controller, taskId: string) {
this.controller = controller
this.taskId = taskId
}
@@ -51,14 +51,21 @@ export class FileContextTracker {
return
}
// Create a file system watcher for this specific file
const fileUri = vscode.Uri.file(path.resolve(cwd, filePath))
const watcher = vscode.workspace.createFileSystemWatcher(
new vscode.RelativePattern(path.dirname(fileUri.fsPath), path.basename(fileUri.fsPath)),
)
// Create a chokidar file watcher for this specific file
const resolvedFilePath = path.resolve(cwd, filePath)
const watcher = chokidar.watch(resolvedFilePath, {
persistent: true, // Keep process alive while watching
ignoreInitial: true, // Don't emit events for existing files on startup
atomic: true, // Handle atomic writes (editors that use temp files)
awaitWriteFinish: {
// Wait for writes to finish before emitting events
stabilityThreshold: 100, // Wait 100ms for file size to stabilize
pollInterval: 100, // Check every 100ms while waiting
},
})
// Track file changes
watcher.onDidChange(() => {
watcher.on("change", () => {
if (this.recentlyEditedByCline.has(filePath)) {
this.recentlyEditedByCline.delete(filePath) // This was an edit by Cline, no need to inform Cline
} else {
@@ -84,7 +91,7 @@ export class FileContextTracker {
}
// Add file to metadata
await this.addFileToFileContextTracker(this.context, this.taskId, filePath, operation)
await this.addFileToFileContextTracker(this.controller.context, this.taskId, filePath, operation)
// Set up file watcher for this file
await this.setupFileWatcher(filePath)
@@ -179,10 +186,9 @@ export class FileContextTracker {
/**
* Disposes all file watchers
*/
dispose(): void {
for (const watcher of this.fileWatchers.values()) {
watcher.dispose()
}
async dispose(): Promise<void> {
const closePromises = Array.from(this.fileWatchers.values()).map((watcher) => watcher.close())
await Promise.all(closePromises)
this.fileWatchers.clear()
}
@@ -195,7 +201,7 @@ export class FileContextTracker {
try {
// Check task metadata for files that were edited by Cline or users after the message timestamp
const taskMetadata = await getTaskMetadata(this.context, this.taskId)
const taskMetadata = await getTaskMetadata(this.controller.context, this.taskId)
if (taskMetadata?.files_in_context) {
for (const fileEntry of taskMetadata.files_in_context) {
@@ -237,7 +243,7 @@ export class FileContextTracker {
const key = `pendingFileContextWarning_${this.taskId}`
// NOTE: Using 'as any' because dynamic keys like pendingFileContextWarning_${taskId}
// are legitimate workspace state keys but don't fit the strict LocalStateKey type system
await updateWorkspaceState(this.context, key as any, files)
this.controller.cacheService.setWorkspaceState(key as any, files)
} catch (error) {
console.error("Error storing pending file context warning:", error)
}
@@ -249,7 +255,7 @@ export class FileContextTracker {
async retrievePendingFileContextWarning(): Promise<string[] | undefined> {
try {
const key = `pendingFileContextWarning_${this.taskId}`
const files = (await getWorkspaceState(this.context, key as any)) as string[]
const files = this.controller.cacheService.getWorkspaceStateKey(key as any) as string[]
return files
} catch (error) {
console.error("Error retrieving pending file context warning:", error)
@@ -264,7 +270,7 @@ export class FileContextTracker {
try {
const files = await this.retrievePendingFileContextWarning()
if (files) {
await updateWorkspaceState(this.context, `pendingFileContextWarning_${this.taskId}` as any, undefined)
this.controller.cacheService.setWorkspaceState(`pendingFileContextWarning_${this.taskId}` as any, undefined)
return files
}
} catch (error) {
@@ -280,7 +286,8 @@ export class FileContextTracker {
static async cleanupOrphanedWarnings(context: vscode.ExtensionContext): Promise<void> {
const startTime = Date.now()
try {
const taskHistory = ((await getGlobalState(context, "taskHistory")) as Array<{ id: string }>) || []
// eslint-disable-next-line eslint-rules/no-direct-vscode-state-api
const taskHistory = (context.globalState.get("taskHistory") as HistoryItem[]) || []
const existingTaskIds = new Set(taskHistory.map((task) => task.id))
const allStateKeys = context.workspaceState.keys()
const pendingWarningKeys = allStateKeys.filter((key) => key.startsWith("pendingFileContextWarning_"))
@@ -295,7 +302,8 @@ export class FileContextTracker {
if (orphanedPendingContextTasks.length > 0) {
for (const key of orphanedPendingContextTasks) {
await updateWorkspaceState(context, key as any, undefined)
// eslint-disable-next-line eslint-rules/no-direct-vscode-state-api
await context.workspaceState.update(key, undefined)
}
}
@@ -4,9 +4,8 @@ import { fileExistsAtPath, isDirectory, readDirectory } from "@utils/fs"
import { formatResponse } from "@core/prompts/responses"
import fs from "fs/promises"
import { ClineRulesToggles } from "@shared/cline-rules"
import { getGlobalState, getWorkspaceState, updateGlobalState, updateWorkspaceState } from "@core/storage/state"
import * as vscode from "vscode"
import { synchronizeRuleToggles, getRuleFilesTotalContent } from "@core/context/instructions/user-instructions/rule-helpers"
import { Controller } from "@/core/controller"
export const getGlobalClineRules = async (globalClineRulesFilePath: string, toggles: ClineRulesToggles) => {
if (await fileExistsAtPath(globalClineRulesFilePath)) {
@@ -68,25 +67,25 @@ export const getLocalClineRules = async (cwd: string, toggles: ClineRulesToggles
}
export async function refreshClineRulesToggles(
context: vscode.ExtensionContext,
controller: Controller,
workingDirectory: string,
): Promise<{
globalToggles: ClineRulesToggles
localToggles: ClineRulesToggles
}> {
// Global toggles
const globalClineRulesToggles = ((await getGlobalState(context, "globalClineRulesToggles")) as ClineRulesToggles) || {}
const globalClineRulesToggles = controller.cacheService.getGlobalStateKey("globalClineRulesToggles")
const globalClineRulesFilePath = await ensureRulesDirectoryExists()
const updatedGlobalToggles = await synchronizeRuleToggles(globalClineRulesFilePath, globalClineRulesToggles)
await updateGlobalState(context, "globalClineRulesToggles", updatedGlobalToggles)
controller.cacheService.setGlobalState("globalClineRulesToggles", updatedGlobalToggles)
// Local toggles
const localClineRulesToggles = ((await getWorkspaceState(context, "localClineRulesToggles")) as ClineRulesToggles) || {}
const localClineRulesToggles = controller.cacheService.getWorkspaceStateKey("localClineRulesToggles")
const localClineRulesFilePath = path.resolve(workingDirectory, GlobalFileNames.clineRules)
const updatedLocalToggles = await synchronizeRuleToggles(localClineRulesFilePath, localClineRulesToggles, "", [
[".clinerules", "workflows"],
])
await updateWorkspaceState(context, "localClineRulesToggles", updatedLocalToggles)
controller.cacheService.setWorkspaceState("localClineRulesToggles", updatedLocalToggles)
return {
globalToggles: updatedGlobalToggles,
@@ -3,7 +3,6 @@ import fs from "fs/promises"
import { GlobalFileNames } from "@core/storage/disk"
import { fileExistsAtPath, isDirectory } from "@utils/fs"
import { formatResponse } from "@core/prompts/responses"
import { getWorkspaceState, updateWorkspaceState } from "@core/storage/state"
import {
synchronizeRuleToggles,
combineRuleToggles,
@@ -11,26 +10,26 @@ import {
readDirectoryRecursive,
} from "@core/context/instructions/user-instructions/rule-helpers"
import { ClineRulesToggles } from "@shared/cline-rules"
import * as vscode from "vscode"
import { Controller } from "@/core/controller"
/**
* Refreshes the toggles for windsurf and cursor rules
*/
export async function refreshExternalRulesToggles(
context: vscode.ExtensionContext,
controller: Controller,
workingDirectory: string,
): Promise<{
windsurfLocalToggles: ClineRulesToggles
cursorLocalToggles: ClineRulesToggles
}> {
// local windsurf toggles
const localWindsurfRulesToggles = ((await getWorkspaceState(context, "localWindsurfRulesToggles")) as ClineRulesToggles) || {}
const localWindsurfRulesToggles = controller.cacheService.getWorkspaceStateKey("localWindsurfRulesToggles")
const localWindsurfRulesFilePath = path.resolve(workingDirectory, GlobalFileNames.windsurfRules)
const updatedLocalWindsurfToggles = await synchronizeRuleToggles(localWindsurfRulesFilePath, localWindsurfRulesToggles)
await updateWorkspaceState(context, "localWindsurfRulesToggles", updatedLocalWindsurfToggles)
controller.cacheService.setWorkspaceState("localWindsurfRulesToggles", updatedLocalWindsurfToggles)
// local cursor toggles
const localCursorRulesToggles = ((await getWorkspaceState(context, "localCursorRulesToggles")) as ClineRulesToggles) || {}
const localCursorRulesToggles = controller.cacheService.getWorkspaceStateKey("localCursorRulesToggles")
// cursor has two valid locations for rules files, so we need to check both and combine
// synchronizeRuleToggles will drop whichever rules files are not in each given path, but combining the results will result in no data loss
@@ -41,7 +40,7 @@ export async function refreshExternalRulesToggles(
const updatedLocalCursorToggles2 = await synchronizeRuleToggles(localCursorRulesFilePath, localCursorRulesToggles)
const updatedLocalCursorToggles = combineRuleToggles(updatedLocalCursorToggles1, updatedLocalCursorToggles2)
await updateWorkspaceState(context, "localCursorRulesToggles", updatedLocalCursorToggles)
controller.cacheService.setWorkspaceState("localCursorRulesToggles", updatedLocalCursorToggles)
return {
windsurfLocalToggles: updatedLocalWindsurfToggles,
@@ -1,10 +1,9 @@
import { fileExistsAtPath, isDirectory, readDirectory } from "@utils/fs"
import { ensureRulesDirectoryExists, ensureWorkflowsDirectoryExists, GlobalFileNames } from "@core/storage/disk"
import { getGlobalState, getWorkspaceState, updateGlobalState, updateWorkspaceState } from "@core/storage/state"
import * as path from "path"
import fs from "fs/promises"
import { ClineRulesToggles } from "@shared/cline-rules"
import * as vscode from "vscode"
import { Controller } from "@/core/controller"
/**
* Recursively traverses directory and finds all files, including checking for optional whitelisted file extension
@@ -224,7 +223,7 @@ export const createRuleFile = async (isGlobal: boolean, filename: string, cwd: s
* Delete a rule file or workflow file
*/
export async function deleteRuleFile(
context: vscode.ExtensionContext,
controller: Controller,
rulePath: string,
isGlobal: boolean,
type: string,
@@ -248,31 +247,31 @@ export async function deleteRuleFile(
// Update the appropriate toggles
if (isGlobal) {
if (type === "workflow") {
const toggles = ((await getGlobalState(context, "globalWorkflowToggles")) as ClineRulesToggles) || {}
const toggles = controller.cacheService.getGlobalStateKey("globalWorkflowToggles")
delete toggles[rulePath]
await updateGlobalState(context, "globalWorkflowToggles", toggles)
controller.cacheService.setGlobalState("globalWorkflowToggles", toggles)
} else {
const toggles = ((await getGlobalState(context, "globalClineRulesToggles")) as ClineRulesToggles) || {}
const toggles = controller.cacheService.getGlobalStateKey("globalClineRulesToggles")
delete toggles[rulePath]
await updateGlobalState(context, "globalClineRulesToggles", toggles)
controller.cacheService.setGlobalState("globalClineRulesToggles", toggles)
}
} else {
if (type === "workflow") {
const toggles = ((await getWorkspaceState(context, "workflowToggles")) as ClineRulesToggles) || {}
const toggles = controller.cacheService.getWorkspaceStateKey("workflowToggles")
delete toggles[rulePath]
await updateWorkspaceState(context, "workflowToggles", toggles)
controller.cacheService.setWorkspaceState("workflowToggles", toggles)
} else if (type === "cursor") {
const toggles = ((await getWorkspaceState(context, "localCursorRulesToggles")) as ClineRulesToggles) || {}
const toggles = controller.cacheService.getWorkspaceStateKey("localCursorRulesToggles")
delete toggles[rulePath]
await updateWorkspaceState(context, "localCursorRulesToggles", toggles)
controller.cacheService.setWorkspaceState("localCursorRulesToggles", toggles)
} else if (type === "windsurf") {
const toggles = ((await getWorkspaceState(context, "localWindsurfRulesToggles")) as ClineRulesToggles) || {}
const toggles = controller.cacheService.getWorkspaceStateKey("localWindsurfRulesToggles")
delete toggles[rulePath]
await updateWorkspaceState(context, "localWindsurfRulesToggles", toggles)
controller.cacheService.setWorkspaceState("localWindsurfRulesToggles", toggles)
} else {
const toggles = ((await getWorkspaceState(context, "localClineRulesToggles")) as ClineRulesToggles) || {}
const toggles = controller.cacheService.getWorkspaceStateKey("localClineRulesToggles")
delete toggles[rulePath]
await updateWorkspaceState(context, "localClineRulesToggles", toggles)
controller.cacheService.setWorkspaceState("localClineRulesToggles", toggles)
}
}
@@ -1,30 +1,29 @@
import path from "path"
import { GlobalFileNames, ensureWorkflowsDirectoryExists } from "@core/storage/disk"
import { ClineRulesToggles } from "@shared/cline-rules"
import { getWorkspaceState, updateWorkspaceState, getGlobalState, updateGlobalState } from "@core/storage/state"
import * as vscode from "vscode"
import { synchronizeRuleToggles } from "@core/context/instructions/user-instructions/rule-helpers"
import { Controller } from "@/core/controller"
/**
* Refresh the workflow toggles
*/
export async function refreshWorkflowToggles(
context: vscode.ExtensionContext,
controller: Controller,
workingDirectory: string,
): Promise<{
globalWorkflowToggles: ClineRulesToggles
localWorkflowToggles: ClineRulesToggles
}> {
// Global workflows
const globalWorkflowToggles = ((await getGlobalState(context, "globalWorkflowToggles")) as ClineRulesToggles) || {}
const globalWorkflowToggles = controller.cacheService.getGlobalStateKey("globalWorkflowToggles")
const globalClineWorkflowsFilePath = await ensureWorkflowsDirectoryExists()
const updatedGlobalWorkflowToggles = await synchronizeRuleToggles(globalClineWorkflowsFilePath, globalWorkflowToggles)
await updateGlobalState(context, "globalWorkflowToggles", updatedGlobalWorkflowToggles)
controller.cacheService.setGlobalState("globalWorkflowToggles", updatedGlobalWorkflowToggles)
const workflowRulesToggles = ((await getWorkspaceState(context, "workflowToggles")) as ClineRulesToggles) || {}
const workflowRulesToggles = controller.cacheService.getWorkspaceStateKey("workflowToggles")
const workflowsDirPath = path.resolve(workingDirectory, GlobalFileNames.workflows)
const updatedWorkflowToggles = await synchronizeRuleToggles(workflowsDirPath, workflowRulesToggles)
await updateWorkspaceState(context, "workflowToggles", updatedWorkflowToggles)
controller.cacheService.setWorkspaceState("workflowToggles", updatedWorkflowToggles)
return {
globalWorkflowToggles: updatedGlobalWorkflowToggles,
@@ -2,8 +2,6 @@ import { Controller } from "../index"
import { AuthService } from "@/services/auth/AuthService"
import { EmptyRequest, String } from "@shared/proto/cline/common"
const authService = AuthService.getInstance()
/**
* Handles the user clicking the login link in the UI.
* Generates a secure nonce for state validation, stores it in secrets,
@@ -13,5 +11,5 @@ const authService = AuthService.getInstance()
* @returns The login URL as a string.
*/
export async function accountLoginClicked(_controller: Controller, _: EmptyRequest): Promise<String> {
return await authService.createAuthRequest()
return await AuthService.getInstance().createAuthRequest()
}
@@ -3,7 +3,6 @@ import { Empty } from "@shared/proto/cline/common"
import type { EmptyRequest } from "@shared/proto/cline/common"
import type { Controller } from "../index"
const authService = AuthService.getInstance()
/**
* Handles the account logout action
* @param controller The controller instance
@@ -12,6 +11,6 @@ const authService = AuthService.getInstance()
*/
export async function accountLogoutClicked(controller: Controller, _request: EmptyRequest): Promise<Empty> {
await controller.handleSignOut()
await authService.handleDeauth()
await AuthService.getInstance().handleDeauth()
return Empty.create({})
}
@@ -1,6 +1,5 @@
import { AuthStateChangedRequest, AuthState } from "@shared/proto/cline/account"
import type { Controller } from "../index"
import { updateGlobalState } from "../../storage/state"
/**
* Handles authentication state changes from the Firebase context.
@@ -12,7 +11,7 @@ import { updateGlobalState } from "../../storage/state"
export async function authStateChanged(controller: Controller, request: AuthStateChangedRequest): Promise<AuthState> {
try {
// Store the user info directly in global state
await updateGlobalState(controller.context, "userInfo", request.user)
controller.cacheService.setGlobalState("userInfo", request.user)
// Return the same user info
return AuthState.create({ user: request.user })
@@ -0,0 +1,17 @@
import { HostProvider } from "@/hosts/host-provider"
import { openExternal } from "@/utils/env"
import { Controller } from ".."
import { EmptyRequest, Empty } from "@shared/proto/cline/common"
/**
* Initiates OpenRouter auth
*/
export async function openrouterAuthClicked(_: Controller, __: EmptyRequest): Promise<Empty> {
const callbackUri = await HostProvider.get().getCallbackUri()
const authUri = `https://openrouter.ai/auth?callback_url=${callbackUri}/openrouter`
await openExternal(authUri)
return {}
}
@@ -1,5 +1,13 @@
import { AuthService } from "../../../services/auth/AuthService"
import { AuthState, EmptyRequest } from "@/shared/proto/index.cline"
import { AuthService } from "@services/auth/AuthService"
import { Controller } from ".."
import { StreamingResponseHandler } from "../grpc-handler"
const authService = AuthService.getInstance()
export const subscribeToAuthStatusUpdate = authService.subscribeToAuthStatusUpdate.bind(authService)
export const sendAuthStatusUpdateEvent = authService.sendAuthStatusUpdate.bind(authService)
export async function subscribeToAuthStatusUpdate(
controller: Controller,
request: EmptyRequest,
responseStream: StreamingResponseHandler<AuthState>,
requestId?: string,
): Promise<void> {
return AuthService.getInstance().subscribeToAuthStatusUpdate(controller, request, responseStream, requestId)
}
@@ -1,7 +1,6 @@
import { BrowserConnection } from "@shared/proto/cline/browser"
import { EmptyRequest } from "@shared/proto/cline/common"
import { Controller } from "../index"
import { getAllExtensionState } from "@core/storage/state"
import { BrowserSession } from "@services/browser/BrowserSession"
import { discoverChromeInstances } from "@services/browser/BrowserDiscovery"
@@ -20,7 +19,7 @@ export async function discoverBrowser(controller: Controller, request: EmptyRequ
// This way we don't override the user's preference
// Test the connection to get the endpoint
const { browserSettings } = await getAllExtensionState(controller.context)
const browserSettings = controller.cacheService.getGlobalStateKey("browserSettings")
const browserSession = new BrowserSession(controller.context, browserSettings)
const result = await browserSession.testConnection(discoveredHost)
@@ -1,7 +1,6 @@
import { BrowserConnectionInfo } from "@shared/proto/cline/browser"
import { EmptyRequest } from "@shared/proto/cline/common"
import { Controller } from "../index"
import { getAllExtensionState } from "@core/storage/state"
/**
* Get information about the current browser connection
@@ -12,7 +11,7 @@ import { getAllExtensionState } from "@core/storage/state"
export async function getBrowserConnectionInfo(controller: Controller, _: EmptyRequest): Promise<BrowserConnectionInfo> {
try {
// Get browser settings from extension state
const { browserSettings } = await getAllExtensionState(controller.context)
const browserSettings = controller.cacheService.getGlobalStateKey("browserSettings")
// Check if there's an active browser session by using the controller's handleWebviewMessage approach
// This is similar to what's done in controller/index.ts for the "getBrowserConnectionInfo" message
@@ -1,7 +1,6 @@
import { ChromePath } from "@shared/proto/cline/browser"
import { EmptyRequest } from "@shared/proto/cline/common"
import { Controller } from "../index"
import { getAllExtensionState } from "../../storage/state"
import { BrowserSession } from "../../../services/browser/BrowserSession"
/**
@@ -12,7 +11,7 @@ import { BrowserSession } from "../../../services/browser/BrowserSession"
*/
export async function getDetectedChromePath(controller: Controller, _: EmptyRequest): Promise<ChromePath> {
try {
const { browserSettings } = await getAllExtensionState(controller.context)
const browserSettings = controller.cacheService.getGlobalStateKey("browserSettings")
const browserSession = new BrowserSession(controller.context, browserSettings)
const result = await browserSession.getDetectedChromePath()
@@ -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)}`)
}
@@ -1,7 +1,6 @@
import { BrowserConnection } from "@shared/proto/cline/browser"
import { StringRequest } from "@shared/proto/cline/common"
import { Controller } from "../index"
import { getAllExtensionState } from "@core/storage/state"
import { BrowserSession } from "@services/browser/BrowserSession"
import { discoverChromeInstances } from "@services/browser/BrowserDiscovery"
@@ -13,7 +12,7 @@ import { discoverChromeInstances } from "@services/browser/BrowserDiscovery"
*/
export async function testBrowserConnection(controller: Controller, request: StringRequest): Promise<BrowserConnection> {
try {
const { browserSettings } = await getAllExtensionState(controller.context)
const browserSettings = controller.cacheService.getGlobalStateKey("browserSettings")
const browserSession = new BrowserSession(controller.context, browserSettings)
const text = request.value || ""
@@ -1,7 +1,6 @@
import { UpdateBrowserSettingsRequest } from "@shared/proto/cline/browser"
import { Boolean } from "@shared/proto/cline/common"
import { Controller } from "../index"
import { updateGlobalState, getGlobalState } from "../../storage/state"
import { BrowserSettings as SharedBrowserSettings, DEFAULT_BROWSER_SETTINGS } from "../../../shared/BrowserSettings"
/**
@@ -13,7 +12,7 @@ import { BrowserSettings as SharedBrowserSettings, DEFAULT_BROWSER_SETTINGS } fr
export async function updateBrowserSettings(controller: Controller, request: UpdateBrowserSettingsRequest): Promise<Boolean> {
try {
// Get current browser settings to preserve fields not in the request
const currentSettings = (await getGlobalState(controller.context, "browserSettings")) as SharedBrowserSettings | undefined
const currentSettings = controller.cacheService.getGlobalStateKey("browserSettings")
const mergedWithDefaults = { ...DEFAULT_BROWSER_SETTINGS, ...currentSettings }
// Convert from protobuf format to shared format, merging with existing settings
@@ -36,10 +35,11 @@ export async function updateBrowserSettings(controller: Controller, request: Upd
// Otherwise, fall back to mergedWithDefaults.
"chromeExecutablePath" in request ? request.chromeExecutablePath : mergedWithDefaults.chromeExecutablePath,
disableToolUse: request.disableToolUse === undefined ? mergedWithDefaults.disableToolUse : request.disableToolUse,
customArgs: "customArgs" in request ? request.customArgs : mergedWithDefaults.customArgs,
}
// Update global state with new settings
await updateGlobalState(controller.context, "browserSettings", newBrowserSettings)
controller.cacheService.setGlobalState("browserSettings", newBrowserSettings)
// Update task browser settings if task exists
if (controller.task) {
@@ -0,0 +1,34 @@
import { Controller } from "../index"
import { CommandContext, Empty } from "@/shared/proto/index.cline"
import { telemetryService } from "@/services/posthog/PostHogClientProvider"
import { getFileMentionFromPath } from "@/core/mentions"
import { singleFileDiagnosticsToProblemsString } from "@/integrations/diagnostics"
import { WebviewProvider } from "@/core/webview"
import { sendAddToInputEventToClient } from "../ui/subscribeToAddToInput"
// 'Add to Cline' context menu in editor and code action
// Inserts the selected code into the chat.
export async function addToCline(controller: Controller, request: CommandContext): Promise<Empty> {
if (!request.selectedText) {
return {}
}
const filePath = request.filePath || ""
const fileMention = await getFileMentionFromPath(filePath)
let input = `${fileMention}\n\`\`\`\n${request.selectedText}\n\`\`\``
if (request.diagnostics.length) {
const problemsString = await singleFileDiagnosticsToProblemsString(filePath, request.diagnostics)
input += `\nProblems:\n${problemsString}`
}
const lastActiveWebview = WebviewProvider.getLastActiveInstance()
if (lastActiveWebview) {
await sendAddToInputEventToClient(lastActiveWebview.getClientId(), input)
}
console.log("addToCline", request.selectedText, filePath, request.language)
telemetryService.captureButtonClick("codeAction_addToChat", controller.task?.ulid)
return {}
}
@@ -0,0 +1,23 @@
import { Controller } from "../index"
import { CommandContext, Empty } from "@/shared/proto/index.cline"
import { telemetryService } from "@/services/posthog/PostHogClientProvider"
import { HostProvider } from "@/hosts/host-provider"
import { ShowMessageType } from "@/shared/proto/index.host"
import { getFileMentionFromPath } from "@/core/mentions"
export async function explainWithCline(controller: Controller, request: CommandContext): Promise<Empty> {
if (!request.selectedText || !request.selectedText.trim()) {
HostProvider.window.showMessage({
type: ShowMessageType.INFORMATION,
message: "Please select some code to explain.",
})
return {}
}
const fileMention = await getFileMentionFromPath(request.filePath || "")
const prompt = `Explain the following code from ${fileMention}:
\`\`\`${request.language}\n${request.selectedText}\n\`\`\``
await controller.initTask(prompt)
telemetryService.captureButtonClick("codeAction_explainCode", controller.task?.ulid)
return {}
}
@@ -0,0 +1,20 @@
import { Controller } from "../index"
import { CommandContext, Empty } from "@/shared/proto/index.cline"
import { telemetryService } from "@/services/posthog/PostHogClientProvider"
import { getFileMentionFromPath } from "@/core/mentions"
import { singleFileDiagnosticsToProblemsString } from "@/integrations/diagnostics"
export async function fixWithCline(controller: Controller, request: CommandContext): Promise<Empty> {
const filePath = request.filePath || ""
const fileMention = await getFileMentionFromPath(filePath)
const problemsString = await singleFileDiagnosticsToProblemsString(filePath, request.diagnostics)
await controller.initTask(
`Fix the following code in ${fileMention}
\`\`\`\n${request.selectedText}\n\`\`\`\n\nProblems:\n${problemsString}`,
)
console.log("fixWithCline", request.selectedText, request.filePath, request.language, problemsString)
telemetryService.captureButtonClick("codeAction_fixWithCline", controller.task?.ulid)
return {}
}
@@ -0,0 +1,25 @@
import { Controller } from "../index"
import { CommandContext, Empty } from "@/shared/proto/index.cline"
import { telemetryService } from "@/services/posthog/PostHogClientProvider"
import { HostProvider } from "@/hosts/host-provider"
import { ShowMessageType } from "@/shared/proto/index.host"
import { getFileMentionFromPath } from "@/core/mentions"
export async function improveWithCline(controller: Controller, request: CommandContext): Promise<Empty> {
if (!request.selectedText || !request.selectedText.trim()) {
HostProvider.window.showMessage({
type: ShowMessageType.INFORMATION,
message: "Please select some code to improve.",
})
return {}
}
const fileMention = await getFileMentionFromPath(request.filePath || "")
const prompt = `Improve the following code from ${fileMention} (e.g., suggest refactorings, optimizations, or better practices):
\`\`\`${request.language}\n${request.selectedText}\n\`\`\``
await controller.initTask(prompt)
telemetryService.captureButtonClick("codeAction_improveCode", controller.task?.ulid)
return {}
}
@@ -0,0 +1,101 @@
import { describe, it, beforeEach, afterEach } from "mocha"
import { expect } from "chai"
import * as sinon from "sinon"
import { ifFileExistsRelativePath } from "../ifFileExistsRelativePath"
import { Controller } from "@core/controller"
import { StringRequest, BooleanResponse } from "@shared/proto/cline/common"
import * as pathUtils from "@utils/path"
describe("ifFileExistsRelativePath", () => {
let sandbox: sinon.SinonSandbox
let mockController: Controller
let getWorkspacePathStub: sinon.SinonStub
let consoleErrorStub: sinon.SinonStub
beforeEach(() => {
sandbox = sinon.createSandbox()
// Create a mock controller
mockController = {} as any
// Stub getWorkspacePath utility
getWorkspacePathStub = sandbox.stub(pathUtils, "getWorkspacePath")
// Stub console.error to prevent test output pollution
consoleErrorStub = sandbox.stub(console, "error")
})
afterEach(() => {
sandbox.restore()
})
it("should return BooleanResponse with boolean value", async () => {
getWorkspacePathStub.resolves("/workspace")
const request = StringRequest.create({
value: "src/test.ts",
})
const result = await ifFileExistsRelativePath(mockController, request)
// The result should be a BooleanResponse object
expect(result).to.have.property("value")
expect(typeof result.value).to.equal("boolean")
})
it("should return false and log error when no workspace path is available", async () => {
const noWorkspaceScenarios = [null, undefined]
for (const workspaceValue of noWorkspaceScenarios) {
getWorkspacePathStub.resolves(workspaceValue)
consoleErrorStub.resetHistory()
const request = StringRequest.create({
value: "src/test.ts",
})
const result = await ifFileExistsRelativePath(mockController, request)
expect(result).to.deep.equal(BooleanResponse.create({ value: false }))
expect(consoleErrorStub.called).to.be.true
}
})
it("should return false when path is invalid", async () => {
getWorkspacePathStub.resolves("/workspace")
const invalidPaths = ["", undefined]
for (const invalidPath of invalidPaths) {
const request = StringRequest.create({
value: invalidPath,
})
const result = await ifFileExistsRelativePath(mockController, request)
expect(result).to.deep.equal(BooleanResponse.create({ value: false }))
}
})
it("should handle valid relative paths correctly", async () => {
getWorkspacePathStub.resolves("/workspace")
// Test with valid workspace-relative paths only
const validPaths = ["src/file.ts", "./src/file.ts", "package.json", ".gitignore", "src/components/ui/Button/Button.tsx"]
for (const testPath of validPaths) {
const request = StringRequest.create({
value: testPath,
})
const result = await ifFileExistsRelativePath(mockController, request)
// Each should return a BooleanResponse
expect(result).to.have.property("value")
expect(typeof result.value).to.equal("boolean")
}
// Verify that getWorkspacePath was called for each path
expect(getWorkspacePathStub.callCount).to.equal(validPaths.length)
})
})
@@ -0,0 +1,117 @@
import { describe, it, beforeEach, afterEach } from "mocha"
import { expect } from "chai"
import * as sinon from "sinon"
import { openFileRelativePath } from "../openFileRelativePath"
import { Controller } from "@core/controller"
import { StringRequest, Empty } from "@shared/proto/cline/common"
import * as openFileIntegration from "@integrations/misc/open-file"
import * as pathUtils from "@utils/path"
import * as path from "path"
describe("openFileRelativePath", () => {
let sandbox: sinon.SinonSandbox
let mockController: Controller
let openFileIntegrationStub: sinon.SinonStub
let getWorkspacePathStub: sinon.SinonStub
let consoleErrorStub: sinon.SinonStub
beforeEach(() => {
sandbox = sinon.createSandbox()
// Create a mock controller
mockController = {} as any
// Stub the openFileIntegration function
openFileIntegrationStub = sandbox.stub(openFileIntegration, "openFile")
// Stub getWorkspacePath utility
getWorkspacePathStub = sandbox.stub(pathUtils, "getWorkspacePath")
// Stub console.error to prevent test output pollution
consoleErrorStub = sandbox.stub(console, "error")
})
afterEach(() => {
sandbox.restore()
})
it("should return Empty response on successful execution", async () => {
getWorkspacePathStub.resolves("/workspace")
const request = StringRequest.create({
value: "src/test.ts",
})
const result = await openFileRelativePath(mockController, request)
expect(result).to.deep.equal(Empty.create())
})
it("should call openFileIntegration with absolute path when relative path is provided", async () => {
const workspacePath = "/workspace"
const relativePath = "src/components/Test.tsx"
const expectedAbsolutePath = path.resolve(workspacePath, relativePath)
getWorkspacePathStub.resolves(workspacePath)
const request = StringRequest.create({
value: relativePath,
})
await openFileRelativePath(mockController, request)
expect(openFileIntegrationStub.calledOnceWith(expectedAbsolutePath)).to.be.true
})
it("should not call openFileIntegration when path is invalid", async () => {
getWorkspacePathStub.resolves("/workspace")
const invalidPaths = ["", undefined]
for (const invalidPath of invalidPaths) {
const request = StringRequest.create({
value: invalidPath,
})
await openFileRelativePath(mockController, request)
expect(openFileIntegrationStub.called).to.be.false
openFileIntegrationStub.resetHistory()
}
})
it("should return Empty and log error when no workspace path is available", async () => {
const noWorkspaceScenarios = [null, undefined]
for (const workspaceValue of noWorkspaceScenarios) {
getWorkspacePathStub.resolves(workspaceValue)
consoleErrorStub.resetHistory()
const request = StringRequest.create({
value: "src/test.ts",
})
const result = await openFileRelativePath(mockController, request)
expect(result).to.deep.equal(Empty.create())
expect(consoleErrorStub.called).to.be.true
expect(openFileIntegrationStub.called).to.be.false
}
})
it("should handle nested directory paths", async () => {
const workspacePath = "/workspace"
const relativePath = "src/components/ui/Button/Button.tsx"
const expectedAbsolutePath = path.resolve(workspacePath, relativePath)
getWorkspacePathStub.resolves(workspacePath)
const request = StringRequest.create({
value: relativePath,
})
await openFileRelativePath(mockController, request)
expect(openFileIntegrationStub.calledOnceWith(expectedAbsolutePath)).to.be.true
})
})
+2 -2
View File
@@ -51,9 +51,9 @@ export async function createRuleFile(controller: Controller, request: RuleFileRe
await openFile(controller, { value: filePath })
} else {
if (request.type === "workflow") {
await refreshWorkflowToggles(controller.context, cwd)
await refreshWorkflowToggles(controller, cwd)
} else {
await refreshClineRulesToggles(controller.context, cwd)
await refreshClineRulesToggles(controller, cwd)
}
await controller.postStateToWebview()
+1 -1
View File
@@ -28,7 +28,7 @@ export async function deleteRuleFile(controller: Controller, request: RuleFileRe
throw new Error("Missing or invalid parameters")
}
const result = await deleteRuleFileImpl(controller.context, request.rulePath, request.isGlobal, request.type)
const result = await deleteRuleFileImpl(controller, request.rulePath, request.isGlobal, request.type)
if (!result.success) {
throw new Error(result.message || "Failed to delete rule file")
@@ -0,0 +1,35 @@
import * as path from "path"
import * as fs from "fs"
import { Controller } from ".."
import { StringRequest, BooleanResponse } from "@shared/proto/cline/common"
import { getWorkspacePath } from "@utils/path"
/**
* Check if a file exists in the project using a relative path
* @param controller The controller instance
* @param request The request containing the relative file path to check
* @returns BooleanResponse indicating whether the file exists
*/
export async function ifFileExistsRelativePath(_controller: Controller, request: StringRequest): Promise<BooleanResponse> {
const workspacePath = await getWorkspacePath()
if (!workspacePath) {
// If no workspace is open, return false
console.error("Error in ifFileExistsRelativePath: No workspace path available") // TODO
return BooleanResponse.create({ value: false })
}
if (!request.value) {
// If no path provided, return false
return BooleanResponse.create({ value: false })
}
// Resolve the relative path to absolute path
const absolutePath = path.resolve(workspacePath, request.value)
// Check if the file exists
try {
return BooleanResponse.create({ value: fs.statSync(absolutePath).isFile() })
} catch {
return BooleanResponse.create({ value: false })
}
}
@@ -0,0 +1,30 @@
import * as path from "path"
import { Controller } from ".."
import { Empty, StringRequest } from "@shared/proto/cline/common"
import { openFile as openFileIntegration } from "@integrations/misc/open-file"
import { getWorkspacePath } from "@utils/path"
/**
* Opens a file in the editor by a relative path
* @param controller The controller instance
* @param request The request message containing the relative file path in the 'value' field
* @returns Empty response
*/
export async function openFileRelativePath(_controller: Controller, request: StringRequest): Promise<Empty> {
const workspacePath = await getWorkspacePath()
if (!workspacePath) {
console.error("Error in openFileRelativePath: No workspace path available")
return Empty.create()
}
if (request.value) {
// Resolve the relative path to absolute path
const absolutePath = path.resolve(workspacePath, request.value)
// Open the file using the existing integration
openFileIntegration(absolutePath)
}
return Empty.create()
}
@@ -0,0 +1,40 @@
import { Controller } from ".."
import { openFile as openFileIntegration } from "@integrations/misc/open-file"
import { Empty, StringRequest } from "../../../shared/proto/cline/common"
import { ensureFocusChainFile, extractFocusChainListFromText } from "../../task/focus-chain/file-utils"
import { telemetryService } from "../../../services/posthog/PostHogClientProvider"
/**
* Opens or creates a focus chain checklist markdown file for editing
* The file is stored at <globalStorage>/tasks/<taskId>/focus_chain_taskid_<taskId>.md
*/
export async function openFocusChainFile(controller: Controller, request: StringRequest): Promise<Empty> {
if (!request.value) {
throw new Error("Task ID is required")
}
const taskId = request.value
// Get the current focus chain list from the task's most recent task_progress message
let initialFocusChainContent: string | undefined
const currentTask = controller.task
if (currentTask) {
// Get the task's message history and find the most recent task_progress message
// TODO - can we decouple this from ClineMessages?
const clineMessages = currentTask.messageStateHandler.getClineMessages()
const lastProgressMessage = clineMessages
.slice()
.reverse()
.find((m) => m.say === "task_progress")
if (lastProgressMessage && lastProgressMessage.text) {
initialFocusChainContent = extractFocusChainListFromText(lastProgressMessage.text) || undefined
}
}
const focusChainFilePath = await ensureFocusChainFile(controller.context, taskId, initialFocusChainContent)
telemetryService.captureFocusChainListOpened(taskId)
await openFileIntegration(focusChainFilePath)
return Empty.create()
}
+3 -3
View File
@@ -15,9 +15,9 @@ import { getCwd, getDesktopDir } from "@/utils/path"
export async function refreshRules(controller: Controller, _request: EmptyRequest): Promise<RefreshedRules> {
try {
const cwd = await getCwd(getDesktopDir())
const { globalToggles, localToggles } = await refreshClineRulesToggles(controller.context, cwd)
const { cursorLocalToggles, windsurfLocalToggles } = await refreshExternalRulesToggles(controller.context, cwd)
const { localWorkflowToggles, globalWorkflowToggles } = await refreshWorkflowToggles(controller.context, cwd)
const { globalToggles, localToggles } = await refreshClineRulesToggles(controller, cwd)
const { cursorLocalToggles, windsurfLocalToggles } = await refreshExternalRulesToggles(controller, cwd)
const { localWorkflowToggles, globalWorkflowToggles } = await refreshWorkflowToggles(controller, cwd)
return RefreshedRules.create({
globalClineRulesToggles: { toggles: globalToggles },
+10 -1
View File
@@ -1,5 +1,5 @@
import { Controller } from ".."
import { FileSearchRequest, FileSearchResults } from "@shared/proto/cline/file"
import { FileSearchRequest, FileSearchResults, FileSearchType } from "@shared/proto/cline/file"
import { searchWorkspaceFiles } from "@services/search/file-search"
import { getWorkspacePath } from "@utils/path"
import { convertSearchResultsToProtoFileInfos } from "@shared/proto-conversions/file/search-result-conversion"
@@ -23,11 +23,20 @@ export async function searchFiles(_controller: Controller, request: FileSearchRe
}
try {
// Map enum to string for the search service
let selectedTypeString: "file" | "folder" | undefined = undefined
if (request.selectedType === FileSearchType.FILE) {
selectedTypeString = "file"
} else if (request.selectedType === FileSearchType.FOLDER) {
selectedTypeString = "folder"
}
// Call file search service with query from request
const searchResults = await searchWorkspaceFiles(
request.query || "",
workspacePath,
request.limit || 20, // Use default limit of 20 if not specified
selectedTypeString,
)
// Convert search results to proto FileInfo objects using the conversion function
@@ -1,58 +0,0 @@
import { Controller } from "../index"
import { EmptyRequest, StringArray } from "@shared/proto/cline/common"
import { StreamingResponseHandler, getRequestRegistry } from "../grpc-handler"
// Keep track of active subscriptions
const activeWorkspaceUpdateSubscriptions = new Set<StreamingResponseHandler<StringArray>>()
/**
* Subscribe to workspace file updates
* @param controller The controller instance
* @param request The empty request
* @param responseStream The streaming response handler
* @param requestId The ID of the request (passed by the gRPC handler)
*/
export async function subscribeToWorkspaceUpdates(
_controller: Controller,
_request: EmptyRequest,
responseStream: StreamingResponseHandler<StringArray>,
requestId?: string,
): Promise<void> {
// Add this subscription to the active subscriptions
activeWorkspaceUpdateSubscriptions.add(responseStream)
// Register cleanup when the connection is closed
const cleanup = () => {
activeWorkspaceUpdateSubscriptions.delete(responseStream)
}
// Register the cleanup function with the request registry if we have a requestId
if (requestId) {
getRequestRegistry().registerRequest(requestId, cleanup, { type: "workspace_update_subscription" }, responseStream)
}
}
/**
* Send a workspace update event to all active subscribers
* @param filePaths Array of file paths to send
*/
export async function sendWorkspaceUpdateEvent(filePaths: string[]): Promise<void> {
// Send the event to all active subscribers
const promises = Array.from(activeWorkspaceUpdateSubscriptions).map(async (responseStream) => {
try {
const event = StringArray.create({
values: filePaths,
})
await responseStream(
event,
false, // Not the last message
)
} catch (error) {
console.error("Error sending workspace update event:", error)
// Remove the subscription if there was an error
activeWorkspaceUpdateSubscriptions.delete(responseStream)
}
})
await Promise.all(promises)
}
+6 -7
View File
@@ -1,7 +1,6 @@
import { ToggleClineRules } from "@shared/proto/cline/file"
import type { ToggleClineRuleRequest } from "@shared/proto/cline/file"
import type { Controller } from "../index"
import { getGlobalState, getWorkspaceState, updateGlobalState, updateWorkspaceState } from "../../../core/storage/state"
import { ClineRulesToggles as AppClineRulesToggles } from "@shared/cline-rules"
/**
@@ -24,18 +23,18 @@ export async function toggleClineRule(controller: Controller, request: ToggleCli
// This is the same core logic as in the original handler
if (isGlobal) {
const toggles = ((await getGlobalState(controller.context, "globalClineRulesToggles")) as AppClineRulesToggles) || {}
const toggles = controller.cacheService.getGlobalStateKey("globalClineRulesToggles")
toggles[rulePath] = enabled
await updateGlobalState(controller.context, "globalClineRulesToggles", toggles)
controller.cacheService.setGlobalState("globalClineRulesToggles", toggles)
} else {
const toggles = ((await getWorkspaceState(controller.context, "localClineRulesToggles")) as AppClineRulesToggles) || {}
const toggles = controller.cacheService.getWorkspaceStateKey("localClineRulesToggles")
toggles[rulePath] = enabled
await updateWorkspaceState(controller.context, "localClineRulesToggles", toggles)
controller.cacheService.setWorkspaceState("localClineRulesToggles", toggles)
}
// Get the current state to return in the response
const globalToggles = ((await getGlobalState(controller.context, "globalClineRulesToggles")) as AppClineRulesToggles) || {}
const localToggles = ((await getWorkspaceState(controller.context, "localClineRulesToggles")) as AppClineRulesToggles) || {}
const globalToggles = controller.cacheService.getGlobalStateKey("globalClineRulesToggles")
const localToggles = controller.cacheService.getWorkspaceStateKey("localClineRulesToggles")
return ToggleClineRules.create({
globalClineRulesToggles: { toggles: globalToggles },
+3 -4
View File
@@ -1,7 +1,6 @@
import type { ToggleCursorRuleRequest } from "@shared/proto/cline/file"
import { ClineRulesToggles } from "@shared/proto/cline/file"
import type { Controller } from "../index"
import { getWorkspaceState, updateWorkspaceState } from "../../../core/storage/state"
import { ClineRulesToggles as AppClineRulesToggles } from "@shared/cline-rules"
/**
@@ -22,12 +21,12 @@ export async function toggleCursorRule(controller: Controller, request: ToggleCu
}
// Update the toggles in workspace state
const toggles = ((await getWorkspaceState(controller.context, "localCursorRulesToggles")) as AppClineRulesToggles) || {}
const toggles = controller.cacheService.getWorkspaceStateKey("localCursorRulesToggles")
toggles[rulePath] = enabled
await updateWorkspaceState(controller.context, "localCursorRulesToggles", toggles)
controller.cacheService.setWorkspaceState("localCursorRulesToggles", toggles)
// Get the current state to return in the response
const cursorToggles = ((await getWorkspaceState(controller.context, "localCursorRulesToggles")) as AppClineRulesToggles) || {}
const cursorToggles = controller.cacheService.getWorkspaceStateKey("localCursorRulesToggles")
return ClineRulesToggles.create({
toggles: cursorToggles,
@@ -1,7 +1,6 @@
import type { ToggleWindsurfRuleRequest } from "@shared/proto/cline/file"
import { ClineRulesToggles } from "@shared/proto/cline/file"
import type { Controller } from "../index"
import { getWorkspaceState, updateWorkspaceState } from "../../../core/storage/state"
import { ClineRulesToggles as AppClineRulesToggles } from "@shared/cline-rules"
/**
@@ -22,9 +21,9 @@ export async function toggleWindsurfRule(controller: Controller, request: Toggle
}
// Update the toggles
const toggles = ((await getWorkspaceState(controller.context, "localWindsurfRulesToggles")) as AppClineRulesToggles) || {}
const toggles = controller.cacheService.getWorkspaceStateKey("localWindsurfRulesToggles")
toggles[rulePath] = enabled
await updateWorkspaceState(controller.context, "localWindsurfRulesToggles", toggles)
controller.cacheService.setWorkspaceState("localWindsurfRulesToggles", toggles)
// Return the toggles directly
return ClineRulesToggles.create({ toggles: toggles })

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