Compare commits

...

130 Commits

Author SHA1 Message Date
celestial-vault b8986c43b7 package lock 2025-03-25 12:35:01 -07:00
celestial-vault 995944c4f3 update package-lock.json 2025-03-25 12:28:25 -07:00
celestial-vault 09a11a5db9 changeset 2025-03-25 12:23:44 -07:00
celestial-vault 5dd9f8e151 context manager tests 2025-03-25 12:22:26 -07:00
watany 616d56468f chore: Allow CI testing locally (#1708)
* doc: Adding install for WebIDE Settings

* adding

* test:dev

* Revert "test:dev"

This reverts commit 91eb6a6510.

* update

* updated

* changeset

* update ci script

* update
2025-03-24 21:57:46 -07:00
Saoud Rizwan efd28b3eeb Revert "ContextManager Tests (#2415)" (#2422)
This reverts commit 19c4439877.
2025-03-24 21:36:43 -07:00
watany 997798fd0a feat(bedrock): adding two regions (#2420)
* feat(bedrock): adding two regions

* changeset
2025-03-24 21:35:24 -07:00
Peter Dave Hello 7247877049 Update and improve zh-tw Traditional Chinese docs (#2384) 2025-03-24 17:10:05 -07:00
Evan 3859d11beb Tasks Galore! (#2256)
* test script to create tasks

* changeset

* fix type error

* make is_dev checks consistent

---------

Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2025-03-24 17:07:47 -07:00
Evan 19c4439877 ContextManager Tests (#2415)
* add tests around truncating context

* remove comments
2025-03-24 17:00:43 -07:00
Toshii ec01e1f19d move remaining context management out of Cline.ts (#2367)
* move context management out

* changeset
2025-03-24 16:49:11 -07:00
Peter Dave Hello 4a0a40ead1 Add OpenAI "dynamic" model chatgpt-4o-latest (#2385)
This model represents the latest GPT-4o on chatgpt.com, dynamically
updated to always reflect the most current version, bridging the gap
between GPT-4o and GPT-4.5.

Reference:
- https://platform.openai.com/docs/models/chatgpt-4o-latest
- https://x.com/OpenAIDevs/status/1823510395619000525
- https://x.com/lmarena_ai/status/1823515229491515715
2025-03-24 16:20:45 -07:00
Nir 124569c391 feat: add QwQ-32B model to SambaNova provider (#2389)
Signed-off-by: Nir Ben-Or <nir@swirldslabs.com>
Co-authored-by: Nir Ben-Or <nir@swirldslabs.com>
2025-03-24 16:20:05 -07:00
Andrei Edell 5a08f977c3 #2272 fix 'see more' not showing up (#2390)
* #2272 fix 'see more' not showing up

* changeset & format

---------

Co-authored-by: Andrei Edell <andrei@nugbase.com>
2025-03-24 16:19:36 -07:00
Jille 4ec85af1fb fix: improve file handling for NextJS folder naming conventions and increase limits (#2398)
* fix: improve file handling for NextJS folder naming conventions and increase limits

* Update WorkspaceTracker.ts

* Update list-files.ts

* Update beige-monkeys-prove.md

---------

Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2025-03-24 16:16:49 -07:00
Peter Dave Hello 00f88050f7 Fix gpt-4.5-preview's supportsPromptCache value to true (#2399)
Reference:
- https://platform.openai.com/docs/models/gpt-4.5-preview
2025-03-24 16:11:56 -07:00
Evan c4f73e7752 Hoist The Sails - Danger Ahead! (#2405)
* Create Tailwind DangerButton

* changeset

* remove unused import

* Remove old DangerButton component

* rename DangerButtonTW to DangerButton
2025-03-24 16:03:39 -07:00
watany e84b8669a6 feat(bedrock): adding Amazon Nova (#2406)
* amazon nova

* changeset
2025-03-24 16:01:21 -07:00
Evan 5b6eae2905 remove delay package (#2414) 2025-03-24 15:57:29 -07:00
Evan c153077bde Handle Anthropic token input too large (#2407)
* correct calculation of quarter truncation

* changeset

* truncate and retry if we encounter context window exceeded error

* optional chaining for error message check

* changeset

* Handle Anthropic input breaks context window error

* changeset

* remove extra changeset

* remove logger

* use optional chaining
2025-03-24 15:56:16 -07:00
Saoud Rizwan 1796558733 Prepare for release 2025-03-24 14:51:59 -07:00
Saoud Rizwan 2f7f8bff20 Fix openrouter model id being set to object when switching plan/act (#2412) 2025-03-24 14:49:37 -07:00
Saoud Rizwan dc9fb4ddb8 Fix vs code lm plan/act toggle (#2409) 2025-03-24 14:03:31 -07:00
Saoud Rizwan f9c46f898f Fixes 2025-03-22 19:28:36 -07:00
Saoud Rizwan 2d2eb5dd36 Add Announcement 2025-03-22 19:25:46 -07:00
Saoud Rizwan 9562671bcc Remove gallery banner color on marketplace 2025-03-22 19:04:29 -07:00
Saoud Rizwan 6a2aef27ea FIx z-index for dropdowns 2025-03-22 18:58:05 -07:00
Saoud Rizwan c7d6173e20 Fixes 2025-03-22 18:40:12 -07:00
Saoud Rizwan 62da7fe720 Fixes 2025-03-22 18:32:11 -07:00
Saoud Rizwan f97f3e47cc Move sort routing option 2025-03-22 17:43:24 -07:00
Saoud Rizwan f47be1a7bb Add OpenRouter usage feature with original generation endpoint as fallback 2025-03-22 17:21:31 -07:00
Saoud Rizwan 0cd2e69551 Revert "Revert "Fix bug where OpenRouter/Cline providers generation endpoint failed (#2262)""
This reverts commit 2ca4738b59.
2025-03-22 17:15:36 -07:00
Saoud Rizwan 4b19010d56 Revert "Use OpenRouter's new usage flag for more reliable pricing responses"
This reverts commit a29689dfd3.
2025-03-22 17:15:26 -07:00
Saoud Rizwan 49803bf958 Fixes 2025-03-22 17:13:58 -07:00
Saoud Rizwan 33704dcdc1 Copy 2025-03-22 14:53:46 -07:00
Saoud Rizwan 78552287ae Update CHANGELOG 2025-03-22 14:51:56 -07:00
Saoud Rizwan a29689dfd3 Use OpenRouter's new usage flag for more reliable pricing responses 2025-03-22 14:40:23 -07:00
Saoud Rizwan 2ca4738b59 Revert "Fix bug where OpenRouter/Cline providers generation endpoint failed (#2262)"
This reverts commit 8cc7759584.
2025-03-22 14:39:30 -07:00
Saoud Rizwan b4a2d5a06d Add 'Add to Cline' and 'Fix with Cline' menu options 2025-03-22 14:18:50 -07:00
Saoud Rizwan 7f2de3956c Add docs button 2025-03-22 11:47:03 -07:00
Saoud Rizwan 059b14e494 Fix #2167 where openRouterModelId was returning a non-string value 2025-03-22 11:37:55 -07:00
Saoud Rizwan 4d95817eec Move tracer initialization to capture 2025-03-22 00:48:52 -07:00
Saoud Rizwan d8c13db683 Make conversation telemetry only available in dev builds for testing 2025-03-22 00:44:12 -07:00
Saoud Rizwan a35e1ff8df Add delete mcp server button for failed server 2025-03-21 23:48:36 -07:00
Saoud Rizwan a0c5276fb1 Fixes 2025-03-21 23:43:35 -07:00
Saoud Rizwan 696a2c4e48 Prepare for release 2025-03-21 23:34:43 -07:00
Saoud Rizwan e983358dde Clean up new accounts billing view styles 2025-03-21 23:22:57 -07:00
Saoud Rizwan 9ae90c99ad Allow anyone to opt in to conversation telemetry 2025-03-21 21:29:41 -07:00
Saoud Rizwan 4cd914970e Update llm obs description 2025-03-21 20:11:05 -07:00
Evan 4919424faf Handle Openrouter Input Too Large (#2375)
* correct calculation of quarter truncation

* changeset

* truncate and retry if we encounter context window exceeded error

* optional chaining for error message check

* changeset
2025-03-21 20:05:41 -07:00
Frostbourne e65fa89566 [ENG-251] Add Account View (#2324)
* Accounts Modal mocked

* fix dividers

* Fix flex styling

* Fix account card in other views

* Move AccountInfoCard to tailwind and fix flex styling

* Get credits data from endpoint

* refactor creditshistorytable

* prettier

* changeset

* Properly connect and format data from endpoint

* Overflow scroll box

* linter

* Update webview-ui/src/utils/format.ts

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

* Make svg asset a component

* fix svg type

* Use clineprovider weakref

* Move shared types to shared folder

* Make account view buttons full width

* Make history table take full height

* Update webview-ui/src/components/account/AccountView.tsx

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>
Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2025-03-21 20:03:41 -07:00
Saoud Rizwan 935a68fedc Add provider sorting option to openrouter/cline 2025-03-21 14:37:39 -07:00
Saoud Rizwan 5c78f64978 Revert "[cline.ts] Refactor out ensureTaskDirectoryExists [1/8] (#2356)"
This reverts commit af0cd9bdff.
2025-03-21 13:21:02 -07:00
Evan 328e49bd3c Correct Calculation of Quarter Truncation (#2373)
* correct calculation of quarter truncation

* changeset
2025-03-21 12:59:47 -07:00
pashpashpash 0f7c0205b6 LLM Observability (#2198)
* conversation telemetry

* also tracing responses

* moved to advanced settings

* checkbox instead of dropdown

* system prompt llm observability

* explicit comments

* explicit comments

* better dates

* logs

* dont forget the key

* cleanup step is important

* curly braces after if

* wow that was harder than i thought

* changeset

* throwing behind is dev

* Update packages

* Resolve conflicts

* Revert lock

* Fix conflict

* Fixes

* Add conversation data setting

* Remove conversationDataSetting state

* Fixes

* Fixes

* Fixes

* package lock

* removing uneccessary logging

* Update package-lock.json

* Update package-lock.json

* Clean up tracer

* Update lock

---------

Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2025-03-20 18:22:05 -07:00
Evan af0cd9bdff [cline.ts] Refactor out ensureTaskDirectoryExists [1/8] (#2356)
* refactor out ensureTaskDirectoryExists

* changeset

---------

Co-authored-by: Marlon Monroy <7920256+saito-sv@users.noreply.github.com>
2025-03-20 18:07:32 -07:00
Andrei Edell 647a964853 Hugelung/mcp url crash 2170 (#2177)
* #2170 stricter URL handling, URL limits

* better throttling / batching & error displays for embeds

* successfully load 100 popular url embeds

* closer

* fixed heights for embeds to avoid vertical movement

* normalize relative images in og metadata

* Fixed image loading issues

* properly size boxy or wide embed logos

* svg support

* webp support

* re-process image/link previews when plain -> rich

* remove unused functions, add isLocalhost helper

* dont modify response text when upgrading http->https

* consistently use content type header for detecting images

* fix url loading cancellation

* clean up logs, ensure timeout cancel

* testing docs & 50 max urls

* give link to echo mcp server

* force update trick to make rich toggle smoother

* fix linter error

* changeset

* big cleanup: logs, old code, webp handling, sizings

* prettier fix

* ChatErrorBoundary: a guard against evil react crashes (#2327)

* PR Review: extract ChatErrorBoundary

* ChatErrorBoundary in ImagePreview

* Remove a probably unneeded boundary

* Delete the demo errors

* formatting fix

* changeset & final cleanup

---------

Co-authored-by: Andrei Edell <andrei@nugbase.com>
2025-03-20 13:42:30 -10:00
Evan 58305dd6b3 move global file names to constants (#2353) 2025-03-20 12:03:02 -07:00
Toshii 88c33ee96d encapsulate ContextManager (#2337)
Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2025-03-20 02:50:40 -07:00
Dennis Bartlett 23a0d41646 Add saito-sv as CodeOwner (#2339)
* Add saito-sv as CodeOwner

* Create tricky-glasses-cheer.md
2025-03-19 22:14:45 -07:00
Dennis Bartlett 974f698e54 Revert "Added option for skipping diff animation. Useful when diff animation …" (#2341)
This reverts commit 12f16a47e3.
2025-03-19 20:12:07 -07:00
deepman 12f16a47e3 Added option for skipping diff animation. Useful when diff animation … (#1765)
* Added option for skipping diff animation. Useful when diff animation is slow.

* Update README.md

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

* fixed copyright symbol and default value

* Update README.md

---------

Co-authored-by: Mandeep Kumar <mandeep@deepcognition.ai>
Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>
Co-authored-by: Dennis Bartlett <bartlett.dc.1@gmail.com>
2025-03-19 16:34:51 -07:00
Lobay bc7d6f0c66 fix: improve Azure API detection to support custom endpoints (#2332)
* fix: improve Azure API detection to support custom endpoints

* chore: add changeset for Azure API version detection fix
2025-03-19 11:48:42 -07:00
Saoud Rizwan c8b446ab6c Refactor 2025-03-19 11:24:13 -07:00
Evan 2cc64973fb Size up the Task (#2246)
* Calculate and display total tasks and checkpoints size

* changeset

* Remove logs and add try/catch to file deletion

* call postMessage from HistoryView instead
2025-03-18 11:34:35 -07:00
Evan 599c3f6717 SuccessButton -> TW (#2289)
* migrate SuccessButton to Tailwind

* changeset
2025-03-17 23:54:25 -07:00
Saoud Rizwan 1c491bf719 Prepare for release 2025-03-17 21:04:51 -07:00
canvrno 10ac28b9d6 Issue with 'See More' button in task header (#2302)
* Fixes issue with 'See More' button in task header

* Removed requestAnimationFrame

* Cleanup

---------

Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2025-03-17 20:59:14 -07:00
omercelik 95b2e327a8 Added mistral-small-latest (Mistral Small 3.1) (#2300) 2025-03-17 17:38:37 -07:00
Evan 53d11c88bb Upgrade MCP SDK (#2301)
* upgrade mcp sdk

* changeset
2025-03-17 17:38:09 -07:00
canvrno 7ef0f6d1b6 Add --no-verify to checkpoint commits (#2298)
* initial

* Corrected options
2025-03-17 13:53:29 -07:00
Saoud Rizwan 9012d6a1fc Copy 2025-03-14 19:07:24 -07:00
Saoud Rizwan e3d363c56f Prepare for release 2025-03-14 18:56:30 -07:00
canvrno 62a04ad38a Add extension version to telemetry 2025-03-14 18:28:16 -07:00
Saoud Rizwan 1774cf8b79 Add options to question & plan tools (#2264)
* Add question options

* Fixes

* Fix taskheader for anthropic only showing cache info after first request

* Add plan mode options

* Fixes

* Start task header not expanded
2025-03-14 18:25:40 -07:00
minorunara 6133eb4864 fix: Correct VPC endpoint parameter for AnthropicBedrock (#2237)
The VPC endpoint for AnthropicBedrock should be specified using the
'baseURL' parameter instead of the 'endpoint' parameter.
2025-03-14 17:05:56 -07:00
canvrno 087f1d3be3 eng-245 / More checkpoints git telemetry (#2258)
* Checkpoints telemetry improvements

* Checkpoints telemetry improvements
2025-03-14 17:03:43 -07:00
Saoud Rizwan 8cc7759584 Fix bug where OpenRouter/Cline providers generation endpoint failed (#2262)
* Fix openrouter/cline provider cost endpoint bug

* Increase generation endpoint timeout
2025-03-14 17:01:48 -07:00
Evan f9d70ceb6c Stream claude thinking vertex (#2244)
* stream thinking

* changeset
2025-03-14 11:36:59 -07:00
watany e8bf106965 fix(bedrock): Support VPC Endpoint options for Deepseek (#2224)
* fix(bedrock): Support VPC Endpoint options for Deepseek

* change set
2025-03-12 23:26:51 -07:00
Saoud Rizwan b528640bd5 Add warning when checkpoints takes too long to load (#2230)
* Add warning for when checkpoints takes too long to load

* Add changeset
2025-03-12 23:25:13 -07:00
Evan 470778aa2f Display Sonnet Thinking - Bedrock (#2225)
* stream thinking blocks from anthropic

* changeset

* remove logger
2025-03-12 22:35:25 -07:00
dependabot[bot] 14f726c5e8 Bump @babel/runtime in /webview-ui in the npm_and_yarn group (#2222)
Bumps the npm_and_yarn group in /webview-ui with 1 update: [@babel/runtime](https://github.com/babel/babel/tree/HEAD/packages/babel-runtime).


Updates `@babel/runtime` from 7.26.0 to 7.26.10
- [Release notes](https://github.com/babel/babel/releases)
- [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md)
- [Commits](https://github.com/babel/babel/commits/v7.26.10/packages/babel-runtime)

---
updated-dependencies:
- dependency-name: "@babel/runtime"
  dependency-type: indirect
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-03-12 19:42:33 -07:00
Saoud Rizwan 89e1086981 Fix lock 2025-03-12 19:39:46 -07:00
watany c762583d38 feat(bedrock): adding deepseek-r1 (#2185)
* feat(bedrock): adding deepseek-r1

* changeset

* fix(ellipsis-dev):Token usage

* fix(ellipsis): Consider propagating these errors or yielding an error response.

* fix msg

* fix: reuse the convertToR1Format

* fix: token count

* Revert lock

* Install package

---------

Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2025-03-12 19:35:27 -07:00
minorunara 73a340c75f feat: Enable VPC endpoint configuration for Amazon Bedrock in AWS Profile mode (#2214)
* feat: Enable VPC endpoint configuration for Amazon Bedrock in AWS Profile mode

This update allows specifying a VPC endpoint when connecting to Amazon Bedrock using AWS Profile mode. It ensures that all API requests stay within a private network and do not traverse the public internet.

* Hide VPC textfield behind toggle

---------

Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2025-03-12 19:15:19 -07:00
Evan d3bb6ff456 Configure Computer Use Openai Compatible (#2215)
* Set up branch

* feat: Allow to configure computer use for openapi compatible provider (#1923)

* feat: Allow to set computerUse for OpenAI Compatible provider

* Update webview-ui/src/components/settings/ApiOptions.tsx

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>

---------

Co-authored-by: FlavioInacta <flavio.pellanda@inacta.ch>
Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>
2025-03-12 19:00:40 -07:00
Evan 93cd64f031 Add SambaNova Provider (#2216)
* Set up branch

* Feat/add sambanova provider (#2142)

* feat(api): Add SambaNova provider type definitions

- Add 'sambanova' to ApiProvider type
- Add sambanovaApiKey to ApiHandlerOptions interface
- Add SambaNova model definitions

* feat(api): Implement SambaNova provider handler

- Create SambanovaHandler class
- Implement createMessage and getModel methods
- Register the provider in the API factory

* feat(ui): Add SambaNova provider UI components

- Add SambaNova to provider dropdown
- Add UI components for API key and model selection

* feat(storage): Update secret storage for SambaNova API key

- Update SecretKey type to include sambanovaApiKey
- Update getState method to include the SambaNova API key
- Update API configuration handler to store the SambaNova API key

* feat(sambanova): format fix

* feat(sambanova): adding previously untracked files

* fix: will ensure that when no model ID is explicitly selected for the Sambanova provider, it will use the default model ID that's already defined in the codebase (Meta-Llama-3.3-70B-Instruct)

* fix: changing how the dropdown is created by following existing pattern

* fix: changing how the dropdown is created by following existing pattern

* fix: added SamanovaHandler to buildApiHandler

---------

Co-authored-by: saad.khan.hd <saad.khan@hd.digital>
Co-authored-by: Dennis Bartlett <bartlett.dc.1@gmail.com>

* support deepseek and qwen models

* remove unused imports

* Add missing API provider implementation

---------

Co-authored-by: saad-noodleseed <saad@noodleseed.com>
Co-authored-by: saad.khan.hd <saad.khan@hd.digital>
Co-authored-by: Dennis Bartlett <bartlett.dc.1@gmail.com>
Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2025-03-12 18:59:42 -07:00
k-inoway 36836ad4d6 Fix temperature param for o3mini and o1mini when using LiteLLM (#2203)
* fix: prevent invalid temperature 0 param when using o3mini model

* fix: handle temperature param properly for o1-mini and o3-mini models
2025-03-12 11:33:12 -07:00
Taosh 4ade5daeff fix: #2153 make sure isolated qwen model setting on switching mode settings (#2210)
* fix: #2153 make sure isolated qwen model setting on switch mode settings

* Re-add openainative

---------

Co-authored-by: 青炽 <hanyuntao.hyt@alibaba-inc.com>
Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2025-03-12 11:26:49 -07:00
canvrno 24fe5c9c98 Add getDiffCount (#2195)
* changeset

* Track changes with working directory if rhs omitted

---------

Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2025-03-12 11:24:46 -07:00
TongZ 18d4a268e2 Fix tooltip layer bug (#2189) 2025-03-11 20:25:21 -07:00
Saoud Rizwan 686f51c248 Fix plan/act model switching for deepseek 2025-03-11 20:23:17 -07:00
Saoud Rizwan 9aa04a376f Add limit for files Cline reads into context (#2199)
* Revert "Merge pull request #1964 from cline/ocasta181/ENG-209"

This reverts commit ca17a06618, reversing
changes made to 6b9c2a1a45.

* Set hard 300kb limit on file size cline reads into context

* Add changeset
2025-03-11 19:46:28 -07:00
akfoster ca17a06618 Merge pull request #1964 from cline/ocasta181/ENG-209
[Fix] Cline crashes when reading files with large outputs
2025-03-11 17:23:07 -07:00
ocasta181 48ea04f2ea add test description 2025-03-11 15:06:24 -07:00
ocasta181 e11ad166e6 pass less data between functions 2025-03-11 15:01:38 -07:00
Daniel Steigman 6b9c2a1a45 Merge pull request #2182 from jiyeonseo/main
Add Korean language
2025-03-11 14:08:51 -07:00
Daniel Steigman bf0bbb4a16 Create selfish-bikes-decide.md 2025-03-11 14:07:27 -07:00
Daniel Steigman a8a24fcb62 Update locales/ko/README.md
Co-authored-by: Dennis Bartlett <bartlett.dc.1@gmail.com>
2025-03-11 14:07:09 -07:00
Daniel Steigman e570662d89 Merge pull request #2193 from cline/bug/readme_copyright
Update copyright for Readme Files
2025-03-11 14:06:28 -07:00
canvrno 78f5faf6d2 Create poor-zoos-jump.md 2025-03-11 13:01:31 -07:00
Dennis Bartlett cc28851ba8 Update copyright for Readme Files 2025-03-11 12:56:54 -07:00
Dennis Bartlett 0afa4c4fd8 Update src/utils/fs.ts
Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>
2025-03-10 20:58:18 -07:00
ocasta181 0f0512363f read config in try/catch 2025-03-10 20:52:53 -07:00
ocasta181 ddc91a46c0 Merge branch 'main' into ocasta181/ENG-209 2025-03-10 20:18:50 -07:00
ocasta181 11a32d9a3a update changelog 2025-03-10 20:08:00 -07:00
ocasta181 45ddaf99e1 fix tests 2025-03-10 20:07:12 -07:00
ocasta181 6f21cb98cc back out non-file related checks 2025-03-10 20:03:33 -07:00
ocasta181 544d4503ac current progress 2025-03-10 19:32:38 -07:00
Cheese a5663e7cf8 Update locales/ko/CONTRIBUTING.md
Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>
2025-03-11 01:47:36 +09:00
Cheese 09b0873e94 Update locales/ko/README.md
Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>
2025-03-11 01:47:25 +09:00
cheese e54623bc7e Add Korean language 2025-03-11 01:39:47 +09:00
ryo-ma 8909ee77fc Support loading by directory in .clinerules/ (#2156)
* Support loading by directory in .clinerules/

* Update src/utils/fs.ts

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

* Update docs/prompting/README.md

* Add changeset

* Add tests of isDirectory

* Use relative path in instructions

* Remove log

---------

Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>
Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2025-03-09 20:08:12 -07:00
Evan 3a3943623b Hoist the Sails! (#2169)
* set up tailwind

* changeset
2025-03-09 19:49:09 -07:00
Saoud Rizwan 2446b16828 Fix plan/act model switching for OpenAI native 2025-03-09 19:19:46 -07:00
Saoud Rizwan 30f1d72eb5 Prepare for release 2025-03-09 19:16:41 -07:00
Saoud Rizwan c67edf4b91 Fix type error when opening openrouter model picker with invalid model id 2025-03-09 19:15:56 -07:00
ocasta181 a2572ea233 Merge branch 'main' into ocasta181/ENG-209 2025-03-07 15:19:09 -08:00
ocasta181 f608b914b8 remove uneccesary pass through getMaxAllowedSize; fix tests 2025-02-28 22:09:54 -08:00
ocasta181 9f5df9836c remove exclusion from tsconfig 2025-02-28 21:58:08 -08:00
akfoster 0cdbdb057e Update src/integrations/terminal/TerminalProcess.ts
Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>
2025-02-28 21:49:29 -08:00
ocasta181 4846476f6c remove more unused logic 2025-02-28 21:47:12 -08:00
ocasta181 a6f189c5c5 remove calculateUsedContext 2025-02-28 21:26:06 -08:00
ocasta181 ad8e0b19be remove usedContext references 2025-02-28 21:21:53 -08:00
ocasta181 22b2e50210 use real context window size 2025-02-28 18:50:28 -08:00
ocasta181 af0d2f31a7 add changeset 2025-02-28 18:35:04 -08:00
ocasta181 8313fa099c reduce complexity, get working 2025-02-28 18:35:04 -08:00
Ocasta 9f418b10f6 cleanup vscode module declaration 2025-02-28 18:35:04 -08:00
Ocasta 1b56c3fc11 new attempt 2025-02-28 18:34:43 -08:00
106 changed files with 10512 additions and 1931 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
feat(bedrock): adding Amazon Nova
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
Improve file handling for NextJS folder naming conventions and increase file listing limits. Fix glob pattern interpretation issues with parentheses in folder names
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
Handle input too large Anthropic
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
Fix "See more" not showing up for tasks after task un-fold
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
Fix gpt-4.5-preview's supportsPromptCache value to true
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
Can test on WebIDE
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": minor
---
Added a script to create test tasks in dev mode
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
updated move context management out of cline
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": minor
---
Added support for SambaNova QwQ-32B model
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": minor
---
Add OpenAI "dynamic" model chatgpt-4o-latest
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
Unit tests
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
DangerButton.tsx to Tailwind
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
feat(bedrock): adding two regions
+1 -1
View File
@@ -252,7 +252,7 @@ class Cline {
} catch (error) {
// 4. Error handling with retry
if (isOpenRouter && !this.didAutomaticallyRetryFailedApiRequest) {
await delay(1000)
await setTimeoutPromise(1000)
this.didAutomaticallyRetryFailedApiRequest = true
yield* this.attemptApiRequest(previousApiReqIndex)
return
+1 -1
View File
@@ -1 +1 @@
* @saoudrizwan @ocasta181 @NightTrek @pashpashpash @dcbartlett
* @saoudrizwan @ocasta181 @NightTrek @pashpashpash @dcbartlett @saito-sv
+33
View File
@@ -1,5 +1,38 @@
# Changelog
## [3.8.2]
- Fix bug where switching to plan/act would result in VS Code LM/OpenRouter model being reset
## [3.8.0]
- Add 'Add to Cline' as an option when you right-click in a file or the terminal, making it easier to add context to your current task
- Add 'Fix with Cline' code action - when you see a lightbulb icon in your editor, you can now select 'Fix with Cline' to send the code and associated errors for Cline to fix. (Cursor users can also use the 'Quick Fix (CMD + .)' menu to see this option)
- Add Account view to display billing and usage history for Cline account users. You can now keep track of credits used and transaction history right in the extension!
- Add 'Sort underling provider routing' setting to Cline/OpenRouter allowing you to sort provider used by throughput, price, latency, or the default (combination of price and uptime)
- Improve rich MCP display with dynamic image loading and support for GIFs
- Add 'Documentation' menu item to easily access Cline's docs
- Add OpenRouter's new usage_details feature for more reliable cost reporting
- Display total space Cline takes on disk next to 'Delete all Tasks' button in History view
- Fix 'Context Window Exceeded' error for OpenRouter/Cline Accounts (additional support coming soon)
- Fix bug where OpenRouter model ID would be set to invalid value
- Add button to delete MCP servers in a failure state
## [3.7.1]
- Fix issue with 'See more' button in task header not showing when starting new tasks
- Fix issue with checkpoints using local git commit hooks
## [3.7.0]
- Cline now displays selectable options when asking questions or presenting a plan, saving you from having to type out responses!
- Add support for a `.clinerules/` directory to load multiple files at once (thanks @ryo-ma!)
- Prevent Cline from reading extremely large files into context that would overload context window
- Improve checkpoints loading performance and display warning for large projects not suited for checkpoints
- Add SambaNova API provider (thanks @saad-noodleseed!)
- Add VPC endpoint option for AWS Bedrock profiles (thanks @minorunara!)
- Add DeepSeek-R1 to AWS Bedrock (thanks @watany-dev!)
## [3.6.5]
- Add 'Delete all Task History' button to History view
+30
View File
@@ -31,6 +31,36 @@ If you're planning to work on a bigger feature, please create a [feature request
- Run `npm run test` to run tests locally
- Before submitting PR, run `npm run format:fix` to format your code
3. **Linux-specific Setup**
VS Code extension tests on Linux require the following system libraries:
- `libatk1.0-0`
- `libatk-bridge2.0-0`
- `libxkbfile1`
- `libx11-xcb1`
- `libxcomposite1`
- `libxdamage1`
- `libxfixes3`
- `libxrandr2`
- `libgbm1`
- `libdrm2`
- `libgtk-3-0`
- `dbus`
- `xvfb`
These libraries provide necessary GUI components and system services for the test environment.
For example, on Debian-based distributions (e.g., Ubuntu), you can install these libraries using apt:
```bash
sudo apt update
sudo apt install -y \
libatk1.0-0 libatk-bridge2.0-0 libxkbfile1 libx11-xcb1 \
libxcomposite1 libxdamage1 libxfixes3 libxrandr2 libgbm1 \
libdrm2 libgtk-3-0 dbus xvfb
```
- Run `npm run test:ci` to run tests locally
## Writing and Submitting Code
Anyone can contribute code to Cline, but we ask that you follow these guidelines to ensure your contributions can be smoothly integrated:
+1 -1
View File
@@ -1,5 +1,5 @@
<div align="center"><sub>
English | <a href="https://github.com/cline/cline/blob/main/locales/es/README.md" target="_blank">Español</a> | <a href="https://github.com/cline/cline/blob/main/locales/de/README.md" target="_blank">Deutsch</a> | <a href="https://github.com/cline/cline/blob/main/locales/ja/README.md" target="_blank">日本語</a> | <a href="https://github.com/cline/cline/blob/main/locales/zh-cn/README.md" target="_blank">简体中文</a> | <a href="https://github.com/cline/cline/blob/main/locales/zh-tw/README.md" target="_blank">繁體中文</a>
English | <a href="https://github.com/cline/cline/blob/main/locales/es/README.md" target="_blank">Español</a> | <a href="https://github.com/cline/cline/blob/main/locales/de/README.md" target="_blank">Deutsch</a> | <a href="https://github.com/cline/cline/blob/main/locales/ja/README.md" target="_blank">日本語</a> | <a href="https://github.com/cline/cline/blob/main/locales/zh-cn/README.md" target="_blank">简体中文</a> | <a href="https://github.com/cline/cline/blob/main/locales/zh-tw/README.md" target="_blank">繁體中文</a> | <a href="https://github.com/cline/cline/blob/main/locales/ko/README.md" target="_blank">한국어</a>
</sub></div>
# Cline \#1 on OpenRouter
+21
View File
@@ -128,6 +128,27 @@ Cline's system prompt, on the other hand, is not user-editable ([here's where yo
- Focus on Desired Outcomes: Describe the results you want, not the specific steps.
- Test and Iterate: Experiment to find what works best for your workflow.
### Support for Loading Files from the `.clinerules/` Directory
All files under the `.clinerules/` directory are recursively loaded, and their contents are merged into clineRulesFileInstructions.
#### Example 1:
```
.clinerules/
├── .local-clinerules
└── .project-clinerules
```
#### Example 2:
```
.clinerules/
├── .clinerules-nextjs
├── .clinerules-serverside
└── tests/
├── .pytest-clinerules
└── .jest-clinerules
```
## Prompting Cline 💬
**Prompting is how you communicate your needs for a given task in the back-and-forth chat with Cline.** Cline understands natural language, so write conversationally.
+1 -1
View File
@@ -158,5 +158,5 @@ Um zum Projekt beizutragen, beginnen Sie mit unserem [Beitragsleitfaden](CONTRIB
## Lizenz
[Apache 2.0 © 2024 Cline Bot Inc.](./LICENSE)
[Apache 2.0 © 2025 Cline Bot Inc.](./LICENSE)
+1 -1
View File
@@ -158,4 +158,4 @@ Para contribuir al proyecto, comience con nuestra [guía de contribución](CONTR
## Licencia
[Apache 2.0 © 2024 Cline Bot Inc.](./LICENSE)
[Apache 2.0 © 2025 Cline Bot Inc.](./LICENSE)
+1 -1
View File
@@ -158,4 +158,4 @@ Clineがタスクを進める中で、拡張機能は各ステップでワーク
## ライセンス
[Apache 2.0 © 2024 Cline Bot Inc.](./LICENSE)
[Apache 2.0 © 2025 Cline Bot Inc.](./LICENSE)
+47
View File
@@ -0,0 +1,47 @@
# 기여자 행동 강령
## 서약
우리는 개방적이고 환영하는 환경을 조성하기 위해 노력하며, 기여자 및 유지 관리자로서 모든 사람이 차별과 괴롭힘 없이 프로젝트와 커뮤니티에 참여할 수 있도록 최선을 다할 것을 서약합니다. 이는 연령, 체형, 장애, 민족성, 성적 특성, 성 정체성 및 표현, 경험 수준, 교육 수준, 사회·경제적 지위, 국적, 외모, 인종, 종교, 성 정체성과 성적 지향에 관계없이 모든 사람에게 적용됩니다.
## 행동 기준
긍정적인 환경을 조성하기 위한 바람직한 행동의 예시:
- 환영하고 포용적인 언어 사용하기
- 서로 다른 관점과 경험을 존중하기
- 건설적인 비판을 우아하게 수용하기
- 커뮤니티에 최선이 되는 것에 집중하기
- 다른 커뮤니티 구성원들에 대한 공감 보여주기
참여자가 해서는 안 되는 행동의 예시:
- 성적인 언어와 이미지 사용, 원치 않는 성적 관심이나 접근
- 트롤링, 모욕적/경멸적인 댓글, 개인적 또는 정치적 공격
- 공개적 또는 사적인 괴롭힘
- 상대방의 동의 없이 개인정보(실제 주소나 전자 주소 등) 공개하기
- 전문적 환경에서 부적절하다고 여겨질 수 있는 기타 행위
## 책임
프로젝트 유지 관리자는 허용 가능한 행동 기준을 명확히 설명할 책임이 있으며, 부적절한 행동이 발생할 경우 적절하고 공정한 시정 조치를 취해야 합니다.
프로젝트 유지 관리자는 본 행동 강령에 부합하지 않는 댓글, 커밋, 코드, 위키 수정, 이슈 및 기타 기여를 삭제, 수정 또는 거부할 권리와 책임이 있으며, 부적절하다고 판단되는 행동(위협적이거나, 공격적이거나, 해로운 행위 등)을 한 기여자를 일시적 또는 영구적으로 차단할 권리를 가집니다.
## 범위
이 행동 강령은 프로젝트 공간과 개인이 프로젝트나 커뮤니티를 대표하는 공개 공간에서 모두 적용됩니다. 프로젝트 또는 커뮤니티를 대표하는 예로는 공식 프로젝트 이메일 주소 사용, 공식 소셜 미디어 계정을 통한 게시, 온라인 또는 오프라인 행사에서 지정된 대표자로 활동하는 경우 등이 포함됩니다. 프로젝트의 대표성은 프로젝트 유지 관리자가 추가로 정의하고 명확히 할 수 있습니다.
## 집행
학대, 괴롭힘 또는 기타 용납할 수 없는 행동은 프로젝트 팀에 hi@cline.bot을 통해 신고 할 수 있습니다. 모든 신고는 검토 및 조사되며, 상황에 따라 필요하고 적절한 조치가 취해질 것입니다. 프로젝트 팀은 사건 신고자의 신원을 보호할 의무가 있습니다. 특정 시행 정책에 대한 추가 세부 사항은 별도로 게시될 수 있습니다.
행동 강령을 성실히 준수하거나 집행하지 않는 프로젝트 유지관리자는 프로젝트 리더십의 구성원에 의해 일시적 또는 영구적인 제재를 받을 수 있습니다.
## 출처
이 행동 강령은 [Contributor Covenant][homepage] 버전 1.4에서 수정되었으며, https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 에서 확인할 수 있습니다.
[homepage]: https://www.contributor-covenant.org
이 행동 강령에 대한 일반적인 질문에 대한 답변은 https://www.contributor-covenant.org/faq 를 참조하시기 바랍니다.
+92
View File
@@ -0,0 +1,92 @@
# Cline에 기여하기
Cline에 기여하는 것에 관심을 가져주셔서 감사합니다! 버그 수정, 기능 추가, 문서 개선 등 모든 기여는 Cline을 더욱 스마트하게 만드는 데 기여합니다. 활기차고 환영하는 커뮤니티를 유지하기 위해 모든 구성원은 [행동 강령](CODE_OF_CONDUCT.md)을 준수해야 합니다.
## 버그와 문제 보고
버그 보고는 Cline을 모두에게 더 나은 것으로 만드는 데 도움이 됩니다! 새로운 이슈를 생성하기 전에, 중복을 피하기 위해 [기존 이슈를 검색](https://github.com/cline/cline/issues)해 주세요. 버그를 보고할 준비가 되었다면, [이슈 페이지](https://github.com/cline/cline/issues/new/choose)로 이동하여 관련 정보를 작성하기 위한 템플릿을 사용해 주세요.
<blockquote class='warning-note'>
🔐 <b>중요:</b> 보안 취약점을 발견한 경우, <a href="https://github.com/cline/cline/security/advisories/new">GitHub 보안 도구를 사용하여 비공개로 보고</a>해 주세요.
</blockquote>
## 작업 내용 결정하기
첫 기여를 찾고 계신가요? ["good first issue"](https://github.com/cline/cline/labels/good%20first%20issue)나 ["help wanted"](https://github.com/cline/cline/labels/help%20wanted) 라벨이 붙은 이슈를 확인해 보세요. 이러한 이슈들은 새로운 기여자를 위해 특별히 선정된 작업으로, 도움이 필요한 영역이 표시되어 있습니다!
또한, [문서](https://github.com/cline/cline/tree/main/docs)에 대한 기여도 환영합니다! 오타 수정, 기존 가이드 개선, 새로운 교육 콘텐츠 작성 등, 커뮤니티 주도의 리소스 저장소를 구축하는 데 여러분의 도움이 필요합니다. `/docs`를 살펴보고 개선이 필요한 부분을 찾아보세요.
큰 기능에 대해 작업할 계획이 있다면, 먼저 [기능 요청](https://github.com/cline/cline/discussions/categories/feature-requests?discussions_q=is%3Aopen+category%3A%22Feature+Requests%22+sort%3Atop)을 생성하여 이것이 Cline의 비전과 부합하는지 논의하는 것이 좋습니다.
## 개발 환경 설정
1. **VS Code 확장 프로그램**
- 프로젝트를 열면 VS Code가 권장 확장 프로그램 설치를 안내합니다
- 개발을 위해 이 확장 프로그램들이 필요하므로, 설치 안내를 수락해 주세요.
- 프롬프트를 닫은 경우 확장 프로그램 패널에서 수동으로 설치할 수 있습니다
2. **로컬 개발**
- `npm run install:all`을 실행하여 의존성을 설치합니다
- `npm run test`를 실행하여 로컬에서 테스트를 실행합니다
- PR을 제출하기 전에 `npm run format:fix`를 실행하여 코드를 포맷팅합니다
## 코드 작성과 제출
누구나 Cline에 코드를 기여할 수 있지만, 기여가 원활하게 통합되도록 다음 가이드라인을 따라주세요:
1. **Pull Request 집중하기**
- PR은 단일 기능 또는 버그 수정으로 제한해 주세요
- 큰 변경사항은 작은 관련 PR로 분할해 주세요
- 논리적으로 독립적인 커밋 단위로 나누어 리뷰가 용이하도록 구성하세요.
2. **코드 품질**
- `npm run lint`를 실행하여 코드 스타일을 체크합니다
- `npm run format`을 실행하여 코드를 자동으로 포맷팅합니다
- 모든 PR은 린팅과 포맷팅을 포함한 CI 체크를 통과해야 합니다
- 제출 전에 ESLint 경고나 에러를 모두 해결해 주세요
- TypeScript 모범 사례를 따르고, 타입 안전성을 유지해 주세요
3. **테스트**
- 새로운 기능에는 테스트를 추가해 주세요
- `npm test`를 실행하여 모든 테스트가 통과하는지 확인해 주세요
- 변경사항이 기존 테스트에 영향을 미치는 경우 해당 테스트를 업데이트해 주세요
- 적절한 경우 단위 테스트와 통합 테스트를 모두 포함해 주세요
4. **Changesets를 활용한 버전 관리**
- 사용자에게 영향을 미치는 변경 사항이 있는 경우, `npm run changeset`을 실행하여 changeset을 생성해 주세요
- 적절한 버전 증가 옵션을 선택하세요:
- `major` 호환되지 않는 변경 (1.0.0 → 2.0.0)
- `minor` 새로운 기능 추가 (1.0.0 → 1.1.0)
- `patch` 버그 수정 (1.0.0 → 1.0.1)
- 영향을 설명하는 명확한 변경사항 메시지를 작성해 주세요
- 문서 변경만 있는 경우 changeset이 필요하지 않습니다
5. **커밋 가이드라인**
- 명확하고 설명적인 커밋 메시지를 작성해 주세요
- 컨벤셔널 커밋 형식(예: "feat:", "fix:", "docs:")을 사용해 주세요
- 커밋에서 관련 이슈를 #issue-number를 사용하여 참조해 주세요
6. **제출 전 확인사항**
- 최신 main에 브랜치를 리베이스해 주세요
- 브랜치가 정상적으로 빌드되는지 확인해 주세요
- 모든 테스트가 통과하는지 다시 확인해 주세요
- 디버그 코드나 콘솔 로그가 없는지 변경사항을 확인해 주세요
7. **Pull Request 설명**
- 변경 내용을 명확하게 설명해 주세요
- 변경사항을 테스트하는 방법을 포함해 주세요
- 호환되지 않는 변경 사항이 있다면 목록으로 작성해주세요
- UI 변경이 있는 경우, 스크린샷을 추가해 주세요
## 기여 동의서
Pull Request를 제출함으로써, 귀하의 기여가 프로젝트와 동일한 라이선스([Apache 2.0](/LICENSE)) 에 따라 제공됨에 동의하는 것입니다.
기억하세요: Cline에 기여하는 것은 코드를 작성하는 것뿐만 아니라, AI 지원 개발의 미래를 형성하는 커뮤니티의 일원이 되는 것입니다. 함께 멋진 것을 만들어봅시다! 🚀
+172
View File
@@ -0,0 +1,172 @@
# Cline - 최고의 OpenRouter
<p align="center">
<img src="https://media.githubusercontent.com/media/cline/cline/main/assets/docs/demo.gif" width="100%" />
</p>
<div align="center">
<table>
<tbody>
<td align="center">
<a href="https://marketplace.visualstudio.com/items?itemName=saoudrizwan.claude-dev" target="_blank"><strong>VS Marketplace에서 다운로드</strong></a>
</td>
<td align="center">
<a href="https://discord.gg/cline" target="_blank"><strong>Discord</strong></a>
</td>
<td align="center">
<a href="https://www.reddit.com/r/cline/" target="_blank"><strong>r/cline</strong></a>
</td>
<td align="center">
<a href="https://github.com/cline/cline/discussions/categories/feature-requests?discussions_q=is%3Aopen+category%3A%22Feature+Requests%22+sort%3Atop" target="_blank"><strong>기능 요청</strong></a>
</td>
<td align="center">
<a href="https://cline.bot/join-us" target="_blank"><strong>채용 정보</strong></a>
</td>
</tbody>
</table>
</div>
Cline을 만나보세요, **CLI** 및 **에디터**를 활용할 수 있는 AI 어시스턴트입니다.
[Claude 3.7 Sonnet의 에이전트형 코딩 기능](https://www.anthropic.com/claude/sonnet) 덕분에, Cline은 복잡한 소프트웨어 개발 작업을 단계별로 처리할 수 있습니다. 파일 생성과 편집, 대규모 프로젝트 탐색, 브라우저 사용, 터미널 명령 실행(권한 허가 필요) 등의 도구를 사용하여 단순 코드 완성이나 기술 지원을 넘어서는 도움을 제공합니다. Cline은 Model Context Protocol(MCP)를 사용하여 새로운 도구를 만들고 자신의 기능을 확장할 수도 있습니다. 자율적인 AI 스크립트는 일반적으로 샌드박스 환경에서 실행되지만, 이 확장 프로그램은 모든 파일 변경 및 터미널 명령을 승인할 수 있는 사람이 개입가능한 GUI를 제공하여, 에이전트형 AI의 잠재력을 보다 안전하고 쉽게 탐색할 수 있도록 합니다.
1. 작업을 입력하고, 목업을 기능하는 앱으로 변환하거나 스크린샷으로 버그를 수정합니다.
2. Cline은 파일 구조와 소스코드 AST의 분석, 정규식 검색 실행, 관련 파일 읽기부터 시작하여 기존 프로젝트를 파악합니다. 또한, 어떤 정보를 컨텍스트에 추가할지를 신중하게 관리하여, 대규모 복잡한 프로젝트에서도 컨텍스트 윈도우를 과부하시키지 않으면서도 효과적인 지원을 제공합니다.
3. Cline이 필요한 정보를 얻은 후 다음과 같은 작업을 할 수 있습니다:
- 파일 생성과 편집 + 린터/컴파일러 오류 모니터링을 수행하여 누락된 임포트나 구문 오류 등의 문제를 자동으로 수정합니다.
- 터미널에서 명령을 직접 실행하고 작업 중에 출력을 모니터링합니다. 이를 통해 파일 편집 후 개발 서버의 문제에 대응할 수 있습니다.
- 웹 개발 작업에서는 헤드리스 브라우저로 사이트를 실행하고, 클릭, 입력, 스크롤, 스크린샷과 콘솔 로그 캡처를 수행하여 런타임 오류나 시각적 버그를 수정합니다.
4. 작업이 완료되면 Cline은 `open -a "Google Chrome" index.html`과 같은 터미널 명령을 제공하여 버튼 클릭 한 번으로 결과를 확인할 수 있도록 합니다.
> [!TIP]
> `CMD/CTRL + Shift + P` 단축키를 사용하여 명령 팔레트를 열고 "Cline: Open In New Tab"을 입력하여 에디터의 탭으로 확장 프로그램을 엽니다. 이를 통해 파일 탐색기와 병행하여 Cline을 사용하고 워크스페이스의 변경을 더 명확하게 확인할 수 있습니다.
---
<img align="right" width="340" src="https://github.com/user-attachments/assets/3cf21e04-7ce9-4d22-a7b9-ba2c595e88a4">
### 어떤 API나 모델이든 사용 가능
Cline은 OpenRouter, Anthropic, OpenAI, Google Gemini, AWS Bedrock, Azure, GCP Vertex 등의 API 제공자를 지원합니다. 또한 OpenAI 호환 API를 설정하거나 LM Studio/Ollama를 통해 로컬 모델을 사용할 수도 있습니다. OpenRouter를 사용하는 경우, 확장 프로그램에서 최신 모델 목록을 가져와 바로 최신 모델을 사용할 수 있게 합니다.
또한, Cline은 전체 작업 루프와 개별 요청별로 토큰 사용량과 API 비용을 추적하여, 진행 중인 작업의 비용을 실시간으로 확인할 수 있도록 도와줍니다.
<img width="2000" height="0" src="https://github.com/user-attachments/assets/ee14e6f7-20b8-4391-9091-8e8e25561929"><br>
<img align="left" width="370" src="https://github.com/user-attachments/assets/81be79a8-1fdb-4028-9129-5fe055e01e76">
### 터미널에서 명령 실행
VSCode v1.93의 새로운 [셸 통합 업데이트](https://code.visualstudio.com/updates/v1_93#_terminal-shell-integration-api) 덕분에, Cline은 터미널에서 명령을 직접 실행하고 출력을 받을 수 있습니다. 이를 통해 패키지 설치나 빌드 스크립트 실행부터 애플리케이션 배포, 데이터베이스 관리, 테스트 실행까지 광범위한 작업을 수행할 수 있습니다. Cline은 개발 환경과 도구 체인에 맞추어 정확하게 작업을 실행합니다.
개발 서버와 같은 오래 실행되는 프로세스의 경우, "실행 중 계속"(Proceed While Running) 버튼을 사용하여 명령이 백그라운드에서 실행되는 동안 Cline이 작업을 계속할 수 있게 합니다. 작업이 진행되는 동안 Cline은 새로운 터미널 출력을 실시간으로 확인하여, 파일 편집 시 발생하는 컴파일 오류와 같은 문제에 즉시 대응할 수 있습니다.
<img width="2000" height="0" src="https://github.com/user-attachments/assets/ee14e6f7-20b8-4391-9091-8e8e25561929"><br>
<img align="right" width="400" src="https://github.com/user-attachments/assets/c5977833-d9b8-491e-90f9-05f9cd38c588">
### 파일 생성과 편집
Cline은 에디터 내에서 파일을 생성 및 편집하고 변경의 Diff 뷰로 표시합니다. Diff 뷰 에디터에서 Cline의 변경을 직접 편집하거나 되돌릴 수 있으며, 채팅에서 피드백을 제공하여 만족할 때까지 개선 요청할 수 있습니다. Cline은 린터/컴파일러 오류(누락된 임포트, 구문 오류 등)도 모니터링하고 발생한 문제를 자동으로 수정합니다.
Cline에 의한 모든 변경은 파일의 타임라인에 기록되어 필요할 때 변경을 추적하고 되돌릴 수 있는 간단한 방법을 제공합니다.
<img width="2000" height="0" src="https://github.com/user-attachments/assets/ee14e6f7-20b8-4391-9091-8e8e25561929"><br>
<img align="left" width="370" src="https://github.com/user-attachments/assets/bc2e85ba-dfeb-4fe6-9942-7cfc4703cbe5">
### 브라우저 사용
Claude 3.5 Sonnet의 새로운 [컴퓨터 사용](https://www.anthropic.com/news/3-5-models-and-computer-use) 기능으로 인해, Cline은 브라우저를 실행하고 요소를 클릭하고 텍스트를 입력하고 스크롤하며 각 단계에서 스크린샷과 콘솔 로그를 캡처할 수 있습니다. 이를 통해 인터랙티브한 디버깅, 엔드투엔드 테스트, 심지어 일반적인 웹 탐색까지 가능해집니다. 이로 인해 오류 로그를 수동으로 복사 & 붙여넣기 할 필요 없이 시각적 버그나 런타임 문제를 자율적으로 수정할 수 있습니다.
Cline에게 "앱을 테스트해줘"라고 요청하면, `npm run dev`와 같은 명령을 실행하고 로컬에서 실행 중인 개발 서버를 브라우저에서 실행하여 일련의 테스트를 수행하고 모든 것이 정상적으로 작동하는지 확인합니다. [데모는 여기를 참조하세요.](https://x.com/sdrzn/status/1850880547825823989)
<img width="2000" height="0" src="https://github.com/user-attachments/assets/ee14e6f7-20b8-4391-9091-8e8e25561929"><br>
<img align="right" width="350" src="https://github.com/user-attachments/assets/ac0efa14-5c1f-4c26-a42d-9d7c56f5fadd">
### "도구를 추가 해주세요."
Cline은 [Model Context Protocol](https://github.com/modelcontextprotocol)을 활용하여 커스텀 도구를 생성하고 기능을 확장할 수 있습니다. 기존의 [커뮤니티 서버](https://github.com/modelcontextprotocol/servers)를 사용할 수도 있지만, Cline은 사용자의 워크플로우에 최적화된 도구를 직접 제작하고 설치할 수도 있습니다. "~ 도구를 추가해주세요."라고 요청만 하면, Cline은 새로운 MCP 서버 생성부터 확장 프로그램 내 설치까지 모두 자동으로 처리합니다. 이러한 커스텀 도구는 Cline의 툴키트의 일부가 되어 향후 작업에서 사용할 수 있게 됩니다.
- "Jira 티켓을 가져오는 도구를 추가해주세요": 티켓 AC를 가져와 Cline에게 작업을 요청
- "AWS EC2를 관리하는 도구를 추가해주세요": 서버 메트릭을 확인하고 인스턴스를 확장 또는 축소
- "최신 PagerDuty 인시던트를 가져오는 도구를 추가해주세요": 최신 장애 정보를 가져와 Cline에게 버그 수정 요청
<img width="2000" height="0" src="https://github.com/user-attachments/assets/ee14e6f7-20b8-4391-9091-8e8e25561929"><br>
<img align="left" width="360" src="https://github.com/user-attachments/assets/7fdf41e6-281a-4b4b-ac19-020b838b6970">
### 컨텍스트 추가
**`@url`:** URL을 붙여넣으면 확장이 해당 페이지를 가져와 Markdown으로 변환합니다. 최신 문서를 Cline에게 제공할 때 유용합니다.
**`@problems`:** Cline이 수정할 워크스페이스 오류와 경고(Problems' panel)를 추가합니다.
**`@file`:** 파일의 내용을 추가하여, 파일을 읽는 데 API 요청을 허비하지 않고도 Cline이 접근할 수 있도록 합니다. (+ 파일 검색 가능)
**`@folder`:** 폴더 내 모든 파일을 한 번에 추가하여 워크플로우를 더욱 빠르게 진행할 수 있습니다.
<img width="2000" height="0" src="https://github.com/user-attachments/assets/ee14e6f7-20b8-4391-9091-8e8e25561929"><br>
<img align="right" width="350" src="https://github.com/user-attachments/assets/140c8606-d3bf-41b9-9a1f-4dbf0d4c90cb">
### 체크포인트: 비교 및 복원
Cline이 작업을 진행하는 동안 확장 프로그램은 각 단계에서 워크스페이스의 스냅샷을 저장합니다. “Compare” 버튼을 사용하여 스냅샷과 현재 워크스페이스의 차이를 확인하고, “Restore” 버튼을 사용하여 해당 시점으로 롤백할 수 있습니다.
예를 들어, 로컬 웹 서버에서 작업 중일 때 “Restore Workspace Only”을 사용하여 서로 다른 버전의 앱을 신속하게 테스트하고, “Restore Task and Workspace”을 사용하여 계속 진행할 버전을 찾을 수 있습니다. 이를 통해 진행 상황을 잃지 않고 안전하게 다양한 접근 방식을 실험할 수 있습니다.
<img width="2000" height="0" src="https://github.com/user-attachments/assets/ee14e6f7-20b8-4391-9091-8e8e25561929"><br>
## 기여
프로젝트에 기여하려면, [기여 가이드](CONTRIBUTING.md)에서 기본 사항을 익히세요. 또한, [Discord](https://discord.gg/cline)에 참여하여 `#contributors` 채널에서 다른 기여자들과 이야기할 수 있습니다. 풀타임 직업을 찾고 있다면, [채용 페이지](https://cline.bot/join-us)에서 열려있는 포지션을 확인하세요.
<details>
<summary>로컬 개발 방법</summary>
1. 리포지토리를 클론합니다 _(Requires [git-lfs](https://git-lfs.com/))_
```bash
git clone https://github.com/cline/cline.git
```
2. 프로젝트를 VSCode에서 엽니다:
```bash
code cline
```
3. 확장 프로그램과 webview-gui의 필요한 의존성을 설치합니다:
```bash
npm run install:all
```
4. `F5`를 눌러(또는 `Run`->`Start Debugging`), 확장 프로그램이 로드된 새로운 VSCode 창을 엽니다. (프로젝트 빌드에 문제가 있는 경우, [esbuild problem matchers extension](https://marketplace.visualstudio.com/items?itemName=connor4312.esbuild-problem-matchers)을 설치해야 할 수도 있습니다.)
</details>
<details>
<summary>Pull Request 생성 방법</summary>
1. PR을 만들기 전, 변경 사항을 기록하는 changeset 항목을 생성:
```bash
npm run changeset
```
이후 프롬프트에서 다음 정보를 입력하세요:
- 변경 유형 (major, minor, patch)
- `major` → 호환되지 않는 변경 (1.0.0 → 2.0.0)
- `minor` → 새로운 기능 추가 (1.0.0 → 1.1.0)
- `patch` → 버그 수정 (1.0.0 → 1.0.1)
- 변경 사항 설명 입력
2. 변경 사항과 생성된 `.changeset` 파일을 커밋 후 브랜치를 푸시하고 GitHub에서 PR을 생성하세요.
3. 브랜치를 푸시하고 GitHub에서 PR을 생성하세요. CI가 다음과 같은 작업을 수행합니다:
- 테스트 및 코드 검증 실행
- Changesetbot이 버전 변경 영향을 보여주는 코멘트를 생성
- 브랜치가 메인에 머지되면, Changesetbot이 버전 패키지 PR을 생성
- 버전 패키지 PR이 머지되면, 새로운 릴리즈가 게시됨
</details>
## 라이센스
[Apache 2.0 © 2025 Cline Bot Inc.](/LICENSE)
+1 -1
View File
@@ -158,4 +158,4 @@ Para contribuir com o projeto, comece com nosso [Guia de Contribuição](CONTRIB
## Licença
[Apache 2.0 © 2024 Cline Bot Inc.](./LICENSE)
[Apache 2.0 © 2025 Cline Bot Inc.](./LICENSE)
+1 -1
View File
@@ -158,5 +158,5 @@ Cline 所做的所有更改都会记录在你的文件时间轴中,提供了
## 许可证
[Apache 2.0 © 2024 Cline Bot Inc.](./LICENSE)
[Apache 2.0 © 2025 Cline Bot Inc.](./LICENSE)
+25 -23
View File
@@ -2,46 +2,48 @@
## 我們的承諾
為了促進一個開放和歡迎的環境,我們為貢獻者維護者承諾,使我們的項目和社區的參與對每個人來說都是一個無騷擾的體驗,不論年齡、體型、殘疾、種族、性別特徵、性別認同和表達、經驗水平、教育程度、社會經濟地位、國籍、個人外、種族、宗教或性向。
為了營造開放且友善的環境,我們為貢獻者維護者承諾讓參與本專案及社群的體驗,對每個人都不帶有騷擾,不論年齡、體型、身心障礙、族裔、性徵、性別認同與表現、經驗程度、教育程度、社地位、國籍、個人外、種族、宗教信仰、或性向。
## 我們的
## 我們的準
有助於創造積極環境的行為示例包括:
有助於創造正面環境的行為包括:
- 使用歡迎和包容的語言
- 尊重不同的觀點經驗
- 優雅地接受建設性批評
- 專注於對社最有利的事情
- 對其他社成員表示同情
- 使用友善和包容的語言
- 尊重不同的觀點經驗
- 優雅地接受建設性批評
- 著重於對社最有利的事情
- 對其他社成員展現同理心
參與者不可接受的行為示例包括:
參與者不可接受的行為包括:
- 使用性化語言或圖像以及不受歡迎的性注意或挑逗
- 騷擾、侮辱/貶低性評論和個人或政治攻擊
- 公開或私下騷擾
- 未經明確許可發布他人的私人信息,例如物理或電子地址
- 其他在專業環境中合理認為不當的行為
- 使用帶有性暗示的言語或影像,以及不受歡迎的性關注或騷擾
- 挑釁、羞辱/貶低他人的評論,以及人身或政治攻擊
- 公開或私下騷擾行為
- 未經他人明確許可,公開他人的私人資料,如實體或電子郵件地址
- 其他在專業環境中可被合理認為不當的行為
## 我們的責任
項目維護者有責任清可接受行為的標準,並預期對任何不可接受行為的實例採取適當公平的糾正行動
專案維護者有責任清可接受行為的標準,並對任何不可接受行為採取適當公平的糾正措施
項目維護者有權利和責任除、編輯或拒絕本行為準則不符的評論、提交、碼、維基編輯、題和其他貢獻,或暫時或永久禁止任何他們認為不當、威脅、冒犯或有害的貢獻者。
專案維護者有權利和責任除、編輯或拒絕不符合本行為準則的評論、提交、程式碼、維基編輯、題和其他貢獻,或暫時或永久封鎖任何他們認為不當、威脅、冒犯或有害行為的貢獻者。
## 範
## 範
行為準則適用於項目空間內以及當個人代表項目或其社區時的公共空間。代表項目或社區的示例包括使用官方項目電子郵件地址、過官方社媒體帳戶發布或作為在線或離線活動的指定代表。項目的代表可能由項目維護者進一步定義和澄清
行為準則適用於專案空間及公開場合,當個人代表本專案或其社群時都必須遵守。代表本專案或社群的情況包括使用官方專案電子郵件地址、過官方社媒體帳號發文,或在線上或實體活動中擔任指定代表。專案維護者進一步定義並釐清專案代表的其他情況
## 執行
濫用、騷擾或其他不可接受行為的實例可以通過聯繫項目團隊 hi@cline.bot 來報告。所有投訴將被審查和調查,並將根據情況作出必要和適當的回應。項目團隊有義務事件的報告者保密。具體執行政策的詳細信息可能會單獨發布
如發生辱罵、騷擾或其他不可接受行為,請透過 hi@cline.bot 聯絡專案團隊回報。所有申訴都將被審查和調查,並做出必要且合適的回應。專案團隊有義務事件回報者保密。具體執行政策的更多細節可能另行公佈
能善意遵循或執行行為準則的項目維護者可能會面臨由項目領導層其他成員決定的暫時或永久後果
遵守或未切實執行行為準則的專案維護者可能會面臨由專案領導團隊其他成員決定的暫時或永久的處置
## 歸屬
## 來源說明
行為準則改編自 [Contributor Covenant][homepage],版本 1.4,可在 https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 獲得。
行為準則改編自[貢獻者公約][homepage] 1.4,可在此查閱:
https://www.contributor-covenant.org/version/1/4/code-of-conduct.html
[homepage]: https://www.contributor-covenant.org
有關此行為準則的常見問題的答案,請參見 https://www.contributor-covenant.org/faq
關於本行為準則的常見問題解答,請參考:
https://www.contributor-covenant.org/faq
+57 -54
View File
@@ -1,82 +1,85 @@
# 貢獻 Cline
# 貢獻 Cline
我們很高興您有興趣為 Cline 做出貢獻。無論是修復錯誤、添加功能還是改進我們的文檔,每一貢獻都讓 Cline 更加智能!為了保持我們的社區充滿活力和歡迎,所有成員必須遵守我們的[行為準則](CODE_OF_CONDUCT.md)。
我們非常感謝您有意願貢獻至 Cline。無論是修正程式錯誤、新增功能或改善文件,每一貢獻都讓 Cline 更加出色!為了維持社群的活力與友善,所有成員必須遵守我們的[行為準則](CODE_OF_CONDUCT.md)。
## 報告錯誤或問題
## 回報程式錯誤或問題
錯誤報告有助於讓 Cline 對每個人都更好!在創建新問題之前,請[現有](https://github.com/cline/cline/issues)避免重複。當您準備報告錯誤時,請前往我們的[題頁面](https://github.com/cline/cline/issues/new/choose),您會找到一個模板來幫助您填寫相關信息
程式錯誤回報能幫助 Cline 變得更好!在建立新的議題之前,請[現有](https://github.com/cline/cline/issues)避免重複。當您準備好回報程式錯誤時,請前往我們的[題頁面](https://github.com/cline/cline/issues/new/choose),您會找到協助填寫相關資訊的範本
<blockquote class='warning-note'>
🔐 <b>重要:</b> 如果您發現安全漏洞,請使用<a href="https://github.com/cline/cline/security/advisories/new">Github 安全工具私下報告</a>。
🔐 <b>重要:</b> 您發現安全漏洞,請使用 <a href="https://github.com/cline/cline/security/advisories/new">GitHub 安全工具進行私密回報</a>。
</blockquote>
## 決定要做什麼
## 決定要處理的工作
尋找一個好的首次貢獻?查看標有["good first issue"](https://github.com/cline/cline/labels/good%20first%20issue)或["help wanted"](https://github.com/cline/cline/labels/help%20wanted)的題。這些是專門為新貢獻者我們希望得到幫助的領域策劃的
想找適合第一次貢獻的工作嗎?請檢視標示為[good first issue](https://github.com/cline/cline/labels/good%20first%20issue)或[help wanted](https://github.com/cline/cline/labels/help%20wanted)的題。這些議題特別適合新手貢獻者我們也非常歡迎您的協助
我們也歡迎對我們[](https://github.com/cline/cline/tree/main/docs)的貢獻!無論是修正錯字、改現有指南還是創建新的教內容 - 我們希望建立一個由社區驅動的資源庫,助每個人充分用 Cline。您可以從深入研究 `/docs` 尋找需要改進的領域開始
我們也歡迎對[](https://github.com/cline/cline/tree/main/docs)的貢獻!無論是修正錯字、改現有指南或建立新的教內容,我們都期待能建立一個由社群共同維護的知識庫,助每個人充分用 Cline。您可以從 `/docs` 開始,尋找需要改善的地方
如果您計劃開發一個更大的功能,請先建一個[功能請求](https://github.com/cline/cline/discussions/categories/feature-requests?discussions_q=is%3Aopen+category%3A%22Feature+Requests%22+sort%3Atop),以便我們討論是否符合 Cline 的願景。
若您計畫處理較大的功能,請先建一個[功能請求](https://github.com/cline/cline/discussions/categories/feature-requests?discussions_q=is%3Aopen+category%3A%22Feature+Requests%22+sort%3Atop),以便我們討論該功能是否符合 Cline 的願景。
## 開發設置
## 開發環境設定
1. **VS Code 擴**
1. **VS Code 擴充套件**
- 開啟專案時,VS Code 會提示您安裝建議的擴充套件
- 這些擴充套件是開發所需,請接受所有安裝提示
- 若您已關閉提示,可從擴充套件面板手動安裝
- 打開項目時,VS Code 會提示您安裝推薦的擴展
- 這些擴展是開發所需的 - 請接受所有安裝提示
- 如果您忽略了提示,可以從擴展面板手動安裝它們
2. **本機開發**
- 執行 `npm run install:all` 安裝相依套件
- 執行 `npm run test` 在本機執行測試
- 提交 PR 前,執行 `npm run format:fix` 格式化您的程式碼
2. **本地開發**
- 運行 `npm run install:all` 安裝依賴項
- 運行 `npm run test` 本地運行測試
- 提交 PR 之前,運行 `npm run format:fix` 格式化您的代碼
## 撰寫與提交程式碼
## 編寫和提交代碼
任何人都可以貢獻程式碼至 Cline,但我們要求您遵守以下指引,以確保您的貢獻能順利整合:
任何人都可以為 Cline 貢獻代碼,但我們要求您遵循以下指南,以確保您的貢獻能夠順利集成:
1. **保持 Pull Request 聚焦**
- 每個 PR 限制在單一功能或錯誤修正
- 將較大的變更拆分成較小且相關的 PR
- 將變更拆分成邏輯性的提交,以便獨立審查
1. **保持 Pull Requests 集中**
- 將 PR 限制在單個功能或錯誤修復
- 將較大的更改拆分為較小的相關 PR
- 將更改分為邏輯提交,可以獨立審查
2. **代碼質量**
- 運行 `npm run lint` 檢查代碼風格
- 運行 `npm run format` 自動格式化代碼
- 所有 PR 必須通過包括 lint 和格式化在內的 CI 檢查
2. **程式碼品質**
- 執行 `npm run lint` 檢查程式碼風格
- 執行 `npm run format` 自動格式化程式碼
- 所有 PR 必須通過包含程式碼風格檢查與格式化的 CI 檢查
- 提交前解決所有 ESLint 警告或錯誤
- 遵循 TypeScript 最佳實踐並保持類型安全
- 遵循 TypeScript 最佳實務並維持型別安全
3. **測試**
- 為新功能新增測試
- 執行 `npm test` 確保所有測試通過
- 若您的變更影響現有測試,請更新測試
- 適當時包含單元測試與整合測試
- 為新功能添加測試
- 運行 `npm test` 確保所有測試通過
- 如果您的更改影響現有測試,請更新它們
- 在適當的地方包括單元測試和集成測試
4. **使用 Changesets 管理版本**
- 使用 `npm run changeset` 為任何面向使用者的變更建立 changeset
- 選擇適當的版本升級:
- `major` 重大變更 (1.0.0 → 2.0.0)
- `minor` 新功能 (1.0.0 → 1.1.0)
- `patch` 錯誤修正 (1.0.0 → 1.0.1)
- 撰寫清晰且描述性的 changeset 訊息,說明影響
- 僅文件變更不需建立 changeset
4. **提交指**
5. **提交指**
- 撰寫清晰且描述性的提交訊息
- 使用慣用提交格式(例如:「feat:」、「fix:」、「docs:」)
- 在提交中引用相關議題,使用 #issue-number
- 撰寫清晰、描述性的提交消息
- 使用常規提交格式(例如 "feat:"、"fix:"、"docs:"
- 在提交中引用相關問題,使用 #issue-number
6. **提交前檢查**
- 將您的分支 rebase 到最新的 main
- 確保您的分支可以成功建置
- 再次確認所有測試通過
- 檢查您的變更是否包含除錯程式碼或 console 紀錄
5. **提交前**
- 將您的分支重新基於最新的 main
- 確保您的分支成功構建
- 仔細檢查所有測試是否通過
- 檢查您的更改是否有任何調試代碼或控制台日誌
6. **Pull Request 描述**
- 清楚地描述您的更改內容
- 包括測試更改的步驟
- 列出任何重大更改
- 為 UI 更改添加截圖
7. **Pull Request 說明**
- 清楚描述您的變更內容
- 包含測試變更的步驟
- 列出任何重大變更
- 若有使用者介面變更,請附上截圖
## 貢獻協議
通過提交 pull request您同意您的貢獻將根據與項目相同的許可證[Apache 2.0](LICENSE))進行許可
提交 Pull Request 即表示您同意您的貢獻將依照專案相同的授權條款[Apache 2.0](LICENSE))進行授權
記住:貢獻 Cline 不僅僅是編寫代碼 - 這是關於成為一個塑造 AI 輔助開發未來的社區的一部分。讓我們一起創造一些驚人的東西!🚀
記住:貢獻 Cline 不只是撰寫程式碼,更是成為塑造 AI 輔助開發未來的社群一份子。讓我們一起打造令人驚艷的成果吧!🚀
+98 -72
View File
@@ -1,4 +1,8 @@
# Cline OpenRouter 上的 \#1
<div align="center"><sub>
<a href="https://github.com/cline/cline/blob/main/README.md" target="_blank">English</a> | <a href="https://github.com/cline/cline/blob/main/locales/es/README.md" target="_blank">Español</a> | <a href="https://github.com/cline/cline/blob/main/locales/de/README.md" target="_blank">Deutsch</a> | <a href="https://github.com/cline/cline/blob/main/locales/ja/README.md" target="_blank">日本語</a> | <a href="https://github.com/cline/cline/blob/main/locales/zh-cn/README.md" target="_blank">简体中文</a> | 繁體中文 | <a href="https://github.com/cline/cline/blob/main/locales/ko/README.md" target="_blank">한국어</a>
</sub></div>
# Cline OpenRouter 第一名的 AI 工具
<p align="center">
<img src="https://media.githubusercontent.com/media/cline/cline/main/assets/docs/demo.gif" width="100%" />
@@ -8,7 +12,7 @@
<table>
<tbody>
<td align="center">
<a href="https://marketplace.visualstudio.com/items?itemName=saoudrizwan.claude-dev" target="_blank"><strong> VS Marketplace 下載</strong></a>
<a href="https://marketplace.visualstudio.com/items?itemName=saoudrizwan.claude-dev" target="_blank"><strong> VS Marketplace 下載</strong></a>
</td>
<td align="center">
<a href="https://discord.gg/cline" target="_blank"><strong>Discord</strong></a>
@@ -17,29 +21,29 @@
<a href="https://www.reddit.com/r/cline/" target="_blank"><strong>r/cline</strong></a>
</td>
<td align="center">
<a href="https://github.com/cline/cline/discussions/categories/feature-requests?discussions_q=is%3Aopen+category%3A%22Feature+Requests%22+sort%3Atop" target="_blank"><strong>功能請求</strong></a>
<a href="https://github.com/cline/cline/discussions/categories/feature-requests?discussions_q=is%3Aopen+category%3A%22Feature+Requests%22+sort%3Atop" target="_blank"><strong>功能建議</strong></a>
</td>
<td align="center">
<a href="https://cline.bot/join-us" target="_blank"><strong>我們正在招聘!</strong></a>
<a href="https://docs.cline.bot/getting-started/getting-started-new-coders" target="_blank"><strong>新手上路</strong></a>
</td>
</tbody>
</table>
</div>
認識 Cline,一個可以使用你的 **CLI****編輯器** 的 AI 助
認識 Cline,一個可以使用您的**命令列介面** (CLI) 和**程式編輯器** (Editor) 的 AI 助
感謝 [Claude 3.7 Sonnet 的代理編碼能力](https://www.anthropic.com/claude/sonnet)Cline 可以一步步處理複雜的軟開發任務。通過允許他創建和編輯文件、探索大型項目、使用瀏覽器執行終端令(在你授予權限後),他可以提供超越代碼完成或技術支持的幫助。Cline 甚至可以使用 Model Context Protocol (MCP) 創建新工具並擴展自己的能。雖然自主 AI 腳本傳統上在沙環境中行,但此擴展提供了一個人機交互的 GUI 來批准每個文件更改和終端令,提供了一種安全且可訪問的方式來探索代理 AI 的潛力。
感謝 [Claude 3.7 Sonnet 的代理式程式設計能力](https://www.anthropic.com/claude/sonnet)Cline 能夠逐步處理複雜的軟開發任務。透過能讓他建立和編輯檔案、探索大型專案、使用瀏覽器,以及執行終端機指令(在您授權後)的工具,他能以超越程式碼自動完成或技術支援的方式協助您。Cline 甚至能使用模型上下文協定(Model Context ProtocolMCP)來建立新工具並擴展自己的能。雖然自主 AI 腳本傳統上在沙環境中行,但這個擴充套件提供了人機互動的圖形介面,讓您可以核准每個檔案變更和終端機指令,提供一個安全且容易使用的方式來探索代理 AI 的潛力。
1. 輸入的任務並添加圖像,將模型轉換功能應用程序或通過截圖修復錯誤。
2. Cline 先分析你的文件結構和源代碼 AST,運行正表達式搜,並閱讀相關文件以了解現有項目。通過仔細管理添加到上下文中的信息,Cline 即使在大型複雜項目中也能提供有價值的幫助,而不會使上下文窗口過載
3. 一旦 Cline 得所需信息,他可以:
- 建和編輯文件 + 監控 linter/編譯器錯誤,從而主動修復諸如缺少導入和語法錯誤等問題。
- 直接在的終端中執行令並監控其輸出,從而在編輯文件後對開發服務器問題做出反應
- 對於 Web 開發任務,Cline 可以在無頭瀏覽器中啟動網站,點擊、輸入、動並捕獲截圖和控制台日誌,從而修復運行時錯誤和視覺錯誤
4. 當任務完成時,Cline 將通過終端命令`open -a "Google Chrome" index.html` 向你展示結果,你可以通過點擊按鈕運行該命令
1. 輸入的任務,並可以加入圖片來將設計稿轉換功能應用程式,或使用截圖來修正錯誤。
2. Cline 先分析您的檔案結構和程式碼 AST、執行正表達式搜,並讀取相關檔案,以便在現有專案中快速掌握狀況。透過仔細管理加入上下文的資訊,Cline 可以在不超過上下文視窗的情況下,為大型複雜的專案提供有價值的協助
3. 一旦 Cline 得所需資訊後,他可以:
-和編輯檔案,並在過程中監控程式碼檢查工具/編譯器錯誤,讓他能主動修正缺少的匯入語句和語法錯誤等問題。
- 直接在的終端中執行令並監控其輸出,讓他能夠在編輯檔案後回應開發伺服器的問題
- 對於網頁開發任務,Cline 可以在無頭瀏覽器中啟動網站、點選、輸入、動並擷取螢幕截圖和主控台記錄,讓他能修正執行時錯誤和視覺問題
4. 當任務完成時,Cline 會以終端機指令(`open -a "Google Chrome" index.html`)向您呈現結果,您只需點選按鈕即可執行
> [!提示]
> 使用 `CMD/CTRL + Shift + P` 快捷鍵打開命令面板並輸入 "Cline: Open In New Tab" 將擴展作為標籤在編輯器中打開。這讓可以與文件資源管理器並排使用 Cline,更清楚地看到他如何改變你的工作空間
> [!TIP]
> 使用 `CMD/CTRL + Shift + P` 快速鍵開啟命令選擇區,輸入「Cline: Open In New Tab」即可在編輯器中以分頁方式開啟擴充套件。這讓可以同時檢視檔案總管,並更清楚地看到 Cline 如何變更您的工作
---
@@ -47,115 +51,137 @@
### 使用任何 API 和模型
Cline 支 OpenRouter、Anthropic、OpenAI、Google Gemini、AWS Bedrock、Azure 和 GCP Vertex 等 API 提供商。你還可以配置任何兼容 OpenAI 的 API,或過 LM Studio/Ollama 使用本模型。如果你使用 OpenRouter擴展會獲取他們最新模型列表,讓在新模型可用時立即使用。
Cline 支 OpenRouter、Anthropic、OpenAI、Google Gemini、AWS Bedrock、Azure 和 GCP Vertex 等 API 提供者。您也可以設定任何與 OpenAI 相容的 API,或過 LM Studio/Ollama 使用本模型。若您使用 OpenRouter此擴充套件會擷取他們最新模型列表,讓您能在新模型推出時立即使用。
擴展還會跟蹤整個任務循環和單個請求的總令牌和 API 使用成本,讓你在每一步都了解支出情況
<!-- 透明像素以在浮動圖像後創建換行 -->
此擴充套件也會追蹤整個任務迴圈和個別請求的 token 總數和 API 使用成本,讓您隨時掌握費用支出
<!-- 透明像素用於浮動圖片後的換行 -->
<img width="2000" height="0" src="https://github.com/user-attachments/assets/ee14e6f7-20b8-4391-9091-8e8e25561929"><br>
<img align="left" width="370" src="https://github.com/user-attachments/assets/81be79a8-1fdb-4028-9129-5fe055e01e76">
### 在終端中運行命
### 在終端機中執行指
感謝 VSCode v1.93 中的新 [終端 shell 集成更新](https://code.visualstudio.com/updates/v1_93#_terminal-shell-integration-api)Cline 可以直接在的終端中執行令並接收輸出。這使他能執行廣泛的任務,從安裝包和運行構建腳本到部署應用程、管理數據庫和執行測試,同時適應的開發環境和工具鏈以正確完成工作。
感謝 [VSCode v1.93 的終端機整合更新](https://code.visualstudio.com/updates/v1_93#_terminal-shell-integration-api)Cline 可以直接在的終端中執行令並接收輸出。這他能執行各種任務,從安裝套件和執行建置腳本到部署應用程、管理資料庫和執行測試,同時適應的開發環境和工具鏈以正確完成工作。
對於長時間行的進程如開發服務器,使用“在運行時繼續”按鈕讓 Cline 在命令後台運行時繼續任務。當 Cline 工作時,他會在過程中收到任何新的終端輸出通知,讓他可能出現的問題做出反應,例如編輯文件時的編譯錯誤。
<!-- 透明像素以在浮動圖像後創建換行 -->
對於開發伺服器等長時間行的程序,使用「繼續執行中的程序」按鈕讓 Cline 在指令於背景執行時繼續任務。當 Cline 工作時,他會收到任何新的終端輸出通知,讓他能回應可能出現的問題,例如編輯檔案時的編譯錯誤。
<!-- 透明像素用於浮動圖片後的換行 -->
<img width="2000" height="0" src="https://github.com/user-attachments/assets/ee14e6f7-20b8-4391-9091-8e8e25561929"><br>
<img align="right" width="400" src="https://github.com/user-attachments/assets/c5977833-d9b8-491e-90f9-05f9cd38c588">
### 建和編輯文件
### 建和編輯檔案
Cline 可以直接在的編輯器中建和編輯文件,向你展示更改的差異視圖。你可以直接在差異視編輯器中編輯或恢復 Cline 的更,或在聊天中提供饋,直到你對結果滿意。Cline 會監控 linter/編譯器錯誤(缺少導入、語法錯誤等),以便他在過程中自行修復出現的問題。
Cline 可以直接在的編輯器中建和編輯檔案,並顯示變更的差異檢視。您可以直接在差異視編輯器中編輯或還原 Cline 的更,或在聊天中提供意見回饋,直到您滿意結果為止。Cline 會監控程式碼檢查工具/編譯器錯誤(缺少的匯入語句、語法錯誤等),讓他能自行修正過程中出現的問題。
Cline 做的所有更改都會記錄在你的文件時間軸中,提供了一種簡單的方法來跟蹤和恢復修改(如果需要)
<!-- 透明像素以在浮動圖像後創建換行 -->
所有 Cline 做的變更都會記錄在您檔案的時間軸中,提供簡單的方式來追蹤和還原修改
<!-- 透明像素用於浮動圖片後的換行 -->
<img width="2000" height="0" src="https://github.com/user-attachments/assets/ee14e6f7-20b8-4391-9091-8e8e25561929"><br>
<img align="left" width="370" src="https://github.com/user-attachments/assets/bc2e85ba-dfeb-4fe6-9942-7cfc4703cbe5">
### 使用瀏覽器
借助 Claude 3.5 Sonnet 的新 [計算機使用](https://www.anthropic.com/news/3-5-models-and-computer-use) 功能,Cline 可以啟動瀏覽器,點擊元素輸入文本和滾動,在每一步捕獲截圖和控制台日誌。這允許進行交互式調試、端端測試,甚至一般網頁使用!這使他能夠自主修復視覺錯誤和運行時問題,而無需你親自操作和複製粘貼錯誤日誌
透過 Claude 3.5 Sonnet 的新[電腦使用](https://www.anthropic.com/news/3-5-models-and-computer-use)功能,Cline 可以啟動瀏覽器、點選元素輸入文字和捲動,在每個步驟擷取螢幕截圖和主控台記錄。這讓互動式除錯、端端測試,甚至一般網頁使用成為可能!這他能獨立修正視覺問題和執行時錯誤,而不需要您手動複製錯誤記錄
試讓 Cline 測試應用程序”,看看他如何`npm run dev` 命令,在瀏覽器中啟動你本地運行的開發服務器,並執行一系列測試確認一切正常。[在這裡查看演示。](https://x.com/sdrzn/status/1850880547825823989)
<!-- 透明像素以在浮動圖像後創建換行 -->
著請 Cline 測試應用程式」,觀察他如何`npm run dev`在瀏覽器中啟動您的本機開發伺服器,並執行一系列測試確認一切正常運作。[點此觀看示範](https://x.com/sdrzn/status/1850880547825823989)
<!-- 透明像素用於浮動圖片後的換行 -->
<img width="2000" height="0" src="https://github.com/user-attachments/assets/ee14e6f7-20b8-4391-9091-8e8e25561929"><br>
<img align="right" width="350" src="https://github.com/user-attachments/assets/ac0efa14-5c1f-4c26-a42d-9d7c56f5fadd">
### “添加一個工具……”
### 「新增一個工具來...」
感謝 [Model Context Protocol](https://github.com/modelcontextprotocol)Cline 可以過自定義工具擴展他的能。雖然可以使用 [製作的服](https://github.com/modelcontextprotocol/servers),但 Cline 可以創建和安裝適合你特定工作流程的工具。只需讓 Cline “添加一個工具,他處理所有事情,從建新的 MCP 服器到將其安裝到擴中。這些自定義工具將成為 Cline 工具的一部分,準備在未來的任務中使用
感謝[模型上下文協定](https://github.com/modelcontextprotocol)Cline 可以過自工具擴展他的能。雖然可以使用[製作的服器](https://github.com/modelcontextprotocol/servers),但 Cline 可以改為建立專門為您的工作流程量身打造的工具。只要請 Cline 「新增工具,他就會處理所有事情,從建新的 MCP 服器到將其安裝到擴充套件中。這些自訂工具就會成為 Cline 工具的一部分,隨時可用於未來的任務。
- “添加一個取 Jira 工單的工具”:檢索工單 AC 並讓 Cline 開始工作
- “添加一個管理 AWS EC2 的工具:檢查服器指標並上下擴展實例
- “添加一個取最新 PagerDuty 事件的工具”:獲取詳細信息並讓 Cline 修復錯誤
<!-- 透明像素以在浮動圖像後創建換行 -->
- 「新增一個取 Jira 工單的工具」:取得工單驗收條件並讓 Cline 開始工作
- 「新增一個管理 AWS EC2 的工具:檢查服器指標並調整執行個體規模
- 「新增一個取最新 PagerDuty 事件的工具」:取得詳細資訊並請 Cline 修復錯誤
<!-- 透明像素用於浮動圖片後的換行 -->
<img width="2000" height="0" src="https://github.com/user-attachments/assets/ee14e6f7-20b8-4391-9091-8e8e25561929"><br>
<img align="left" width="360" src="https://github.com/user-attachments/assets/7fdf41e6-281a-4b4b-ac19-020b838b6970">
### 添加上下文
### 新增上下文
**`@url`** 粘貼一個 URL 以供擴展獲取並轉換為 markdown,當想給 Cline 提供最新文檔時非常有用
**`@problems`:** 添加工作區錯誤和警告(“問題”面板)以供 Cline 修復
**`@file`:** 添加文件內容,這樣你就不必浪費 API 請求批准讀取文件(+ 輸入以搜索文件)
**`@folder`:** 一次添加文件夾的文件,以進一步加快你的工作流程
<!-- 透明像素以在浮動圖像後創建換行 -->
**`@url`**貼上網址讓擴充套件擷取並轉換為 Markdown,當想給 Cline 最新文件時很有用
**`@problems`:**新增工作區的錯誤和警告(「問題」面板)給 Cline 修正
**`@file`:**新增檔案內容,讓您不必浪費 API 請求來核准讀取檔案(+ 輸入以搜尋檔案)
**`@folder`:**一次新增整個資料夾的檔案,讓您的工作流程更快速
<!-- 透明像素用於浮動圖片後的換行 -->
<img width="2000" height="0" src="https://github.com/user-attachments/assets/ee14e6f7-20b8-4391-9091-8e8e25561929"><br>
<img align="right" width="350" src="https://github.com/user-attachments/assets/140c8606-d3bf-41b9-9a1f-4dbf0d4c90cb">
### 檢查點:比較和恢復
### 檢查點:比較和還原
當 Cline 完成任務時,擴展會在每一步拍攝你的工作區快照。可以使用比較按鈕查看快照和當前工作區之間的差異,並使用“恢復”按鈕回到該點。
當 Cline 處理任務時,擴充套件會在每個步驟擷取您工作區快照。可以使用比較按鈕檢視快照與目前工作區的差異,並使用「還原」按鈕回到該時間點。
例如,使用本地 Web 服務器時,可以使用“僅恢復工作區快速測試應用程的不同版本,然後在找到要繼續構建的版本時使用“恢復任務和工作區。這讓你可以安全地探索不同方法而不會失進度。
<!-- 透明像素以在浮動圖像後創建換行 -->
例如,使用本機網頁伺服器時,可以使用「僅還原工作區」來快速測試應用程的不同版本,然後在找到要繼續開發的版本時使用「還原任務和工作區。這讓您能安全地探索不同方法而不會失進度。
<!-- 透明像素用於浮動圖片後的換行 -->
<img width="2000" height="0" src="https://github.com/user-attachments/assets/ee14e6f7-20b8-4391-9091-8e8e25561929"><br>
## 貢獻
要為項目做出貢獻,請我們的 [貢獻指南](CONTRIBUTING.md) 開始,了解基礎知識。你還可以加入我們的 [Discord](https://discord.gg/cline) `#contributors` 頻道與其他貢獻者聊天。如果你正在尋找全職工作,請查看我們在 [招聘頁面](https://cline.bot/join-us) 上的開放職位
要為專案貢獻,請先閱讀我們的[貢獻指南](CONTRIBUTING.md)了解基礎知識。您也可以加入我們的 [Discord](https://discord.gg/cline)`#contributors` 頻道與其他貢獻者交流。如果在尋找全職工作,請檢視我們[職涯頁面](https://cline.bot/join-us)上的職缺
<details>
<summary>本開發說明</summary>
<summary>本開發說明</summary>
1. 克隆倉庫 _(需要 [git-lfs](https://git-lfs.com/))_
```bash
git clone https://github.com/cline/cline.git
```
2. 在 VSCode 中打開項目:
```bash
code cline
```
3. 安裝擴展和 webview-gui 的必要依賴:
```bash
npm run install:all
```
4. 按 `F5`(或 `運行`->`開始調試`)啟動以打開一個加載了擴展的新 VSCode 窗口。(如果你在構建項目時遇到問題,可能需要安裝 [esbuild problem matchers 擴展](https://marketplace.visualstudio.com/items?itemName=connor4312.esbuild-problem-matchers)
1. 複製程式碼庫(需要 [git-lfs](https://git-lfs.com/)
```bash
git clone https://github.com/cline/cline.git
```
2. 在 VSCode 中開啟專案:
```bash
code cline
```
3. 安裝擴充套件和網頁介面所需的相依套件:
```bash
npm run install:all
```
4. 按下 `F5`(或選擇「執行」->「開始除錯」)來啟動並開啟一個已載入擴充套件的新 VSCode 視窗。(如果建置專案時遇到問題,您可能需要安裝 [esbuild problem matchers 擴充套件](https://marketplace.visualstudio.com/items?itemName=connor4312.esbuild-problem-matchers)
</details>
## 許可證
<details>
<summary>建立 Pull Request</summary>
[Apache 2.0 © 2024 Cline Bot Inc.](./LICENSE)
1. 在建立 PR 前,產生一個 changeset 項目:
```bash
npm run changeset
```
這會提示您填寫:
- 變更類型(major、minor、patch
- `major` → 重大變更(1.0.0 → 2.0.0
- `minor` → 新功能(1.0.0 → 1.1.0
- `patch` → 錯誤修正(1.0.0 → 1.0.1
- 您的變更說明
2. 提交您的變更和產生的 `.changeset` 檔案
3. 推送您的分支並在 GitHub 上建立 PR。我們的 CI 會:
- 執行測試和檢查
- Changesetbot 會建立一個顯示版本影響的評論
- 當合併到 main 時,changesetbot 會建立一個 Version Packages PR
- 當 Version Packages PR 合併時,就會發布新版本
</details>
## 授權條款
[Apache 2.0 © 2025 Cline Bot Inc.](./LICENSE)
+3669 -639
View File
File diff suppressed because it is too large Load Diff
+68 -10
View File
@@ -2,12 +2,8 @@
"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.6.9",
"version": "3.8.2",
"icon": "assets/icons/icon.png",
"galleryBanner": {
"color": "#617A91",
"theme": "dark"
},
"engines": {
"vscode": "^1.84.0"
},
@@ -73,7 +69,7 @@
{
"command": "cline.mcpButtonClicked",
"title": "MCP Servers",
"icon": "$(extensions)"
"icon": "$(server)"
},
{
"command": "cline.historyButtonClicked",
@@ -85,6 +81,11 @@
"title": "Open in Editor",
"icon": "$(link-external)"
},
{
"command": "cline.accountButtonClicked",
"title": "Account",
"icon": "$(account)"
},
{
"command": "cline.settingsButtonClicked",
"title": "Settings",
@@ -94,6 +95,32 @@
"command": "cline.openInNewTab",
"title": "Open In New Tab",
"category": "Cline"
},
{
"command": "cline.dev.createTestTasks",
"title": "Create Test Tasks",
"category": "Cline",
"when": "cline.isDevMode"
},
{
"command": "cline.openDocumentation",
"title": "Documentation",
"icon": "$(book)"
},
{
"command": "cline.addToChat",
"title": "Add to Cline",
"category": "Cline"
},
{
"command": "cline.addTerminalOutputToChat",
"title": "Add to Cline",
"category": "Cline"
},
{
"command": "cline.fixWithCline",
"title": "Fix with Cline",
"category": "Cline"
}
],
"menus": {
@@ -119,9 +146,32 @@
"when": "view == claude-dev.SidebarProvider"
},
{
"command": "cline.settingsButtonClicked",
"command": "cline.openDocumentation",
"group": "navigation@5",
"when": "view == claude-dev.SidebarProvider"
},
{
"command": "cline.accountButtonClicked",
"group": "navigation@6",
"when": "view == claude-dev.SidebarProvider"
},
{
"command": "cline.settingsButtonClicked",
"group": "navigation@7",
"when": "view == claude-dev.SidebarProvider"
}
],
"editor/context": [
{
"command": "cline.addToChat",
"group": "navigation",
"when": "editorHasSelection"
}
],
"terminal/context": [
{
"command": "cline.addTerminalOutputToChat",
"group": "navigation"
}
]
},
@@ -160,7 +210,7 @@
"cline.enableCheckpoints": {
"type": "boolean",
"default": true,
"description": "Enables extension to save checkpoints of workspace throughout the task."
"description": "Enables extension to save checkpoints of workspace throughout the task. Uses git under the hood which may not work well with large workspaces."
},
"cline.disableBrowserTool": {
"type": "boolean",
@@ -230,6 +280,7 @@
"format": "prettier . --check",
"format:fix": "prettier . --write",
"test": "vscode-test",
"test:ci": "node scripts/test-ci.js",
"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",
@@ -267,10 +318,17 @@
"@anthropic-ai/bedrock-sdk": "^0.12.4",
"@anthropic-ai/sdk": "^0.37.0",
"@anthropic-ai/vertex-sdk": "^0.6.4",
"@aws-sdk/client-bedrock-runtime": "^3.758.0",
"@google-cloud/vertexai": "^1.9.3",
"@google/generative-ai": "^0.18.0",
"@mistralai/mistralai": "^1.5.0",
"@modelcontextprotocol/sdk": "^1.0.1",
"@modelcontextprotocol/sdk": "^1.7.0",
"@opentelemetry/api": "^1.4.1",
"@opentelemetry/exporter-trace-otlp-http": "^0.39.1",
"@opentelemetry/resources": "^1.30.1",
"@opentelemetry/sdk-node": "^0.39.1",
"@opentelemetry/sdk-trace-node": "^1.30.1",
"@opentelemetry/semantic-conventions": "^1.30.0",
"@types/clone-deep": "^4.0.4",
"@types/get-folder-size": "^3.0.4",
"@types/pdf-parse": "^1.1.4",
@@ -281,7 +339,6 @@
"chokidar": "^4.0.1",
"clone-deep": "^4.0.1",
"default-shell": "^2.2.0",
"delay": "^6.0.0",
"diff": "^5.2.0",
"execa": "^9.5.2",
"fast-deep-equal": "^3.1.3",
@@ -296,6 +353,7 @@
"open-graph-scraper": "^6.9.0",
"openai": "^4.83.0",
"os-name": "^6.0.0",
"p-timeout": "^6.1.4",
"p-wait-for": "^5.0.2",
"pdf-parse": "^1.1.1",
"posthog-node": "^4.8.1",
+30
View File
@@ -0,0 +1,30 @@
#!/usr/bin/env node
const { execSync } = require("child_process")
const process = require("process")
try {
if (process.platform === "linux") {
console.log("Detected Linux environment.")
execSync("which xvfb-run", { stdio: "ignore" })
console.log("xvfb-run is installed. Running tests with xvfb-run...")
execSync("xvfb-run -a npm run test", { stdio: "inherit" })
} else {
console.log("Non-Linux environment detected. Running tests normally.")
execSync("npm run test", { stdio: "inherit" })
}
} catch (error) {
if (process.platform === "linux") {
console.error(
`Error: xvfb-run is not installed.\n` +
`Please install it using the following command:\n` +
` Debian/Ubuntu: sudo apt install xvfb\n` +
` RHEL/CentOS: sudo yum install xvfb\n` +
` Arch Linux: sudo pacman -S xvfb`,
)
} else {
console.error("Error running tests:", error.message)
}
process.exit(1)
}
+5 -1
View File
@@ -9,7 +9,7 @@ import { OllamaHandler } from "./providers/ollama"
import { LmStudioHandler } from "./providers/lmstudio"
import { GeminiHandler } from "./providers/gemini"
import { OpenAiNativeHandler } from "./providers/openai-native"
import { ApiStream } from "./transform/stream"
import { ApiStream, ApiStreamUsageChunk } from "./transform/stream"
import { DeepSeekHandler } from "./providers/deepseek"
import { RequestyHandler } from "./providers/requesty"
import { TogetherHandler } from "./providers/together"
@@ -20,10 +20,12 @@ import { ClineHandler } from "./providers/cline"
import { LiteLlmHandler } from "./providers/litellm"
import { AskSageHandler } from "./providers/asksage"
import { XAIHandler } from "./providers/xai"
import { SambanovaHandler } from "./providers/sambanova"
export interface ApiHandler {
createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream
getModel(): { id: string; info: ModelInfo }
getApiStreamUsage?(): Promise<ApiStreamUsageChunk | undefined>
}
export interface SingleCompletionHandler {
@@ -71,6 +73,8 @@ export function buildApiHandler(configuration: ApiConfiguration): ApiHandler {
return new AskSageHandler(options)
case "xai":
return new XAIHandler(options)
case "sambanova":
return new SambanovaHandler(options)
default:
return new AnthropicHandler(options)
}
+472 -13
View File
@@ -2,9 +2,17 @@ import AnthropicBedrock from "@anthropic-ai/bedrock-sdk"
import { Anthropic } from "@anthropic-ai/sdk"
import { withRetry } from "../retry"
import { ApiHandler } from "../"
import { convertToR1Format } from "../transform/r1-format"
import { ApiHandlerOptions, bedrockDefaultModelId, BedrockModelId, bedrockModels, ModelInfo } from "../../shared/api"
import { calculateApiCostOpenAI } from "../../utils/cost"
import { ApiStream } from "../transform/stream"
import { fromNodeProviderChain } from "@aws-sdk/credential-providers"
import {
BedrockRuntimeClient,
ConversationRole,
ConverseStreamCommand,
InvokeModelWithResponseStreamCommand,
} from "@aws-sdk/client-bedrock-runtime"
// https://docs.anthropic.com/en/api/claude-on-amazon-bedrock
export class AwsBedrockHandler implements ApiHandler {
@@ -18,19 +26,31 @@ export class AwsBedrockHandler implements ApiHandler {
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
// cross region inference requires prefixing the model id with the region
let modelId = await this.getModelId()
const model = this.getModel()
// Check if this is an Amazon Nova model
if (modelId.includes("amazon.nova")) {
yield* this.createNovaMessage(systemPrompt, messages, modelId, model)
return
}
// Check if this is a Deepseek model
if (modelId.includes("deepseek")) {
yield* this.createDeepseekMessage(systemPrompt, messages, modelId, model)
return
}
let budget_tokens = this.options.thinkingBudgetTokens || 0
const reasoningOn = modelId.includes("3-7") && budget_tokens !== 0 ? true : false
// Get model info and message indices for caching
const model = this.getModel()
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
// Create anthropic client, using sessions created or renewed after this handler's
// initialization, and allowing for session renewal if necessary as well
const client = await this.getClient()
const client = await this.getAnthropicClient()
const stream = await client.messages.create({
model: modelId,
@@ -99,6 +119,20 @@ export class AwsBedrockHandler implements ApiHandler {
break
case "content_block_start":
switch (chunk.content_block.type) {
case "thinking":
yield {
type: "reasoning",
reasoning: chunk.content_block.thinking || "",
}
break
case "redacted_thinking":
// Handle redacted thinking blocks - we still mark it as reasoning
// but note that the content is encrypted
yield {
type: "reasoning",
reasoning: "[Redacted thinking block]",
}
break
case "text":
if (chunk.index > 0) {
yield {
@@ -115,6 +149,12 @@ export class AwsBedrockHandler implements ApiHandler {
break
case "content_block_delta":
switch (chunk.delta.type) {
case "thinking_delta":
yield {
type: "reasoning",
reasoning: chunk.delta.thinking,
}
break
case "text_delta":
yield {
type: "text",
@@ -139,12 +179,21 @@ export class AwsBedrockHandler implements ApiHandler {
}
}
private async getClient(): Promise<AnthropicBedrock> {
// Create AWS credentials by executing a an AWS provider chain exactly as the
// Anthropic SDK does it, by wrapping the default chain into a temporary process
// environment.
// Default AWS region
private static readonly DEFAULT_REGION = "us-east-1"
/**
* Gets AWS credentials using the provider chain
* Centralizes credential retrieval logic for all AWS services
*/
private async getAwsCredentials(): Promise<{
accessKeyId: string
secretAccessKey: string
sessionToken?: string
}> {
// Create AWS credentials by executing an AWS provider chain
const providerChain = fromNodeProviderChain()
const credentials = await AwsBedrockHandler.withTempEnv(
return await AwsBedrockHandler.withTempEnv(
() => {
AwsBedrockHandler.setEnv("AWS_REGION", this.options.awsRegion)
AwsBedrockHandler.setEnv("AWS_ACCESS_KEY_ID", this.options.awsAccessKey)
@@ -154,23 +203,54 @@ export class AwsBedrockHandler implements ApiHandler {
},
() => providerChain(),
)
}
/**
* Gets the AWS region to use, with fallback to default
*/
private getRegion(): string {
return this.options.awsRegion || AwsBedrockHandler.DEFAULT_REGION
}
/**
* Creates a BedrockRuntimeClient with the appropriate credentials
*/
private async getBedrockClient(): Promise<BedrockRuntimeClient> {
const credentials = await this.getAwsCredentials()
return new BedrockRuntimeClient({
region: this.getRegion(),
credentials: {
accessKeyId: credentials.accessKeyId,
secretAccessKey: credentials.secretAccessKey,
sessionToken: credentials.sessionToken,
},
...(this.options.awsBedrockEndpoint && { endpoint: this.options.awsBedrockEndpoint }),
})
}
/**
* Creates an AnthropicBedrock client with the appropriate credentials
*/
private async getAnthropicClient(): Promise<AnthropicBedrock> {
const credentials = await this.getAwsCredentials()
// Return an AnthropicBedrock client with the resolved/assumed credentials.
//
// When AnthropicBedrock creates its AWS client, the chain will execute very
// fast as the access/secret keys will already be already provided, and have
// a higher precedence than the profiles.
return new AnthropicBedrock({
awsAccessKey: credentials.accessKeyId,
awsSecretKey: credentials.secretAccessKey,
awsSessionToken: credentials.sessionToken,
awsRegion: this.options.awsRegion || "us-east-1",
awsRegion: this.getRegion(),
...(this.options.awsBedrockEndpoint && { baseURL: this.options.awsBedrockEndpoint }),
})
}
/**
* Gets the appropriate model ID, accounting for cross-region inference if enabled
*/
async getModelId(): Promise<string> {
if (this.options.awsUseCrossRegionInference) {
let regionPrefix = (this.options.awsRegion || "").slice(0, 3)
let regionPrefix = this.getRegion().slice(0, 3)
switch (regionPrefix) {
case "us-":
return `us.${this.getModel().id}`
@@ -202,4 +282,383 @@ export class AwsBedrockHandler implements ApiHandler {
process.env[key] = value
}
}
/**
* Creates a message using the Deepseek R1 model through AWS Bedrock
*/
private async *createDeepseekMessage(
systemPrompt: string,
messages: Anthropic.Messages.MessageParam[],
modelId: string,
model: { id: BedrockModelId; info: ModelInfo },
): ApiStream {
// Get Bedrock client with proper credentials
const client = await this.getBedrockClient()
// Format prompt for DeepSeek R1 according to documentation
const formattedPrompt = this.formatDeepseekR1Prompt(systemPrompt, messages)
// Prepare the request based on DeepSeek R1's expected format
const command = new InvokeModelWithResponseStreamCommand({
modelId: modelId,
contentType: "application/json",
accept: "application/json",
body: JSON.stringify({
prompt: formattedPrompt,
max_tokens: model.info.maxTokens || 8000,
temperature: 0,
}),
})
// Track token usage
const inputTokenEstimate = this.estimateInputTokens(systemPrompt, messages)
let outputTokens = 0
let isFirstChunk = true
let accumulatedTokens = 0
const TOKEN_REPORT_THRESHOLD = 100 // Report usage after accumulating this many tokens
// Execute the streaming request
const response = await client.send(command)
if (response.body) {
for await (const chunk of response.body) {
if (chunk.chunk?.bytes) {
try {
// Parse the response chunk
const decodedChunk = new TextDecoder().decode(chunk.chunk.bytes)
const parsedChunk = JSON.parse(decodedChunk)
// Report usage on first chunk
if (isFirstChunk) {
isFirstChunk = false
const totalCost = calculateApiCostOpenAI(model.info, inputTokenEstimate, 0, 0, 0)
yield {
type: "usage",
inputTokens: inputTokenEstimate,
outputTokens: 0,
totalCost: totalCost,
}
}
// Handle DeepSeek R1 response format
if (parsedChunk.choices && parsedChunk.choices.length > 0) {
// For non-streaming response (full response)
const text = parsedChunk.choices[0].text
if (text) {
const chunkTokens = this.estimateTokenCount(text)
outputTokens += chunkTokens
accumulatedTokens += chunkTokens
yield {
type: "text",
text: text,
}
if (accumulatedTokens >= TOKEN_REPORT_THRESHOLD) {
const totalCost = calculateApiCostOpenAI(model.info, 0, accumulatedTokens, 0, 0)
yield {
type: "usage",
inputTokens: 0,
outputTokens: accumulatedTokens,
totalCost: totalCost,
}
accumulatedTokens = 0
}
}
} else if (parsedChunk.delta?.text) {
// For streaming response (delta updates)
const text = parsedChunk.delta.text
const chunkTokens = this.estimateTokenCount(text)
outputTokens += chunkTokens
accumulatedTokens += chunkTokens
yield {
type: "text",
text: text,
}
// Report aggregated token usage only when threshold is reached
if (accumulatedTokens >= TOKEN_REPORT_THRESHOLD) {
const totalCost = calculateApiCostOpenAI(model.info, 0, accumulatedTokens, 0, 0)
yield {
type: "usage",
inputTokens: 0,
outputTokens: accumulatedTokens,
totalCost: totalCost,
}
accumulatedTokens = 0
}
}
} catch (error) {
console.error("Error parsing Deepseek response chunk:", error)
// Propagate the error by yielding a text response with error information
yield {
type: "text",
text: `[ERROR] Failed to parse Deepseek response: ${error instanceof Error ? error.message : String(error)}`,
}
}
}
}
// Report any remaining accumulated tokens at the end of the stream
if (accumulatedTokens > 0) {
const totalCost = calculateApiCostOpenAI(model.info, 0, accumulatedTokens, 0, 0)
yield {
type: "usage",
inputTokens: 0,
outputTokens: accumulatedTokens,
totalCost: totalCost,
}
}
// Add final total cost calculation that includes both input and output tokens
const finalTotalCost = calculateApiCostOpenAI(model.info, inputTokenEstimate, outputTokens, 0, 0)
yield {
type: "usage",
inputTokens: inputTokenEstimate,
outputTokens: outputTokens,
totalCost: finalTotalCost,
}
}
}
/**
* Formats prompt for DeepSeek R1 model according to documentation
* First uses convertToR1Format to merge consecutive messages with the same role,
* then converts to the string format that DeepSeek R1 expects
*/
private formatDeepseekR1Prompt(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): string {
// First use convertToR1Format to merge consecutive messages with the same role
const r1Messages = convertToR1Format([{ role: "user", content: systemPrompt }, ...messages])
// Then convert to the special string format expected by DeepSeek R1
let combinedContent = ""
for (const message of r1Messages) {
let content = ""
if (message.content) {
if (typeof message.content === "string") {
content = message.content
} else {
// Extract text content from message parts
content = message.content
.filter((part) => part.type === "text")
.map((part) => part.text)
.join("\n")
}
}
combinedContent += message.role === "user" ? "User: " + content + "\n" : "Assistant: " + content + "\n"
}
// Format according to DeepSeek R1's expected prompt format
return `<begin▁of▁sentence><User>${combinedContent}<Assistant><think>\n`
}
/**
* Estimates token count based on text length (approximate)
* Note: This is a rough estimation, as the actual token count depends on the tokenizer
*/
private estimateInputTokens(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): number {
// For Deepseek R1, we estimate the token count of the formatted prompt
// The formatted prompt includes special tokens and consistent formatting
const formattedPrompt = this.formatDeepseekR1Prompt(systemPrompt, messages)
return Math.ceil(formattedPrompt.length / 4)
}
/**
* Estimates token count for a text string
*/
private estimateTokenCount(text: string): number {
// Approximate 4 characters per token
return Math.ceil(text.length / 4)
}
/**
* Creates a message using Amazon Nova models through AWS Bedrock
* Implements support for Nova Micro, Nova Lite, and Nova Pro models
*/
private async *createNovaMessage(
systemPrompt: string,
messages: Anthropic.Messages.MessageParam[],
modelId: string,
model: { id: BedrockModelId; info: ModelInfo },
): ApiStream {
// Get Bedrock client with proper credentials
const client = await this.getBedrockClient()
// Format messages for Nova model
const formattedMessages = this.formatNovaMessages(messages)
// Prepare request for Nova model
const command = new ConverseStreamCommand({
modelId: modelId,
messages: formattedMessages,
system: systemPrompt ? [{ text: systemPrompt }] : undefined,
inferenceConfig: {
maxTokens: model.info.maxTokens || 5000,
temperature: 0,
// topP: 0.9, // Alternative: use topP instead of temperature
},
})
// Execute the streaming request and handle response
try {
const response = await client.send(command)
if (response.stream) {
let hasReportedInputTokens = false
for await (const chunk of response.stream) {
// Handle metadata events with token usage information
if (chunk.metadata?.usage) {
// Report complete token usage from the model itself
const inputTokens = chunk.metadata.usage.inputTokens || 0
const outputTokens = chunk.metadata.usage.outputTokens || 0
yield {
type: "usage",
inputTokens,
outputTokens,
totalCost: calculateApiCostOpenAI(model.info, inputTokens, outputTokens, 0, 0),
}
hasReportedInputTokens = true
}
// Handle content delta (text generation)
if (chunk.contentBlockDelta?.delta?.text) {
yield {
type: "text",
text: chunk.contentBlockDelta.delta.text,
}
}
// Handle reasoning content if present
if (chunk.contentBlockDelta?.delta?.reasoningContent?.text) {
yield {
type: "reasoning",
reasoning: chunk.contentBlockDelta.delta.reasoningContent.text,
}
}
// Handle errors
if (chunk.internalServerException) {
yield {
type: "text",
text: `[ERROR] Internal server error: ${chunk.internalServerException.message}`,
}
} else if (chunk.modelStreamErrorException) {
yield {
type: "text",
text: `[ERROR] Model stream error: ${chunk.modelStreamErrorException.message}`,
}
} else if (chunk.validationException) {
yield {
type: "text",
text: `[ERROR] Validation error: ${chunk.validationException.message}`,
}
} else if (chunk.throttlingException) {
yield {
type: "text",
text: `[ERROR] Throttling error: ${chunk.throttlingException.message}`,
}
} else if (chunk.serviceUnavailableException) {
yield {
type: "text",
text: `[ERROR] Service unavailable: ${chunk.serviceUnavailableException.message}`,
}
}
}
}
} catch (error) {
console.error("Error processing Nova model response:", error)
yield {
type: "text",
text: `[ERROR] Failed to process Nova response: ${error instanceof Error ? error.message : String(error)}`,
}
}
}
/**
* Formats messages for Amazon Nova models according to the SDK specification
*/
private formatNovaMessages(messages: Anthropic.Messages.MessageParam[]): { role: ConversationRole; content: any[] }[] {
return messages.map((message) => {
// Determine role (user or assistant)
const role = message.role === "user" ? ConversationRole.USER : ConversationRole.ASSISTANT
// Process content based on type
let content: any[] = []
if (typeof message.content === "string") {
// Simple text content
content = [{ text: message.content }]
} else if (Array.isArray(message.content)) {
// Convert Anthropic content format to Nova content format
content = message.content
.map((item) => {
// Text content
if (item.type === "text") {
return { text: item.text }
}
// Image content
if (item.type === "image") {
// Handle different image source formats
let imageData: Uint8Array
let format = "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]) {
format = formatMatch[1]
// Ensure format is one of the allowed values
if (!["png", "jpeg", "gif", "webp"].includes(format)) {
format = "jpeg" // Default to jpeg if not supported
}
}
}
// Get image data
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 any))
} else {
console.error("Unsupported image data format")
return null // Skip this item if format is not supported
}
} catch (error) {
console.error("Could not convert image data to Uint8Array:", error)
return null // Skip this item if conversion fails
}
return {
image: {
format,
source: {
bytes: imageData,
},
},
}
}
// Return null for unsupported content types
return null
})
.filter(Boolean) // Remove any null items
}
// Return formatted message
return {
role,
content,
}
})
}
}
+83 -22
View File
@@ -2,13 +2,15 @@ import { Anthropic } from "@anthropic-ai/sdk"
import OpenAI from "openai"
import { ApiHandler } from "../"
import { ApiHandlerOptions, ModelInfo, openRouterDefaultModelId, openRouterDefaultModelInfo } from "../../shared/api"
import { streamOpenRouterFormatRequest } from "../transform/openrouter-stream"
import { ApiStream } from "../transform/stream"
import { createOpenRouterStream } from "../transform/openrouter-stream"
import { ApiStream, ApiStreamUsageChunk } from "../transform/stream"
import axios from "axios"
import { OpenRouterErrorResponse } from "./types"
export class ClineHandler implements ApiHandler {
private options: ApiHandlerOptions
private client: OpenAI
lastGenerationId?: string
constructor(options: ApiHandlerOptions) {
this.options = options
@@ -19,36 +21,95 @@ export class ClineHandler implements ApiHandler {
}
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
const model = this.getModel()
const genId = yield* streamOpenRouterFormatRequest(
this.lastGenerationId = undefined
const stream = await createOpenRouterStream(
this.client,
systemPrompt,
messages,
model,
this.getModel(),
this.options.o3MiniReasoningEffort,
this.options.thinkingBudgetTokens,
this.options.openRouterProviderSorting,
)
try {
const response = await axios.get(`https://api.cline.bot/v1/generation?id=${genId}`, {
headers: {
Authorization: `Bearer ${this.options.clineApiKey}`,
},
timeout: 5_000, // this request hangs sometimes
})
let didOutputUsage: boolean = false
const generation = response.data
console.log("cline generation details:", generation)
yield {
type: "usage",
inputTokens: generation?.native_tokens_prompt || 0,
outputTokens: generation?.native_tokens_completion || 0,
totalCost: generation?.total_cost || 0,
for await (const chunk of stream) {
// openrouter returns an error object instead of the openai sdk throwing an error
if ("error" in chunk) {
const error = chunk.error as OpenRouterErrorResponse["error"]
console.error(`Cline API Error: ${error?.code} - ${error?.message}`)
// Include metadata in the error message if available
const metadataStr = error.metadata ? `\nMetadata: ${JSON.stringify(error.metadata, null, 2)}` : ""
throw new Error(`Cline API Error ${error.code}: ${error.message}${metadataStr}`)
}
if (!this.lastGenerationId && chunk.id) {
this.lastGenerationId = chunk.id
}
const delta = chunk.choices[0]?.delta
if (delta?.content) {
yield {
type: "text",
text: delta.content,
}
}
// Reasoning tokens are returned separately from the content
if ("reasoning" in delta && delta.reasoning) {
yield {
type: "reasoning",
// @ts-ignore-next-line
reasoning: delta.reasoning,
}
}
if (!didOutputUsage && chunk.usage) {
yield {
type: "usage",
inputTokens: chunk.usage.prompt_tokens || 0,
outputTokens: chunk.usage.completion_tokens || 0,
// @ts-ignore-next-line
totalCost: chunk.usage.cost || 0,
}
didOutputUsage = true
}
} catch (error) {
// ignore if fails
console.error("Error fetching cline generation details:", error)
}
// Fallback to generation endpoint if usage chunk not returned
if (!didOutputUsage) {
const apiStreamUsage = await this.getApiStreamUsage()
if (apiStreamUsage) {
yield apiStreamUsage
}
}
}
async getApiStreamUsage(): Promise<ApiStreamUsageChunk | undefined> {
if (this.lastGenerationId) {
try {
const response = await axios.get(`https://api.cline.bot/v1/generation?id=${this.lastGenerationId}`, {
headers: {
Authorization: `Bearer ${this.options.clineApiKey}`,
},
timeout: 15_000, // this request hangs sometimes
})
const generation = response.data
return {
type: "usage",
inputTokens: generation?.native_tokens_prompt || 0,
outputTokens: generation?.native_tokens_completion || 0,
totalCost: generation?.total_cost || 0,
}
} catch (error) {
// ignore if fails
console.error("Error fetching cline generation details:", error)
}
}
return undefined
}
getModel(): { id: string; info: ModelInfo } {
+8 -1
View File
@@ -23,11 +23,18 @@ export class LiteLlmHandler implements ApiHandler {
role: "system",
content: systemPrompt,
}
const modelId = this.options.liteLlmModelId || liteLlmDefaultModelId
const isOminiModel = modelId.includes("o1-mini") || modelId.includes("o3-mini")
let temperature: number | undefined = 0
if (isOminiModel) {
temperature = undefined // does not support temperature
}
const stream = await this.client.chat.completions.create({
model: this.options.liteLlmModelId || liteLlmDefaultModelId,
messages: [systemMessage, ...formattedMessages],
temperature: 0,
temperature,
stream: true,
stream_options: { include_usage: true },
})
+2 -1
View File
@@ -15,7 +15,8 @@ export class OpenAiHandler implements ApiHandler {
constructor(options: ApiHandlerOptions) {
this.options = options
// Azure API shape slightly differs from the core API shape: https://github.com/openai/openai-node?tab=readme-ov-file#microsoft-azure-openai
if (this.options.openAiBaseUrl?.toLowerCase().includes("azure.com")) {
// Use azureApiVersion to determine if this is an Azure endpoint, since the URL may not always contain 'azure.com'
if (this.options.azureApiVersion || this.options.openAiBaseUrl?.toLowerCase().includes("azure.com")) {
this.client = new AzureOpenAI({
baseURL: this.options.openAiBaseUrl,
apiKey: this.options.openAiApiKey,
+71 -13
View File
@@ -1,18 +1,18 @@
import { Anthropic } from "@anthropic-ai/sdk"
import axios from "axios"
import delay from "delay"
import { setTimeout as setTimeoutPromise } from "node:timers/promises"
import OpenAI from "openai"
import { withRetry } from "../retry"
import { ApiHandler } from "../"
import { ApiHandlerOptions, ModelInfo, openRouterDefaultModelId, openRouterDefaultModelInfo } from "../../shared/api"
import { streamOpenRouterFormatRequest } from "../transform/openrouter-stream"
import { ApiStream } from "../transform/stream"
import { convertToR1Format } from "../transform/r1-format"
import { withRetry } from "../retry"
import { createOpenRouterStream } from "../transform/openrouter-stream"
import { ApiStream, ApiStreamUsageChunk } from "../transform/stream"
import { OpenRouterErrorResponse } from "./types"
export class OpenRouterHandler implements ApiHandler {
private options: ApiHandlerOptions
private client: OpenAI
lastGenerationId?: string
constructor(options: ApiHandlerOptions) {
this.options = options
@@ -28,23 +28,80 @@ export class OpenRouterHandler implements ApiHandler {
@withRetry()
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
const model = this.getModel()
const genId = yield* streamOpenRouterFormatRequest(
this.lastGenerationId = undefined
const stream = await createOpenRouterStream(
this.client,
systemPrompt,
messages,
model,
this.getModel(),
this.options.o3MiniReasoningEffort,
this.options.thinkingBudgetTokens,
this.options.openRouterProviderSorting,
)
if (genId) {
await delay(500) // FIXME: necessary delay to ensure generation endpoint is ready
let didOutputUsage: boolean = false
for await (const chunk of stream) {
// openrouter returns an error object instead of the openai sdk throwing an error
if ("error" in chunk) {
const error = chunk.error as OpenRouterErrorResponse["error"]
console.error(`OpenRouter API Error: ${error?.code} - ${error?.message}`)
// Include metadata in the error message if available
const metadataStr = error.metadata ? `\nMetadata: ${JSON.stringify(error.metadata, null, 2)}` : ""
throw new Error(`OpenRouter API Error ${error.code}: ${error.message}${metadataStr}`)
}
if (!this.lastGenerationId && chunk.id) {
this.lastGenerationId = chunk.id
}
const delta = chunk.choices[0]?.delta
if (delta?.content) {
yield {
type: "text",
text: delta.content,
}
}
// Reasoning tokens are returned separately from the content
if ("reasoning" in delta && delta.reasoning) {
yield {
type: "reasoning",
// @ts-ignore-next-line
reasoning: delta.reasoning,
}
}
if (!didOutputUsage && chunk.usage) {
yield {
type: "usage",
inputTokens: chunk.usage.prompt_tokens || 0,
outputTokens: chunk.usage.completion_tokens || 0,
// @ts-ignore-next-line
totalCost: chunk.usage.cost || 0,
}
didOutputUsage = true
}
}
// Fallback to generation endpoint if usage chunk not returned
if (!didOutputUsage) {
const apiStreamUsage = await this.getApiStreamUsage()
if (apiStreamUsage) {
yield apiStreamUsage
}
}
}
async getApiStreamUsage(): Promise<ApiStreamUsageChunk | undefined> {
if (this.lastGenerationId) {
await setTimeoutPromise(500) // FIXME: necessary delay to ensure generation endpoint is ready
try {
const generationIterator = this.fetchGenerationDetails(genId)
const generationIterator = this.fetchGenerationDetails(this.lastGenerationId)
const generation = (await generationIterator.next()).value
// console.log("OpenRouter generation details:", generation)
yield {
return {
type: "usage",
// cacheWriteTokens: 0,
// cacheReadTokens: 0,
@@ -58,6 +115,7 @@ export class OpenRouterHandler implements ApiHandler {
console.error("Error fetching OpenRouter generation details:", error)
}
}
return undefined
}
@withRetry({ maxRetries: 4, baseDelay: 250, maxDelay: 1000, retryAllErrors: true })
@@ -68,7 +126,7 @@ export class OpenRouterHandler implements ApiHandler {
headers: {
Authorization: `Bearer ${this.options.openRouterApiKey}`,
},
timeout: 5_000, // this request hangs sometimes
timeout: 15_000, // this request hangs sometimes
})
yield response.data?.data
} catch (error) {
+75
View File
@@ -0,0 +1,75 @@
import { Anthropic } from "@anthropic-ai/sdk"
import OpenAI from "openai"
import { withRetry } from "../retry"
import { ApiHandlerOptions, ModelInfo, SambanovaModelId, sambanovaDefaultModelId, sambanovaModels } from "../../shared/api"
import { ApiHandler } from "../index"
import { convertToOpenAiMessages } from "../transform/openai-format"
import { ApiStream } from "../transform/stream"
import { convertToR1Format } from "../transform/r1-format"
export class SambanovaHandler implements ApiHandler {
private options: ApiHandlerOptions
private client: OpenAI
constructor(options: ApiHandlerOptions) {
this.options = options
this.client = new OpenAI({
baseURL: "https://api.sambanova.ai/v1",
apiKey: this.options.sambanovaApiKey,
})
}
@withRetry()
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
const model = this.getModel()
let openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [
{ role: "system", content: systemPrompt },
...convertToOpenAiMessages(messages),
]
const modelId = model.id.toLowerCase()
if (modelId.includes("deepseek") || modelId.includes("qwen") || modelId.includes("qwq")) {
openAiMessages = convertToR1Format([{ role: "user", content: systemPrompt }, ...messages])
}
const stream = await this.client.chat.completions.create({
model: this.getModel().id,
messages: openAiMessages,
temperature: 0,
stream: true,
stream_options: { include_usage: true },
})
for await (const chunk of stream) {
const delta = chunk.choices[0]?.delta
if (delta?.content) {
yield {
type: "text",
text: delta.content,
}
}
if (chunk.usage) {
yield {
type: "usage",
inputTokens: chunk.usage.prompt_tokens || 0,
outputTokens: chunk.usage.completion_tokens || 0,
}
}
}
}
getModel(): { id: string; info: ModelInfo } {
const modelId = this.options.apiModelId
if (modelId && modelId in sambanovaModels) {
const id = modelId as SambanovaModelId
return { id, info: sambanovaModels[id] }
}
return {
id: sambanovaDefaultModelId,
info: sambanovaModels[sambanovaDefaultModelId],
}
}
}
+21
View File
@@ -162,6 +162,21 @@ export class VertexHandler implements ApiHandler {
break
case "content_block_start":
switch (chunk.content_block.type) {
case "thinking":
yield {
type: "reasoning",
reasoning: chunk.content_block.thinking || "",
}
break
case "redacted_thinking":
// Handle redacted thinking blocks - we still mark it as reasoning
// but note that the content is encrypted
yield {
type: "reasoning",
reasoning: "[Redacted thinking block]",
}
break
case "text":
if (chunk.index > 0) {
yield {
@@ -178,6 +193,12 @@ export class VertexHandler implements ApiHandler {
break
case "content_block_delta":
switch (chunk.delta.type) {
case "thinking_delta":
yield {
type: "reasoning",
reasoning: chunk.delta.thinking,
}
break
case "text_delta":
yield {
type: "text",
+6 -37
View File
@@ -6,14 +6,15 @@ import { Anthropic } from "@anthropic-ai/sdk"
import OpenAI from "openai"
import { OpenRouterErrorResponse } from "../providers/types"
export async function* streamOpenRouterFormatRequest(
export async function createOpenRouterStream(
client: OpenAI,
systemPrompt: string,
messages: Anthropic.Messages.MessageParam[],
model: { id: string; info: ModelInfo },
o3MiniReasoningEffort?: string,
thinkingBudgetTokens?: number,
): AsyncGenerator<ApiStreamChunk, string | undefined, unknown> {
openRouterProviderSorting?: string,
) {
// Convert Anthropic messages to OpenAI format
let openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [
{ role: "system", content: systemPrompt },
@@ -141,45 +142,13 @@ export async function* streamOpenRouterFormatRequest(
top_p: topP,
messages: openAiMessages,
stream: true,
stream_options: { include_usage: true },
transforms: shouldApplyMiddleOutTransform ? ["middle-out"] : undefined,
include_reasoning: true,
...(model.id === "openai/o3-mini" ? { reasoning_effort: o3MiniReasoningEffort || "medium" } : {}),
...(reasoning ? { reasoning } : {}),
...(openRouterProviderSorting ? { provider: { sort: openRouterProviderSorting } } : {}),
})
let genId: string | undefined
for await (const chunk of stream) {
// openrouter returns an error object instead of the openai sdk throwing an error
if ("error" in chunk) {
const error = chunk.error as OpenRouterErrorResponse["error"]
console.error(`OpenRouter API Error: ${error?.code} - ${error?.message}`)
// Include metadata in the error message if available
const metadataStr = error.metadata ? `\nMetadata: ${JSON.stringify(error.metadata, null, 2)}` : ""
throw new Error(`OpenRouter API Error ${error.code}: ${error.message}${metadataStr}`)
}
if (!genId && chunk.id) {
genId = chunk.id
}
const delta = chunk.choices[0]?.delta
if (delta?.content) {
yield {
type: "text",
text: delta.content,
}
}
// Reasoning tokens are returned separately from the content
if ("reasoning" in delta && delta.reasoning) {
yield {
type: "reasoning",
// @ts-ignore-next-line
reasoning: delta.reasoning,
}
}
}
return genId
return stream
}
+286 -88
View File
@@ -1,6 +1,6 @@
import { Anthropic } from "@anthropic-ai/sdk"
import cloneDeep from "clone-deep"
import delay from "delay"
import { setTimeout as setTimeoutPromise } from "node:timers/promises"
import fs from "fs/promises"
import getFolderSize from "get-folder-size"
import os from "os"
@@ -22,7 +22,7 @@ import { listFiles } from "../services/glob/list-files"
import { regexSearchFiles } from "../services/ripgrep"
import { parseSourceCodeForDefinitionsTopLevel } from "../services/tree-sitter"
import { ApiConfiguration } from "../shared/api"
import { findLast, findLastIndex } from "../shared/array"
import { findLast, findLastIndex, parsePartialArrayString } from "../shared/array"
import { AutoApprovalSettings } from "../shared/AutoApprovalSettings"
import { BrowserSettings } from "../shared/BrowserSettings"
import { ChatSettings } from "../shared/ChatSettings"
@@ -35,8 +35,10 @@ import {
ClineApiReqCancelReason,
ClineApiReqInfo,
ClineAsk,
ClineAskQuestion,
ClineAskUseMcpServer,
ClineMessage,
ClinePlanModeResponse,
ClineSay,
ClineSayBrowserAction,
ClineSayTool,
@@ -46,7 +48,7 @@ import { getApiMetrics } from "../shared/getApiMetrics"
import { HistoryItem } from "../shared/HistoryItem"
import { ClineAskResponse, ClineCheckpointRestore } from "../shared/WebviewMessage"
import { calculateApiCostAnthropic } from "../utils/cost"
import { fileExistsAtPath } from "../utils/fs"
import { fileExistsAtPath, isDirectory } from "../utils/fs"
import { arePathsEqual, getReadablePath } from "../utils/path"
import { fixModelHtmlEscaping, removeInvalidChars } from "../utils/string"
import { AssistantMessageContent, parseAssistantMessage, ToolParamName, ToolUseName } from "./assistant-message"
@@ -55,13 +57,21 @@ import { ClineIgnoreController, LOCK_TEXT_SYMBOL } from "./ignore/ClineIgnoreCon
import { parseMentions } from "./mentions"
import { formatResponse } from "./prompts/responses"
import { addUserInstructions, SYSTEM_PROMPT } from "./prompts/system"
import { getNextTruncationRange, getTruncatedMessages } from "./sliding-window"
import { ContextManager } from "./context-management/ContextManager"
import { OpenAiHandler } from "../api/providers/openai"
import { ApiStream } from "../api/transform/stream"
import { ClineHandler } from "../api/providers/cline"
import { ClineProvider, GlobalFileNames } from "./webview/ClineProvider"
import { ClineProvider } from "./webview/ClineProvider"
import { DEFAULT_LANGUAGE_SETTINGS, getLanguageKey, LanguageDisplay, LanguageKey } from "../shared/Languages"
import { telemetryService } from "../services/telemetry/TelemetryService"
import { ConversationTelemetryService, TelemetryChatMessage } from "../services/telemetry/ConversationTelemetryService"
import pTimeout from "p-timeout"
import { GlobalFileNames } from "../global-constants"
import {
checkIsAnthropicContextWindowError,
checkIsOpenRouterContextWindowError,
} from "./context-management/context-error-handling"
import { AnthropicHandler } from "../api/providers/anthropic"
const cwd = vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath).at(0) ?? path.join(os.homedir(), "Desktop") // may or may not exist but fs checking existence would immediately ask for permission which would be bad UX, need to come up with a better solution
@@ -75,6 +85,7 @@ export class Cline {
private terminalManager: TerminalManager
private urlContentFetcher: UrlContentFetcher
browserSession: BrowserSession
contextManager: ContextManager
private didEditFile: boolean = false
customInstructions?: string
autoApprovalSettings: AutoApprovalSettings
@@ -136,6 +147,7 @@ export class Cline {
this.terminalManager = new TerminalManager()
this.urlContentFetcher = new UrlContentFetcher(provider.context)
this.browserSession = new BrowserSession(provider.context, browserSettings)
this.contextManager = new ContextManager()
this.diffViewProvider = new DiffViewProvider(cwd)
this.customInstructions = customInstructions
this.autoApprovalSettings = autoApprovalSettings
@@ -294,13 +306,12 @@ export class Cline {
break
case "taskAndWorkspace":
case "workspace":
if (!this.checkpointTracker) {
if (!this.checkpointTracker && !this.checkpointTrackerErrorMessage) {
try {
this.checkpointTracker = await CheckpointTracker.create(
this.taskId,
this.providerRef.deref()?.context.globalStorageUri.fsPath,
)
this.checkpointTrackerErrorMessage = undefined
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "Unknown error"
console.error("Failed to initialize checkpoint tracker:", errorMessage)
@@ -410,13 +421,12 @@ export class Cline {
}
// TODO: handle if this is called from outside original workspace, in which case we need to show user error message we cant show diff outside of workspace?
if (!this.checkpointTracker) {
if (!this.checkpointTracker && !this.checkpointTrackerErrorMessage) {
try {
this.checkpointTracker = await CheckpointTracker.create(
this.taskId,
this.providerRef.deref()?.context.globalStorageUri.fsPath,
)
this.checkpointTrackerErrorMessage = undefined
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "Unknown error"
console.error("Failed to initialize checkpoint tracker:", errorMessage)
@@ -526,13 +536,12 @@ export class Cline {
return false
}
if (!this.checkpointTracker) {
if (!this.checkpointTracker && !this.checkpointTrackerErrorMessage) {
try {
this.checkpointTracker = await CheckpointTracker.create(
this.taskId,
this.providerRef.deref()?.context.globalStorageUri.fsPath,
)
this.checkpointTrackerErrorMessage = undefined
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "Unknown error"
console.error("Failed to initialize checkpoint tracker:", errorMessage)
@@ -562,9 +571,8 @@ export class Cline {
return false
}
// Get changed files between current state and commit
const changedFiles = await this.checkpointTracker?.getDiffSet(previousCheckpointHash, hash)
const changedFilesCount = changedFiles?.length || 0
// Get count of changed files between current state and commit
const changedFilesCount = (await this.checkpointTracker?.getDiffCount(previousCheckpointHash, hash)) || 0
if (changedFilesCount > 0) {
return true
}
@@ -1207,7 +1215,7 @@ export class Cline {
// for their associated messages to be sent to the webview, maintaining
// the correct order of messages (although the webview is smart about
// grouping command_output messages despite any gaps anyways)
await delay(50)
await setTimeoutPromise(50)
result = result.trim()
@@ -1296,13 +1304,33 @@ export class Cline {
const clineRulesFilePath = path.resolve(cwd, GlobalFileNames.clineRules)
let clineRulesFileInstructions: string | undefined
if (await fileExistsAtPath(clineRulesFilePath)) {
try {
const ruleFileContent = (await fs.readFile(clineRulesFilePath, "utf8")).trim()
if (ruleFileContent) {
clineRulesFileInstructions = `# .clinerules\n\nThe following is provided by a root-level .clinerules file where the user has specified instructions for this working directory (${cwd.toPosix()})\n\n${ruleFileContent}`
if (await isDirectory(clineRulesFilePath)) {
try {
// Read all files in the .clinerules/ directory.
const ruleFiles = await fs
.readdir(clineRulesFilePath, { withFileTypes: true, recursive: true })
.then((files) => files.filter((file) => file.isFile()))
.then((files) => files.map((file) => path.resolve(file.parentPath, file.name)))
const ruleFileContent = await Promise.all(
ruleFiles.map(async (file) => {
const ruleFilePath = path.resolve(clineRulesFilePath, file)
const ruleFilePathRelative = path.relative(cwd, ruleFilePath)
return `${ruleFilePathRelative}\n` + (await fs.readFile(ruleFilePath, "utf8")).trim()
}),
).then((contents) => contents.join("\n\n"))
clineRulesFileInstructions = `# .clinerules/\n\nThe following is provided by a root-level .clinerules/ directory where the user has specified instructions for this working directory (${cwd.toPosix()})\n\n${ruleFileContent}`
} catch {
console.error(`Failed to read .clinerules directory at ${clineRulesFilePath}`)
}
} else {
try {
const ruleFileContent = (await fs.readFile(clineRulesFilePath, "utf8")).trim()
if (ruleFileContent) {
clineRulesFileInstructions = `# .clinerules\n\nThe following is provided by a root-level .clinerules file where the user has specified instructions for this working directory (${cwd.toPosix()})\n\n${ruleFileContent}`
}
} catch {
console.error(`Failed to read .clinerules file at ${clineRulesFilePath}`)
}
} catch {
console.error(`Failed to read .clinerules file at ${clineRulesFilePath}`)
}
}
@@ -1327,58 +1355,38 @@ export class Cline {
)
}
// 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 = this.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)
let contextWindow = this.api.getModel().info.contextWindow || 128_000
// FIXME: hack to get anyone using openai compatible with deepseek to have the proper context window instead of the default 128k. We need a way for the user to specify the context window for models they input through openai compatible
if (this.api instanceof OpenAiHandler && this.api.getModel().id.toLowerCase().includes("deepseek")) {
contextWindow = 64_000
}
let maxAllowedSize: number
switch (contextWindow) {
case 64_000: // deepseek models
maxAllowedSize = contextWindow - 27_000
break
case 128_000: // most models
maxAllowedSize = contextWindow - 30_000
break
case 200_000: // claude models
maxAllowedSize = contextWindow - 40_000
break
default:
maxAllowedSize = Math.max(contextWindow - 40_000, contextWindow * 0.8) // for deepseek, 80% of 64k meant only ~10k buffer which was too small and resulted in users getting context window errors.
}
// 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
// FIXME: truncating the conversation in a way that is optimal for prompt caching AND takes into account multi-context window complexity is something we need to improve
const keep = totalTokens / 2 > maxAllowedSize ? "quarter" : "half"
// 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
this.conversationHistoryDeletedRange = getNextTruncationRange(
this.apiConversationHistory,
this.conversationHistoryDeletedRange,
keep,
)
await this.saveClineMessages() // saves task history item which we use to keep track of conversation history deleted range
// await this.overwriteApiConversationHistory(truncatedMessages)
}
// Capture system prompt for telemetry,
// ONLY if user is opted in, in advanced settings
if (this.providerRef.deref()?.conversationTelemetryService.isOptedInToConversationTelemetry()) {
const systemMessage: TelemetryChatMessage = {
role: "system",
content: systemPrompt,
ts: Date.now(), // we dont uniquely identify system messages, so we use the timestamp as the id
}
// no need for timeout here, as there's no timestamp to compare to
this.providerRef.deref()?.conversationTelemetryService.captureMessage(this.taskId, systemMessage, {
apiProvider: this.apiProvider,
model: this.api.getModel().id,
tokensIn: 0,
tokensOut: 0,
})
}
// conversationHistoryDeletedRange is updated only when we're close to hitting the context window, so we don't continuously break the prompt cache
const truncatedConversationHistory = getTruncatedMessages(
const contextManagementMetadata = this.contextManager.getNewContextMessagesAndMetadata(
this.apiConversationHistory,
this.clineMessages,
this.api,
this.conversationHistoryDeletedRange,
previousApiReqIndex,
)
let stream = this.api.createMessage(systemPrompt, truncatedConversationHistory)
if (contextManagementMetadata.updatedConversationHistoryDeletedRange) {
this.conversationHistoryDeletedRange = contextManagementMetadata.conversationHistoryDeletedRange
await this.saveClineMessages() // saves task history item which we use to keep track of conversation history deleted range
}
let stream = this.api.createMessage(systemPrompt, contextManagementMetadata.truncatedConversationHistory)
const iterator = stream[Symbol.asyncIterator]()
@@ -1390,20 +1398,59 @@ export class Cline {
this.isWaitingForFirstChunk = false
} catch (error) {
const isOpenRouter = this.api instanceof OpenRouterHandler || this.api instanceof ClineHandler
if (isOpenRouter && !this.didAutomaticallyRetryFailedApiRequest) {
const isAnthropic = this.api instanceof AnthropicHandler
const isOpenRouterContextWindowError = checkIsOpenRouterContextWindowError(error) && isOpenRouter
const isAnthropicContextWindowError = checkIsAnthropicContextWindowError(error) && isAnthropic
if (isAnthropic && isAnthropicContextWindowError && !this.didAutomaticallyRetryFailedApiRequest) {
this.conversationHistoryDeletedRange = this.contextManager.getNextTruncationRange(
this.apiConversationHistory,
this.conversationHistoryDeletedRange,
"quarter", // Force aggressive truncation
)
await this.saveClineMessages()
this.didAutomaticallyRetryFailedApiRequest = true
} else if (isOpenRouter && !this.didAutomaticallyRetryFailedApiRequest) {
if (isOpenRouterContextWindowError) {
this.conversationHistoryDeletedRange = this.contextManager.getNextTruncationRange(
this.apiConversationHistory,
this.conversationHistoryDeletedRange,
"quarter", // Force aggressive truncation
)
await this.saveClineMessages()
}
console.log("first chunk failed, waiting 1 second before retrying")
await delay(1000)
await setTimeoutPromise(1000)
this.didAutomaticallyRetryFailedApiRequest = true
} else {
// request failed after retrying automatically once, ask user if they want to retry again
// note that this api_req_failed ask is unique in that we only present this option if the api hasn't streamed any content yet (ie it fails on the first chunk due), as it would allow them to hit a retry button. However if the api failed mid-stream, it could be in any arbitrary state where some tools may have executed, so that error is handled differently and requires cancelling the task entirely.
if (isOpenRouterContextWindowError || isAnthropicContextWindowError) {
const truncatedConversationHistory = this.contextManager.getTruncatedMessages(
this.apiConversationHistory,
this.conversationHistoryDeletedRange,
)
// If the conversation has more than 3 messages, we can truncate again. If not, then the conversation is bricked.
// ToDo: Allow the user to change their input if this is the case.
if (truncatedConversationHistory.length > 3) {
error = new Error("Context window exceeded. Click retry to truncate the conversation and try again.")
this.didAutomaticallyRetryFailedApiRequest = false
}
}
const errorMessage = this.formatErrorWithStatusCode(error)
const { response } = await this.ask("api_req_failed", errorMessage)
if (response !== "yesButtonClicked") {
// this will never happen since if noButtonClicked, we will clear current task, aborting this instance
throw new Error("API request failed")
}
await this.say("api_req_retried")
}
// delegate generator output from the recursive call
@@ -1817,7 +1864,7 @@ export class Cline {
await this.diffViewProvider.open(relPath)
}
await this.diffViewProvider.update(newContent, true)
await delay(300) // wait for diff view to update
await setTimeoutPromise(300) // wait for diff view to update
this.diffViewProvider.scrollToFirstDiff()
// showOmissionWarning(this.diffViewProvider.originalContent || "", newContent)
@@ -1839,7 +1886,7 @@ export class Cline {
telemetryService.captureToolUsage(this.taskId, block.name, true, true)
// we need an artificial delay to let the diagnostics catch up to the changes
await delay(3_500)
await setTimeoutPromise(3_500)
} else {
// If auto-approval is enabled but this tool wasn't auto-approved, send notification
showNotificationForApprovalIfAutoApprovalEnabled(
@@ -2701,9 +2748,14 @@ export class Cline {
}
case "ask_followup_question": {
const question: string | undefined = block.params.question
const optionsRaw: string | undefined = block.params.options
const sharedMessage = {
question: removeClosingTag("question", question),
options: parsePartialArrayString(removeClosingTag("options", optionsRaw)),
} satisfies ClineAskQuestion
try {
if (block.partial) {
await this.ask("followup", removeClosingTag("question", question), block.partial).catch(() => {})
await this.ask("followup", JSON.stringify(sharedMessage), block.partial).catch(() => {})
break
} else {
if (!question) {
@@ -2721,8 +2773,25 @@ export class Cline {
})
}
const { text, images } = await this.ask("followup", question, false)
await this.say("user_feedback", text ?? "", images)
const { text, images } = await this.ask("followup", JSON.stringify(sharedMessage), false)
// Check if options contains the text response
if (optionsRaw && text && parsePartialArrayString(optionsRaw).includes(text)) {
// Valid option selected, don't show user message in UI
// Update last followup message with selected option
const lastFollowupMessage = findLast(this.clineMessages, (m) => m.ask === "followup")
if (lastFollowupMessage) {
lastFollowupMessage.text = JSON.stringify({
...sharedMessage,
selected: text,
} satisfies ClineAskQuestion)
await this.saveClineMessages()
}
} else {
// Option not selected, send user feedback
await this.say("user_feedback", text ?? "", images)
}
pushToolResult(formatResponse.toolResult(`<answer>\n${text}\n</answer>`, images))
break
@@ -2735,11 +2804,14 @@ export class Cline {
}
case "plan_mode_response": {
const response: string | undefined = block.params.response
const optionsRaw: string | undefined = block.params.options
const sharedMessage = {
response: removeClosingTag("response", response),
options: parsePartialArrayString(removeClosingTag("options", optionsRaw)),
} satisfies ClinePlanModeResponse
try {
if (block.partial) {
await this.ask("plan_mode_response", removeClosingTag("response", response), block.partial).catch(
() => {},
)
await this.ask("plan_mode_response", JSON.stringify(sharedMessage), block.partial).catch(() => {})
break
} else {
if (!response) {
@@ -2758,7 +2830,7 @@ export class Cline {
// }
this.isAwaitingPlanResponse = true
let { text, images } = await this.ask("plan_mode_response", response, false)
let { text, images } = await this.ask("plan_mode_response", JSON.stringify(sharedMessage), false)
this.isAwaitingPlanResponse = false
// webview invoke sendMessage will send this marker in order to put webview into the proper state (responding to an ask) and as a flag to extension that the user switched to ACT mode.
@@ -2766,6 +2838,25 @@ export class Cline {
text = ""
}
// Check if options contains the text response
if (optionsRaw && text && parsePartialArrayString(optionsRaw).includes(text)) {
// Valid option selected, don't show user message in UI
// Update last followup message with selected option
const lastPlanMessage = findLast(this.clineMessages, (m) => m.ask === "plan_mode_response")
if (lastPlanMessage) {
lastPlanMessage.text = JSON.stringify({
...sharedMessage,
selected: text,
} satisfies ClinePlanModeResponse)
await this.saveClineMessages()
}
} else {
// Option not selected, send user feedback
if (text || images?.length) {
await this.say("user_feedback", text ?? "", images)
}
}
if (this.didRespondToPlanAskBySwitchingMode) {
pushToolResult(
formatResponse.toolResult(
@@ -2781,10 +2872,6 @@ export class Cline {
pushToolResult(formatResponse.toolResult(`<user_message>\n${text}\n</user_message>`, images))
}
if (text || images?.length) {
await this.say("user_feedback", text ?? "", images)
}
//
break
}
@@ -3064,13 +3151,16 @@ export class Cline {
// use this opportunity to initialize the checkpoint tracker (can be expensive to initialize in the constructor)
// FIXME: right now we're letting users init checkpoints for old tasks, but this could be a problem if opening a task in the wrong workspace
// isNewTask &&
if (!this.checkpointTracker) {
if (!this.checkpointTracker && !this.checkpointTrackerErrorMessage) {
try {
this.checkpointTracker = await CheckpointTracker.create(
this.taskId,
this.providerRef.deref()?.context.globalStorageUri.fsPath,
this.checkpointTracker = await pTimeout(
CheckpointTracker.create(this.taskId, this.providerRef.deref()?.context.globalStorageUri.fsPath),
{
milliseconds: 15_000,
message:
"Checkpoints taking too long to initialize. Consider re-opening Cline in a project that uses git, or disabling checkpoints.",
},
)
this.checkpointTrackerErrorMessage = undefined
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "Unknown error"
console.error("Failed to initialize checkpoint tracker:", errorMessage)
@@ -3100,6 +3190,39 @@ export class Cline {
telemetryService.captureConversationTurnEvent(this.taskId, this.apiProvider, this.api.getModel().id, "user")
// Capture message data for telemetry,
// ONLY if user is opted in, in advanced settings
if (this.providerRef.deref()?.conversationTelemetryService.isOptedInToConversationTelemetry()) {
// Get the last message from apiConversationHistory
const lastMessage = this.apiConversationHistory[this.apiConversationHistory.length - 1]
// Get the corresponding timestamp from clineMessages
// The last message in clineMessages should be the one we just added
const lastClineMessage = this.clineMessages[this.clineMessages.length - 1]
const ts = lastClineMessage.ts
// Send individual message to telemetry
this.providerRef.deref()?.conversationTelemetryService.captureMessage(
this.taskId,
// Add the timestamp to the message object for telemetry
{
...lastMessage,
ts,
},
{
apiProvider: this.apiProvider,
model: this.api.getModel().id,
tokensIn: 0,
tokensOut: 0,
},
)
// Send entire conversation history to cleanup endpoint
// This ensures deleted messages are properly handled in telemetry
this.providerRef.deref()?.conversationTelemetryService.cleanupTask(this.taskId, this.clineMessages)
}
// since we sent off a placeholder api_req_started message to update the webview while waiting to actually start the API request (to load potential details for example), we need to update the text of that message
const lastApiReqIndex = findLastIndex(this.clineMessages, (m) => m.say === "api_req_started")
this.clineMessages[lastApiReqIndex].text = JSON.stringify({
@@ -3177,6 +3300,36 @@ export class Cline {
telemetryService.captureConversationTurnEvent(this.taskId, this.apiProvider, this.api.getModel().id, "assistant")
// Capture message data for telemetry after assistant response
// ONLY if user is opted in, in advanced settings
if (this.providerRef.deref()?.conversationTelemetryService.isOptedInToConversationTelemetry()) {
// Get the last message from apiConversationHistory
const lastMessage = this.apiConversationHistory[this.apiConversationHistory.length - 1]
// Find the corresponding timestamp from clineMessages
// For assistant messages, we need to find the most recent "text" message
const lastTextMessage = findLast(this.clineMessages, (m) => m.say === "text")
// Add the timestamp to the message object for telemetry
if (!lastTextMessage) {
console.error("No text message found in clineMessages")
} else {
this.providerRef.deref()?.conversationTelemetryService.captureMessage(
this.taskId,
{
...lastMessage,
ts: lastTextMessage.ts,
},
{
apiProvider: this.apiProvider,
model: this.api.getModel().id,
tokensIn: inputTokens,
tokensOut: outputTokens,
},
)
}
}
// signals to provider that it can retrieve the saved messages from disk, as abortTask can not be awaited on in nature
this.didFinishAbortingStream = true
}
@@ -3198,6 +3351,7 @@ export class Cline {
let assistantMessage = ""
let reasoningMessage = ""
this.isStreaming = true
let didReceiveUsageChunk = false
try {
for await (const chunk of stream) {
if (!chunk) {
@@ -3205,6 +3359,7 @@ export class Cline {
}
switch (chunk.type) {
case "usage":
didReceiveUsageChunk = true
inputTokens += chunk.inputTokens
outputTokens += chunk.outputTokens
cacheWriteTokens += chunk.cacheWriteTokens ?? 0
@@ -3274,6 +3429,23 @@ export class Cline {
this.isStreaming = false
}
// OpenRouter/Cline may not return token usage as part of the stream (since it may abort early), so we fetch after the stream is finished
// (updateApiReq below will update the api_req_started message with the usage details. we do this async so it updates the api_req_started message in the background)
if (!didReceiveUsageChunk) {
this.api.getApiStreamUsage?.().then(async (apiStreamUsage) => {
if (apiStreamUsage) {
inputTokens += apiStreamUsage.inputTokens
outputTokens += apiStreamUsage.outputTokens
cacheWriteTokens += apiStreamUsage.cacheWriteTokens ?? 0
cacheReadTokens += apiStreamUsage.cacheReadTokens ?? 0
totalCost = apiStreamUsage.totalCost
}
updateApiReqMsg()
await this.saveClineMessages()
await this.providerRef.deref()?.postStateToWebview()
})
}
// need to call here in case the stream was aborted
if (this.abort) {
throw new Error("Cline instance aborted")
@@ -3307,6 +3479,32 @@ export class Cline {
content: [{ type: "text", text: assistantMessage }],
})
// Capture message data for telemetry after assistant response,
// ONLY if user is opted in, in advanced settings
if (this.providerRef.deref()?.conversationTelemetryService.isOptedInToConversationTelemetry()) {
// Get the last message from apiConversationHistory
const lastMessage = this.apiConversationHistory[this.apiConversationHistory.length - 1]
// Find the corresponding timestamp from clineMessages
const lastClineMessage = this.clineMessages[this.clineMessages.length - 1]
if (lastClineMessage) {
this.providerRef.deref()?.conversationTelemetryService.captureMessage(
this.taskId,
{
...lastMessage,
ts: lastClineMessage.ts,
},
{
apiProvider: this.apiProvider,
model: this.api.getModel().id,
tokensIn: inputTokens,
tokensOut: outputTokens,
},
)
}
}
// NOTE: this comment is here for future reference - this was a workaround for userMessageContent not getting set to true. It was due to it not recursively calling for partial blocks when didRejectTool, so it would get stuck waiting for a partial block to complete before it could continue.
// in case the content blocks finished
// it may be the api stream finished after the last parsed content block was executed, so we are able to detect out of bounds and set userMessageContentReady to true (note you should not call presentAssistantMessage since if the last block is completed it will be presented again)
@@ -3430,7 +3628,7 @@ export class Cline {
if (busyTerminals.length > 0 && this.didEditFile) {
// || this.didEditFile
await delay(300) // delay after saving file to let terminals catch up
await setTimeoutPromise(300) // delay after saving file to let terminals catch up
}
// let terminalWasBusy = false
@@ -3544,7 +3742,7 @@ export class Cline {
details +=
"\nIn this mode you should focus on information gathering, asking questions, and architecting a solution. Once you have a plan, use the plan_mode_response tool to engage in a conversational back and forth with the user. Do not use the plan_mode_response tool until you've gathered all the information you need e.g. with read_file or ask_followup_question."
details +=
'\n(Remember: If it seems the user wants you to use tools only available in Act Mode, you should ask the user to "toggle to Act mode" (use those words) - they will have to manually do this themselves with the Plan/Act toggle button below. You do not have the ability to switch to Act Mode yourself, and must wait for the user to do it themselves once they are satisfied with the plan.)'
'\n(Remember: If it seems the user wants you to use tools only available in Act Mode, you should ask the user to "toggle to Act mode" (use those words) - they will have to manually do this themselves with the Plan/Act toggle button below. You do not have the ability to switch to Act Mode yourself, and must wait for the user to do it themselves once they are satisfied with the plan. You also cannot present an option to toggle to Act mode, as this will be something you need to direct the user to do manually themselves.)'
} else {
details += "\nACT MODE"
}
+1 -61
View File
@@ -45,6 +45,7 @@ export const toolParamNames = [
"arguments",
"uri",
"question",
"options",
"response",
"result",
] as const
@@ -58,64 +59,3 @@ export interface ToolUse {
params: Partial<Record<ToolParamName, string>>
partial: boolean
}
export interface ExecuteCommandToolUse extends ToolUse {
name: "execute_command"
// Pick<Record<ToolParamName, string>, "command"> makes "command" required, but Partial<> makes it optional
params: Partial<Pick<Record<ToolParamName, string>, "command" | "requires_approval">>
}
export interface ReadFileToolUse extends ToolUse {
name: "read_file"
params: Partial<Pick<Record<ToolParamName, string>, "path">>
}
export interface WriteToFileToolUse extends ToolUse {
name: "write_to_file"
params: Partial<Pick<Record<ToolParamName, string>, "path" | "content">>
}
export interface ReplaceInFileToolUse extends ToolUse {
name: "replace_in_file"
params: Partial<Pick<Record<ToolParamName, string>, "path" | "diff">>
}
export interface SearchFilesToolUse extends ToolUse {
name: "search_files"
params: Partial<Pick<Record<ToolParamName, string>, "path" | "regex" | "file_pattern">>
}
export interface ListFilesToolUse extends ToolUse {
name: "list_files"
params: Partial<Pick<Record<ToolParamName, string>, "path" | "recursive">>
}
export interface ListCodeDefinitionNamesToolUse extends ToolUse {
name: "list_code_definition_names"
params: Partial<Pick<Record<ToolParamName, string>, "path">>
}
export interface BrowserActionToolUse extends ToolUse {
name: "browser_action"
params: Partial<Pick<Record<ToolParamName, string>, "action" | "url" | "coordinate" | "text">>
}
export interface UseMcpToolToolUse extends ToolUse {
name: "use_mcp_tool"
params: Partial<Pick<Record<ToolParamName, string>, "server_name" | "tool_name" | "arguments">>
}
export interface AccessMcpResourceToolUse extends ToolUse {
name: "access_mcp_resource"
params: Partial<Pick<Record<ToolParamName, string>, "server_name" | "uri">>
}
export interface AskFollowupQuestionToolUse extends ToolUse {
name: "ask_followup_question"
params: Partial<Pick<Record<ToolParamName, string>, "question">>
}
export interface AttemptCompletionToolUse extends ToolUse {
name: "attempt_completion"
params: Partial<Pick<Record<ToolParamName, string>, "result" | "command">>
}
@@ -0,0 +1,155 @@
import { ContextManager } from "./ContextManager"
import { Anthropic } from "@anthropic-ai/sdk"
import { expect } from "chai"
describe("ContextManager", () => {
function createMessages(count: number): Anthropic.Messages.MessageParam[] {
const messages: Anthropic.Messages.MessageParam[] = []
messages.push({
role: "user",
content: "Initial task message",
})
let role: "user" | "assistant" = "assistant"
for (let i = 1; i < count; i++) {
messages.push({
role,
content: `Message ${i}`,
})
role = role === "user" ? "assistant" : "user"
}
return messages
}
describe("getNextTruncationRange", () => {
let contextManager: ContextManager
beforeEach(() => {
contextManager = new ContextManager()
})
it("first truncation with half keep", () => {
const messages = createMessages(11)
const result = contextManager.getNextTruncationRange(messages, undefined, "half")
expect(result).to.deep.equal([1, 4])
})
it("first truncation with quarter keep", () => {
const messages = createMessages(11)
const result = contextManager.getNextTruncationRange(messages, undefined, "quarter")
expect(result).to.deep.equal([1, 6])
})
it("sequential truncation with half keep", () => {
const messages = createMessages(21)
const firstRange = contextManager.getNextTruncationRange(messages, undefined, "half")
expect(firstRange).to.deep.equal([1, 10])
// Pass the previous range for sequential truncation
const secondRange = contextManager.getNextTruncationRange(messages, firstRange, "half")
expect(secondRange).to.deep.equal([1, 14])
})
it("sequential truncation with quarter keep", () => {
const messages = createMessages(41)
const firstRange = contextManager.getNextTruncationRange(messages, undefined, "quarter")
const secondRange = contextManager.getNextTruncationRange(messages, firstRange, "quarter")
expect(secondRange[0]).to.equal(1)
expect(secondRange[1]).to.be.greaterThan(firstRange[1])
})
it("ensures the last message in range is a user message", () => {
const messages = createMessages(14)
const result = contextManager.getNextTruncationRange(messages, undefined, "half")
// Check if the message at the end of range is a user message
const lastRemovedMessage = messages[result[1]]
expect(lastRemovedMessage.role).to.equal("user")
// Check if the next message after the range is an assistant message
const nextMessage = messages[result[1] + 1]
expect(nextMessage.role).to.equal("assistant")
})
it("handles small message arrays", () => {
const messages = createMessages(3)
const result = contextManager.getNextTruncationRange(messages, undefined, "half")
expect(result).to.deep.equal([1, 0])
})
it("preserves the message structure when truncating", () => {
const messages = createMessages(20)
const result = contextManager.getNextTruncationRange(messages, undefined, "half")
// Get messages after removing the range
const effectiveMessages = [...messages.slice(0, result[0]), ...messages.slice(result[1] + 1)]
// Check first message and alternating pattern
expect(effectiveMessages[0].role).to.equal("user")
for (let i = 1; i < effectiveMessages.length; i++) {
const expectedRole = i % 2 === 1 ? "assistant" : "user"
expect(effectiveMessages[i].role).to.equal(expectedRole)
}
})
})
describe("getTruncatedMessages", () => {
let contextManager: ContextManager
beforeEach(() => {
contextManager = new ContextManager()
})
it("returns original messages when no range is provided", () => {
const messages = createMessages(3)
const result = contextManager.getTruncatedMessages(messages, undefined)
expect(result).to.deep.equal(messages)
})
it("correctly removes messages in the specified range", () => {
const messages = createMessages(5)
const range: [number, number] = [1, 3]
const result = contextManager.getTruncatedMessages(messages, range)
expect(result).to.have.lengthOf(2)
expect(result[0]).to.deep.equal(messages[0])
expect(result[1]).to.deep.equal(messages[4])
})
it("works with a range that starts at the first message after task", () => {
const messages = createMessages(4)
const range: [number, number] = [1, 2]
const result = contextManager.getTruncatedMessages(messages, range)
expect(result).to.have.lengthOf(2)
expect(result[0]).to.deep.equal(messages[0])
expect(result[1]).to.deep.equal(messages[3])
})
it("correctly handles removing a range while preserving alternation pattern", () => {
const messages = createMessages(5)
const range: [number, number] = [1, 2]
const result = contextManager.getTruncatedMessages(messages, range)
expect(result).to.have.lengthOf(3)
expect(result[0]).to.deep.equal(messages[0])
expect(result[1]).to.deep.equal(messages[3])
expect(result[2]).to.deep.equal(messages[4])
expect(result[0].role).to.equal("user")
expect(result[1].role).to.equal("assistant")
expect(result[2].role).to.equal("user")
})
})
})
@@ -0,0 +1,120 @@
import { Anthropic } from "@anthropic-ai/sdk"
import { ClineApiReqInfo, ClineMessage } from "../../shared/ExtensionMessage"
import { ApiHandler } from "../../api"
import { OpenAiHandler } from "../../api/providers/openai"
export class ContextManager {
getNewContextMessagesAndMetadata(
apiConversationHistory: Anthropic.Messages.MessageParam[],
clineMessages: ClineMessage[],
api: ApiHandler,
conversationHistoryDeletedRange: [number, number] | undefined,
previousApiReqIndex: number,
) {
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 { tokensIn, tokensOut, cacheWrites, cacheReads }: ClineApiReqInfo = JSON.parse(previousRequest.text)
const totalTokens = (tokensIn || 0) + (tokensOut || 0) + (cacheWrites || 0) + (cacheReads || 0)
let contextWindow = api.getModel().info.contextWindow || 128_000
// FIXME: hack to get anyone using openai compatible with deepseek to have the proper context window instead of the default 128k. We need a way for the user to specify the context window for models they input through openai compatible
if (api instanceof OpenAiHandler && api.getModel().id.toLowerCase().includes("deepseek")) {
contextWindow = 64_000
}
let maxAllowedSize: number
switch (contextWindow) {
case 64_000: // deepseek models
maxAllowedSize = contextWindow - 27_000
break
case 128_000: // most models
maxAllowedSize = contextWindow - 30_000
break
case 200_000: // claude models
maxAllowedSize = contextWindow - 40_000
break
default:
maxAllowedSize = Math.max(contextWindow - 40_000, contextWindow * 0.8) // for deepseek, 80% of 64k meant only ~10k buffer which was too small and resulted in users getting context window errors.
}
// 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
// FIXME: truncating the conversation in a way that is optimal for prompt caching AND takes into account multi-context window complexity is something we need to improve
const keep = totalTokens / 2 > maxAllowedSize ? "quarter" : "half"
// 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
}
}
}
// conversationHistoryDeletedRange is updated only when we're close to hitting the context window, so we don't continuously break the prompt cache
const truncatedConversationHistory = this.getTruncatedMessages(apiConversationHistory, conversationHistoryDeletedRange)
return {
conversationHistoryDeletedRange: conversationHistoryDeletedRange,
updatedConversationHistoryDeletedRange: updatedConversationHistoryDeletedRange,
truncatedConversationHistory: truncatedConversationHistory,
}
}
public getNextTruncationRange(
apiMessages: Anthropic.Messages.MessageParam[],
currentDeletedRange: [number, number] | undefined,
keep: "half" | "quarter",
): [number, number] {
// Since we always keep the first message, currentDeletedRange[0] will always be 1 (for now until we have a smarter truncation algorithm)
const rangeStartIndex = 1
const startOfRest = currentDeletedRange ? currentDeletedRange[1] + 1 : 1
let messagesToRemove: number
if (keep === "half") {
// Remove half of remaining user-assistant pairs
// We first calculate half of the messages then divide by 2 to get the number of pairs.
// After flooring, we multiply by 2 to get the number of messages.
// Note that this will also always be an even number.
messagesToRemove = Math.floor((apiMessages.length - startOfRest) / 4) * 2 // Keep even number
} else {
// Remove 3/4 of remaining user-assistant pairs
// We calculate 3/4ths of the messages then divide by 2 to get the number of pairs.
// After flooring, we multiply by 2 to get the number of messages.
// Note that this will also always be an even number.
messagesToRemove = Math.floor(((apiMessages.length - startOfRest) * 3) / 4 / 2) * 2
}
let rangeEndIndex = startOfRest + messagesToRemove - 1
// Make sure the last message being removed is a user message, so that the next message after the initial task message is an assistant message. This preservers the user-assistant-user-assistant structure.
// NOTE: anthropic format messages are always user-assistant-user-assistant, while openai format messages can have multiple user messages in a row (we use anthropic format throughout cline)
if (apiMessages[rangeEndIndex].role !== "user") {
rangeEndIndex -= 1
}
// this is an inclusive range that will be removed from the conversation history
return [rangeStartIndex, rangeEndIndex]
}
public getTruncatedMessages(
messages: Anthropic.Messages.MessageParam[],
deletedRange: [number, number] | undefined,
): Anthropic.Messages.MessageParam[] {
if (!deletedRange) {
return messages
}
const [start, end] = deletedRange
// the range is inclusive - both start and end indices and everything in between will be removed from the final result.
// NOTE: if you try to console log these, don't forget that logging a reference to an array may not provide the same result as logging a slice() snapshot of that array at that exact moment. The following DOES in fact include the latest assistant message.
return [...messages.slice(0, start), ...messages.slice(end + 1)]
}
}
@@ -0,0 +1,10 @@
export function checkIsOpenRouterContextWindowError(error: any): boolean {
return error.code === 400 && error.message?.includes("context length")
}
export function checkIsAnthropicContextWindowError(response: any): boolean {
return (
response?.error?.error?.type === "invalid_request_error" &&
response?.error?.error?.message?.includes("prompt is too long")
)
}
+4 -1
View File
@@ -72,7 +72,10 @@ export async function parseMentions(text: string, cwd: string, urlContentFetcher
}
}
for (const mention of mentions) {
// Filter out duplicate mentions while preserving order
const uniqueMentions = Array.from(new Set(mentions))
for (const mention of uniqueMentions) {
if (mention.startsWith("http")) {
let result: string
if (launchBrowserError) {
+11 -3
View File
@@ -216,9 +216,13 @@ Usage:
Description: Ask the user a question to gather additional information needed to complete the task. This tool should be used when you encounter ambiguities, need clarification, or require more details to proceed effectively. It allows for interactive problem-solving by enabling direct communication with the user. Use this tool judiciously to maintain a balance between gathering necessary information and avoiding excessive back-and-forth.
Parameters:
- question: (required) The question to ask the user. This should be a clear, specific question that addresses the information you need.
- options: (optional) An array of 2-5 options for the user to choose from. Each option should be a string describing a possible answer. You may not always need to provide options, but it may be helpful in many cases where it can save the user from having to type out a response manually.
Usage:
<ask_followup_question>
<question>Your question here</question>
<options>
Array of options here (optional), e.g. ["Option 1", "Option 2", "Option 3"]
</options>
</ask_followup_question>
## attempt_completion
@@ -239,9 +243,13 @@ Your final result description here
Description: Respond to the user's inquiry in an effort to plan a solution to the user's task. This tool should be used when you need to provide a response to a question or statement from the user about how you plan to accomplish the task. This tool is only available in PLAN MODE. The environment_details will specify the current mode, if it is not PLAN MODE then you should not use this tool. Depending on the user's message, you may ask questions to get clarification about the user's request, architect a solution to the task, and to brainstorm ideas with the user. For example, if the user's task is to create a website, you may start by asking some clarifying questions, then present a detailed plan for how you will accomplish the task given the context, and perhaps engage in a back and forth to finalize the details before the user switches you to ACT MODE to implement the solution.
Parameters:
- response: (required) The response to provide to the user. Do not try to use tools in this parameter, this is simply a chat response.
- options: (optional) An array of 2-5 options for the user to choose from. Each option should be a string describing a possible choice or path forward in the planning process. This can help guide the discussion and make it easier for the user to provide input on key decisions. You may not always need to provide options, but it may be helpful in many cases where it can save the user from having to type out a response manually. Do NOT present an option to toggle to Act mode, as this will be something you need to direct the user to do manually themselves.
Usage:
<plan_mode_response>
<response>Your response here</response>
<options>
Array of options here (optional), e.g. ["Option 1", "Option 2", "Option 3"]
</options>
</plan_mode_response>
# Tool Use Examples
@@ -813,7 +821,7 @@ You have access to two tools for working with files: **write_to_file** and **rep
## Important Considerations
- Using write_to_file requires providing the files complete final content.
- Using write_to_file requires providing the file's complete final content.
- If you only need to make small changes to an existing file, consider using replace_in_file instead to avoid unnecessarily rewriting the entire file.
- While write_to_file should not be your default choice, don't hesitate to use it when the situation truly calls for it.
@@ -826,12 +834,12 @@ You have access to two tools for working with files: **write_to_file** and **rep
## When to Use
- Small, localized changes like updating a few lines, function implementations, changing variable names, modifying a section of text, etc.
- Targeted improvements where only specific portions of the files content needs to be altered.
- Targeted improvements where only specific portions of the file's content needs to be altered.
- Especially useful for long files where much of the file will remain unchanged.
## Advantages
- More efficient for minor edits, since you dont need to supply the entire file content.
- More efficient for minor edits, since you don't need to supply the entire file content.
- Reduces the chance of errors that can occur when overwriting large files.
# Choosing the Appropriate Tool
-97
View File
@@ -1,97 +0,0 @@
import { Anthropic } from "@anthropic-ai/sdk"
/*
We can't implement a dynamically updating sliding window as it would break prompt cache
every time. To maintain the benefits of caching, we need to keep conversation history
static. This operation should be performed as infrequently as possible. If a user reaches
a 200k context, we can assume that the first half is likely irrelevant to their current task.
Therefore, this function should only be called when absolutely necessary to fit within
context limits, not as a continuous process.
*/
// export function truncateHalfConversation(
// messages: Anthropic.Messages.MessageParam[],
// ): Anthropic.Messages.MessageParam[] {
// // API expects messages to be in user-assistant order, and tool use messages must be followed by tool results. We need to maintain this structure while truncating.
// // Always keep the first Task message (this includes the project's file structure in environment_details)
// const truncatedMessages = [messages[0]]
// // Remove half of user-assistant pairs
// const messagesToRemove = Math.floor(messages.length / 4) * 2 // has to be even number
// const remainingMessages = messages.slice(messagesToRemove + 1) // has to start with assistant message since tool result cannot follow assistant message with no tool use
// truncatedMessages.push(...remainingMessages)
// return truncatedMessages
// }
/*
getNextTruncationRange: Calculates the next range of messages to be "deleted"
- Takes the full messages array and optional current deleted range
- Always preserves the first message (task message)
- Removes 1/2 of remaining messages (rounded down to even number) after current deleted range
- Returns [startIndex, endIndex] representing inclusive range to delete
getTruncatedMessages: Constructs the truncated array using the deleted range
- Takes full messages array and optional deleted range
- Returns new array with messages in deleted range removed
- Preserves order and structure of remaining messages
The range is represented as [startIndex, endIndex] where both indices are inclusive
The functions maintain the original array integrity while allowing progressive truncation
through the deletedRange parameter
Usage example:
const messages = [user1, assistant1, user2, assistant2, user3, assistant3];
let deletedRange = getNextTruncationRange(messages); // [1,2] (assistant1,user2)
let truncated = getTruncatedMessages(messages, deletedRange);
// [user1, assistant2, user3, assistant3]
deletedRange = getNextTruncationRange(messages, deletedRange); // [2,3] (assistant2,user3)
truncated = getTruncatedMessages(messages, deletedRange);
// [user1, assistant3]
*/
export function getNextTruncationRange(
messages: Anthropic.Messages.MessageParam[],
currentDeletedRange: [number, number] | undefined = undefined,
keep: "half" | "quarter" = "half",
): [number, number] {
// Since we always keep the first message, currentDeletedRange[0] will always be 1 (for now until we have a smarter truncation algorithm)
const rangeStartIndex = 1
const startOfRest = currentDeletedRange ? currentDeletedRange[1] + 1 : 1
let messagesToRemove: number
if (keep === "half") {
// Remove half of user-assistant pairs
messagesToRemove = Math.floor((messages.length - startOfRest) / 4) * 2 // Keep even number
} else {
// Remove 3/4 of user-assistant pairs
messagesToRemove = Math.floor((messages.length - startOfRest) / 8) * 3 * 2
}
let rangeEndIndex = startOfRest + messagesToRemove - 1
// Make sure the last message being removed is a user message, so that the next message after the initial task message is an assistant message. This preservers the user-assistant-user-assistant structure.
// NOTE: anthropic format messages are always user-assistant-user-assistant, while openai format messages can have multiple user messages in a row (we use anthropic format throughout cline)
if (messages[rangeEndIndex].role !== "user") {
rangeEndIndex -= 1
}
// this is an inclusive range that will be removed from the conversation history
return [rangeStartIndex, rangeEndIndex]
}
export function getTruncatedMessages(
messages: Anthropic.Messages.MessageParam[],
deletedRange: [number, number] | undefined,
): Anthropic.Messages.MessageParam[] {
if (!deletedRange) {
return messages
}
const [start, end] = deletedRange
// the range is inclusive - both start and end indices and everything in between will be removed from the final result.
// NOTE: if you try to console log these, don't forget that logging a reference to an array may not provide the same result as logging a slice() snapshot of that array at that exact moment. The following DOES in fact include the latest assistant message.
return [...messages.slice(0, start), ...messages.slice(end + 1)]
}
+226 -48
View File
@@ -14,6 +14,7 @@ import { fetchOpenGraphData, isImageUrl } from "../../integrations/misc/link-pre
import { selectImages } from "../../integrations/misc/process-images"
import { getTheme } from "../../integrations/theme/getTheme"
import WorkspaceTracker from "../../integrations/workspace/WorkspaceTracker"
import { ClineAccountService } from "../../services/account/ClineAccountService"
import { McpHub } from "../../services/mcp/McpHub"
import { UserInfo } from "../../shared/UserInfo"
import { ApiConfiguration, ApiProvider, ModelInfo } from "../../shared/api"
@@ -36,6 +37,10 @@ import { telemetryService } from "../../services/telemetry/TelemetryService"
import { TelemetrySetting } from "../../shared/TelemetrySetting"
import { cleanupLegacyCheckpoints } from "../../integrations/checkpoints/CheckpointMigration"
import CheckpointTracker from "../../integrations/checkpoints/CheckpointTracker"
import { getTotalTasksSize } from "../../utils/storage"
import { ConversationTelemetryService } from "../../services/telemetry/ConversationTelemetryService"
import { GlobalFileNames } from "../../global-constants"
import { setTimeout as setTimeoutPromise } from "node:timers/promises"
/*
https://github.com/microsoft/vscode-webview-ui-toolkit-samples/blob/main/default/weather-webview/src/providers/WeatherViewProvider.ts
@@ -62,12 +67,14 @@ type SecretKey =
| "authNonce"
| "asksageApiKey"
| "xaiApiKey"
| "sambanovaApiKey"
type GlobalStateKey =
| "apiProvider"
| "apiModelId"
| "awsRegion"
| "awsUseCrossRegionInference"
| "awsBedrockUsePromptCache"
| "awsBedrockEndpoint"
| "awsProfile"
| "awsUseProfile"
| "vertexProjectId"
@@ -87,6 +94,7 @@ type GlobalStateKey =
| "azureApiVersion"
| "openRouterModelId"
| "openRouterModelInfo"
| "openRouterProviderSorting"
| "autoApprovalSettings"
| "browserSettings"
| "chatSettings"
@@ -95,6 +103,7 @@ type GlobalStateKey =
| "previousModeApiProvider"
| "previousModeModelId"
| "previousModeThinkingBudgetTokens"
| "previousModeVsCodeLmModelSelector"
| "previousModeModelInfo"
| "liteLlmBaseUrl"
| "liteLlmModelId"
@@ -107,14 +116,6 @@ type GlobalStateKey =
| "thinkingBudgetTokens"
| "planActSeparateModelsSetting"
export const GlobalFileNames = {
apiConversationHistory: "api_conversation_history.json",
uiMessages: "ui_messages.json",
openRouterModels: "openrouter_models.json",
mcpSettings: "cline_mcp_settings.json",
clineRules: ".clinerules",
}
export class ClineProvider implements vscode.WebviewViewProvider {
public static readonly sideBarId = "claude-dev.SidebarProvider" // used in package.json as the view's id. This value cannot be changed due to how vscode caches views based on their id, and updating the id would break existing instances of the extension.
public static readonly tabPanelId = "claude-dev.TabPanelProvider"
@@ -124,7 +125,9 @@ export class ClineProvider implements vscode.WebviewViewProvider {
private cline?: Cline
workspaceTracker?: WorkspaceTracker
mcpHub?: McpHub
private latestAnnouncementId = "feb-19-2025" // update to some unique identifier when we add a new announcement
accountService?: ClineAccountService
private latestAnnouncementId = "march-22-2025" // update to some unique identifier when we add a new announcement
conversationTelemetryService: ConversationTelemetryService
constructor(
readonly context: vscode.ExtensionContext,
@@ -134,6 +137,8 @@ export class ClineProvider implements vscode.WebviewViewProvider {
ClineProvider.activeInstances.add(this)
this.workspaceTracker = new WorkspaceTracker(this)
this.mcpHub = new McpHub(this)
this.accountService = new ClineAccountService(this)
this.conversationTelemetryService = new ConversationTelemetryService(this)
// Clean up legacy checkpoints
cleanupLegacyCheckpoints(this.context.globalStorageUri.fsPath, this.outputChannel).catch((error) => {
@@ -164,6 +169,8 @@ export class ClineProvider implements vscode.WebviewViewProvider {
this.workspaceTracker = undefined
this.mcpHub?.dispose()
this.mcpHub = undefined
this.accountService = undefined
this.conversationTelemetryService.shutdown()
this.outputChannel.appendLine("Disposed all disposables")
ClineProvider.activeInstances.delete(this)
}
@@ -572,6 +579,13 @@ export class ClineProvider implements vscode.WebviewViewProvider {
await this.togglePlanActModeWithChatSettings(message.chatSettings, message.chatContent)
}
break
case "optionsResponse":
await this.postMessageToWebview({
type: "invoke",
invoke: "sendMessage",
text: message.text,
})
break
// case "relaunchChromeDebugMode":
// if (this.cline) {
// this.cline.browserSession.relaunchChromeDebugMode()
@@ -717,6 +731,14 @@ export class ClineProvider implements vscode.WebviewViewProvider {
await this.handleSignOut()
break
}
case "showAccountViewClicked": {
await this.postMessageToWebview({ type: "action", action: "accountButtonClicked" })
break
}
case "fetchUserCreditsData": {
await this.fetchUserCreditsData()
break
}
case "showMcpView": {
await this.postMessageToWebview({ type: "action", action: "mcpButtonClicked" })
break
@@ -807,6 +829,10 @@ export class ClineProvider implements vscode.WebviewViewProvider {
}
break
}
case "requestTotalTasksSize": {
this.refreshTotalTasksSize()
break
}
case "restartMcpServer": {
try {
await this.mcpHub?.restartConnection(message.text!)
@@ -908,6 +934,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
case "clearAllTaskHistory": {
await this.deleteAllTaskHistory()
await this.postStateToWebview()
this.refreshTotalTasksSize()
this.postMessageToWebview({ type: "relinquishControl" })
break
}
@@ -938,6 +965,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
previousModeApiProvider: newApiProvider,
previousModeModelId: newModelId,
previousModeModelInfo: newModelInfo,
previousModeVsCodeLmModelSelector: newVsCodeLmModelSelector,
previousModeThinkingBudgetTokens: newThinkingBudgetTokens,
planActSeparateModelsSetting,
} = await this.getState()
@@ -954,6 +982,9 @@ export class ClineProvider implements vscode.WebviewViewProvider {
case "vertex":
case "gemini":
case "asksage":
case "openai-native":
case "qwen":
case "deepseek":
await this.updateGlobalState("previousModeModelId", apiConfiguration.apiModelId)
break
case "openrouter":
@@ -962,7 +993,8 @@ export class ClineProvider implements vscode.WebviewViewProvider {
await this.updateGlobalState("previousModeModelInfo", apiConfiguration.openRouterModelInfo)
break
case "vscode-lm":
await this.updateGlobalState("previousModeModelId", apiConfiguration.vsCodeLmModelSelector)
// Important we don't set modelId to this, as it's an object not string (webview expects model id to be a string)
await this.updateGlobalState("previousModeVsCodeLmModelSelector", apiConfiguration.vsCodeLmModelSelector)
break
case "openai":
await this.updateGlobalState("previousModeModelId", apiConfiguration.openAiModelId)
@@ -983,7 +1015,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
}
// Restore the model used in previous mode
if (newApiProvider || newModelId || newThinkingBudgetTokens !== undefined) {
if (newApiProvider || newModelId || newThinkingBudgetTokens !== undefined || newVsCodeLmModelSelector) {
await this.updateGlobalState("apiProvider", newApiProvider)
await this.updateGlobalState("thinkingBudgetTokens", newThinkingBudgetTokens)
switch (newApiProvider) {
@@ -992,6 +1024,9 @@ export class ClineProvider implements vscode.WebviewViewProvider {
case "vertex":
case "gemini":
case "asksage":
case "openai-native":
case "qwen":
case "deepseek":
await this.updateGlobalState("apiModelId", newModelId)
break
case "openrouter":
@@ -1000,7 +1035,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
await this.updateGlobalState("openRouterModelInfo", newModelInfo)
break
case "vscode-lm":
await this.updateGlobalState("vsCodeLmModelSelector", newModelId)
await this.updateGlobalState("vsCodeLmModelSelector", newVsCodeLmModelSelector)
break
case "openai":
await this.updateGlobalState("openAiModelId", newModelId)
@@ -1096,6 +1131,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
awsRegion,
awsUseCrossRegionInference,
awsBedrockUsePromptCache,
awsBedrockEndpoint,
awsProfile,
awsUseProfile,
vertexProjectId,
@@ -1122,6 +1158,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
azureApiVersion,
openRouterModelId,
openRouterModelInfo,
openRouterProviderSorting,
vsCodeLmModelSelector,
liteLlmBaseUrl,
liteLlmModelId,
@@ -1132,6 +1169,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
xaiApiKey,
thinkingBudgetTokens,
clineApiKey,
sambanovaApiKey,
} = apiConfiguration
await this.updateGlobalState("apiProvider", apiProvider)
await this.updateGlobalState("apiModelId", apiModelId)
@@ -1143,6 +1181,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
await this.updateGlobalState("awsRegion", awsRegion)
await this.updateGlobalState("awsUseCrossRegionInference", awsUseCrossRegionInference)
await this.updateGlobalState("awsBedrockUsePromptCache", awsBedrockUsePromptCache)
await this.updateGlobalState("awsBedrockEndpoint", awsBedrockEndpoint)
await this.updateGlobalState("awsProfile", awsProfile)
await this.updateGlobalState("awsUseProfile", awsUseProfile)
await this.updateGlobalState("vertexProjectId", vertexProjectId)
@@ -1169,6 +1208,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
await this.updateGlobalState("azureApiVersion", azureApiVersion)
await this.updateGlobalState("openRouterModelId", openRouterModelId)
await this.updateGlobalState("openRouterModelInfo", openRouterModelInfo)
await this.updateGlobalState("openRouterProviderSorting", openRouterProviderSorting)
await this.updateGlobalState("vsCodeLmModelSelector", vsCodeLmModelSelector)
await this.updateGlobalState("liteLlmBaseUrl", liteLlmBaseUrl)
await this.updateGlobalState("liteLlmModelId", liteLlmModelId)
@@ -1179,6 +1219,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
await this.updateGlobalState("asksageApiUrl", asksageApiUrl)
await this.updateGlobalState("thinkingBudgetTokens", thinkingBudgetTokens)
await this.storeSecret("clineApiKey", clineApiKey)
await this.storeSecret("sambanovaApiKey", sambanovaApiKey)
if (this.cline) {
this.cline.api = buildApiHandler(apiConfiguration)
}
@@ -1289,6 +1330,20 @@ export class ClineProvider implements vscode.WebviewViewProvider {
}
}
// Account
async fetchUserCreditsData() {
try {
await Promise.all([
this.accountService?.fetchBalance(),
this.accountService?.fetchUsageTransactions(),
this.accountService?.fetchPaymentTransactions(),
])
} catch (error) {
console.error("Failed to fetch user credits data:", error)
}
}
// Auth
public async validateAuthState(state: string | null): Promise<boolean> {
@@ -1327,7 +1382,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
}
await this.postStateToWebview()
vscode.window.showInformationMessage("Successfully logged in to Cline")
// vscode.window.showInformationMessage("Successfully logged in to Cline")
} catch (error) {
console.error("Failed to handle auth callback:", error)
vscode.window.showErrorMessage("Failed to log in to Cline")
@@ -1697,6 +1752,104 @@ Here is the project's README to help you get started:\n\n${mcpDetails.readmeCont
return models
}
// Context menus and code actions
getFileMentionFromPath(filePath: string) {
const cwd = vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath).at(0)
if (!cwd) {
return "@/" + filePath
}
const relativePath = path.relative(cwd, filePath)
return "@/" + relativePath
}
// 'Add to Cline' context menu in editor and code action
async addSelectedCodeToChat(code: string, filePath: string, languageId: string, diagnostics?: vscode.Diagnostic[]) {
// Ensure the sidebar view is visible
await vscode.commands.executeCommand("claude-dev.SidebarProvider.focus")
await setTimeoutPromise(100)
// Post message to webview with the selected code
const fileMention = this.getFileMentionFromPath(filePath)
let input = `${fileMention}\n\`\`\`\n${code}\n\`\`\``
if (diagnostics) {
const problemsString = this.convertDiagnosticsToProblemsString(diagnostics)
input += `\nProblems:\n${problemsString}`
}
await this.postMessageToWebview({
type: "addToInput",
text: input,
})
console.log("addSelectedCodeToChat", code, filePath, languageId)
}
// 'Add to Cline' context menu in Terminal
async addSelectedTerminalOutputToChat(output: string, terminalName: string) {
// Ensure the sidebar view is visible
await vscode.commands.executeCommand("claude-dev.SidebarProvider.focus")
await setTimeoutPromise(100)
// Post message to webview with the selected terminal output
// await this.postMessageToWebview({
// type: "addSelectedTerminalOutput",
// output,
// terminalName
// })
await this.postMessageToWebview({
type: "addToInput",
text: `Terminal output:\n\`\`\`\n${output}\n\`\`\``,
})
console.log("addSelectedTerminalOutputToChat", output, terminalName)
}
// 'Fix with Cline' in code actions
async fixWithCline(code: string, filePath: string, languageId: string, diagnostics: vscode.Diagnostic[]) {
// Ensure the sidebar view is visible
await vscode.commands.executeCommand("claude-dev.SidebarProvider.focus")
await setTimeoutPromise(100)
const fileMention = this.getFileMentionFromPath(filePath)
const problemsString = this.convertDiagnosticsToProblemsString(diagnostics)
await this.initClineWithTask(
`Fix the following code in ${fileMention}\n\`\`\`\n${code}\n\`\`\`\n\nProblems:\n${problemsString}`,
)
console.log("fixWithCline", code, filePath, languageId, diagnostics, problemsString)
}
convertDiagnosticsToProblemsString(diagnostics: vscode.Diagnostic[]) {
let problemsString = ""
for (const diagnostic of diagnostics) {
let label: string
switch (diagnostic.severity) {
case vscode.DiagnosticSeverity.Error:
label = "Error"
break
case vscode.DiagnosticSeverity.Warning:
label = "Warning"
break
case vscode.DiagnosticSeverity.Information:
label = "Information"
break
case vscode.DiagnosticSeverity.Hint:
label = "Hint"
break
default:
label = "Diagnostic"
}
const line = diagnostic.range.start.line + 1 // VSCode lines are 0-indexed
const source = diagnostic.source ? `${diagnostic.source} ` : ""
problemsString += `\n- [${source}${label}] Line ${line}: ${diagnostic.message}`
}
problemsString = problemsString.trim()
return problemsString
}
// Task history
async getTaskWithId(id: string): Promise<{
@@ -1769,46 +1922,56 @@ Here is the project's README to help you get started:\n\n${mcpDetails.readmeCont
// await this.postStateToWebview()
}
async refreshTotalTasksSize() {
getTotalTasksSize(this.context.globalStorageUri.fsPath)
.then((newTotalSize) => {
this.postMessageToWebview({
type: "totalTasksSize",
totalTasksSize: newTotalSize,
})
})
.catch((error) => {
console.error("Error calculating total tasks size:", error)
})
}
async deleteTaskWithId(id: string) {
console.info("deleteTaskWithId: ", id)
if (id === this.cline?.taskId) {
await this.clearTask()
console.debug("cleared task")
try {
if (id === this.cline?.taskId) {
await this.clearTask()
console.debug("cleared task")
}
const { taskDirPath, apiConversationHistoryFilePath, uiMessagesFilePath } = await this.getTaskWithId(id)
const updatedTaskHistory = await this.deleteTaskFromState(id)
// Delete the task files
const apiConversationHistoryFileExists = await fileExistsAtPath(apiConversationHistoryFilePath)
if (apiConversationHistoryFileExists) {
await fs.unlink(apiConversationHistoryFilePath)
}
const uiMessagesFileExists = await fileExistsAtPath(uiMessagesFilePath)
if (uiMessagesFileExists) {
await fs.unlink(uiMessagesFilePath)
}
const legacyMessagesFilePath = path.join(taskDirPath, "claude_messages.json")
if (await fileExistsAtPath(legacyMessagesFilePath)) {
await fs.unlink(legacyMessagesFilePath)
}
await fs.rmdir(taskDirPath) // succeeds if the dir is empty
if (updatedTaskHistory.length === 0) {
await this.deleteAllTaskHistory()
}
} catch (error) {
console.debug(`Error deleting task:`, error)
}
const { taskDirPath, apiConversationHistoryFilePath, uiMessagesFilePath } = await this.getTaskWithId(id)
// Delete checkpoints
console.info("deleting checkpoints")
const taskHistory = ((await this.getGlobalState("taskHistory")) as HistoryItem[] | undefined) || []
const historyItem = taskHistory.find((item) => item.id === id)
//console.log("historyItem: ", historyItem)
// if (historyItem) {
// try {
// await CheckpointTracker.deleteCheckpoints(id, historyItem, this.context.globalStorageUri.fsPath)
// } catch (error) {
// console.error(`Failed to delete checkpoints for task ${id}:`, error)
// }
// }
await this.deleteTaskFromState(id)
// Delete the task files
const apiConversationHistoryFileExists = await fileExistsAtPath(apiConversationHistoryFilePath)
if (apiConversationHistoryFileExists) {
await fs.unlink(apiConversationHistoryFilePath)
}
const uiMessagesFileExists = await fileExistsAtPath(uiMessagesFilePath)
if (uiMessagesFileExists) {
await fs.unlink(uiMessagesFilePath)
}
const legacyMessagesFilePath = path.join(taskDirPath, "claude_messages.json")
if (await fileExistsAtPath(legacyMessagesFilePath)) {
await fs.unlink(legacyMessagesFilePath)
}
await fs.rmdir(taskDirPath) // succeeds if the dir is empty
this.refreshTotalTasksSize()
}
async deleteTaskFromState(id: string) {
@@ -1819,6 +1982,8 @@ Here is the project's README to help you get started:\n\n${mcpDetails.readmeCont
// Notify the webview that the task has been deleted
await this.postStateToWebview()
return updatedTaskHistory
}
async postStateToWebview() {
@@ -1930,6 +2095,7 @@ Here is the project's README to help you get started:\n\n${mcpDetails.readmeCont
awsRegion,
awsUseCrossRegionInference,
awsBedrockUsePromptCache,
awsBedrockEndpoint,
awsProfile,
awsUseProfile,
vertexProjectId,
@@ -1956,6 +2122,7 @@ Here is the project's README to help you get started:\n\n${mcpDetails.readmeCont
azureApiVersion,
openRouterModelId,
openRouterModelInfo,
openRouterProviderSorting,
lastShownAnnouncementId,
customInstructions,
taskHistory,
@@ -1969,6 +2136,7 @@ Here is the project's README to help you get started:\n\n${mcpDetails.readmeCont
previousModeApiProvider,
previousModeModelId,
previousModeModelInfo,
previousModeVsCodeLmModelSelector,
previousModeThinkingBudgetTokens,
qwenApiLine,
liteLlmApiKey,
@@ -1977,6 +2145,7 @@ Here is the project's README to help you get started:\n\n${mcpDetails.readmeCont
asksageApiUrl,
xaiApiKey,
thinkingBudgetTokens,
sambanovaApiKey,
planActSeparateModelsSettingRaw,
] = await Promise.all([
this.getGlobalState("apiProvider") as Promise<ApiProvider | undefined>,
@@ -1990,6 +2159,7 @@ Here is the project's README to help you get started:\n\n${mcpDetails.readmeCont
this.getGlobalState("awsRegion") as Promise<string | undefined>,
this.getGlobalState("awsUseCrossRegionInference") as Promise<boolean | undefined>,
this.getGlobalState("awsBedrockUsePromptCache") as Promise<boolean | undefined>,
this.getGlobalState("awsBedrockEndpoint") as Promise<string | undefined>,
this.getGlobalState("awsProfile") as Promise<string | undefined>,
this.getGlobalState("awsUseProfile") as Promise<boolean | undefined>,
this.getGlobalState("vertexProjectId") as Promise<string | undefined>,
@@ -2016,6 +2186,7 @@ Here is the project's README to help you get started:\n\n${mcpDetails.readmeCont
this.getGlobalState("azureApiVersion") as Promise<string | undefined>,
this.getGlobalState("openRouterModelId") as Promise<string | undefined>,
this.getGlobalState("openRouterModelInfo") as Promise<ModelInfo | undefined>,
this.getGlobalState("openRouterProviderSorting") as Promise<string | undefined>,
this.getGlobalState("lastShownAnnouncementId") as Promise<string | undefined>,
this.getGlobalState("customInstructions") as Promise<string | undefined>,
this.getGlobalState("taskHistory") as Promise<HistoryItem[] | undefined>,
@@ -2029,6 +2200,7 @@ Here is the project's README to help you get started:\n\n${mcpDetails.readmeCont
this.getGlobalState("previousModeApiProvider") as Promise<ApiProvider | undefined>,
this.getGlobalState("previousModeModelId") as Promise<string | undefined>,
this.getGlobalState("previousModeModelInfo") as Promise<ModelInfo | undefined>,
this.getGlobalState("previousModeVsCodeLmModelSelector") as Promise<vscode.LanguageModelChatSelector | undefined>,
this.getGlobalState("previousModeThinkingBudgetTokens") as Promise<number | undefined>,
this.getGlobalState("qwenApiLine") as Promise<string | undefined>,
this.getSecret("liteLlmApiKey") as Promise<string | undefined>,
@@ -2037,6 +2209,7 @@ Here is the project's README to help you get started:\n\n${mcpDetails.readmeCont
this.getGlobalState("asksageApiUrl") as Promise<string | undefined>,
this.getSecret("xaiApiKey") as Promise<string | undefined>,
this.getGlobalState("thinkingBudgetTokens") as Promise<number | undefined>,
this.getSecret("sambanovaApiKey") as Promise<string | undefined>,
this.getGlobalState("planActSeparateModelsSetting") as Promise<boolean | undefined>,
])
@@ -2091,6 +2264,7 @@ Here is the project's README to help you get started:\n\n${mcpDetails.readmeCont
awsRegion,
awsUseCrossRegionInference,
awsBedrockUsePromptCache,
awsBedrockEndpoint,
awsProfile,
awsUseProfile,
vertexProjectId,
@@ -2118,6 +2292,7 @@ Here is the project's README to help you get started:\n\n${mcpDetails.readmeCont
azureApiVersion,
openRouterModelId,
openRouterModelInfo,
openRouterProviderSorting,
vsCodeLmModelSelector,
o3MiniReasoningEffort,
thinkingBudgetTokens,
@@ -2127,6 +2302,7 @@ Here is the project's README to help you get started:\n\n${mcpDetails.readmeCont
asksageApiKey,
asksageApiUrl,
xaiApiKey,
sambanovaApiKey,
},
lastShownAnnouncementId,
customInstructions,
@@ -2138,6 +2314,7 @@ Here is the project's README to help you get started:\n\n${mcpDetails.readmeCont
previousModeApiProvider,
previousModeModelId,
previousModeModelInfo,
previousModeVsCodeLmModelSelector,
previousModeThinkingBudgetTokens,
mcpMarketplaceEnabled,
telemetrySetting: telemetrySetting || "unset",
@@ -2273,6 +2450,7 @@ Here is the project's README to help you get started:\n\n${mcpDetails.readmeCont
"liteLlmApiKey",
"asksageApiKey",
"xaiApiKey",
"sambanovaApiKey",
]
for (const key of secretKeys) {
await this.storeSecret(key, undefined)
+288
View File
@@ -0,0 +1,288 @@
import * as vscode from "vscode"
import * as fs from "fs/promises"
import * as path from "path"
import { ClineProvider } from "../../core/webview/ClineProvider"
import { HistoryItem } from "../../shared/HistoryItem"
import { ClineMessage } from "../../shared/ExtensionMessage"
/**
* Registers development-only commands for task manipulation.
* These are only activated in development mode.
*/
export function registerTaskCommands(context: vscode.ExtensionContext, provider: ClineProvider): vscode.Disposable[] {
return [
vscode.commands.registerCommand("cline.dev.createTestTasks", async () => {
const count = await vscode.window.showInputBox({
title: "Test Tasks",
prompt: "How many test tasks to create?",
value: "10",
})
if (!count) {
return
}
const tasksCount = parseInt(count)
const globalStoragePath = context.globalStorageUri.fsPath
const tasksDir = path.join(globalStoragePath, "tasks")
vscode.window.withProgress(
{
location: vscode.ProgressLocation.Notification,
title: `Creating ${tasksCount} test tasks...`,
cancellable: false,
},
async (progress) => {
for (let i = 0; i < tasksCount; i++) {
// Generate a timestamp to ensure unique IDs
const timestamp = Date.now() + i
const taskId = `${timestamp}`
const taskDir = path.join(tasksDir, taskId)
await fs.mkdir(taskDir, { recursive: true })
// Generate a task prompt
const taskName = getRandomTaskName(i)
// Create realistic message sequence
const messages = createRealisticMessageSequence(timestamp, taskName, i)
// Create API conversation history file
await fs.writeFile(
path.join(taskDir, "api_conversation_history.json"),
JSON.stringify(
[
{
role: "user",
content: [{ type: "text", text: `<task>\n${taskName}\n</task>` }],
},
{
role: "assistant",
content: [
{
type: "text",
text: `I'll help you ${taskName.toLowerCase()}. Let me break this down into steps.`,
},
],
},
],
null,
2,
),
)
// Create UI messages file with realistic message sequence
await fs.writeFile(path.join(taskDir, "ui_messages.json"), JSON.stringify(messages, null, 2))
// Create history item to be shown in the HistoryView
const historyItem: HistoryItem = {
id: taskId,
ts: timestamp,
task: taskName,
tokensIn: Math.floor(100 + Math.random() * 900), // Random token count from 100-1000
tokensOut: Math.floor(200 + Math.random() * 1800), // Random token count from 200-2000
cacheWrites: i % 3 === 0 ? Math.floor(50 + Math.random() * 150) : undefined, // Only add cache writes to every 3rd task
cacheReads: i % 3 === 0 ? Math.floor(20 + Math.random() * 80) : undefined, // Only add cache reads to every 3rd task
totalCost: Number((0.0001 + Math.random() * 0.01).toFixed(5)), // Random cost from $0.0001 to $0.0101
size: 1024 * 1024, // 1MB
}
// Update task history in global state
await provider.updateTaskHistory(historyItem)
progress.report({ increment: 100 / tasksCount })
}
// Update the UI to show the new tasks
await provider.postStateToWebview()
vscode.window.showInformationMessage(`Created ${tasksCount} test tasks`)
},
)
}),
]
}
/**
* Creates a realistic sequence of messages that would occur in a typical task
*/
function createRealisticMessageSequence(baseTimestamp: number, taskPrompt: string, taskIndex: number): ClineMessage[] {
// Use an incrementing timestamp to ensure messages appear in sequence
let timestamp = baseTimestamp
const getNextTimestamp = () => {
timestamp += 1000 // Add 1 second between messages
return timestamp
}
// Variables to make different test tasks look unique
const fileName = getRandomFileName(taskIndex)
const commitHash = `commit${taskIndex}${Math.floor(Math.random() * 1000000).toString(16)}`
// Create a realistic message sequence
const messages: ClineMessage[] = [
// Initial task message - uses "say" with "text" which is the format used in Cline.ts
{
ts: baseTimestamp,
type: "say",
say: "text",
text: taskPrompt,
},
// API request started
{
ts: getNextTimestamp(),
type: "say",
say: "api_req_started",
text: JSON.stringify({
request: `<task>\n${taskPrompt}\n</task>`,
tokensIn: Math.floor(100 + Math.random() * 200),
tokensOut: Math.floor(300 + Math.random() * 500),
}),
},
// Reasoning message
{
ts: getNextTimestamp(),
type: "say",
say: "reasoning",
text: `I'll approach this task by breaking it down into manageable steps. First, I'll analyze the requirements, then create a plan, and finally implement the solution systematically.`,
},
// Text response
{
ts: getNextTimestamp(),
type: "say",
say: "text",
text: `I'll help you with this task. Let me start by creating the necessary files and implementing the core functionality.`,
},
]
// Add task-specific messages based on index modulo to create variety
const messageType = taskIndex % 5
if (messageType === 0 || messageType === 2) {
// Tool use - file operations
messages.push({
ts: getNextTimestamp(),
type: "say",
say: "tool",
text: JSON.stringify({
tool: "newFileCreated",
path: fileName,
content: `// Sample code for ${taskPrompt}`,
}),
})
}
if (messageType === 1 || messageType === 3) {
// Command execution
messages.push(
{
ts: getNextTimestamp(),
type: "ask",
ask: "command",
text: `ls -la`,
},
{
ts: getNextTimestamp(),
type: "say",
say: "command_output",
text: `total 24\ndrwxr-xr-x 3 user staff 96 Mar 10 12:34 .\ndrwxr-xr-x 8 user staff 256 Mar 10 12:30 ..\n-rw-r--r-- 1 user staff 158 Mar 10 12:34 ${fileName}`,
},
)
}
if (messageType === 2 || messageType === 4) {
// Browser actions
messages.push(
{
ts: getNextTimestamp(),
type: "ask",
ask: "browser_action_launch",
text: `https://example.com`,
},
{
ts: getNextTimestamp(),
type: "say",
say: "browser_action_result",
text: JSON.stringify({
logs: "Page loaded successfully",
screenshot:
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==",
}),
},
{
ts: getNextTimestamp(),
type: "say",
say: "browser_action",
text: JSON.stringify({
action: "close",
}),
},
)
}
// Add checkpoint
messages.push({
ts: getNextTimestamp(),
type: "say",
say: "checkpoint_created",
lastCheckpointHash: commitHash,
})
// Add completion result (all tasks end with this)
messages.push({
ts: getNextTimestamp(),
type: "say",
say: "completion_result",
text: `I've completed the task to ${taskPrompt.toLowerCase()}. The implementation includes all the required functionality and meets the specifications. ${"x".repeat(1024 * 1024)}`, // 1MB file
lastCheckpointHash: commitHash,
})
return messages
}
/**
* Returns a random task name for test data
*/
function getRandomTaskName(index: number): string {
const tasks = [
"Create a simple todo application",
"Build a weather forecast widget",
"Implement a markdown parser",
"Design a responsive landing page",
"Develop a currency converter",
"Create a file upload component",
"Build a data visualization dashboard",
"Implement a search functionality",
"Create a user authentication system",
"Design a dark mode toggle",
"Build a countdown timer",
"Create a drag and drop interface",
"Implement form validation",
"Design a multi-step wizard",
"Create a notification system",
]
return tasks[index % tasks.length] + ` (Test ${index + 1})`
}
/**
* Returns a random file name for test data
*/
function getRandomFileName(index: number): string {
const files = [
"index.html",
"styles.css",
"script.js",
"app.jsx",
"main.ts",
"utils.py",
"config.json",
"server.js",
"data.csv",
"README.md",
]
return files[index % files.length]
}
+171 -4
View File
@@ -1,6 +1,6 @@
// The module 'vscode' contains the VS Code extensibility API
// Import the module and reference it with the alias vscode in your code below
import delay from "delay"
import { setTimeout as setTimeoutPromise } from "node:timers/promises"
import * as vscode from "vscode"
import { ClineProvider } from "./core/webview/ClineProvider"
import { Logger } from "./services/logging/Logger"
@@ -32,6 +32,8 @@ export function activate(context: vscode.ExtensionContext) {
const sidebarProvider = new ClineProvider(context, outputChannel)
vscode.commands.executeCommand("setContext", "cline.isDevMode", IS_DEV && IS_DEV === "true")
context.subscriptions.push(
vscode.window.registerWebviewViewProvider(ClineProvider.sideBarId, sidebarProvider, {
webviewOptions: { retainContextWhenHidden: true },
@@ -88,7 +90,7 @@ export function activate(context: vscode.ExtensionContext) {
tabProvider.resolveWebviewView(panel)
// Lock the editor group so clicking on files doesn't open them over the panel
await delay(100)
await setTimeoutPromise(100)
await vscode.commands.executeCommand("workbench.action.lockEditorGroup")
}
@@ -115,14 +117,20 @@ export function activate(context: vscode.ExtensionContext) {
)
context.subscriptions.push(
vscode.commands.registerCommand("cline.accountLoginClicked", () => {
vscode.commands.registerCommand("cline.accountButtonClicked", () => {
sidebarProvider.postMessageToWebview({
type: "action",
action: "accountLoginClicked",
action: "accountButtonClicked",
})
}),
)
context.subscriptions.push(
vscode.commands.registerCommand("cline.openDocumentation", () => {
vscode.env.openExternal(vscode.Uri.parse("https://docs.cline.bot/"))
}),
)
/*
We use the text document content provider API to show the left side for diff view by creating a virtual document for the original content. This makes it readonly so users know to edit the right side if they want to keep their changes.
@@ -187,6 +195,165 @@ export function activate(context: vscode.ExtensionContext) {
}
context.subscriptions.push(vscode.window.registerUriHandler({ handleUri }))
// Register size testing commands in development mode
if (IS_DEV && IS_DEV === "true") {
// Use dynamic import to avoid loading the module in production
import("./dev/commands/tasks")
.then((module) => {
const devTaskCommands = module.registerTaskCommands(context, sidebarProvider)
context.subscriptions.push(...devTaskCommands)
Logger.log("Cline dev task commands registered")
})
.catch((error) => {
Logger.log("Failed to register dev task commands: " + error)
})
}
context.subscriptions.push(
vscode.commands.registerCommand("cline.addToChat", async (range?: vscode.Range, diagnostics?: vscode.Diagnostic[]) => {
const editor = vscode.window.activeTextEditor
if (!editor) {
return
}
// Use provided range if available, otherwise use current selection
// (vscode command passes an argument in the first param by default, so we need to ensure it's a Range object)
const textRange = range instanceof vscode.Range ? range : editor.selection
const selectedText = editor.document.getText(textRange)
if (!selectedText) {
return
}
// Get the file path and language ID
const filePath = editor.document.uri.fsPath
const languageId = editor.document.languageId
// Send to sidebar provider
await sidebarProvider.addSelectedCodeToChat(
selectedText,
filePath,
languageId,
Array.isArray(diagnostics) ? diagnostics : undefined,
)
}),
)
context.subscriptions.push(
vscode.commands.registerCommand("cline.addTerminalOutputToChat", async () => {
const terminal = vscode.window.activeTerminal
if (!terminal) {
return
}
// Save current clipboard content
const tempCopyBuffer = await vscode.env.clipboard.readText()
try {
// Copy the *existing* terminal selection (without selecting all)
await vscode.commands.executeCommand("workbench.action.terminal.copySelection")
// Get copied content
let terminalContents = (await vscode.env.clipboard.readText()).trim()
// Restore original clipboard content
await vscode.env.clipboard.writeText(tempCopyBuffer)
if (!terminalContents) {
// No terminal content was copied (either nothing selected or some error)
return
}
// [Optional] Any additional logic to process multi-line content can remain here
// For example:
/*
const lines = terminalContents.split("\n")
const lastLine = lines.pop()?.trim()
if (lastLine) {
let i = lines.length - 1
while (i >= 0 && !lines[i].trim().startsWith(lastLine)) {
i--
}
terminalContents = lines.slice(Math.max(i, 0)).join("\n")
}
*/
// Send to sidebar provider
await sidebarProvider.addSelectedTerminalOutputToChat(terminalContents, terminal.name)
} catch (error) {
// Ensure clipboard is restored even if an error occurs
await vscode.env.clipboard.writeText(tempCopyBuffer)
console.error("Error getting terminal contents:", error)
vscode.window.showErrorMessage("Failed to get terminal contents")
}
}),
)
// Register code action provider
context.subscriptions.push(
vscode.languages.registerCodeActionsProvider(
"*",
new (class implements vscode.CodeActionProvider {
public static readonly providedCodeActionKinds = [vscode.CodeActionKind.QuickFix]
provideCodeActions(
document: vscode.TextDocument,
range: vscode.Range,
context: vscode.CodeActionContext,
): vscode.CodeAction[] {
// Expand range to include surrounding 3 lines
const expandedRange = new vscode.Range(
Math.max(0, range.start.line - 3),
0,
Math.min(document.lineCount - 1, range.end.line + 3),
document.lineAt(Math.min(document.lineCount - 1, range.end.line + 3)).text.length,
)
const addAction = new vscode.CodeAction("Add to Cline", vscode.CodeActionKind.QuickFix)
addAction.command = {
command: "cline.addToChat",
title: "Add to Cline",
arguments: [expandedRange, context.diagnostics],
}
const fixAction = new vscode.CodeAction("Fix with Cline", vscode.CodeActionKind.QuickFix)
fixAction.command = {
command: "cline.fixWithCline",
title: "Fix with Cline",
arguments: [expandedRange, context.diagnostics],
}
// Only show actions when there are errors
if (context.diagnostics.length > 0) {
return [addAction, fixAction]
} else {
return []
}
}
})(),
{
providedCodeActionKinds: [vscode.CodeActionKind.QuickFix],
},
),
)
// Register the command handler
context.subscriptions.push(
vscode.commands.registerCommand("cline.fixWithCline", async (range: vscode.Range, diagnostics: any[]) => {
const editor = vscode.window.activeTextEditor
if (!editor) {
return
}
const selectedText = editor.document.getText(range)
const filePath = editor.document.uri.fsPath
const languageId = editor.document.languageId
// Send to sidebar provider with diagnostics
await sidebarProvider.fixWithCline(selectedText, filePath, languageId, diagnostics)
}),
)
return createClineAPI(outputChannel, sidebarProvider)
}
+8
View File
@@ -0,0 +1,8 @@
// NOTE: These are here temporarily until we find a better home for them
export const GlobalFileNames = {
apiConversationHistory: "api_conversation_history.json",
uiMessages: "ui_messages.json",
openRouterModels: "openrouter_models.json",
mcpSettings: "cline_mcp_settings.json",
clineRules: ".clinerules",
}
@@ -4,6 +4,7 @@ import * as path from "path"
import simpleGit, { SimpleGit } from "simple-git"
import { fileExistsAtPath } from "../../utils/fs"
import { getLfsPatterns, writeExcludesFile } from "./CheckpointExclusions"
import { telemetryService } from "../../services/telemetry/TelemetryService"
interface CheckpointAddResult {
success: boolean
@@ -53,7 +54,7 @@ export class GitOperations {
* - Unable to create initial commit
* - LFS pattern setup fails
*/
public async initShadowGit(gitPath: string, cwd: string): Promise<string> {
public async initShadowGit(gitPath: string, cwd: string, taskId: string): Promise<string> {
console.info(`Initializing shadow git`)
// If repo exists, just verify worktree
@@ -72,6 +73,7 @@ export class GitOperations {
}
// Initialize new repo
const startTime = performance.now()
const checkpointsDir = path.dirname(gitPath)
console.warn(`Creating new shadow git in ${checkpointsDir}`)
@@ -93,6 +95,9 @@ export class GitOperations {
// Initial commit only on first repo creation
await git.commit("initial commit", { "--allow-empty": null })
const durationMs = Math.round(performance.now() - startTime)
telemetryService.captureCheckpointUsage(taskId, "shadow_git_initialized", durationMs)
console.warn(`Shadow git initialization completed`)
return gitPath
@@ -179,6 +184,7 @@ export class GitOperations {
* - Nested git repo handling fails
*/
public async addCheckpointFiles(git: SimpleGit): Promise<CheckpointAddResult> {
const startTime = performance.now()
try {
// Update exclude patterns before each commit
await this.renameNestedGitRepos(true)
@@ -186,6 +192,8 @@ export class GitOperations {
try {
await git.add(".")
const durationMs = Math.round(performance.now() - startTime)
console.debug(`Checkpoint add operation completed in ${durationMs}ms`)
return { success: true }
} catch (error) {
console.error("Checkpoint add operation failed:", error)
@@ -88,6 +88,7 @@ class CheckpointTracker {
}
try {
console.info(`Creating new CheckpointTracker for task ${taskId}`)
const startTime = performance.now()
// Check if checkpoints are disabled in VS Code settings
const enableCheckpoints = vscode.workspace.getConfiguration("cline").get<boolean>("enableCheckpoints") ?? true
@@ -109,9 +110,10 @@ class CheckpointTracker {
const newTracker = new CheckpointTracker(globalStoragePath, taskId, workingDir, cwdHash)
const gitPath = await getShadowGitPath(newTracker.globalStoragePath, newTracker.taskId, newTracker.cwdHash)
await newTracker.gitOperations.initShadowGit(gitPath, workingDir)
await newTracker.gitOperations.initShadowGit(gitPath, workingDir, taskId)
telemetryService.captureCheckpointUsage(taskId, "shadow_git_initialized")
const durationMs = Math.round(performance.now() - startTime)
telemetryService.captureCheckpointUsage(taskId, "shadow_git_initialized", durationMs)
return newTracker
} catch (error) {
@@ -148,6 +150,8 @@ class CheckpointTracker {
public async commit(): Promise<string | undefined> {
try {
console.info(`Creating new checkpoint commit for task ${this.taskId}`)
const startTime = performance.now()
const gitPath = await getShadowGitPath(this.globalStoragePath, this.taskId, this.cwdHash)
const git = simpleGit(path.dirname(gitPath))
@@ -160,10 +164,14 @@ class CheckpointTracker {
console.info(`Creating checkpoint commit with message: ${commitMessage}`)
const result = await git.commit(commitMessage, {
"--allow-empty": null,
"--no-verify": null,
})
const commitHash = result.commit || ""
console.warn(`Checkpoint commit created.`)
telemetryService.captureCheckpointUsage(this.taskId, "commit_created")
const durationMs = Math.round(performance.now() - startTime)
telemetryService.captureCheckpointUsage(this.taskId, "commit_created", durationMs)
return commitHash
} catch (error) {
console.error("Failed to create checkpoint:", {
@@ -229,12 +237,16 @@ class CheckpointTracker {
*/
public async resetHead(commitHash: string): Promise<void> {
console.info(`Resetting to checkpoint: ${commitHash}`)
const startTime = performance.now()
const gitPath = await getShadowGitPath(this.globalStoragePath, this.taskId, this.cwdHash)
const git = simpleGit(path.dirname(gitPath))
console.debug(`Using shadow git at: ${gitPath}`)
await git.reset(["--hard", commitHash]) // Hard reset to target commit
console.debug(`Successfully reset to checkpoint: ${commitHash}`)
telemetryService.captureCheckpointUsage(this.taskId, "restored")
const durationMs = Math.round(performance.now() - startTime)
telemetryService.captureCheckpointUsage(this.taskId, "restored", durationMs)
}
/**
@@ -261,6 +273,8 @@ class CheckpointTracker {
after: string
}>
> {
const startTime = performance.now()
const gitPath = await getShadowGitPath(this.globalStoragePath, this.taskId, this.cwdHash)
const git = simpleGit(path.dirname(gitPath))
@@ -308,8 +322,39 @@ class CheckpointTracker {
})
}
const durationMs = Math.round(performance.now() - startTime)
telemetryService.captureCheckpointUsage(this.taskId, "diff_generated", durationMs)
return result
}
/**
* Returns the number of files changed between two commits.
*
* @param lhsHash - The commit to compare from (older commit)
* @param rhsHash - The commit to compare to (newer commit).
* If omitted, we compare to the working directory.
* @returns The number of files changed between the commits
*/
public async getDiffCount(lhsHash: string, rhsHash?: string): Promise<number> {
const startTime = performance.now()
const gitPath = await getShadowGitPath(this.globalStoragePath, this.taskId, this.cwdHash)
const git = simpleGit(path.dirname(gitPath))
console.info(`Getting diff count between commits: ${lhsHash || "initial"} -> ${rhsHash || "working directory"}`)
// Stage all changes so that untracked files appear in diff summary
await this.gitOperations.addCheckpointFiles(git)
const diffRange = rhsHash ? `${lhsHash}..${rhsHash}` : lhsHash
const diffSummary = await git.diffSummary([diffRange])
const durationMs = Math.round(performance.now() - startTime)
telemetryService.captureCheckpointUsage(this.taskId, "diff_generated", durationMs)
return diffSummary.files.length
}
}
export default CheckpointTracker
@@ -158,7 +158,6 @@ export class DiffViewProvider {
await updatedDocument.save()
}
// await delay(100)
// get text after save in case there is any auto-formatting done by the editor
const postSaveContent = updatedDocument.getText()
+6
View File
@@ -4,6 +4,7 @@ import pdf from "pdf-parse/lib/pdf-parse"
import mammoth from "mammoth"
import fs from "fs/promises"
import { isBinaryFile } from "isbinaryfile"
import { getFileSizeInKB } from "../../utils/fs"
export async function extractTextFromFile(filePath: string): Promise<string> {
try {
@@ -22,6 +23,11 @@ export async function extractTextFromFile(filePath: string): Promise<string> {
default:
const isBinary = await isBinaryFile(filePath).catch(() => false)
if (!isBinary) {
// If file is over 300KB, throw an error
const fileSizeInKB = await getFileSizeInKB(filePath)
if (fileSizeInKB > 300) {
throw new Error(`File is too large to read into context.`)
}
return await fs.readFile(filePath, "utf8")
} else {
throw new Error(`Cannot read text for file type: ${fileExtension}`)
+1 -3
View File
@@ -63,7 +63,6 @@ export async function fetchOpenGraphData(url: string): Promise<OpenGraphData> {
type: data.ogType,
}
} catch (error) {
console.error(`Error fetching Open Graph data for ${url}:`, error)
// Return basic information based on the URL
try {
const urlObj = new URL(url)
@@ -100,8 +99,7 @@ export async function isImageUrl(url: string): Promise<boolean> {
const contentType = response.headers["content-type"]
return contentType && contentType.startsWith("image/")
} catch (error) {
console.error(`Error checking if URL is an image: ${url}`, error)
// If we can't determine, fall back to checking the file extension
return /\.(jpg|jpeg|png|gif|webp|svg)$/i.test(url)
return /\.(jpg|jpeg|png|gif|webp|bmp|svg|tiff|tif|avif)$/i.test(url)
}
}
+118
View File
@@ -0,0 +1,118 @@
import axios, { AxiosRequestConfig, AxiosResponse } from "axios"
import { ClineProvider } from "../../core/webview/ClineProvider"
import type { BalanceResponse, PaymentTransaction, UsageTransaction } from "../../shared/ClineAccount"
export class ClineAccountService {
private readonly baseUrl = "https://api.cline.bot/v1"
private providerRef: WeakRef<ClineProvider>
constructor(provider: ClineProvider) {
this.providerRef = new WeakRef(provider)
}
/**
* Get the user's Cline Account key from the apiConfiguration
*/
private async getClineApiKey(): Promise<string | undefined> {
const provider = this.providerRef.deref()
if (!provider) {
return undefined
}
const { apiConfiguration } = await provider.getStateToPostToWebview()
return apiConfiguration?.clineApiKey
}
/**
* Helper function to make authenticated requests to the Cline API
* @param endpoint The API endpoint to call (without the base URL)
* @param config Additional axios request configuration
* @returns The API response data
* @throws Error if the API key is not found or the request fails
*/
private async authenticatedRequest<T>(endpoint: string, config: AxiosRequestConfig = {}): Promise<T> {
const clineApiKey = await this.getClineApiKey()
if (!clineApiKey) {
throw new Error("Cline API key not found")
}
const url = `${this.baseUrl}${endpoint}`
const requestConfig: AxiosRequestConfig = {
...config,
headers: {
Authorization: `Bearer ${clineApiKey}`,
"Content-Type": "application/json",
...config.headers,
},
}
const response: AxiosResponse<T> = await axios.get(url, requestConfig)
if (!response.data) {
throw new Error(`Invalid response from ${endpoint} API`)
}
return response.data
}
/**
* Fetches the user's current credit balance
*/
async fetchBalance(): Promise<BalanceResponse | undefined> {
try {
const data = await this.authenticatedRequest<BalanceResponse>("/user/credits/balance")
// Post to webview
await this.providerRef.deref()?.postMessageToWebview({
type: "userCreditsBalance",
userCreditsBalance: data,
})
return data
} catch (error) {
console.error("Failed to fetch balance:", error)
return undefined
}
}
/**
* Fetches the user's usage transactions
*/
async fetchUsageTransactions(): Promise<UsageTransaction[] | undefined> {
try {
const data = await this.authenticatedRequest<UsageTransaction[]>("/user/credits/usage")
// Post to webview
await this.providerRef.deref()?.postMessageToWebview({
type: "userCreditsUsage",
userCreditsUsage: data,
})
return data
} catch (error) {
console.error("Failed to fetch usage transactions:", error)
return undefined
}
}
/**
* Fetches the user's payment transactions
*/
async fetchPaymentTransactions(): Promise<PaymentTransaction[] | undefined> {
try {
const data = await this.authenticatedRequest<PaymentTransaction[]>("/user/credits/payments")
// Post to webview
await this.providerRef.deref()?.postMessageToWebview({
type: "userCreditsPayments",
userCreditsPayments: data,
})
return data
} catch (error) {
console.error("Failed to fetch payment transactions:", error)
return undefined
}
}
}
+5 -5
View File
@@ -5,7 +5,7 @@ import { Browser, Page, ScreenshotOptions, TimeoutError, launch } from "puppetee
// @ts-ignore
import PCR from "puppeteer-chromium-resolver"
import pWaitFor from "p-wait-for"
import delay from "delay"
import { setTimeout as setTimeoutPromise } from "node:timers/promises"
import { fileExistsAtPath } from "../../utils/fs"
import { BrowserActionResult } from "../../shared/ExtensionMessage"
import { BrowserSettings } from "../../shared/BrowserSettings"
@@ -295,7 +295,7 @@ export class BrowserSession {
}
lastHTMLSize = currentHTMLSize
await delay(checkDurationMsecs)
await setTimeoutPromise(checkDurationMsecs)
}
}
@@ -314,7 +314,7 @@ export class BrowserSession {
this.currentMousePosition = coordinate
// Small delay to check if click triggered any network activity
await delay(100)
await setTimeoutPromise(100)
if (hasNetworkActivity) {
// If we detected network activity, wait for navigation/loading
@@ -346,7 +346,7 @@ export class BrowserSession {
behavior: "auto",
})
})
await delay(300)
await setTimeoutPromise(300)
})
}
@@ -358,7 +358,7 @@ export class BrowserSession {
behavior: "auto",
})
})
await delay(300)
await setTimeoutPromise(300)
})
}
}
+7 -1
View File
@@ -4,6 +4,7 @@ import * as path from "path"
import { arePathsEqual } from "../../utils/path"
export async function listFiles(dirPath: string, recursive: boolean, limit: number): Promise<[string[], boolean]> {
// First resolve the path normally - path.resolve doesn't care about glob special characters
const absolutePath = path.resolve(dirPath)
// Do not allow listing files in root or home directory, which cline tends to want to do when the user's prompt is vague.
const root = process.platform === "win32" ? path.parse(absolutePath).root : "/"
@@ -48,6 +49,7 @@ export async function listFiles(dirPath: string, recursive: boolean, limit: numb
}
// * globs all files in one dir, ** globs files in nested directories
// For non-recursive listing, we still use a simple pattern
const filePaths = recursive ? await globbyLevelByLevel(limit, options) : (await globby("*", options)).slice(0, limit)
return [filePaths, filePaths.length >= limit]
@@ -80,7 +82,11 @@ async function globbyLevelByLevel(limit: number, options?: Options) {
}
results.add(file)
if (file.endsWith("/")) {
queue.push(`${file}*`)
// Escape parentheses in the path to prevent glob pattern interpretation
// This is crucial for NextJS folder naming conventions which use parentheses like (auth), (dashboard)
// Without escaping, glob treats parentheses as special pattern grouping characters
const escapedFile = file.replace(/\(/g, "\\(").replace(/\)/g, "\\)")
queue.push(`${escapedFile}*`)
}
}
}
+4 -3
View File
@@ -8,13 +8,13 @@ import {
ReadResourceResultSchema,
} from "@modelcontextprotocol/sdk/types.js"
import chokidar, { FSWatcher } from "chokidar"
import delay from "delay"
import { setTimeout as setTimeoutPromise } from "node:timers/promises"
import deepEqual from "fast-deep-equal"
import * as fs from "fs/promises"
import * as path from "path"
import * as vscode from "vscode"
import { z } from "zod"
import { ClineProvider, GlobalFileNames } from "../../core/webview/ClineProvider"
import { ClineProvider } from "../../core/webview/ClineProvider"
import {
DEFAULT_MCP_TIMEOUT_SECONDS,
McpMode,
@@ -29,6 +29,7 @@ import {
import { fileExistsAtPath } from "../../utils/fs"
import { arePathsEqual } from "../../utils/path"
import { secondsToMs } from "../../utils/time"
import { GlobalFileNames } from "../../global-constants"
export type McpConnection = {
server: McpServer
client: Client
@@ -414,7 +415,7 @@ export class McpHub {
connection.server.status = "connecting"
connection.server.error = ""
await this.notifyWebviewOfServerChanges()
await delay(500) // artificial delay to show user that server is restarting
await setTimeoutPromise(500) // artificial delay to show user that server is restarting
try {
await this.deleteConnection(serverName)
// Try to connect again using existing config
@@ -0,0 +1,312 @@
import { context, SpanKind, trace } from "@opentelemetry/api"
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http"
import { Resource } from "@opentelemetry/resources"
import { SimpleSpanProcessor } from "@opentelemetry/sdk-trace-base"
import { NodeTracerProvider } from "@opentelemetry/sdk-trace-node"
import { ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION } from "@opentelemetry/semantic-conventions"
import { Anthropic } from "@anthropic-ai/sdk"
import * as vscode from "vscode"
import { ClineProvider } from "../../core/webview/ClineProvider"
export type TelemetryChatMessage = {
role: "user" | "assistant" | "system"
ts: number
content: Anthropic.Messages.MessageParam["content"]
}
interface ConversationMetadata {
apiProvider?: string
model?: string
tokensIn: number
tokensOut: number
}
const { IS_DEV } = process.env
/**
Cline Telemetry (currently only available in DEV builds)
Advanced Setting to opt-in to LLM observability, allowing you to share message data, code, and more extensive telemetry to help improve prompts used in Cline, train our models, and understand failure states more accurately.
"cline.conversationTelemetry": {
"type": "boolean",
"default": false,
"markdownDescription": "Share message data, code, and more extensive telemetry. This data may be used to improve prompts used in Cline, train models, and understand failure states more accurately. [Learn more](https://docs.cline.bot/more-info/llm-observability)"
}
*/
export class ConversationTelemetryService {
private providerRef: WeakRef<ClineProvider>
private distinctId: string = vscode.env.machineId
private apiEndpoint: string = "https://api.cline.bot/v1/traces"
private tracerProvider: NodeTracerProvider | undefined
private tracer: any
private messageIndices: Map<string, number> = new Map()
constructor(provider: ClineProvider) {
this.providerRef = new WeakRef(provider)
}
private async getClineApiKey(): Promise<string | undefined> {
const provider = this.providerRef.deref()
if (!provider) {
return undefined
}
const { apiConfiguration } = await provider.getStateToPostToWebview()
return apiConfiguration?.clineApiKey
}
public isOptedInToConversationTelemetry(): boolean {
// First check global telemetry level - telemetry should only be enabled when level is "all"
const telemetryLevel = vscode.workspace.getConfiguration("telemetry").get<string>("telemetryLevel", "all")
const isGlobalTelemetryEnabled = telemetryLevel === "all"
// User has to manually opt in to conversation telemetry in Advanced Settings
const isConversationTelemetryEnabled =
vscode.workspace.getConfiguration("cline").get<boolean>("conversationTelemetry") ?? false
// Currently only enabled in dev environment
const isDevEnvironment = !!IS_DEV
return isDevEnvironment && isGlobalTelemetryEnabled && isConversationTelemetryEnabled
}
private async initializeTracer() {
try {
// Create a resource that identifies our service
const resource = new Resource({
[ATTR_SERVICE_NAME]: "cline-extension",
[ATTR_SERVICE_VERSION]: "1.0.0",
})
const clineApiKey = await this.getClineApiKey()
console.log("[ConversationTelemetry] Initializing OpenTelemetry tracer...")
// Configure the OTLP exporter
const headers: Record<string, string> = {
"Content-Type": "application/json",
}
// Add API key to headers if available
if (clineApiKey) {
headers["Authorization"] = `Bearer ${clineApiKey}`
}
const exporter = new OTLPTraceExporter({
url: this.apiEndpoint,
headers,
})
// Create the span processor
const spanProcessor = new SimpleSpanProcessor(exporter as any)
// Create the trace provider with the span processor in the config
this.tracerProvider = new NodeTracerProvider({
resource,
spanProcessors: [spanProcessor as any],
})
// Register the provider
this.tracerProvider.register()
// Get a tracer
this.tracer = trace.getTracer("cline-conversation-tracer")
console.log("[ConversationTelemetry] OpenTelemetry tracer initialized successfully")
} catch (error) {
console.error("[ConversationTelemetry] Failed to initialize OpenTelemetry tracer:", error)
}
}
/**
* Captures a message in the conversation as an OpenTelemetry span
* ONLY HAPPENS IF USER IS OPTED INTO CONVERSATION TELEMETRY IN ADVANCED SETTINGS
*/
public async captureMessage(taskId: string, message: TelemetryChatMessage, metadata: ConversationMetadata) {
// Do NOT capture message if user has not explicitly opted in
if (!this.isOptedInToConversationTelemetry()) {
return
}
if (!this.tracer) {
await this.initializeTracer()
}
try {
// Convert taskId to a valid trace ID (must be 32 hex chars)
const traceId = this.generateTraceIdFromTimestamp(taskId)
// Convert message timestamp to a valid span ID (must be 16 hex chars)
if (!message.ts && message.ts !== 0) {
throw new Error("Message timestamp is required")
}
const timestamp = message.ts
const spanId = this.generateSpanIdFromTimestamp(timestamp)
// Create a span context with our IDs
const spanContext = trace.setSpanContext(context.active(), {
traceId,
spanId,
isRemote: false,
traceFlags: 1, // Sampled
})
// Start a new span with the context
const span = this.tracer.startSpan(
`message.${message.role}`,
{
kind: SpanKind.CLIENT,
startTime: this.millisecondsToHrTime(timestamp), // Convert to nanoseconds
},
spanContext,
)
// Get the message index for this task
const messageIndex = this.getNextMessageIndex(taskId)
// Add attributes to the span
span.setAttribute("task.id", taskId)
span.setAttribute("user.id", this.distinctId)
span.setAttribute("message.role", message.role)
span.setAttribute("message.timestamp", timestamp)
span.setAttribute("message.index", messageIndex)
const c = message.content
// Add Braintrust-compatible attributes
span.setAttribute("gen_ai.request.model", metadata.model)
if (message.role === "user") {
span.setAttribute("gen_ai.prompt", this.extractContent(message))
} else if (message.role === "assistant") {
span.setAttribute("gen_ai.completion", this.extractContent(message))
span.setAttribute("gen_ai.usage.prompt_tokens", metadata.tokensIn)
span.setAttribute("gen_ai.usage.completion_tokens", metadata.tokensOut)
} else if (message.role === "system") {
span.setAttribute("gen_ai.system_prompt", this.extractContent(message))
}
// Add custom metadata in Braintrust format
span.setAttribute("braintrust.metadata.api_provider", metadata.apiProvider)
span.setAttribute("braintrust.metadata.ts", message.ts)
// End the span immediately since messages are discrete events
span.end(this.millisecondsToHrTime(timestamp)) // Convert to nanoseconds
console.log(`[ConversationTelemetry] Captured ${message.role} message for task ${taskId}`, { span })
} catch (error) {
console.error("[ConversationTelemetry] Error capturing message:", error)
}
}
/**
* Convert a decimal timestamp to a valid trace ID (32 hex chars)
*/
private generateTraceIdFromTimestamp(timestamp: string): string {
// Pad with zeros and convert to hex
const hex = BigInt(timestamp).toString(16).padStart(32, "0")
return hex.substring(0, 32) // Ensure it's exactly 32 chars
}
/**
* Converts milliseconds to high-resolution time format expected by OpenTelemetry
* Returns [seconds, nanoseconds]
*/
private millisecondsToHrTime(milliseconds: number): [number, number] {
return [
Math.floor(milliseconds / 1000), // seconds
(milliseconds % 1000) * 1000000, // nanoseconds (remainder in ms * 10^6)
]
}
/**
* Convert a decimal timestamp to a valid span ID (16 hex chars)
*/
private generateSpanIdFromTimestamp(timestamp: number): string {
// Pad with zeros and convert to hex
const hex = BigInt(timestamp).toString(16).padStart(16, "0")
return hex.substring(0, 16) // Ensure it's exactly 16 chars
}
/**
* Helper to extract content from different message formats
*/
private extractContent(message: TelemetryChatMessage): string {
if (typeof message.content === "string") {
return message.content
}
return message.content
.map((block) => (block.type === "text" ? block.text : null))
.filter(Boolean)
.join("\n")
}
/**
* Track message indices per task
*/
private getNextMessageIndex(taskId: string): number {
const currentIndex = this.messageIndices.get(taskId) || 0
this.messageIndices.set(taskId, currentIndex + 1)
return currentIndex
}
/**
* Sends conversation data to cleanup endpoint to remove deleted messages from telemetry
* ONLY HAPPENS IF USER IS OPTED INTO CONVERSATION TELEMETRY IN ADVANCED SETTINGS
*/
public async cleanupTask(taskId: string, conversationData: any): Promise<void> {
// Do NOT send data if user has not explicitly opted in
if (!this.isOptedInToConversationTelemetry()) {
return
}
const clineApiKey = await this.getClineApiKey()
if (!clineApiKey) {
return
}
try {
// Configure the headers with API key
const headers: Record<string, string> = {
"Content-Type": "application/json",
}
// Add API key to headers
headers["Authorization"] = `Bearer ${clineApiKey}`
// Send the data to the cleanup endpoint
const cleanupEndpoint = `${this.apiEndpoint.replace("/traces", "/traces/cleanup")}`
// Use fetch API to send the data
const response = await fetch(cleanupEndpoint, {
method: "POST",
headers,
body: JSON.stringify({
taskId: taskId,
conversationData,
userId: this.distinctId,
}),
})
if (!response.ok) {
throw new Error(`Failed to send cleanup data: ${response.status} ${response.statusText}`)
}
console.log(`[ConversationTelemetry] Cleanup data sent for task ${taskId}`)
} catch (error) {
console.error("[ConversationTelemetry] Error sending cleanup data:", error)
}
}
/**
* Shutdown the tracer provider
*/
public async shutdown(): Promise<void> {
if (this.tracerProvider) {
await this.tracerProvider.shutdown()
}
}
}
+16 -2
View File
@@ -1,5 +1,6 @@
import { PostHog } from "posthog-node"
import * as vscode from "vscode"
import { version as extensionVersion } from "../../../package.json"
/**
* PostHogClient handles telemetry event tracking for the Cline extension
@@ -65,6 +66,8 @@ class PostHogClient {
private distinctId: string = vscode.env.machineId
/** Whether telemetry is currently enabled based on user and VSCode settings */
private telemetryEnabled: boolean = false
/** Current version of the extension */
private readonly version: string = extensionVersion
/**
* Private constructor to enforce singleton pattern
@@ -120,7 +123,12 @@ class PostHogClient {
public capture(event: { event: string; properties?: any }): void {
// Only send events if telemetry is enabled
if (this.telemetryEnabled) {
this.client.capture({ distinctId: this.distinctId, event: event.event, properties: event.properties })
// Include extension version in all event properties
const propertiesWithVersion = {
...event.properties,
extension_version: this.version,
}
this.client.capture({ distinctId: this.distinctId, event: event.event, properties: propertiesWithVersion })
}
}
@@ -250,13 +258,19 @@ class PostHogClient {
* Records interactions with the git-based checkpoint system
* @param taskId Unique identifier for the task
* @param action The type of checkpoint action
* @param durationMs Optional duration of the operation in milliseconds
*/
public captureCheckpointUsage(taskId: string, action: "shadow_git_initialized" | "commit_created" | "restored") {
public captureCheckpointUsage(
taskId: string,
action: "shadow_git_initialized" | "commit_created" | "restored" | "diff_generated",
durationMs?: number,
) {
this.capture({
event: PostHogClient.EVENTS.TASK.CHECKPOINT_USED,
properties: {
taskId,
action,
durationMs,
},
})
}
+18
View File
@@ -0,0 +1,18 @@
export interface BalanceResponse {
currentBalance: number
}
export interface UsageTransaction {
spentAt: string
credits: string
modelProvider: string
model: string
promptTokens: string
completionTokens: string
}
export interface PaymentTransaction {
paidAt: string
amountCents: string
credits: string
}
+34 -11
View File
@@ -8,6 +8,7 @@ import { ChatSettings } from "./ChatSettings"
import { HistoryItem } from "./HistoryItem"
import { McpServer, McpMarketplaceCatalog, McpMarketplaceItem, McpDownloadResponse } from "./mcp"
import { TelemetrySetting } from "./TelemetrySetting"
import type { BalanceResponse, UsageTransaction, PaymentTransaction } from "../shared/ClineAccount"
// webview will hold state
export interface ExtensionMessage {
@@ -34,6 +35,11 @@ export interface ExtensionMessage {
| "openGraphData"
| "isImageUrlResult"
| "didUpdateSettings"
| "userCreditsBalance"
| "userCreditsUsage"
| "userCreditsPayments"
| "totalTasksSize"
| "addToInput"
text?: string
action?:
| "chatButtonClicked"
@@ -43,6 +49,7 @@ export interface ExtensionMessage {
| "didBecomeVisible"
| "accountLoginClicked"
| "accountLogoutClicked"
| "accountButtonClicked"
invoke?: Invoke
state?: ExtensionState
images?: string[]
@@ -69,6 +76,10 @@ export interface ExtensionMessage {
}
url?: string
isImage?: boolean
userCreditsBalance?: BalanceResponse
userCreditsUsage?: UsageTransaction[]
userCreditsPayments?: PaymentTransaction[]
totalTasksSize?: number | null
}
export type Invoke = "sendMessage" | "primaryButtonClick" | "secondaryButtonClick"
@@ -78,27 +89,27 @@ export type Platform = "aix" | "darwin" | "freebsd" | "linux" | "openbsd" | "sun
export const DEFAULT_PLATFORM = "unknown"
export interface ExtensionState {
version: string
apiConfiguration?: ApiConfiguration
customInstructions?: string
uriScheme?: string
currentTaskItem?: HistoryItem
checkpointTrackerErrorMessage?: string
clineMessages: ClineMessage[]
taskHistory: HistoryItem[]
shouldShowAnnouncement: boolean
autoApprovalSettings: AutoApprovalSettings
browserSettings: BrowserSettings
chatSettings: ChatSettings
checkpointTrackerErrorMessage?: string
clineMessages: ClineMessage[]
currentTaskItem?: HistoryItem
customInstructions?: string
mcpMarketplaceEnabled?: boolean
planActSeparateModelsSetting: boolean
platform: Platform
shouldShowAnnouncement: boolean
taskHistory: HistoryItem[]
telemetrySetting: TelemetrySetting
uriScheme?: string
userInfo?: {
displayName: string | null
email: string | null
photoURL: string | null
}
mcpMarketplaceEnabled?: boolean
telemetrySetting: TelemetrySetting
planActSeparateModelsSetting: boolean
version: string
vscMachineId: string
}
@@ -199,6 +210,18 @@ export interface ClineAskUseMcpServer {
uri?: string
}
export interface ClinePlanModeResponse {
response: string
options?: string[]
selected?: string
}
export interface ClineAskQuestion {
question: string
options?: string[]
selected?: string
}
export interface ClineApiReqInfo {
request?: string
tokensIn?: number
+4
View File
@@ -45,6 +45,7 @@ export interface WebviewMessage {
| "getLatestState"
| "accountLoginClicked"
| "accountLogoutClicked"
| "showAccountViewClicked"
| "authStateChanged"
| "authCallback"
| "fetchMcpMarketplace"
@@ -61,6 +62,9 @@ export interface WebviewMessage {
| "invoke"
| "updateSettings"
| "clearAllTaskHistory"
| "fetchUserCreditsData"
| "optionsResponse"
| "requestTotalTasksSize"
// | "relaunchChromeDebugMode"
text?: string
disabled?: boolean
+151 -1
View File
@@ -18,6 +18,7 @@ export type ApiProvider =
| "litellm"
| "asksage"
| "xai"
| "sambanova"
export interface ApiHandlerOptions {
apiModelId?: string
@@ -30,6 +31,7 @@ export interface ApiHandlerOptions {
openRouterApiKey?: string
openRouterModelId?: string
openRouterModelInfo?: ModelInfo
openRouterProviderSorting?: string
awsAccessKey?: string
awsSecretKey?: string
awsSessionToken?: string
@@ -38,6 +40,7 @@ export interface ApiHandlerOptions {
awsBedrockUsePromptCache?: boolean
awsUseProfile?: boolean
awsProfile?: string
awsBedrockEndpoint?: string
vertexProjectId?: string
vertexRegion?: string
openAiBaseUrl?: string
@@ -66,6 +69,7 @@ export interface ApiHandlerOptions {
asksageApiKey?: string
xaiApiKey?: string
thinkingBudgetTokens?: number
sambanovaApiKey?: string
}
export type ApiConfiguration = ApiHandlerOptions & {
@@ -155,6 +159,33 @@ export const anthropicModels = {
export type BedrockModelId = keyof typeof bedrockModels
export const bedrockDefaultModelId: BedrockModelId = "anthropic.claude-3-7-sonnet-20250219-v1:0"
export const bedrockModels = {
"amazon.nova-pro-v1:0": {
maxTokens: 5000,
contextWindow: 300_000,
supportsImages: true,
supportsComputerUse: false,
supportsPromptCache: false,
inputPrice: 0.8,
outputPrice: 3.2,
},
"amazon.nova-lite-v1:0": {
maxTokens: 5000,
contextWindow: 300_000,
supportsImages: true,
supportsComputerUse: false,
supportsPromptCache: false,
inputPrice: 0.06,
outputPrice: 0.24,
},
"amazon.nova-micro-v1:0": {
maxTokens: 5000,
contextWindow: 128_000,
supportsImages: false,
supportsComputerUse: false,
supportsPromptCache: false,
inputPrice: 0.035,
outputPrice: 0.14,
},
"anthropic.claude-3-7-sonnet-20250219-v1:0": {
maxTokens: 8192,
contextWindow: 200_000,
@@ -219,6 +250,14 @@ export const bedrockModels = {
inputPrice: 0.25,
outputPrice: 1.25,
},
"deepseek.r1-v1:0": {
maxTokens: 8_000,
contextWindow: 64_000,
supportsImages: false,
supportsPromptCache: false,
inputPrice: 1.35,
outputPrice: 5.4,
},
} as const satisfies Record<string, ModelInfo>
// OpenRouter
@@ -566,11 +605,19 @@ export const openAiNativeModels = {
outputPrice: 0.6,
cacheReadsPrice: 0.075,
},
"gpt-4.5-preview": {
"chatgpt-4o-latest": {
maxTokens: 16_384,
contextWindow: 128_000,
supportsImages: true,
supportsPromptCache: false,
inputPrice: 5,
outputPrice: 15,
},
"gpt-4.5-preview": {
maxTokens: 16_384,
contextWindow: 128_000,
supportsImages: true,
supportsPromptCache: true,
inputPrice: 75,
outputPrice: 150,
},
@@ -1077,6 +1124,14 @@ export const mistralModels = {
inputPrice: 0.1,
outputPrice: 0.1,
},
"mistral-small-latest": {
maxTokens: 131_000,
contextWindow: 131_000,
supportsImages: true,
supportsPromptCache: false,
inputPrice: 0.1,
outputPrice: 0.3,
},
"mistral-small-2501": {
maxTokens: 32_000,
contextWindow: 32_000,
@@ -1258,3 +1313,98 @@ export const xaiModels = {
description: "X AI's Grok Beta model (legacy) with 131K context window",
},
} as const satisfies Record<string, ModelInfo>
// SambaNova
// https://docs.sambanova.ai/cloud/docs/get-started/supported-models
export type SambanovaModelId = keyof typeof sambanovaModels
export const sambanovaDefaultModelId: SambanovaModelId = "Meta-Llama-3.3-70B-Instruct"
export const sambanovaModels = {
"Meta-Llama-3.3-70B-Instruct": {
maxTokens: 4096,
contextWindow: 128_000,
supportsImages: false,
supportsPromptCache: false,
inputPrice: 0,
outputPrice: 0,
},
"DeepSeek-R1-Distill-Llama-70B": {
maxTokens: 4096,
contextWindow: 32_000,
supportsImages: false,
supportsPromptCache: false,
inputPrice: 0,
outputPrice: 0,
},
"Llama-3.1-Swallow-70B-Instruct-v0.3": {
maxTokens: 4096,
contextWindow: 16_000,
supportsImages: false,
supportsPromptCache: false,
inputPrice: 0,
outputPrice: 0,
},
"Llama-3.1-Swallow-8B-Instruct-v0.3": {
maxTokens: 4096,
contextWindow: 16_000,
supportsImages: false,
supportsPromptCache: false,
inputPrice: 0,
outputPrice: 0,
},
"Meta-Llama-3.1-405B-Instruct": {
maxTokens: 4096,
contextWindow: 16_000,
supportsImages: false,
supportsPromptCache: false,
inputPrice: 0,
outputPrice: 0,
},
"Meta-Llama-3.1-8B-Instruct": {
maxTokens: 4096,
contextWindow: 16_000,
supportsImages: false,
supportsPromptCache: false,
inputPrice: 0,
outputPrice: 0,
},
"Meta-Llama-3.2-1B-Instruct": {
maxTokens: 4096,
contextWindow: 16_000,
supportsImages: false,
supportsPromptCache: false,
inputPrice: 0,
outputPrice: 0,
},
"Qwen2.5-72B-Instruct": {
maxTokens: 4096,
contextWindow: 16_000,
supportsImages: false,
supportsPromptCache: false,
inputPrice: 0,
outputPrice: 0,
},
"Qwen2.5-Coder-32B-Instruct": {
maxTokens: 4096,
contextWindow: 16_000,
supportsImages: false,
supportsPromptCache: false,
inputPrice: 0,
outputPrice: 0,
},
"QwQ-32B-Preview": {
maxTokens: 4096,
contextWindow: 16_000,
supportsImages: false,
supportsPromptCache: false,
inputPrice: 0,
outputPrice: 0,
},
"QwQ-32B": {
maxTokens: 4096,
contextWindow: 16_000,
supportsImages: false,
supportsPromptCache: false,
inputPrice: 0.5,
outputPrice: 1.0,
},
} as const satisfies Record<string, ModelInfo>
+34
View File
@@ -20,3 +20,37 @@ export function findLast<T>(array: Array<T>, predicate: (value: T, index: number
const index = findLastIndex(array, predicate)
return index === -1 ? undefined : array[index]
}
/**
* Converts a partial or complete stringified array into an actual array.
* Handles both complete JSON strings and incomplete array strings.
* Splits on the specific tokens: [" ", " "]
* @param arrayString A string representation of an array, which may be incomplete
* @returns Array of strings parsed from the input
*/
export function parsePartialArrayString(arrayString: string): string[] {
try {
// Try parsing as complete JSON first
return JSON.parse(arrayString)
} catch {
// If JSON parsing fails, handle as partial string
const trimmed = arrayString.trim()
if (!trimmed.startsWith('["')) {
return []
}
// Remove leading ["
let content = trimmed.slice(2)
// Remove trailing "] if it exists
content = content.replace(/"]$/, "")
if (!content) {
return []
}
// Split on ", " token and handle the parts
return content
.split('", "')
.map((item) => item.trim())
.filter(Boolean)
}
}
+21 -1
View File
@@ -3,7 +3,7 @@ import { after, describe, it } from "mocha"
import * as os from "os"
import * as path from "path"
import "should"
import { createDirectoriesForFile, fileExistsAtPath } from "./fs"
import { createDirectoriesForFile, fileExistsAtPath, isDirectory } from "./fs"
describe("Filesystem Utilities", () => {
const tmpDir = path.join(os.tmpdir(), "cline-test-" + Math.random().toString(36).slice(2))
@@ -68,4 +68,24 @@ describe("Filesystem Utilities", () => {
exists.should.be.true()
})
})
describe("isDirectory", () => {
it("should return true for directories", async () => {
await fs.mkdir(tmpDir, { recursive: true })
const isDir = await isDirectory(tmpDir)
isDir.should.be.true()
})
it("should return false for files", async () => {
const testFile = path.join(tmpDir, "test.txt")
await fs.writeFile(testFile, "test")
const isDir = await isDirectory(testFile)
isDir.should.be.false()
})
it("should return false for non-existent paths", async () => {
const nonExistentPath = path.join(tmpDir, "does-not-exist")
const isDir = await isDirectory(nonExistentPath)
isDir.should.be.false()
})
})
})
+29
View File
@@ -45,3 +45,32 @@ export async function fileExistsAtPath(filePath: string): Promise<boolean> {
return false
}
}
/**
* Checks if the path is a directory
* @param filePath - The path to check.
* @returns A promise that resolves to true if the path is a directory, false otherwise.
*/
export async function isDirectory(filePath: string): Promise<boolean> {
try {
const stats = await fs.stat(filePath)
return stats.isDirectory()
} catch {
return false
}
}
/**
* Gets the size of a file in kilobytes
* @param filePath - Path to the file to check
* @returns Promise<number> - Size of the file in KB, or 0 if file doesn't exist
*/
export async function getFileSizeInKB(filePath: string): Promise<number> {
try {
const stats = await fs.stat(filePath)
const fileSizeInKB = stats.size / 1000 // Convert bytes to KB (decimal) - matches OS file size display
return fileSizeInKB
} catch {
return 0
}
}
+21
View File
@@ -0,0 +1,21 @@
import path from "path"
import getFolderSize from "get-folder-size"
/**
* Gets the total size of tasks and checkpoints directories
* @param storagePath The base storage path (typically globalStorageUri.fsPath)
* @returns The total size in bytes, or null if calculation fails
*/
export async function getTotalTasksSize(storagePath: string): Promise<number | null> {
const tasksDir = path.join(storagePath, "tasks")
const checkpointsDir = path.join(storagePath, "checkpoints")
try {
const tasksSize = await getFolderSize.loose(tasksDir)
const checkpointsSize = await getFolderSize.loose(checkpointsDir)
return tasksSize + checkpointsSize
} catch (error) {
console.error("Failed to calculate total task size:", error)
return null
}
}
+550 -13
View File
@@ -21,6 +21,7 @@
"posthog-js": "^1.224.0",
"pretty-bytes": "^6.1.1",
"react": "^18.3.1",
"react-countup": "^6.5.3",
"react-dom": "^18.3.1",
"react-remark": "^2.1.0",
"react-textarea-autosize": "^8.5.7",
@@ -31,6 +32,7 @@
},
"devDependencies": {
"@eslint/js": "^9.17.0",
"@tailwindcss/vite": "^4.0.12",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.2.0",
"@testing-library/user-event": "^14.6.1",
@@ -45,9 +47,10 @@
"eslint-plugin-react-refresh": "^0.4.16",
"globals": "^15.14.0",
"jsdom": "^26.0.0",
"tailwindcss": "^4.0.12",
"typescript": "^5.7.3",
"typescript-eslint": "^8.18.2",
"vite": "^6.1.1",
"vite": "^6.2.1",
"vitest": "^3.0.5"
}
},
@@ -125,9 +128,9 @@
}
},
"node_modules/@babel/runtime": {
"version": "7.26.0",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.26.0.tgz",
"integrity": "sha512-FDSOghenHTiToteC/QRlv2q3DhPZ/oOXTBoirfWNx1Cx3TMVcGWQtMMmQcSvb/JjpNeGzx8Pq/b4fKEJuWm1sw==",
"version": "7.26.10",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.26.10.tgz",
"integrity": "sha512-2WJMeRQPHKSPemqk/awGrAiuFfzBmOIPXKizAsVhWH9YJqLZ0H+HS4c8loHGgW6utJ3E/ejXQUsiGaQy2NZ9Fw==",
"license": "MIT",
"dependencies": {
"regenerator-runtime": "^0.14.0"
@@ -2438,6 +2441,244 @@
"@swc/counter": "^0.1.3"
}
},
"node_modules/@tailwindcss/node": {
"version": "4.0.12",
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.0.12.tgz",
"integrity": "sha512-a6J11K1Ztdln9OrGfoM75/hChYPcHYGNYimqciMrvKXRmmPaS8XZTHhdvb5a3glz4Kd4ZxE1MnuFE2c0fGGmtg==",
"dev": true,
"license": "MIT",
"dependencies": {
"enhanced-resolve": "^5.18.1",
"jiti": "^2.4.2",
"tailwindcss": "4.0.12"
}
},
"node_modules/@tailwindcss/oxide": {
"version": "4.0.12",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.0.12.tgz",
"integrity": "sha512-DWb+myvJB9xJwelwT9GHaMc1qJj6MDXRDR0CS+T8IdkejAtu8ctJAgV4r1drQJLPeS7mNwq2UHW2GWrudTf63A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 10"
},
"optionalDependencies": {
"@tailwindcss/oxide-android-arm64": "4.0.12",
"@tailwindcss/oxide-darwin-arm64": "4.0.12",
"@tailwindcss/oxide-darwin-x64": "4.0.12",
"@tailwindcss/oxide-freebsd-x64": "4.0.12",
"@tailwindcss/oxide-linux-arm-gnueabihf": "4.0.12",
"@tailwindcss/oxide-linux-arm64-gnu": "4.0.12",
"@tailwindcss/oxide-linux-arm64-musl": "4.0.12",
"@tailwindcss/oxide-linux-x64-gnu": "4.0.12",
"@tailwindcss/oxide-linux-x64-musl": "4.0.12",
"@tailwindcss/oxide-win32-arm64-msvc": "4.0.12",
"@tailwindcss/oxide-win32-x64-msvc": "4.0.12"
}
},
"node_modules/@tailwindcss/oxide-android-arm64": {
"version": "4.0.12",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.0.12.tgz",
"integrity": "sha512-dAXCaemu3mHLXcA5GwGlQynX8n7tTdvn5i1zAxRvZ5iC9fWLl5bGnjZnzrQqT7ttxCvRwdVf3IHUnMVdDBO/kQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tailwindcss/oxide-darwin-arm64": {
"version": "4.0.12",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.0.12.tgz",
"integrity": "sha512-vPNI+TpJQ7sizselDXIJdYkx9Cu6JBdtmRWujw9pVIxW8uz3O2PjgGGzL/7A0sXI8XDjSyRChrUnEW9rQygmJQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tailwindcss/oxide-darwin-x64": {
"version": "4.0.12",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.0.12.tgz",
"integrity": "sha512-RL/9jM41Fdq4Efr35C5wgLx98BirnrfwuD+zgMFK6Ir68HeOSqBhW9jsEeC7Y/JcGyPd3MEoJVIU4fAb7YLg7A==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tailwindcss/oxide-freebsd-x64": {
"version": "4.0.12",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.0.12.tgz",
"integrity": "sha512-7WzWiax+LguJcMEimY0Q4sBLlFXu1tYxVka3+G2M9KmU/3m84J3jAIV4KZWnockbHsbb2XgrEjtlJKVwHQCoRA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": {
"version": "4.0.12",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.0.12.tgz",
"integrity": "sha512-X9LRC7jjE1QlfIaBbXjY0PGeQP87lz5mEfLSVs2J1yRc9PSg1tEPS9NBqY4BU9v5toZgJgzKeaNltORyTs22TQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tailwindcss/oxide-linux-arm64-gnu": {
"version": "4.0.12",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.0.12.tgz",
"integrity": "sha512-i24IFNq2402zfDdoWKypXz0ZNS2G4NKaA82tgBlE2OhHIE+4mg2JDb5wVfyP6R+MCm5grgXvurcIcKWvo44QiQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tailwindcss/oxide-linux-arm64-musl": {
"version": "4.0.12",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.0.12.tgz",
"integrity": "sha512-LmOdshJBfAGIBG0DdBWhI0n5LTMurnGGJCHcsm9F//ISfsHtCnnYIKgYQui5oOz1SUCkqsMGfkAzWyNKZqbGNw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tailwindcss/oxide-linux-x64-gnu": {
"version": "4.0.12",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.0.12.tgz",
"integrity": "sha512-OSK667qZRH30ep8RiHbZDQfqkXjnzKxdn0oRwWzgCO8CoTxV+MvIkd0BWdQbYtYuM1wrakARV/Hwp0eA/qzdbw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tailwindcss/oxide-linux-x64-musl": {
"version": "4.0.12",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.0.12.tgz",
"integrity": "sha512-uylhWq6OWQ8krV8Jk+v0H/3AZKJW6xYMgNMyNnUbbYXWi7hIVdxRKNUB5UvrlC3RxtgsK5EAV2i1CWTRsNcAnA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
"version": "4.0.12",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.0.12.tgz",
"integrity": "sha512-XDLnhMoXZEEOir1LK43/gHHwK84V1GlV8+pAncUAIN2wloeD+nNciI9WRIY/BeFTqES22DhTIGoilSO39xDb2g==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tailwindcss/oxide-win32-x64-msvc": {
"version": "4.0.12",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.0.12.tgz",
"integrity": "sha512-I/BbjCLpKDQucvtn6rFuYLst1nfFwSMYyPzkx/095RE+tuzk5+fwXuzQh7T3fIBTcbn82qH/sFka7yPGA50tLw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tailwindcss/vite": {
"version": "4.0.12",
"resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.0.12.tgz",
"integrity": "sha512-JM3gp601UJiryIZ9R2bSqalzcOy15RCybQ1Q+BJqDEwVyo4LkWKeqQAcrpHapWXY31OJFTuOUVBFDWMhzHm2Bg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@tailwindcss/node": "4.0.12",
"@tailwindcss/oxide": "4.0.12",
"lightningcss": "^1.29.1",
"tailwindcss": "4.0.12"
},
"peerDependencies": {
"vite": "^5.2.0 || ^6"
}
},
"node_modules/@testing-library/dom": {
"version": "10.4.0",
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.0.tgz",
@@ -3937,6 +4178,12 @@
"layout-base": "^1.0.0"
}
},
"node_modules/countup.js": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/countup.js/-/countup.js-2.8.0.tgz",
"integrity": "sha512-f7xEhX0awl4NOElHulrl4XRfKoNH3rB+qfNSZZyjSZhaAoUk6elvhH+MNxMmlmuUJ2/QNTWPSA7U4mNtIAKljQ==",
"license": "MIT"
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -4585,6 +4832,16 @@
"node": ">=6"
}
},
"node_modules/detect-libc": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.3.tgz",
"integrity": "sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=8"
}
},
"node_modules/devlop": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz",
@@ -4620,6 +4877,20 @@
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"license": "MIT"
},
"node_modules/enhanced-resolve": {
"version": "5.18.1",
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.1.tgz",
"integrity": "sha512-ZSW3ma5GkcQBIpwZTSRAI8N71Uuwgs93IezB7mf7R60tC8ZbJideoDNKjHn2O9KIlx6rkGTTEk1xUCK2E1Y2Yg==",
"dev": true,
"license": "MIT",
"dependencies": {
"graceful-fs": "^4.2.4",
"tapable": "^2.2.0"
},
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/error-stack-parser": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/error-stack-parser/-/error-stack-parser-2.1.4.tgz",
@@ -5582,15 +5853,13 @@
"license": "ISC"
},
"node_modules/jiti": {
"version": "1.21.7",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz",
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.4.2.tgz",
"integrity": "sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"bin": {
"jiti": "bin/jiti.js"
"jiti": "lib/jiti-cli.mjs"
}
},
"node_modules/js-cookie": {
@@ -5749,6 +6018,245 @@
"node": ">= 0.8.0"
}
},
"node_modules/lightningcss": {
"version": "1.29.2",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.29.2.tgz",
"integrity": "sha512-6b6gd/RUXKaw5keVdSEtqFVdzWnU5jMxTUjA2bVcMNPLwSQ08Sv/UodBVtETLCn7k4S1Ibxwh7k68IwLZPgKaA==",
"dev": true,
"license": "MPL-2.0",
"dependencies": {
"detect-libc": "^2.0.3"
},
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
},
"optionalDependencies": {
"lightningcss-darwin-arm64": "1.29.2",
"lightningcss-darwin-x64": "1.29.2",
"lightningcss-freebsd-x64": "1.29.2",
"lightningcss-linux-arm-gnueabihf": "1.29.2",
"lightningcss-linux-arm64-gnu": "1.29.2",
"lightningcss-linux-arm64-musl": "1.29.2",
"lightningcss-linux-x64-gnu": "1.29.2",
"lightningcss-linux-x64-musl": "1.29.2",
"lightningcss-win32-arm64-msvc": "1.29.2",
"lightningcss-win32-x64-msvc": "1.29.2"
}
},
"node_modules/lightningcss-darwin-arm64": {
"version": "1.29.2",
"resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.29.2.tgz",
"integrity": "sha512-cK/eMabSViKn/PG8U/a7aCorpeKLMlK0bQeNHmdb7qUnBkNPnL+oV5DjJUo0kqWsJUapZsM4jCfYItbqBDvlcA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-darwin-x64": {
"version": "1.29.2",
"resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.29.2.tgz",
"integrity": "sha512-j5qYxamyQw4kDXX5hnnCKMf3mLlHvG44f24Qyi2965/Ycz829MYqjrVg2H8BidybHBp9kom4D7DR5VqCKDXS0w==",
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-freebsd-x64": {
"version": "1.29.2",
"resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.29.2.tgz",
"integrity": "sha512-wDk7M2tM78Ii8ek9YjnY8MjV5f5JN2qNVO+/0BAGZRvXKtQrBC4/cn4ssQIpKIPP44YXw6gFdpUF+Ps+RGsCwg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-arm-gnueabihf": {
"version": "1.29.2",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.29.2.tgz",
"integrity": "sha512-IRUrOrAF2Z+KExdExe3Rz7NSTuuJ2HvCGlMKoquK5pjvo2JY4Rybr+NrKnq0U0hZnx5AnGsuFHjGnNT14w26sg==",
"cpu": [
"arm"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-arm64-gnu": {
"version": "1.29.2",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.29.2.tgz",
"integrity": "sha512-KKCpOlmhdjvUTX/mBuaKemp0oeDIBBLFiU5Fnqxh1/DZ4JPZi4evEH7TKoSBFOSOV3J7iEmmBaw/8dpiUvRKlQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-arm64-musl": {
"version": "1.29.2",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.29.2.tgz",
"integrity": "sha512-Q64eM1bPlOOUgxFmoPUefqzY1yV3ctFPE6d/Vt7WzLW4rKTv7MyYNky+FWxRpLkNASTnKQUaiMJ87zNODIrrKQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-x64-gnu": {
"version": "1.29.2",
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.29.2.tgz",
"integrity": "sha512-0v6idDCPG6epLXtBH/RPkHvYx74CVziHo6TMYga8O2EiQApnUPZsbR9nFNrg2cgBzk1AYqEd95TlrsL7nYABQg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-x64-musl": {
"version": "1.29.2",
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.29.2.tgz",
"integrity": "sha512-rMpz2yawkgGT8RULc5S4WiZopVMOFWjiItBT7aSfDX4NQav6M44rhn5hjtkKzB+wMTRlLLqxkeYEtQ3dd9696w==",
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-win32-arm64-msvc": {
"version": "1.29.2",
"resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.29.2.tgz",
"integrity": "sha512-nL7zRW6evGQqYVu/bKGK+zShyz8OVzsCotFgc7judbt6wnB2KbiKKJwBE4SGoDBQ1O94RjW4asrCjQL4i8Fhbw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-win32-x64-msvc": {
"version": "1.29.2",
"resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.29.2.tgz",
"integrity": "sha512-EdIUW3B2vLuHmv7urfzMI/h2fmlnOQBk1xlsDxkN1tCWKjNFjfLhGxYk8C8mzpSfr+A6jFFIi8fU6LbQGsRWjA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/local-pkg": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-1.0.0.tgz",
@@ -6594,6 +7102,18 @@
"node": ">=0.10.0"
}
},
"node_modules/react-countup": {
"version": "6.5.3",
"resolved": "https://registry.npmjs.org/react-countup/-/react-countup-6.5.3.tgz",
"integrity": "sha512-udnqVQitxC7QWADSPDOxVWULkLvKUWrDapn5i53HE4DPRVgs+Y5rr4bo25qEl8jSh+0l2cToJgGMx+clxPM3+w==",
"license": "MIT",
"dependencies": {
"countup.js": "^2.8.0"
},
"peerDependencies": {
"react": ">= 16.3.0"
}
},
"node_modules/react-dom": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
@@ -7298,6 +7818,23 @@
"integrity": "sha512-QD9qKY3StfbZqWOPLp0++pOrAVb/HbUi5xCc8cUo4XjP19808oaMiDzn0leBY5mCespIBM0CIZePzZjgzR83kA==",
"license": "MIT"
},
"node_modules/tailwindcss": {
"version": "4.0.12",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.0.12.tgz",
"integrity": "sha512-bT0hJo91FtncsAMSsMzUkoo/iEU0Xs5xgFgVC9XmdM9bw5MhZuQFjPNl6wxAE0SiQF/YTZJa+PndGWYSDtuxAg==",
"dev": true,
"license": "MIT"
},
"node_modules/tapable": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.1.tgz",
"integrity": "sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/terser": {
"version": "5.37.0",
"resolved": "https://registry.npmjs.org/terser/-/terser-5.37.0.tgz",
@@ -7841,9 +8378,9 @@
}
},
"node_modules/vite": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.2.0.tgz",
"integrity": "sha512-7dPxoo+WsT/64rDcwoOjk76XHj+TqNTIvHKcuMQ1k4/SeHDaQt5GFAeLYzrimZrMpn/O6DtdI03WUjdxuPM0oQ==",
"version": "6.2.1",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.2.1.tgz",
"integrity": "sha512-n2GnqDb6XPhlt9B8olZPrgMD/es/Nd1RdChF6CBD/fHW6pUyUTt2sQW2fPRX5GiD9XEa6+8A6A4f2vT6pSsE7Q==",
"dev": true,
"license": "MIT",
"dependencies": {
+4 -1
View File
@@ -25,6 +25,7 @@
"posthog-js": "^1.224.0",
"pretty-bytes": "^6.1.1",
"react": "^18.3.1",
"react-countup": "^6.5.3",
"react-dom": "^18.3.1",
"react-remark": "^2.1.0",
"react-textarea-autosize": "^8.5.7",
@@ -35,6 +36,7 @@
},
"devDependencies": {
"@eslint/js": "^9.17.0",
"@tailwindcss/vite": "^4.0.12",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.2.0",
"@testing-library/user-event": "^14.6.1",
@@ -49,9 +51,10 @@
"eslint-plugin-react-refresh": "^0.4.16",
"globals": "^15.14.0",
"jsdom": "^26.0.0",
"tailwindcss": "^4.0.12",
"typescript": "^5.7.3",
"typescript-eslint": "^8.18.2",
"vite": "^6.1.1",
"vite": "^6.2.1",
"vitest": "^3.0.5"
}
}
+2 -1
View File
@@ -42,7 +42,7 @@ const AppContent = () => {
setShowMcp(true)
setShowAccount(false)
break
case "accountLoginClicked":
case "accountButtonClicked":
setShowSettings(false)
setShowHistory(false)
setShowMcp(false)
@@ -96,6 +96,7 @@ const AppContent = () => {
showHistoryView={() => {
setShowSettings(false)
setShowMcp(false)
setShowAccount(false)
setShowHistory(true)
}}
isHidden={showSettings || showHistory || showMcp || showAccount}
+11
View File
@@ -0,0 +1,11 @@
import { SVGProps } from "react"
const ClineLogoWhite = (props: SVGProps<SVGSVGElement>) => (
<svg xmlns="http://www.w3.org/2000/svg" width="47" height="50" viewBox="0 0 47 50" fill="none" {...props}>
<path
d="M46.4075 28.1192L43.5011 22.3166V18.9747C43.5011 13.4354 39.0302 8.94931 33.5162 8.94931H28.5491C28.9086 8.21513 29.106 7.3898 29.106 6.5189C29.106 3.44039 26.6149 0.949219 23.5363 0.949219C20.4578 0.949219 17.9667 3.44039 17.9667 6.5189C17.9667 7.3898 18.1641 8.21513 18.5236 8.94931H13.5565C8.04249 8.94931 3.57155 13.4354 3.57155 18.9747V22.3166L0.604424 28.104C0.305687 28.6863 0.305687 29.3799 0.604424 29.9622L3.57155 35.6838V39.0256C3.57155 44.5649 8.04249 49.0511 13.5565 49.0511H33.5162C39.0302 49.0511 43.5011 44.5649 43.5011 39.0256V35.6838L46.4024 29.942C46.691 29.3698 46.691 28.6964 46.4075 28.1192ZM20.4983 32.8483C20.4983 35.3648 18.4578 37.4053 15.9413 37.4053C13.4248 37.4053 11.3843 35.3648 11.3843 32.8483V24.747C11.3843 22.2305 13.4248 20.19 15.9413 20.19C18.4578 20.19 20.4983 22.2305 20.4983 24.747V32.8483ZM35.182 32.8483C35.182 35.3648 33.1415 37.4053 30.625 37.4053C28.1085 37.4053 26.068 35.3648 26.068 32.8483V24.747C26.068 22.2305 28.1085 20.19 30.625 20.19C33.1415 20.19 35.182 22.2305 35.182 24.747V32.8483Z"
fill="white"
/>
</svg>
)
export default ClineLogoWhite
+123 -125
View File
@@ -1,8 +1,12 @@
import { VSCodeButton, VSCodeDivider } from "@vscode/webview-ui-toolkit/react"
import { memo } from "react"
import { VSCodeButton, VSCodeDivider, VSCodeLink } from "@vscode/webview-ui-toolkit/react"
import { memo, useEffect, useState } from "react"
import { useFirebaseAuth } from "../../context/FirebaseAuthContext"
import { vscode } from "../../utils/vscode"
import VSCodeButtonLink from "../common/VSCodeButtonLink"
import ClineLogoWhite from "../../assets/ClineLogoWhite"
import CountUp from "react-countup"
import CreditsHistoryTable from "./CreditsHistoryTable"
import { UsageTransaction, PaymentTransaction } from "../../../../src/shared/ClineAccount"
type AccountViewProps = {
onDone: () => void
@@ -10,38 +14,13 @@ type AccountViewProps = {
const AccountView = ({ onDone }: AccountViewProps) => {
return (
<div
style={{
position: "fixed",
top: 0,
left: 0,
right: 0,
bottom: 0,
padding: "10px 0px 0px 20px",
display: "flex",
flexDirection: "column",
overflow: "hidden",
}}>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginBottom: "17px",
paddingRight: 17,
}}>
<h3 style={{ color: "var(--vscode-foreground)", margin: 0 }}>Cline Account</h3>
<div className="fixed inset-0 flex flex-col overflow-hidden pt-[10px] pl-[20px]">
<div className="flex justify-between items-center mb-[17px] pr-[17px]">
<h3 className="text-[var(--vscode-foreground)] m-0">Account</h3>
<VSCodeButton onClick={onDone}>Done</VSCodeButton>
</div>
<div
style={{
flexGrow: 1,
overflowY: "scroll",
paddingRight: 8,
display: "flex",
flexDirection: "column",
}}>
<div style={{ marginBottom: 5 }}>
<div className="flex-grow overflow-hidden pr-[8px] flex flex-col">
<div className="h-full mb-[5px]">
<ClineAccountView />
</div>
</div>
@@ -51,6 +30,37 @@ const AccountView = ({ onDone }: AccountViewProps) => {
export const ClineAccountView = () => {
const { user, handleSignOut } = useFirebaseAuth()
const [balance, setBalance] = useState(0)
const [isLoading, setIsLoading] = useState(true)
const [usageData, setUsageData] = useState<UsageTransaction[]>([])
const [paymentsData, setPaymentsData] = useState<PaymentTransaction[]>([])
// Listen for balance and transaction data updates from the extension
useEffect(() => {
const handleMessage = (event: MessageEvent) => {
const message = event.data
if (message.type === "userCreditsBalance" && message.userCreditsBalance) {
setBalance(message.userCreditsBalance.currentBalance)
} else if (message.type === "userCreditsUsage" && message.userCreditsUsage) {
setUsageData(message.userCreditsUsage.usageTransactions)
} else if (message.type === "userCreditsPayments" && message.userCreditsPayments) {
setPaymentsData(message.userCreditsPayments.paymentTransactions)
}
setIsLoading(false)
}
window.addEventListener("message", handleMessage)
// Fetch all account data when component mounts
if (user) {
setIsLoading(true)
vscode.postMessage({ type: "fetchUserCreditsData" })
}
return () => {
window.removeEventListener("message", handleMessage)
}
}, [user])
const handleLogin = () => {
vscode.postMessage({ type: "accountLoginClicked" })
@@ -63,108 +73,96 @@ export const ClineAccountView = () => {
handleSignOut()
}
return (
<div style={{ maxWidth: "600px" }}>
<div className="h-full flex flex-col">
{user ? (
<div
style={{
padding: "8px 10px",
border: "1px solid var(--vscode-input-border)",
borderRadius: "2px",
backgroundColor: "var(--vscode-dropdown-background)",
}}>
<div
style={{
display: "flex",
alignItems: "center",
gap: "8px",
}}>
{user.photoURL ? (
<img
src={user.photoURL}
alt="Profile"
style={{
width: 38,
height: 38,
borderRadius: "50%",
}}
/>
) : (
<div
style={{
width: 38,
height: 38,
borderRadius: "50%",
backgroundColor: "var(--vscode-button-background)",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: "20px",
color: "var(--vscode-button-foreground)",
}}>
{user.displayName?.[0] || user.email?.[0] || "?"}
</div>
)}
<div
style={{
display: "flex",
flexDirection: "column",
gap: "4px",
}}>
{user.displayName && (
<div
style={{
fontSize: "13px",
fontWeight: "bold",
color: "var(--vscode-foreground)",
}}>
{user.displayName}
<div className="flex flex-col pr-3 h-full">
<div className="flex flex-col w-full">
<div className="flex items-center mb-6 flex-wrap gap-y-4">
{user.photoURL ? (
<img src={user.photoURL} alt="Profile" className="size-16 rounded-full mr-4" />
) : (
<div className="size-16 rounded-full bg-[var(--vscode-button-background)] flex items-center justify-center text-2xl text-[var(--vscode-button-foreground)] mr-4">
{user.displayName?.[0] || user.email?.[0] || "?"}
</div>
)}
{user.email && (
<div
style={{
fontSize: "13px",
color: "var(--vscode-descriptionForeground)",
}}>
{user.email}
</div>
)}
<div style={{ display: "flex", gap: "8px", flexWrap: "wrap" }}>
<VSCodeButtonLink
href="https://app.cline.bot/credits"
appearance="primary"
style={{
transform: "scale(0.85)",
transformOrigin: "left center",
width: "fit-content",
marginTop: 2,
marginBottom: 0,
marginRight: -12,
}}>
Account
</VSCodeButtonLink>
<VSCodeButton
appearance="secondary"
onClick={handleLogout}
style={{
transform: "scale(0.85)",
transformOrigin: "left center",
width: "fit-content",
marginTop: 2,
marginBottom: 0,
marginRight: -12,
}}>
Log out
</VSCodeButton>
<div className="flex flex-col">
{user.displayName && (
<h2 className="text-[var(--vscode-foreground)] m-0 mb-1 text-lg font-medium">
{user.displayName}
</h2>
)}
{user.email && (
<div className="text-sm text-[var(--vscode-descriptionForeground)]">{user.email}</div>
)}
</div>
</div>
</div>
<div className="w-full flex gap-2 flex-col min-[225px]:flex-row">
<div className="w-full min-[225px]:w-1/2">
<VSCodeButtonLink href="https://app.cline.bot/credits" appearance="primary" className="w-full">
Dashboard
</VSCodeButtonLink>
</div>
<VSCodeButton appearance="secondary" onClick={handleLogout} className="w-full min-[225px]:w-1/2">
Log out
</VSCodeButton>
</div>
<VSCodeDivider className="w-full my-6" />
<div className="w-full flex flex-col items-center">
<div className="text-sm text-[var(--vscode-descriptionForeground)] mb-3">CURRENT BALANCE</div>
<div className="text-4xl font-bold text-[var(--vscode-foreground)] mb-6 flex items-center gap-2">
{isLoading ? (
<div className="text-[var(--vscode-descriptionForeground)]">Loading...</div>
) : (
<>
<span>$</span>
<CountUp end={balance} duration={0.66} decimals={2} />
<VSCodeButton
appearance="icon"
className="mt-1"
onClick={() => vscode.postMessage({ type: "fetchUserCreditsData" })}>
<span className="codicon codicon-refresh"></span>
</VSCodeButton>
</>
)}
</div>
<div className="w-full">
<VSCodeButtonLink href="https://app.cline.bot/credits/#buy" className="w-full">
Add Credits
</VSCodeButtonLink>
</div>
</div>
<VSCodeDivider className="mt-6 mb-3 w-full" />
<div className="flex-grow flex flex-col min-h-0 pb-[0px]">
<CreditsHistoryTable isLoading={isLoading} usageData={usageData} paymentsData={paymentsData} />
</div>
</div>
) : (
<div style={{}}>
<VSCodeButton onClick={handleLogin} style={{ marginTop: 0 }}>
Sign Up with Cline
<div className="flex flex-col items-center pr-3 max-w-[400px]">
<ClineLogoWhite className="size-16 mb-4" />
<p style={{}}>
Sign up for an account to get access to the latest models, billing dashboard to view usage and credits,
and more upcoming features.
</p>
<VSCodeButton onClick={handleLogin} className="w-full mb-4">
Sign up with Cline
</VSCodeButton>
<p className="text-[var(--vscode-descriptionForeground)] text-xs text-center m-0">
By continuing, you agree to the <VSCodeLink href="https://cline.bot/tos">Terms of Service</VSCodeLink> and{" "}
<VSCodeLink href="https://cline.bot/privacy">Privacy Policy.</VSCodeLink>
</p>
</div>
)}
</div>
@@ -0,0 +1,114 @@
import { VSCodeDataGrid, VSCodeDataGridRow, VSCodeDataGridCell } from "@vscode/webview-ui-toolkit/react"
import { useState } from "react"
import { TabButton } from "../mcp/McpView"
import { UsageTransaction, PaymentTransaction } from "../../../../src/shared/ClineAccount"
import { formatDollars, formatTimestamp } from "../../utils/format"
interface CreditsHistoryTableProps {
isLoading: boolean
usageData: UsageTransaction[]
paymentsData: PaymentTransaction[]
}
const CreditsHistoryTable = ({ isLoading, usageData, paymentsData }: CreditsHistoryTableProps) => {
const [activeTab, setActiveTab] = useState<"usage" | "payments">("usage")
return (
<div className="flex flex-col flex-grow h-full">
{/* Tabs container */}
<div className="flex border-b border-[var(--vscode-panel-border)]">
<TabButton isActive={activeTab === "usage"} onClick={() => setActiveTab("usage")}>
USAGE HISTORY
</TabButton>
<TabButton isActive={activeTab === "payments"} onClick={() => setActiveTab("payments")}>
PAYMENTS HISTORY
</TabButton>
</div>
{/* Content container */}
<div className="mt-[15px] mb-[0px] rounded-md overflow-auto flex-grow">
{isLoading ? (
<div className="flex justify-center items-center p-4">
<div className="text-[var(--vscode-descriptionForeground)]">Loading...</div>
</div>
) : (
<>
{activeTab === "usage" && (
<>
{usageData.length > 0 ? (
<VSCodeDataGrid>
<VSCodeDataGridRow row-type="header">
<VSCodeDataGridCell cell-type="columnheader" grid-column="1">
Date
</VSCodeDataGridCell>
<VSCodeDataGridCell cell-type="columnheader" grid-column="2">
Model
</VSCodeDataGridCell>
{/* <VSCodeDataGridCell cell-type="columnheader" grid-column="3">
Tokens Used
</VSCodeDataGridCell> */}
<VSCodeDataGridCell cell-type="columnheader" grid-column="3">
Credits Used
</VSCodeDataGridCell>
</VSCodeDataGridRow>
{usageData.map((row, index) => (
<VSCodeDataGridRow key={index}>
<VSCodeDataGridCell grid-column="1">
{formatTimestamp(row.spentAt)}
</VSCodeDataGridCell>
<VSCodeDataGridCell grid-column="2">{`${row.modelProvider}/${row.model}`}</VSCodeDataGridCell>
{/* <VSCodeDataGridCell grid-column="3">{`${row.promptTokens} → ${row.completionTokens}`}</VSCodeDataGridCell> */}
<VSCodeDataGridCell grid-column="3">{`$${Number(row.credits).toFixed(7)}`}</VSCodeDataGridCell>
</VSCodeDataGridRow>
))}
</VSCodeDataGrid>
) : (
<div className="flex justify-center items-center p-4">
<div className="text-[var(--vscode-descriptionForeground)]">No usage history</div>
</div>
)}
</>
)}
{activeTab === "payments" && (
<>
{paymentsData.length > 0 ? (
<VSCodeDataGrid>
<VSCodeDataGridRow row-type="header">
<VSCodeDataGridCell cell-type="columnheader" grid-column="1">
Date
</VSCodeDataGridCell>
<VSCodeDataGridCell cell-type="columnheader" grid-column="2">
Total Cost
</VSCodeDataGridCell>
<VSCodeDataGridCell cell-type="columnheader" grid-column="3">
Credits
</VSCodeDataGridCell>
</VSCodeDataGridRow>
{paymentsData.map((row, index) => (
<VSCodeDataGridRow key={index}>
<VSCodeDataGridCell grid-column="1">
{formatTimestamp(row.paidAt)}
</VSCodeDataGridCell>
<VSCodeDataGridCell grid-column="2">{`$${formatDollars(parseInt(row.amountCents))}`}</VSCodeDataGridCell>
<VSCodeDataGridCell grid-column="3">{`${row.credits}`}</VSCodeDataGridCell>
</VSCodeDataGridRow>
))}
</VSCodeDataGrid>
) : (
<div className="flex justify-center items-center p-4">
<div className="text-[var(--vscode-descriptionForeground)]">No payment history</div>
</div>
)}
</>
)}
</>
)}
</div>
</div>
)
}
export default CreditsHistoryTable
@@ -31,34 +31,22 @@ const Announcement = ({ version, hideAnnouncement }: AnnouncementProps) => {
</h3>
<ul style={{ margin: "0 0 8px", paddingLeft: "12px" }}>
<li>
<b>Introducing MCP Marketplace:</b> Discover and install the best MCP servers right from the extension, with
new servers added regularly! Get started by going to the{" "}
<span className="codicon codicon-extensions" style={{ marginRight: "4px", fontSize: 10 }}></span>
<VSCodeLink
onClick={() => {
vscode.postMessage({ type: "showMcpView" })
}}>
MCP Servers tab
</VSCodeLink>
.
<b>Add to Cline:</b> Right-click selected text in any file or terminal to quickly add context to your current
task! Plus, when you see a lightbulb icon, select 'Fix with Cline' to have Cline fix errors in your code.
</li>
<li>
<b>Mermaid diagrams in Plan mode!</b> Cline can now visualize his plans using flowcharts, sequences,
entity-relationships, and more. When he explains his approach using mermaid, you'll see a diagram right in
chat that you can click to expand.
<b>Billing Dashboard:</b> Track your remaining credits and transaction history right in the extension with a{" "}
<span className="codicon codicon-account" style={{ fontSize: 11 }}></span> Cline account!
</li>
<li>
Use <code>@terminal</code> to reference terminal contents, and <code>@git</code> to reference working changes
and commits!
<b>Faster Inference:</b> Cline/OpenRouter users can sort underlying providers used by throughput, price, and
latency. Sorting by throughput will output faster generations (at a higher cost).
</li>
<li>
New visual indicator for checkpoints after edits & commands, and automatic checkpoint at the start of each
task.
<b>Enhanced MCP Support:</b> Dynamic image loading with GIF support, and a new delete button to clean up
failed servers.
</li>
</ul>
<VSCodeLink href="https://x.com/sdrzn/status/1892262424881090721" style={{ display: "inline" }}>
See a demo of the changes here!
</VSCodeLink>
{/*<ul style={{ margin: "0 0 8px", paddingLeft: "12px" }}>
<li>
OpenRouter now supports prompt caching! They also have much higher rate limits than other providers,
@@ -0,0 +1,130 @@
import React from "react"
interface ChatErrorBoundaryProps {
children: React.ReactNode
errorTitle?: string
errorBody?: string
height?: string
}
interface ChatErrorBoundaryState {
hasError: boolean
error: Error | null
}
/**
* A reusable error boundary component specifically designed for chat widgets.
* It provides a consistent error UI with customizable title and body text.
*/
export class ChatErrorBoundary extends React.Component<ChatErrorBoundaryProps, ChatErrorBoundaryState> {
constructor(props: ChatErrorBoundaryProps) {
super(props)
this.state = { hasError: false, error: null }
}
static getDerivedStateFromError(error: Error) {
return { hasError: true, error }
}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
console.error("Error in ChatErrorBoundary:", error.message)
console.error("Component stack:", errorInfo.componentStack)
}
render() {
const { errorTitle, errorBody, height } = this.props
if (this.state.hasError) {
return (
<div
style={{
padding: "10px",
color: "var(--vscode-errorForeground)",
height: height || "auto",
maxWidth: "512px",
overflow: "auto",
border: "1px solid var(--vscode-editorError-foreground)",
borderRadius: "4px",
backgroundColor: "var(--vscode-inputValidation-errorBackground, rgba(255, 0, 0, 0.1))",
}}>
<h3 style={{ margin: "0 0 8px 0" }}>{errorTitle || "Something went wrong displaying this content"}</h3>
<p style={{ margin: "0" }}>{errorBody || `Error: ${this.state.error?.message || "Unknown error"}`}</p>
</div>
)
}
return this.props.children
}
}
/**
* A demo component that throws an error after a delay.
* This is useful for testing error boundaries during development
*/
interface ErrorAfterDelayProps {
numSecondsToWait?: number
}
interface ErrorAfterDelayState {
tickCount: number
}
export class ErrorAfterDelay extends React.Component<ErrorAfterDelayProps, ErrorAfterDelayState> {
private intervalID: NodeJS.Timeout | null = null
constructor(props: ErrorAfterDelayProps) {
super(props)
this.state = {
tickCount: 0,
}
}
componentDidMount() {
const secondsToWait = this.props.numSecondsToWait ?? 5
this.intervalID = setInterval(() => {
if (this.state.tickCount >= secondsToWait) {
if (this.intervalID) {
clearInterval(this.intervalID)
}
// Error boundaries don't catch async code :(
// So this only works by throwing inside of a setState
this.setState(() => {
throw new Error("This is an error for testing the error boundary")
})
} else {
this.setState({
tickCount: this.state.tickCount + 1,
})
}
}, 1000)
}
componentWillUnmount() {
if (this.intervalID) {
clearInterval(this.intervalID)
}
}
render() {
// Add a small visual indicator that this component will cause an error
return (
<div
style={{
position: "absolute",
top: 0,
right: 0,
background: "rgba(255, 0, 0, 0.5)",
color: "var(--vscode-errorForeground)",
padding: "2px 5px",
fontSize: "12px",
borderRadius: "0 0 0 4px",
zIndex: 100,
}}>
Error in {this.state.tickCount}/{this.props.numSecondsToWait ?? 5} seconds
</div>
)
}
}
export default ChatErrorBoundary
+48 -31
View File
@@ -5,8 +5,10 @@ import { useEvent, useSize } from "react-use"
import styled from "styled-components"
import {
ClineApiReqInfo,
ClineAskQuestion,
ClineAskUseMcpServer,
ClineMessage,
ClinePlanModeResponse,
ClineSayTool,
COMPLETION_RESULT_CHANGES_FLAG,
ExtensionMessage,
@@ -15,18 +17,19 @@ import { COMMAND_OUTPUT_STRING, COMMAND_REQ_APP_STRING } from "../../../../src/s
import { useExtensionState } from "../../context/ExtensionStateContext"
import { findMatchingResourceOrTemplate, getMcpServerDisplayName } from "../../utils/mcp"
import { vscode } from "../../utils/vscode"
import { CheckmarkControl } from "../common/CheckmarkControl"
import { CheckpointControls, CheckpointOverlay } from "../common/CheckpointControls"
import CodeAccordian, { cleanPathPrefix } from "../common/CodeAccordian"
import CodeBlock, { CODE_BLOCK_BG_COLOR } from "../common/CodeBlock"
import MarkdownBlock from "../common/MarkdownBlock"
import SuccessButton from "../common/SuccessButton"
import Thumbnails from "../common/Thumbnails"
import McpResourceRow from "../mcp/McpResourceRow"
import McpToolRow from "../mcp/McpToolRow"
import { highlightMentions } from "./TaskHeader"
import CreditLimitError from "./CreditLimitError"
import { CheckmarkControl } from "../common/CheckmarkControl"
import McpResponseDisplay from "../mcp/McpResponseDisplay"
import CreditLimitError from "./CreditLimitError"
import { OptionsButtons } from "./OptionsButtons"
import { highlightMentions } from "./TaskHeader"
import SuccessButton from "../common/SuccessButton"
const ChatRowContainer = styled.div`
padding: 10px 6px 10px 15px;
@@ -790,30 +793,8 @@ export const ChatRowContent = ({ message, isExpanded, onToggleExpand, lastModifi
)
case "api_req_finished":
return null // we should never see this message type
// case "mcp_server_response":
// return <McpResponseDisplay responseText={message.text || ""} />
case "mcp_server_response":
return (
<>
<div style={{ paddingTop: 0 }}>
<div
style={{
marginBottom: "4px",
opacity: 0.8,
fontSize: "12px",
textTransform: "uppercase",
}}>
Response
</div>
<CodeAccordian
code={message.text}
language="json"
isExpanded={true}
onToggleExpand={onToggleExpand}
/>
</div>
</>
)
return <McpResponseDisplay responseText={message.text || ""} />
case "text":
return (
<div>
@@ -1039,8 +1020,8 @@ export const ChatRowContent = ({ message, isExpanded, onToggleExpand, lastModifi
})
}}
style={{
width: "100%",
cursor: seeNewChangesDisabled ? "wait" : "pointer",
width: "100%",
}}>
<i className="codicon codicon-new-file" style={{ marginRight: 6 }} />
See new changes
@@ -1198,6 +1179,19 @@ export const ChatRowContent = ({ message, isExpanded, onToggleExpand, lastModifi
return null // Don't render anything when we get a completion_result ask without text
}
case "followup":
let question: string | undefined
let options: string[] | undefined
let selected: string | undefined
try {
const parsedMessage = JSON.parse(message.text || "{}") as ClineAskQuestion
question = parsedMessage.question
options = parsedMessage.options
selected = parsedMessage.selected
} catch (e) {
// legacy messages would pass question directly
question = message.text
}
return (
<>
{title && (
@@ -1207,16 +1201,39 @@ export const ChatRowContent = ({ message, isExpanded, onToggleExpand, lastModifi
</div>
)}
<div style={{ paddingTop: 10 }}>
<Markdown markdown={message.text} />
<Markdown markdown={question} />
<OptionsButtons
options={options}
selected={selected}
isActive={isLast && lastModifiedMessage?.ask === "followup"}
/>
</div>
</>
)
case "plan_mode_response":
case "plan_mode_response": {
let response: string | undefined
let options: string[] | undefined
let selected: string | undefined
try {
const parsedMessage = JSON.parse(message.text || "{}") as ClinePlanModeResponse
response = parsedMessage.response
options = parsedMessage.options
selected = parsedMessage.selected
} catch (e) {
// legacy messages would pass response directly
response = message.text
}
return (
<div style={{}}>
<Markdown markdown={message.text} />
<Markdown markdown={response} />
<OptionsButtons
options={options}
selected={selected}
isActive={isLast && lastModifiedMessage?.ask === "plan_mode_response"}
/>
</div>
)
}
default:
return null
}
@@ -1132,6 +1132,7 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
</ModelContainer>
</ButtonGroup>
<Tooltip
style={{ zIndex: 1000 }}
visible={shownTooltipMode !== null}
tipText={`In ${shownTooltipMode === "act" ? "Act" : "Plan"} mode, Cline will ${shownTooltipMode === "act" ? "complete the task immediately" : "gather information to architect a plan"}`}
hintText={`Toggle w/ ${metaKeyChar}+Shift+A`}>
@@ -453,6 +453,18 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
setSelectedImages((prevImages) => [...prevImages, ...newImages].slice(0, MAX_IMAGES_PER_MESSAGE))
}
break
case "addToInput":
setInputValue((prevValue) => {
const newText = message.text ?? ""
return prevValue ? `${prevValue}\n${newText}` : newText
})
// Add scroll to bottom after state update
setTimeout(() => {
if (textAreaRef.current) {
textAreaRef.current.scrollTop = textAreaRef.current.scrollHeight
}
}, 0)
break
case "invoke":
switch (message.invoke!) {
case "sendMessage":
@@ -30,7 +30,7 @@ const CreditLimitError: React.FC<CreditLimitErrorProps> = ({ currentBalance, tot
</div>
<VSCodeButtonLink
href="https://app.cline.bot/credits"
href="https://app.cline.bot/credits/#buy"
style={{
width: "100%",
marginBottom: "8px",
@@ -0,0 +1,69 @@
import styled from "styled-components"
import { CODE_BLOCK_BG_COLOR } from "../common/CodeBlock"
import { vscode } from "../../utils/vscode"
const OptionButton = styled.button<{ isSelected?: boolean; isNotSelectable?: boolean }>`
padding: 8px 12px;
background: ${(props) => (props.isSelected ? "var(--vscode-focusBorder)" : CODE_BLOCK_BG_COLOR)};
color: ${(props) => (props.isSelected ? "white" : "var(--vscode-input-foreground)")};
border: 1px solid var(--vscode-editorGroup-border);
border-radius: 2px;
cursor: ${(props) => (props.isNotSelectable ? "default" : "pointer")};
text-align: left;
font-size: 12px;
${(props) =>
!props.isNotSelectable &&
`
&:hover {
background: var(--vscode-focusBorder);
color: white;
}
`}
`
export const OptionsButtons = ({
options,
selected,
isActive,
}: {
options?: string[]
selected?: string
isActive?: boolean
}) => {
if (!options?.length) return null
const hasSelected = selected !== undefined && options.includes(selected)
return (
<div
style={{
display: "flex",
flexDirection: "column",
gap: "8px",
paddingTop: 15,
// marginTop: "22px",
}}>
{/* <div style={{ color: "var(--vscode-descriptionForeground)", fontSize: "11px", textTransform: "uppercase" }}>
SELECT ONE:
</div> */}
{options.map((option, index) => (
<OptionButton
key={index}
isSelected={option === selected}
isNotSelectable={hasSelected || !isActive}
onClick={() => {
if (hasSelected || !isActive) {
return
}
vscode.postMessage({
type: "optionsResponse",
text: option,
})
}}>
{option}
</OptionButton>
))}
</div>
)
}
+91 -51
View File
@@ -34,7 +34,7 @@ const TaskHeader: React.FC<TaskHeaderProps> = ({
onClose,
}) => {
const { apiConfiguration, currentTaskItem, checkpointTrackerErrorMessage } = useExtensionState()
const [isTaskExpanded, setIsTaskExpanded] = useState(true)
const [isTaskExpanded, setIsTaskExpanded] = useState(false)
const [isTextExpanded, setIsTextExpanded] = useState(false)
const [showSeeMore, setShowSeeMore] = useState(false)
const textContainerRef = useRef<HTMLDivElement>(null)
@@ -43,6 +43,22 @@ const TaskHeader: React.FC<TaskHeaderProps> = ({
const { selectedModelInfo } = useMemo(() => normalizeApiConfiguration(apiConfiguration), [apiConfiguration])
const contextWindow = selectedModelInfo?.contextWindow
// Open task header when checkpoint tracker error message is set
const prevErrorMessageRef = useRef(checkpointTrackerErrorMessage)
useEffect(() => {
if (checkpointTrackerErrorMessage !== prevErrorMessageRef.current) {
setIsTaskExpanded(true)
prevErrorMessageRef.current = checkpointTrackerErrorMessage
}
}, [checkpointTrackerErrorMessage])
// Reset isTextExpanded when task is collapsed
useEffect(() => {
if (!isTaskExpanded) {
setIsTextExpanded(false)
}
}, [isTaskExpanded])
/*
When dealing with event listeners in React components that depend on state variables, we face a challenge. We want our listener to always use the most up-to-date version of a callback function that relies on current state, but we don't want to constantly add and remove event listeners as that function updates. This scenario often arises with resize listeners or other window events. Simply adding the listener in a useEffect with an empty dependency array risks using stale state, while including the callback in the dependencies can lead to unnecessary re-registrations of the listener. There are react hook libraries that provide a elegant solution to this problem by utilizing the useRef hook to maintain a reference to the latest callback function without triggering re-renders or effect re-runs. This approach ensures that our event listener always has access to the most current state while minimizing performance overhead and potential memory leaks from multiple listener registrations.
Sources
@@ -85,19 +101,22 @@ const TaskHeader: React.FC<TaskHeaderProps> = ({
}, [isTextExpanded, windowHeight])
useEffect(() => {
if (textRef.current && textContainerRef.current) {
let textContainerHeight = textContainerRef.current.clientHeight
if (!textContainerHeight) {
textContainerHeight = textContainerRef.current.getBoundingClientRect().height
}
const isOverflowing = textRef.current.scrollHeight > textContainerHeight
// necessary to show see more button again if user resizes window to expand and then back to collapse
if (!isOverflowing) {
setIsTextExpanded(false)
}
setShowSeeMore(isOverflowing)
if (isTaskExpanded && textRef.current && textContainerRef.current) {
// Use requestAnimationFrame to ensure DOM is fully updated
requestAnimationFrame(() => {
// Check if refs are still valid
if (textRef.current && textContainerRef.current) {
let textContainerHeight = textContainerRef.current.clientHeight
if (!textContainerHeight) {
textContainerHeight = textContainerRef.current.getBoundingClientRect().height
}
const isOverflowing = textRef.current.scrollHeight > textContainerHeight
setShowSeeMore(isOverflowing)
}
})
}
}, [task.text, windowWidth])
}, [task.text, windowWidth, isTaskExpanded])
const isCostAvailable = useMemo(() => {
const openAiCompatHasPricing =
@@ -386,49 +405,52 @@ const TaskHeader: React.FC<TaskHeaderProps> = ({
)}
</div>
{shouldShowPromptCacheInfo && (cacheReads !== undefined || cacheWrites !== undefined) && (
<div
style={{
display: "flex",
alignItems: "center",
gap: "4px",
flexWrap: "wrap",
}}>
<span style={{ fontWeight: "bold" }}>Cache:</span>
<span
{shouldShowPromptCacheInfo &&
(cacheReads !== undefined ||
cacheWrites !== undefined ||
apiConfiguration?.apiProvider === "anthropic") && (
<div
style={{
display: "flex",
alignItems: "center",
gap: "3px",
gap: "4px",
flexWrap: "wrap",
}}>
<i
className="codicon codicon-database"
<span style={{ fontWeight: "bold" }}>Cache:</span>
<span
style={{
fontSize: "12px",
fontWeight: "bold",
marginBottom: "-1px",
}}
/>
+{formatLargeNumber(cacheWrites || 0)}
</span>
<span
style={{
display: "flex",
alignItems: "center",
gap: "3px",
}}>
<i
className="codicon codicon-arrow-right"
display: "flex",
alignItems: "center",
gap: "3px",
}}>
<i
className="codicon codicon-database"
style={{
fontSize: "12px",
fontWeight: "bold",
marginBottom: "-1px",
}}
/>
+{formatLargeNumber(cacheWrites || 0)}
</span>
<span
style={{
fontSize: "12px",
fontWeight: "bold",
marginBottom: 0,
}}
/>
{formatLargeNumber(cacheReads || 0)}
</span>
</div>
)}
display: "flex",
alignItems: "center",
gap: "3px",
}}>
<i
className="codicon codicon-arrow-right"
style={{
fontSize: "12px",
fontWeight: "bold",
marginBottom: 0,
}}
/>
{formatLargeNumber(cacheReads || 0)}
</span>
</div>
)}
{ContextWindowComponent}
{isCostAvailable && (
<div
@@ -461,7 +483,25 @@ const TaskHeader: React.FC<TaskHeaderProps> = ({
}}>
<i className="codicon codicon-warning" />
<span>
{checkpointTrackerErrorMessage}
{checkpointTrackerErrorMessage.replace(/disabling checkpoints\.$/, "")}
{checkpointTrackerErrorMessage.endsWith("disabling checkpoints.") && (
<>
<a
onClick={() => {
vscode.postMessage({
type: "openExtensionSettings",
text: "enableCheckpoints",
})
}}
style={{
color: "inherit",
textDecoration: "underline",
cursor: "pointer",
}}>
disabling checkpoints.
</a>
</>
)}
{checkpointTrackerErrorMessage.includes("Git must be installed to use checkpoints.") && (
<>
{" "}
@@ -1,30 +1,23 @@
import { VSCodeButton } from "@vscode/webview-ui-toolkit/react"
import styled from "styled-components"
const StyledButton = styled(VSCodeButton)`
--danger-button-bg: #c42b2b;
--danger-button-hover: #a82424;
--danger-button-active: #8f1f1f;
background-color: var(--danger-button-bg) !important;
border-color: var(--danger-button-bg) !important;
color: #ffffff !important;
&:hover {
background-color: var(--danger-button-hover) !important;
border-color: var(--danger-button-hover) !important;
}
&:active {
background-color: var(--danger-button-active) !important;
border-color: var(--danger-button-active) !important;
}
`
interface DangerButtonProps extends React.ComponentProps<typeof VSCodeButton> {}
const DangerButton: React.FC<DangerButtonProps> = (props) => {
return <StyledButton {...props} />
return (
<VSCodeButton
{...props}
className={`
!bg-[#c42b2b]
!border-[#c42b2b]
!text-white
hover:!bg-[#a82424]
hover:!border-[#a82424]
active:!bg-[#8f1f1f]
active:!border-[#8f1f1f]
${props.className || ""}
`}
/>
)
}
export default DangerButton
@@ -1,30 +1,25 @@
import { VSCodeButton } from "@vscode/webview-ui-toolkit/react"
import styled from "styled-components"
const StyledButton = styled(VSCodeButton)`
--success-button-bg: #176f2c;
--success-button-hover: #197f31;
--success-button-active: #156528;
interface SuccessButtonTWProps extends React.ComponentProps<typeof VSCodeButton> {}
background-color: var(--success-button-bg) !important;
border-color: var(--success-button-bg) !important;
color: #ffffff !important;
&:hover {
background-color: var(--success-button-hover) !important;
border-color: var(--success-button-hover) !important;
}
&:active {
background-color: var(--success-button-active) !important;
border-color: var(--success-button-active) !important;
}
`
interface SuccessButtonProps extends React.ComponentProps<typeof VSCodeButton> {}
const SuccessButton: React.FC<SuccessButtonProps> = (props) => {
return <StyledButton {...props} />
const SuccessButtonTW: React.FC<SuccessButtonTWProps> = (props) => {
return (
<VSCodeButton
{...props}
className={`
!bg-[#176f2c]
!border-[#176f2c]
!text-white
hover:!bg-[#197f31]
hover:!border-[#197f31]
active:!bg-[#156528]
active:!border-[#156528]
${props.className || ""}
`
.replace(/\s+/g, " ")
.trim()}
/>
)
}
export default SuccessButton
export default SuccessButtonTW
+5 -4
View File
@@ -13,10 +13,11 @@ interface TooltipProps {
hintText: string
tipText: string
children: React.ReactNode
style?: React.CSSProperties
}
// add styled component for tooltip
const TooltipBody = styled.div`
const TooltipBody = styled.div<Pick<TooltipProps, "style">>`
position: absolute;
background-color: ${getAsVar(VSC_SIDEBAR_BACKGROUND)};
color: ${getAsVar(VSC_DESCRIPTION_FOREGROUND)};
@@ -24,7 +25,7 @@ const TooltipBody = styled.div`
border-radius: 5px;
bottom: 100%;
left: -180%;
z-index: 10;
z-index: ${(props) => props.style?.zIndex ?? 10};
white-space: wrap;
max-width: 200px;
border: 1px solid ${getAsVar(VSC_INPUT_BORDER)};
@@ -39,12 +40,12 @@ const Hint = styled.div`
margin-top: 2px;
`
const Tooltip: React.FC<TooltipProps> = ({ visible, tipText, hintText, children }) => {
const Tooltip: React.FC<TooltipProps> = ({ visible, tipText, hintText, children, style }) => {
return (
<div style={{ position: "relative", display: "inline-block" }}>
{children}
{visible && (
<TooltipBody>
<TooltipBody style={style}>
{tipText}
{hintText && <Hint>{hintText}</Hint>}
</TooltipBody>
@@ -6,9 +6,9 @@ import { memo, useMemo, useState, useEffect, useCallback } from "react"
import Fuse, { FuseResult } from "fuse.js"
import { formatLargeNumber } from "../../utils/format"
import { formatSize } from "../../utils/size"
import DangerButton from "../common/DangerButton"
import { ExtensionMessage } from "../../../../src/shared/ExtensionMessage"
import { useEvent } from "react-use"
import DangerButton from "../common/DangerButton"
type HistoryViewProps = {
onDone: () => void
@@ -17,7 +17,7 @@ type HistoryViewProps = {
type SortOption = "newest" | "oldest" | "mostExpensive" | "mostTokens" | "mostRelevant"
const HistoryView = ({ onDone }: HistoryViewProps) => {
const { taskHistory } = useExtensionState()
const { taskHistory, totalTasksSize } = useExtensionState()
const [searchQuery, setSearchQuery] = useState("")
const [sortOption, setSortOption] = useState<SortOption>("newest")
const [lastNonRelevantSort, setLastNonRelevantSort] = useState<SortOption | null>("newest")
@@ -28,9 +28,13 @@ const HistoryView = ({ onDone }: HistoryViewProps) => {
setDeleteAllDisabled(false)
}
}, [])
useEvent("message", handleMessage)
// Request total tasks size when component mounts
useEffect(() => {
vscode.postMessage({ type: "requestTotalTasksSize" })
}, [])
useEffect(() => {
if (searchQuery && sortOption !== "mostRelevant" && !lastNonRelevantSort) {
setLastNonRelevantSort(sortOption)
@@ -471,7 +475,7 @@ const HistoryView = ({ onDone }: HistoryViewProps) => {
setDeleteAllDisabled(true)
vscode.postMessage({ type: "clearAllTaskHistory" })
}}>
Delete All History
Delete All History{totalTasksSize !== null ? ` (${formatSize(totalTasksSize)})` : ""}
</DangerButton>
</div>
</div>
@@ -0,0 +1,336 @@
import React, { useEffect, useRef } from "react"
import { vscode } from "../../utils/vscode"
import DOMPurify from "dompurify"
import { getSafeHostname, formatUrlForOpening, checkIfImageUrl } from "./McpRichUtil"
import ChatErrorBoundary from "../chat/ChatErrorBoundary"
interface ImagePreviewProps {
url: string
}
// Use a class component to ensure complete isolation between instances
class ImagePreview extends React.Component<
ImagePreviewProps,
{
loading: boolean
error: string | null
fetchStartTime: number
}
> {
private imgRef = React.createRef<HTMLImageElement>()
private timeoutId: NodeJS.Timeout | null = null
private heartbeatId: NodeJS.Timeout | null = null
constructor(props: ImagePreviewProps) {
super(props)
this.state = {
loading: true,
error: null,
fetchStartTime: Date.now(),
}
}
// Track aspect ratio for proper display
private aspectRatio: number = 1
componentDidMount() {
// Set up a timeout to handle cases where the image never loads or errors
this.timeoutId = setTimeout(() => {
console.log(`Image load timeout for ${this.props.url}`)
if (this.state.loading) {
this.setState({
loading: false,
error: `Timeout loading image: ${this.props.url}`,
})
}
}, 15000)
// Set up a heartbeat to update the UI with elapsed time
this.heartbeatId = setInterval(() => {
if (this.state.loading) {
this.forceUpdate() // Just update the component to show new elapsed time
}
}, 1000)
// First, check the content type to verify it's actually an image
this.checkContentType(this.props.url)
}
// Check if the URL is an image using content type verification
checkContentType(url: string) {
// Always verify content type, even for URLs that look like images by extension
checkIfImageUrl(url)
.then((isImage) => {
if (isImage) {
console.log(`URL is confirmed as image: ${url}`)
this.loadImage(url)
} else {
console.log(`URL is not an image: ${url}`)
this.handleImageError()
}
})
.catch((error) => {
console.log(`Error checking if URL is an image: ${error}`)
// Don't fallback to direct image loading on error
// Instead, report the error so the URL can be handled as a non-image
this.handleImageError()
})
}
// Load the image after content type check or as fallback
loadImage(url: string) {
const isSvg = /\.svg(\?.*)?$/i.test(url)
// For SVG files, we don't need to calculate aspect ratio as they're vector-based
if (isSvg) {
console.log(`SVG image detected, skipping aspect ratio calculation: ${url}`)
// Default aspect ratio for SVGs
this.aspectRatio = 1
this.handleImageLoad()
return
}
// Create a test image to check if the URL loads and get dimensions
const testImg = new Image()
testImg.onload = () => {
console.log(`Test image loaded successfully: ${url}`)
// Calculate aspect ratio for proper display
if (testImg.width > 0 && testImg.height > 0) {
this.aspectRatio = testImg.width / testImg.height
}
this.handleImageLoad()
}
testImg.onerror = () => {
console.log(`Test image failed to load: ${url}`)
this.handleImageError()
}
// Force CORS mode to be anonymous to avoid CORS issues
testImg.crossOrigin = "anonymous"
}
componentWillUnmount() {
this.cleanup()
}
private cleanup() {
if (this.timeoutId) {
clearTimeout(this.timeoutId)
this.timeoutId = null
}
if (this.heartbeatId) {
clearInterval(this.heartbeatId)
this.heartbeatId = null
}
}
// Handle image load event
handleImageLoad = () => {
console.log(`Image loaded successfully: ${this.props.url}`)
this.setState({ loading: false })
this.cleanup()
}
// Handle image error event
handleImageError = () => {
console.log(`Image failed to load: ${this.props.url}`)
this.setState({
loading: false,
error: `Failed to load image: ${this.props.url}`,
})
this.cleanup()
}
render() {
const { url } = this.props
const { loading, error, fetchStartTime } = this.state
// Calculate elapsed time for loading state
const elapsedSeconds = loading ? Math.floor((Date.now() - fetchStartTime) / 1000) : 0
// Fallback display while loading
if (loading) {
return (
<div
className="image-preview-loading"
style={{
padding: "12px",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
border: "1px solid var(--vscode-editorWidget-border, rgba(127, 127, 127, 0.3))",
borderRadius: "4px",
height: "128px",
maxWidth: "512px",
}}>
<div style={{ display: "flex", alignItems: "center", marginBottom: "8px" }}>
<div
className="loading-spinner"
style={{
marginRight: "8px",
width: "16px",
height: "16px",
border: "2px solid rgba(127, 127, 127, 0.3)",
borderTopColor: "var(--vscode-textLink-foreground, #3794ff)",
borderRadius: "50%",
animation: "spin 1s linear infinite",
}}
/>
<style>
{`
@keyframes spin {
to { transform: rotate(360deg); }
}
`}
</style>
Loading image from {getSafeHostname(url)}...
</div>
{elapsedSeconds > 3 && (
<div style={{ fontSize: "11px", color: "var(--vscode-descriptionForeground)" }}>
{elapsedSeconds > 60
? `Waiting for ${Math.floor(elapsedSeconds / 60)}m ${elapsedSeconds % 60}s...`
: `Waiting for ${elapsedSeconds}s...`}
</div>
)}
{/* Hidden image that we'll use to detect load/error events */}
{/\.svg(\?.*)?$/i.test(url) ? (
<object
type="image/svg+xml"
data={DOMPurify.sanitize(url)}
style={{ display: "none" }}
onLoad={this.handleImageLoad}
onError={this.handleImageError}
/>
) : (
<img
src={DOMPurify.sanitize(url)}
alt=""
ref={this.imgRef}
onLoad={this.handleImageLoad}
onError={this.handleImageError}
style={{ display: "none" }}
/>
)}
</div>
)
}
// Handle error state
if (error) {
return (
<div
className="image-preview-error"
style={{
padding: "12px",
border: "1px solid var(--vscode-editorWidget-border, rgba(127, 127, 127, 0.3))",
borderRadius: "4px",
color: "var(--vscode-errorForeground)",
}}
onClick={() => {
vscode.postMessage({
type: "openInBrowser",
url: DOMPurify.sanitize(url),
})
}}>
<div style={{ fontWeight: "bold" }}>Failed to load image</div>
<div style={{ fontSize: "12px", marginTop: "4px" }}>{getSafeHostname(url)}</div>
<div style={{ fontSize: "11px", marginTop: "8px", color: "var(--vscode-textLink-foreground)" }}>
Click to open in browser
</div>
</div>
)
}
// Render the image
return (
<div
className="image-preview"
style={{
margin: "10px 0",
maxWidth: "100%",
cursor: "pointer",
}}
onClick={() => {
vscode.postMessage({
type: "openInBrowser",
url: DOMPurify.sanitize(formatUrlForOpening(url)),
})
}}>
{/\.svg(\?.*)?$/i.test(url) ? (
// Special handling for SVG images
<object
type="image/svg+xml"
data={DOMPurify.sanitize(url)}
style={{
width: "85%",
height: "auto",
borderRadius: "4px",
}}
aria-label={`SVG from ${getSafeHostname(url)}`}>
{/* Fallback if object tag fails */}
<img
src={DOMPurify.sanitize(url)}
alt={`SVG from ${getSafeHostname(url)}`}
style={{
width: "85%",
height: "auto",
borderRadius: "4px",
}}
/>
</object>
) : (
<img
src={DOMPurify.sanitize(url)}
alt={`Image from ${getSafeHostname(url)}`}
style={{
width: "85%",
height: "auto",
borderRadius: "4px",
// Use contain only for very extreme aspect ratios, otherwise use cover
objectFit: this.aspectRatio > 3 || this.aspectRatio < 0.33 ? "contain" : "cover",
}}
loading="eager"
onLoad={(e) => {
// Double-check aspect ratio from the actual loaded image
const img = e.currentTarget
if (img.naturalWidth > 0 && img.naturalHeight > 0) {
const newAspectRatio = img.naturalWidth / img.naturalHeight
// Update object-fit based on actual aspect ratio
// Use contain only for very extreme aspect ratios, otherwise use cover
if (newAspectRatio > 3 || newAspectRatio < 0.33) {
img.style.objectFit = "contain"
} else {
img.style.objectFit = "cover"
}
}
}}
/>
)}
</div>
)
}
}
// Create a wrapper component that memoizes the ImagePreview to prevent unnecessary re-renders
const MemoizedImagePreview = React.memo(
(props: ImagePreviewProps) => <ImagePreview {...props} />,
(prevProps, nextProps) => prevProps.url === nextProps.url, // Only re-render if URL changes
)
// Wrap the ImagePreview component with an error boundary
const ImagePreviewWithErrorBoundary: React.FC<ImagePreviewProps> = (props) => {
return (
<ChatErrorBoundary errorTitle="Something went wrong displaying this image">
<MemoizedImagePreview {...props} />
</ChatErrorBoundary>
)
}
export default ImagePreviewWithErrorBoundary
+349 -145
View File
@@ -1,6 +1,8 @@
import React, { useEffect, useState } from "react"
import { vscode } from "../../utils/vscode"
import DOMPurify from "dompurify"
import { getSafeHostname, normalizeRelativeUrl } from "./McpRichUtil"
import ChatErrorBoundary from "../chat/ChatErrorBoundary"
interface OpenGraphData {
title?: string
@@ -15,174 +17,376 @@ interface LinkPreviewProps {
url: string
}
const LinkPreview: React.FC<LinkPreviewProps> = ({ url }) => {
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [ogData, setOgData] = useState<OpenGraphData | null>(null)
// Error types for better UI feedback
type ErrorType = "timeout" | "network" | "general" | null
useEffect(() => {
const fetchOpenGraphData = async () => {
try {
setLoading(true)
// Use a class component to ensure complete isolation between instances
class LinkPreview extends React.Component<
LinkPreviewProps,
{
loading: boolean
error: ErrorType
errorMessage: string | null
ogData: OpenGraphData | null
hasCompletedFetch: boolean // Track if fetch has completed (success or error)
fetchStartTime: number // Track when the fetch started
}
> {
private messageListener: ((event: MessageEvent) => void) | null = null
private timeoutId: NodeJS.Timeout | null = null
private heartbeatId: NodeJS.Timeout | null = null
// Send a message to the extension to fetch Open Graph data
vscode.postMessage({
type: "fetchOpenGraphData",
text: url,
})
constructor(props: LinkPreviewProps) {
super(props)
this.state = {
loading: true,
error: null,
errorMessage: null,
ogData: null,
hasCompletedFetch: false,
fetchStartTime: 0,
}
}
// Set up a listener for the response
const messageListener = (event: MessageEvent) => {
const message = event.data
if (message.type === "openGraphData" && message.url === url) {
setOgData(message.openGraphData)
setLoading(false)
window.removeEventListener("message", messageListener)
}
}
componentDidMount() {
// Only fetch if we haven't completed a fetch yet
if (!this.state.hasCompletedFetch) {
this.fetchOpenGraphData()
}
}
window.addEventListener("message", messageListener)
componentWillUnmount() {
this.cleanup()
}
// Clean up the listener if the component unmounts
return () => {
window.removeEventListener("message", messageListener)
}
} catch (err) {
setError("Failed to fetch preview data")
setLoading(false)
}
// Prevent updates if fetch has completed
shouldComponentUpdate(nextProps: LinkPreviewProps, nextState: any) {
// If URL changes, allow update
if (nextProps.url !== this.props.url) {
return true
}
// Fetch Open Graph data immediately when component mounts
fetchOpenGraphData()
}, [url])
// If we've completed a fetch and state hasn't changed, prevent update
if (
this.state.hasCompletedFetch &&
this.state.loading === nextState.loading &&
this.state.error === nextState.error &&
this.state.ogData === nextState.ogData
) {
return false
}
// Fallback display while loading
if (loading) {
return true
}
private cleanup() {
// Clean up event listeners and timeouts
if (this.messageListener) {
window.removeEventListener("message", this.messageListener)
this.messageListener = null
}
if (this.timeoutId) {
clearTimeout(this.timeoutId)
this.timeoutId = null
}
if (this.heartbeatId) {
clearInterval(this.heartbeatId)
this.heartbeatId = null
}
}
private fetchOpenGraphData() {
try {
// Record fetch start time
const startTime = Date.now()
this.setState({ fetchStartTime: startTime })
// Send a message to the extension to fetch Open Graph data
vscode.postMessage({
type: "fetchOpenGraphData",
text: this.props.url,
})
// Set up a listener for the response
this.messageListener = (event: MessageEvent) => {
const message = event.data
if (message.type === "openGraphData" && message.url === this.props.url) {
// Check if there was an error in the response
if (message.error) {
this.setState({
error: "network",
errorMessage: message.error,
loading: false,
hasCompletedFetch: true,
})
} else {
this.setState({
ogData: message.openGraphData,
loading: false,
hasCompletedFetch: true, // Mark as completed
})
}
this.cleanup()
}
}
window.addEventListener("message", this.messageListener)
// Instead of a fixed timeout, use a heartbeat to update the loading message
// with the elapsed time, but don't actually timeout
this.heartbeatId = setInterval(() => {
const elapsedSeconds = Math.floor((Date.now() - startTime) / 1000)
if (elapsedSeconds > 0) {
this.forceUpdate() // Just update the component to show new elapsed time
}
}, 1000)
} catch (err) {
this.setState({
error: "general",
errorMessage: err instanceof Error ? err.message : "Unknown error occurred",
loading: false,
hasCompletedFetch: true, // Mark as completed on error
})
this.cleanup()
}
}
render() {
const { url } = this.props
const { loading, error, errorMessage, ogData, fetchStartTime } = this.state
// Calculate elapsed time for loading state
const elapsedSeconds = loading ? Math.floor((Date.now() - fetchStartTime) / 1000) : 0
// Fallback display while loading
if (loading) {
return (
<div
className="link-preview-loading"
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
border: "1px solid var(--vscode-editorWidget-border, rgba(127, 127, 127, 0.3))",
borderRadius: "4px",
height: "128px",
maxWidth: "512px",
}}>
<div style={{ display: "flex", alignItems: "center", marginBottom: "8px" }}>
<div
className="loading-spinner"
style={{
marginRight: "8px",
width: "16px",
height: "16px",
border: "2px solid rgba(127, 127, 127, 0.3)",
borderTopColor: "var(--vscode-textLink-foreground, #3794ff)",
borderRadius: "50%",
animation: "spin 1s linear infinite",
}}
/>
<style>
{`
@keyframes spin {
to { transform: rotate(360deg); }
}
`}
</style>
Loading preview for {getSafeHostname(url)}...
</div>
{elapsedSeconds > 5 && (
<div style={{ fontSize: "11px", color: "var(--vscode-descriptionForeground)" }}>
{elapsedSeconds > 60
? `Waiting for ${Math.floor(elapsedSeconds / 60)}m ${elapsedSeconds % 60}s...`
: `Waiting for ${elapsedSeconds}s...`}
</div>
)}
</div>
)
}
// Handle different error states with specific messages
if (error) {
let errorDisplay = "Unable to load preview"
if (error === "timeout") {
errorDisplay = "Preview request timed out"
} else if (error === "network") {
errorDisplay = "Network error loading preview"
}
return (
<div
className="link-preview-error"
style={{
padding: "12px",
border: "1px solid var(--vscode-editorWidget-border, rgba(127, 127, 127, 0.3))",
borderRadius: "4px",
color: "var(--vscode-errorForeground)",
height: "128px",
maxWidth: "512px",
overflow: "auto",
}}
onClick={() => {
vscode.postMessage({
type: "openInBrowser",
url: DOMPurify.sanitize(url),
})
}}>
<div style={{ fontWeight: "bold" }}>{errorDisplay}</div>
<div style={{ fontSize: "12px", marginTop: "4px" }}>{getSafeHostname(url)}</div>
{errorMessage && <div style={{ fontSize: "11px", marginTop: "4px", opacity: 0.8 }}>{errorMessage}</div>}
<div style={{ fontSize: "11px", marginTop: "8px", color: "var(--vscode-textLink-foreground)" }}>
Click to open in browser
</div>
</div>
)
}
// Create a fallback object if ogData is null
const data = ogData || {
title: getSafeHostname(url),
description: "No description available",
siteName: getSafeHostname(url),
url: url,
}
// Render the Open Graph preview
return (
<div
className="link-preview-loading"
className="link-preview"
style={{
padding: "12px",
display: "flex",
alignItems: "center",
justifyContent: "center",
border: "1px solid var(--vscode-editorWidget-border, rgba(127, 127, 127, 0.3))",
borderRadius: "4px",
overflow: "hidden",
cursor: "pointer",
height: "128px",
maxWidth: "512px",
}}
onClick={() => {
vscode.postMessage({
type: "openInBrowser",
url: DOMPurify.sanitize(url),
})
}}>
{data.image && (
<div className="link-preview-image" style={{ width: "128px", height: "128px", flexShrink: 0 }}>
<img
src={DOMPurify.sanitize(normalizeRelativeUrl(data.image, url))}
alt=""
style={{
width: "100%",
height: "100%",
objectFit: "contain", // Use contain for link preview thumbnails to handle logos
objectPosition: "center", // Center the image
}}
onLoad={(e) => {
// Check aspect ratio to determine if we should use contain or cover
const img = e.currentTarget
if (img.naturalWidth > 0 && img.naturalHeight > 0) {
const aspectRatio = img.naturalWidth / img.naturalHeight
// Use contain for extreme aspect ratios (logos), cover for photos
if (aspectRatio > 2.5 || aspectRatio < 0.4) {
img.style.objectFit = "contain"
} else {
img.style.objectFit = "cover"
}
}
}}
onError={(e) => {
console.log(`Image could not be loaded: ${data.image}`)
// Hide the broken image
;(e.target as HTMLImageElement).style.display = "none"
}}
/>
</div>
)}
<div
className="loading-spinner"
className="link-preview-content"
style={{
marginRight: "8px",
width: "16px",
height: "16px",
border: "2px solid rgba(127, 127, 127, 0.3)",
borderTopColor: "var(--vscode-textLink-foreground, #3794ff)",
borderRadius: "50%",
animation: "spin 1s linear infinite",
}}
/>
<style>
{`
@keyframes spin {
to { transform: rotate(360deg); }
}
`}
</style>
Loading preview for {new URL(url).hostname}...
flex: 1,
padding: "12px",
display: "flex",
flexDirection: "column",
overflow: "hidden",
height: "100%", // Ensure full height
}}>
{/* Top section with title and URL - top aligned */}
<div className="link-preview-top">
<div
className="link-preview-title"
style={{
fontWeight: "bold",
marginBottom: "4px",
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
}}>
{data.title || "No title"}
</div>
<div
className="link-preview-url"
style={{
fontSize: "12px",
color: "var(--vscode-textLink-foreground, #3794ff)",
marginBottom: "8px", // Increased for better separation
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
}}>
{data.siteName || getSafeHostname(url)}
</div>
</div>
{/* Description with space-around in the remaining space */}
<div
className="link-preview-description-container"
style={{
flex: 1, // Take up remaining space
display: "flex",
flexDirection: "column",
justifyContent: "space-around", // Space around in the remaining area
}}>
<div
className="link-preview-description"
style={{
fontSize: "12px",
color: "var(--vscode-descriptionForeground, rgba(204, 204, 204, 0.7))",
overflow: "hidden",
display: "-webkit-box",
WebkitLineClamp: 3,
WebkitBoxOrient: "vertical",
textOverflow: "ellipsis",
}}>
{data.description || "No description available"}
</div>
</div>
</div>
</div>
)
}
}
// Create a fallback object if ogData is null
const data = ogData || {
title: new URL(url).hostname,
description: "No description available",
siteName: new URL(url).hostname,
url: url,
}
// Create a wrapper component that memoizes the LinkPreview to prevent unnecessary re-renders
const MemoizedLinkPreview = React.memo(
(props: LinkPreviewProps) => <LinkPreview {...props} />,
(prevProps, nextProps) => prevProps.url === nextProps.url, // Only re-render if URL changes
)
// Render the Open Graph preview
// Wrap the LinkPreview component with an error boundary
const LinkPreviewWithErrorBoundary: React.FC<LinkPreviewProps> = (props) => {
return (
<div
className="link-preview"
style={{
display: "flex",
border: "1px solid var(--vscode-editorWidget-border, rgba(127, 127, 127, 0.3))",
borderRadius: "4px",
overflow: "hidden",
cursor: "pointer",
}}
onClick={() => {
vscode.postMessage({
type: "openInBrowser",
url: DOMPurify.sanitize(url),
})
}}>
{data.image && (
<div className="link-preview-image" style={{ width: "128px", height: "128px", flexShrink: 0 }}>
<img
src={DOMPurify.sanitize(data.image)}
alt=""
style={{
width: "100%",
height: "100%",
objectFit: "cover",
}}
/>
</div>
)}
<div
className="link-preview-content"
style={{
flex: 1,
padding: "12px",
display: "flex",
flexDirection: "column",
overflow: "hidden",
}}>
<div
className="link-preview-title"
style={{
fontWeight: "bold",
marginBottom: "4px",
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
}}>
{data.title || "No title"}
</div>
<div
className="link-preview-url"
style={{
fontSize: "12px",
color: "var(--vscode-textLink-foreground, #3794ff)",
marginBottom: "8px",
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
}}>
{data.siteName || new URL(url).hostname}
</div>
<div
className="link-preview-description"
style={{
fontSize: "12px",
color: "var(--vscode-descriptionForeground, rgba(204, 204, 204, 0.7))",
overflow: "hidden",
display: "-webkit-box",
WebkitLineClamp: 3,
WebkitBoxOrient: "vertical",
textOverflow: "ellipsis",
}}>
{data.description || "No description available"}
</div>
</div>
</div>
<ChatErrorBoundary errorTitle="Something went wrong displaying this link preview">
<MemoizedLinkPreview {...props} />
</ChatErrorBoundary>
)
}
export default LinkPreview
export default LinkPreviewWithErrorBoundary
@@ -1,180 +1,23 @@
import React, { useEffect, useState, useCallback } from "react"
import { vscode } from "../../utils/vscode"
import LinkPreview from "./LinkPreview"
import ImagePreview from "./ImagePreview"
import { vscode } from "../../utils/vscode"
import DOMPurify from "dompurify"
import styled from "styled-components"
import { CODE_BLOCK_BG_COLOR } from "../common/CodeBlock"
import DOMPurify from "dompurify"
import ChatErrorBoundary from "../chat/ChatErrorBoundary"
import {
safeCreateUrl,
isUrl,
getSafeHostname,
isLocalhostUrl,
normalizeRelativeUrl,
formatUrlForOpening,
checkIfImageUrl,
} from "./McpRichUtil"
// We'll use the backend isImageUrl function for HEAD requests
// This is a client-side fallback for data URLs and obvious image extensions
const isImageUrlSync = (str: string): boolean => {
// Check for data URLs which are definitely images
if (str.startsWith("data:image/")) {
return true
}
// Check for common image file extensions
return str.match(/\.(jpg|jpeg|png|gif|webp)$/i) !== null
}
export const isUrl = (str: string): boolean => {
// Basic URL validation
const urlPattern = /^(https?:\/\/)?([a-zA-Z0-9-]+\.)+[a-zA-Z]{2,}(\/[^\s]*)?$/
return urlPattern.test(str)
}
// Function to check if a URL is an image using HEAD request
export const checkIfImageUrl = async (url: string): Promise<boolean> => {
// For data URLs, we can check synchronously
if (url.startsWith("data:image/")) {
return true
}
// For http/https URLs, we need to send a message to the extension
if (url.startsWith("http")) {
try {
// Create a promise that will resolve when we get a response
return new Promise((resolve) => {
// Set up a one-time listener for the response
const messageListener = (event: MessageEvent) => {
const message = event.data
if (message.type === "isImageUrlResult" && message.url === url) {
window.removeEventListener("message", messageListener)
resolve(message.isImage)
}
}
window.addEventListener("message", messageListener)
// Send the request to the extension
vscode.postMessage({
type: "checkIsImageUrl",
text: url,
})
// Set a timeout to avoid hanging indefinitely
setTimeout(() => {
window.removeEventListener("message", messageListener)
// Fall back to extension check
resolve(isImageUrlSync(url))
}, 3000)
})
} catch (error) {
console.error("Error checking if URL is an image:", error)
return isImageUrlSync(url)
}
}
// Fall back to extension check for other URLs
return isImageUrlSync(url)
}
// No longer needed as our regex directly extracts the URL part
// Helper to ensure URL is in a format that can be opened
export const formatUrlForOpening = (url: string): string => {
// If it's a data URI, return as is
if (url.startsWith("data:image/")) {
return url
}
// If it's a regular URL but doesn't have a protocol, add https://
if (!url.startsWith("http://") && !url.startsWith("https://")) {
return `https://${url}`
}
return url
}
// Find all URLs (both image and regular) in an object
export const findUrls = async (obj: any): Promise<{ imageUrls: string[]; regularUrls: string[] }> => {
const imageUrls: string[] = []
const regularUrls: string[] = []
const pendingChecks: Promise<void>[] = []
if (typeof obj === "object" && obj !== null) {
for (const value of Object.values(obj)) {
if (typeof value === "string") {
// First check with synchronous method
if (isImageUrlSync(value)) {
imageUrls.push(value)
} else if (isUrl(value)) {
// For URLs that don't obviously look like images, we'll check asynchronously
const checkPromise = checkIfImageUrl(value).then((isImage) => {
if (isImage) {
imageUrls.push(value)
} else {
regularUrls.push(value)
}
})
pendingChecks.push(checkPromise)
}
} else if (typeof value === "object") {
const nestedUrlsPromise = findUrls(value).then((nestedUrls) => {
imageUrls.push(...nestedUrls.imageUrls)
regularUrls.push(...nestedUrls.regularUrls)
})
pendingChecks.push(nestedUrlsPromise)
}
}
}
// Wait for all async checks to complete
await Promise.all(pendingChecks)
return { imageUrls, regularUrls }
}
// Extract URLs from text using regex
export const extractUrlsFromText = async (text: string): Promise<{ imageUrls: string[]; regularUrls: string[] }> => {
const imageUrls: string[] = []
const regularUrls: string[] = []
const pendingChecks: Promise<void>[] = []
// Match URLs with image: prefix and extract just the URL part
const imageMatches = text.match(/image:\s*(https?:\/\/[^\s]+)/g)
if (imageMatches) {
// Extract just the URL part from matches with image: prefix
const extractedUrls = imageMatches
.map((match) => {
const urlMatch = /image:\s*(https?:\/\/[^\s]+)/.exec(match)
return urlMatch ? urlMatch[1] : null
})
.filter(Boolean) as string[]
imageUrls.push(...extractedUrls)
}
// Match all URLs (including those that might be in the middle of paragraphs)
const urlMatches = text.match(/https?:\/\/[^\s]+/g)
if (urlMatches) {
// Filter out URLs that are already in imageUrls
const filteredUrls = urlMatches.filter((url) => !imageUrls.includes(url))
// Check each URL to see if it's an image
for (const url of filteredUrls) {
// First check with synchronous method
if (isImageUrlSync(url)) {
imageUrls.push(url)
} else {
// For URLs that don't obviously look like images, we'll check asynchronously
const checkPromise = checkIfImageUrl(url).then((isImage) => {
if (isImage) {
imageUrls.push(url)
} else {
regularUrls.push(url)
}
})
pendingChecks.push(checkPromise)
}
}
}
// Wait for all async checks to complete
await Promise.all(pendingChecks)
return { imageUrls, regularUrls }
}
// Maximum number of URLs to process in total, per response
export const MAX_URLS = 50
const ResponseHeader = styled.div`
display: flex;
@@ -271,7 +114,7 @@ interface McpResponseDisplayProps {
// Represents a URL found in the text with its position and metadata
interface UrlMatch {
url: string // The actual URL
fullMatch: string // The full matched text (including any prefix like "image:")
fullMatch: string // The full matched text
index: number // Position in the text
isImage: boolean // Whether this URL is an image
isProcessed: boolean // Whether we've already processed this URL (to avoid duplicates)
@@ -282,59 +125,159 @@ const McpResponseDisplay: React.FC<McpResponseDisplayProps> = ({ responseText })
const [displayMode, setDisplayMode] = useState<"rich" | "plain">(() => {
// Get saved preference from localStorage, default to 'rich'
const savedMode = localStorage.getItem("mcpDisplayMode")
return (savedMode === "plain" ? "plain" : "rich") as "rich" | "plain"
return savedMode === "plain" ? "plain" : "rich"
})
const [urlMatches, setUrlMatches] = useState<UrlMatch[]>([])
const [error, setError] = useState<string | null>(null)
// Add a counter state for forcing re-renders to make toggling run smoother
const [forceUpdateCounter, setForceUpdateCounter] = useState(0)
const toggleDisplayMode = useCallback(() => {
const newMode = displayMode === "rich" ? "plain" : "rich"
// Force an immediate re-render
setForceUpdateCounter((prev) => prev + 1)
// Update display mode and save preference
setDisplayMode(newMode)
localStorage.setItem("mcpDisplayMode", newMode)
// If switching to plain mode, cancel any ongoing processing
if (newMode === "plain") {
console.log("Switching to plain mode - cancelling URL processing")
setUrlMatches([]) // Clear any existing matches when switching to plain mode
} else {
// If switching to rich mode, the useEffect will re-run and fetch data
console.log("Switching to rich mode - will start URL processing")
}
}, [displayMode])
// Find all URLs in the text and determine if they're images
useEffect(() => {
// Skip all processing if in plain mode
if (displayMode === "plain") {
setIsLoading(false)
setUrlMatches([]) // Clear any existing matches when in plain mode
return
}
// Use a direct boolean for cancellation that's scoped to this effect run
let processingCanceled = false
const processResponse = async () => {
console.log("Processing MCP response for URL extraction")
setIsLoading(true)
setError(null)
try {
const text = responseText || ""
const matches: UrlMatch[] = []
const urlRegex = /https?:\/\/[^\s]+/g
const urlRegex = /https?:\/\/[^\s<>"']+/g
let urlMatch: RegExpExecArray | null
let urlCount = 0
while ((urlMatch = urlRegex.exec(text)) !== null) {
// First pass: Extract all URLs and immediately make them available for rendering
while ((urlMatch = urlRegex.exec(text)) !== null && urlCount < MAX_URLS) {
// Get the original URL from the match - never modify the original URL text
const url = urlMatch[0]
const fullMatch = url
// Skip invalid URLs
if (!isUrl(url)) {
console.log("Skipping invalid URL:", url)
continue
}
// Skip localhost URLs to prevent security issues
if (isLocalhostUrl(url)) {
console.log("Skipping localhost URL:", url)
continue
}
matches.push({
url,
fullMatch,
fullMatch: url,
index: urlMatch.index,
isImage: false, // Will check later
isProcessed: false,
})
urlCount++
}
// Check if URLs are images
for (const match of matches) {
match.isImage = await checkIfImageUrl(match.url)
console.log(`Found ${matches.length} URLs in text, will check if they are images`)
// Set matches immediately so UI can start rendering with loading states
setUrlMatches(matches.sort((a, b) => a.index - b.index))
// Mark loading as complete to show content immediately
setIsLoading(false)
// Process image checks in the background - one at a time to avoid network flooding
const processImageChecks = async () => {
console.log(`Starting sequential URL processing for ${matches.length} URLs`)
for (let i = 0; i < matches.length; i++) {
// Skip already processed URLs (from extension check)
if (matches[i].isProcessed) continue
// Check if processing has been canceled (switched to plain mode)
if (processingCanceled) {
console.log("URL processing canceled - display mode changed to plain")
return
}
const match = matches[i]
console.log(`Processing URL ${i + 1} of ${matches.length}: ${match.url}`)
try {
// Process each URL individually
const isImage = await checkIfImageUrl(match.url)
// Skip if processing has been canceled
if (processingCanceled) return
// Update the match in place
match.isImage = isImage
match.isProcessed = true
// Update state after each URL to show progress
// Create a new array to ensure React detects the state change
setUrlMatches([...matches])
} catch (err) {
console.log(`URL check error: ${match.url}`, err)
match.isProcessed = true
// Update state even on error
if (!processingCanceled) {
setUrlMatches([...matches])
}
}
// Delay between URL processing to avoid overwhelming the network
if (!processingCanceled && i < matches.length - 1) {
await new Promise((resolve) => setTimeout(resolve, 100))
}
}
console.log(`URL processing complete. Found ${matches.filter((m) => m.isImage).length} image URLs`)
}
// Sort by position in the text
matches.sort((a, b) => a.index - b.index)
setUrlMatches(matches)
// Start the background processing
processImageChecks()
} catch (error) {
console.error("Error processing MCP response:", error)
} finally {
setError("Failed to process response content. Switch to plain text mode to view safely.")
setIsLoading(false)
}
}
processResponse()
}, [responseText])
// Cleanup function to cancel processing if component unmounts or dependencies change
return () => {
processingCanceled = true
console.log("Cleaning up URL processing")
}
}, [responseText, displayMode, forceUpdateCounter])
// Function to render content based on display mode
const renderContent = () => {
@@ -343,15 +286,26 @@ const McpResponseDisplay: React.FC<McpResponseDisplayProps> = ({ responseText })
return <UrlText>{responseText}</UrlText>
}
// Show error message if there was an error
if (error) {
return (
<>
<div style={{ color: "var(--vscode-errorForeground)", marginBottom: "10px" }}>{error}</div>
<UrlText>{responseText}</UrlText>
</>
)
}
// For rich display mode, show the text with embedded content
if (displayMode === "rich" && !isLoading) {
if (!isLoading) {
// We already know displayMode is "rich" if we get here
// Create an array of text segments and embedded content
const segments: JSX.Element[] = []
let lastIndex = 0
let segmentIndex = 0
// Reset the processed flag for all URLs
const processedUrls = new Set<string>()
// Track embed count for logging
let embedCount = 0
// Add the text before the first URL
if (urlMatches.length === 0) {
@@ -375,38 +329,51 @@ const McpResponseDisplay: React.FC<McpResponseDisplayProps> = ({ responseText })
const urlEndIndex = index + fullMatch.length
// Add embedded content after the URL
// For images, use the ImagePreview component
if (match.isImage) {
segments.push(
<div key={`embed-${segmentIndex++}`} style={{ margin: "10px 0" }}>
<img
src={DOMPurify.sanitize(url)}
alt={`Image for ${url}`}
style={{
width: "85%",
height: "auto",
borderRadius: "4px",
cursor: "pointer",
}}
onClick={() => {
const formattedUrl = formatUrlForOpening(url)
vscode.postMessage({
type: "openInBrowser",
url: DOMPurify.sanitize(formattedUrl),
})
}}
/>
</div>,
)
} else if (!processedUrls.has(url)) {
// For non-image URLs, only show the preview once
segments.push(
<div key={`embed-${segmentIndex++}`} style={{ margin: "10px 0" }}>
<LinkPreview url={formatUrlForOpening(url)} />
<div key={`embed-image-${url}-${segmentIndex++}`}>
{/* Use formatUrlForOpening for network calls but preserve original URL in display */}
<ImagePreview url={formatUrlForOpening(url)} />
</div>,
)
embedCount++
// console.log(`Added image embed for ${url}, embed count: ${embedCount}`);
} else if (match.isProcessed) {
// For non-image URLs or URLs we haven't processed yet, show link preview
try {
// Skip localhost URLs
if (!isLocalhostUrl(url)) {
// Use a unique key that includes the URL to ensure each preview is isolated
segments.push(
<div key={`embed-${url}-${segmentIndex++}`} style={{ margin: "10px 0" }}>
{/* Already using formatUrlForOpening for link previews */}
<LinkPreview url={formatUrlForOpening(url)} />
</div>,
)
// Mark this URL as processed
processedUrls.add(url)
embedCount++
// console.log(`Added link preview for ${url}, embed count: ${embedCount}`);
}
} catch (e) {
console.log("Link preview could not be created")
// Show error message for failed link preview
segments.push(
<div
key={`embed-error-${segmentIndex++}`}
style={{
margin: "10px 0",
padding: "8px",
color: "var(--vscode-errorForeground)",
border: "1px solid var(--vscode-editorError-foreground)",
borderRadius: "4px",
height: "128px", // Fixed height
overflow: "auto", // Allow scrolling if content overflows
}}>
Failed to create preview for: {url}
</div>,
)
}
}
// Update lastIndex for next segment
@@ -442,7 +409,7 @@ const McpResponseDisplay: React.FC<McpResponseDisplayProps> = ({ responseText })
</ResponseContainer>
)
} catch (error) {
console.error("Error parsing MCP response:", error)
console.log("Error rendering MCP response - falling back to plain text")
return (
<ResponseContainer>
<ResponseHeader>
@@ -457,4 +424,13 @@ const McpResponseDisplay: React.FC<McpResponseDisplayProps> = ({ responseText })
}
}
export default McpResponseDisplay
// Wrap the entire McpResponseDisplay component with an error boundary
const McpResponseDisplayWithErrorBoundary: React.FC<McpResponseDisplayProps> = (props) => {
return (
<ChatErrorBoundary>
<McpResponseDisplay {...props} />
</ChatErrorBoundary>
)
}
export default McpResponseDisplayWithErrorBoundary
@@ -0,0 +1,183 @@
import { vscode } from "../../utils/vscode"
// Safely create a URL object with error handling and ensure HTTPS
export const safeCreateUrl = (url: string): URL | null => {
try {
// Convert HTTP to HTTPS for security
if (url.startsWith("http://")) {
url = url.replace("http://", "https://")
}
return new URL(url)
} catch (e) {
// If the URL doesn't have a protocol, add https://
if (!url.startsWith("https://")) {
try {
return new URL(`https://${url}`)
} catch (e) {
console.log(`Invalid URL: ${url}`)
return null
}
}
console.log(`Invalid URL: ${url}`)
return null
}
}
// Check if a string is a valid URL
export const isUrl = (str: string): boolean => {
return safeCreateUrl(str) !== null
}
// Get hostname safely
export const getSafeHostname = (url: string): string => {
try {
const urlObj = safeCreateUrl(url)
return urlObj ? urlObj.hostname : "unknown-host"
} catch (e) {
return "unknown-host"
}
}
// Check if a URL is a localhost URL by examining the hostname
export const isLocalhostUrl = (url: string): boolean => {
try {
const hostname = getSafeHostname(url)
return (
hostname === "localhost" ||
hostname === "127.0.0.1" ||
hostname === "0.0.0.0" ||
hostname.startsWith("192.168.") ||
hostname.startsWith("10.") ||
hostname.endsWith(".local")
)
} catch (e) {
// If we can't parse the URL, assume it's not localhost
return false
}
}
// Function to normalize relative URLs by combining with a base URL
export const normalizeRelativeUrl = (relativeUrl: string, baseUrl: string): string => {
// If it's already an absolute URL or a data URL, return as is
if (relativeUrl.startsWith("http://") || relativeUrl.startsWith("https://") || relativeUrl.startsWith("data:")) {
return relativeUrl
}
try {
// Parse the base URL
const baseUrlObj = safeCreateUrl(baseUrl)
if (!baseUrlObj) {
return relativeUrl // If we can't parse the base URL, return original
}
// Handle different types of relative paths
if (relativeUrl.startsWith("//")) {
// Protocol-relative URL
return `${baseUrlObj.protocol}${relativeUrl}`
} else if (relativeUrl.startsWith("/")) {
// Root-relative URL
return `${baseUrlObj.protocol}//${baseUrlObj.host}${relativeUrl}`
} else {
// Path-relative URL
// Get the directory part of the URL
let basePath = baseUrlObj.pathname
if (!basePath.endsWith("/")) {
// If the path doesn't end with a slash, remove the file part
basePath = basePath.substring(0, basePath.lastIndexOf("/") + 1)
}
return `${baseUrlObj.protocol}//${baseUrlObj.host}${basePath}${relativeUrl}`
}
} catch (error) {
console.log(`Error normalizing relative URL: ${error}`)
return relativeUrl // Return original on error
}
}
// Helper to ensure URL is in a format that can be opened
export const formatUrlForOpening = (url: string): string => {
// If it's a data URI, return as is
if (url.startsWith("data:image/")) {
return url
}
// Use safeCreateUrl to validate and format the URL
const urlObj = safeCreateUrl(url)
if (urlObj) {
return urlObj.href
}
console.log(`Invalid URL format: ${url}`)
// Return a safe fallback that won't crash
return "about:blank"
}
// Function to check if a URL is an image using HEAD request
export const checkIfImageUrl = async (url: string): Promise<boolean> => {
// For data URLs, we can check synchronously
if (url.startsWith("data:image/")) {
return true
}
// Create a secure URL for the check but don't modify the original URL
let secureUrl = url
// Convert HTTP to HTTPS for security in the network request only
if (secureUrl.startsWith("http://")) {
secureUrl = secureUrl.replace("http://", "https://")
console.log(`Using HTTPS version for image check: ${secureUrl}`)
}
// Validate URL before proceeding
if (!isUrl(url)) {
console.log("Invalid URL format:", url)
return false
}
// For https URLs, we need to send a message to the extension
if (url.startsWith("https")) {
try {
// Create a promise that will resolve when we get a response
return new Promise((resolve) => {
let timeoutId: ReturnType<typeof setTimeout> | undefined = undefined
// Set up a one-time listener for the response
const messageListener = (event: MessageEvent) => {
const message = event.data
if (message.type === "isImageUrlResult" && message.url === url) {
window.removeEventListener("message", messageListener)
resolve(message.isImage)
if (timeoutId) {
clearTimeout(timeoutId)
}
}
}
window.addEventListener("message", messageListener)
// Send the request to the extension
vscode.postMessage({
type: "checkIsImageUrl",
text: url,
})
// Set a timeout to avoid hanging indefinitely
timeoutId = setTimeout(() => {
window.removeEventListener("message", messageListener)
console.log("Hit timeout waiting for checkIsImageUrl")
resolve(false)
}, 3000)
})
} catch (error) {
console.log("Error checking if URL is an image:", url)
// Don't fall back to extension check on error
// Instead, return false to indicate it's not an image
return false
}
}
// Don't fall back to extension check for other URLs
// Only data URLs (handled above) are guaranteed to be images
// For all other URLs, we need proper content type verification
console.log(`URL protocol not supported for image check: ${url}`)
return false
}
+10 -6
View File
@@ -13,10 +13,10 @@ import { DEFAULT_MCP_TIMEOUT_SECONDS, McpServer } from "../../../../src/shared/m
import { useExtensionState } from "../../context/ExtensionStateContext"
import { getMcpServerDisplayName } from "../../utils/mcp"
import { vscode } from "../../utils/vscode"
import DangerButton from "../common/DangerButton"
import McpMarketplaceView from "./marketplace/McpMarketplaceView"
import McpResourceRow from "./McpResourceRow"
import McpToolRow from "./McpToolRow"
import DangerButton from "../common/DangerButton"
type McpViewProps = {
onDone: () => void
@@ -387,6 +387,13 @@ const ServerRow = ({ server }: { server: McpServer }) => {
}}>
{server.status === "connecting" ? "Retrying..." : "Retry Connection"}
</VSCodeButton>
<DangerButton
style={{ width: "calc(100% - 20px)", margin: "0 10px 10px 10px" }}
disabled={isDeleting}
onClick={handleDelete}>
{isDeleting ? "Deleting..." : "Delete Server"}
</DangerButton>
</div>
) : (
isExpanded && (
@@ -478,12 +485,9 @@ const ServerRow = ({ server }: { server: McpServer }) => {
</VSCodeButton>
<DangerButton
onClick={handleDelete}
style={{ width: "calc(100% - 14px)", margin: "5px 7px 3px 7px" }}
disabled={isDeleting}
style={{
width: "calc(100% - 14px)",
margin: "5px 7px 3px 7px",
}}>
onClick={handleDelete}>
{isDeleting ? "Deleting..." : "Delete Server"}
</DangerButton>
</div>
@@ -0,0 +1,683 @@
# How To Test Rich MCP Responses
Use the `echo` MCP server to read back one of the test cases below into an MCP response.
https://github.com/Garoth/echo-mcp
Manually check the embeds, images, and whatever other enhancements for proper rendering.
Remember that toggling Rich MCP off should cancel pending fetches. If the toggle was
set to Plain, then the image/link previews should never be fetched until it's enabled.
Remember that rich display mode will only load the first n URLs, currently set to 50
## Main Test Case
Working Image URLs
jpg: https://yavuzceliker.github.io/sample-images/image-205.jpg
webp: https://seenandheard.app/assets/img/face-2.webp
svg: https://seenandheard.app/assets/img/logo-white.svg
Looks like Image URL but is website
site: https://github.com/google/pprof/blob/main/doc/images/webui/flame-multi.png
raw png: https://raw.githubusercontent.com/google/pprof/refs/heads/main/doc/images/webui/flame-multi.png
Gif:
https://upload.wikimedia.org/wikipedia/commons/thumb/d/d0/01_Das_Sandberg-Modell.gif/750px-01_Das_Sandberg-Modell.gif
Normal Working URLs for OG Embeds
https://www.google.com
https://www.blogger.com
https://youtube.com
https://linkedin.com
https://support.google.com
https://cloudflare.com
https://microsoft.com
https://apple.com
https://en.wikipedia.org
https://play.google.com
https://wordpress.org
Attack URLs & Unsupported Formats
data:text/html,<h1>Hello World</h1>
data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==
javascript:alert('XSS')
mailto:user@example.com
tel:+1-234-567-8901
sms:+1-234-567-8901?body=Hello
https://www.example.com/path/to/file.html?param=<script>alert('XSS')</script>
https://www.example.com/path/to/file.html?param=<img src="x" onerror="alert('XSS')">
https://www.example.com/path/to/file.html?param=javascript:alert('XSS')
https://www.example.com/path/to/file.html?param=data:text/html,<script>alert('XSS')</script>
https://www.example.com/path/to/file.html?param=data:image/svg+xml,<svg onload="alert('XSS')">
https://www.example.com/path/to/file.html?param=<iframe src="javascript:alert('XSS')">
https://www.example.com/path/to/file.html?param=<a href="javascript:alert('XSS')">Click me</a>
Broken & Weird Edge Cases
https://tectum.io/blog/dex-tools/
http://0.0.0.0:8025/img.png
https://localhost:8080/img.jpg
http://localhost:8080/
https://localhost/
http://httpbin.org/#/
https://snthonstcrgrfonhenth.com/nthshtf
http://domain/.well-known/acme-challenge/token
https://<strong>dextools</strong>.apiable.io/(Only
## Generated Links Test Case
1. https://www.google.com
2. http://example.com/path/to/resource?query=value#fragment
3. https://images.unsplash.com/photo-1575936123452-b67c3203c357
4. file:///home/user/document.txt
5. https://user:password@example.com:8080/path
6. http://192.168.1.1:8080
7. https://www.example.com/path with spaces/file.html
8. ftp://ftp.example.com/pub/file.zip
9. https://www.example.com/index.php?id=1&name=test
10. https://subdomain.example.co.uk/path
11. https://www.example.com/path/to/image.jpg
12. https://www.example.com:8443/secure
13. http://localhost:3000
14. https://www.example.com/path/to/file.pdf#page=10
15. https://www.example.com/search?q=query+with+spaces
16. https://www.example.com/path/to/file.html#section-2
17. https://www.example.com/path/to/file.php?id=123&action=view
18. https://www.example.com/path/to/file.html?param1=value1&param2=value2#fragment
19. https://www.example.com/path/to/file.html?param=value with spaces
20. https://www.example.com/path/to/file.html?param=value%20with%20encoded%20spaces
21. https://www.example.com/path/to/file.html?param=value+with+plus+signs
22. https://www.example.com/path/to/file.html?param=special@characters!
23. https://www.example.com/path/to/file.html?param=special%40characters%21
24. https://www.example.com/path/to/file.html?param=value&param=duplicate
25. https://www.example.com/path/to/file.html?param=
26. https://www.example.com/path/to/file.html?=value
27. https://www.example.com/path/to/file.html?
28. https://www.example.com/path/to/file.html#
29. https://www.example.com/path/to/file.html#fragment1#fragment2
30. https://www.example.com/path/to/file.html?param1=value1#fragment?param2=value2
31. https://www.example.com/index.html#!hashbang
32. https://www.example.com/path/to/file.html?param=value#fragment=value
33. https://www.example.com/path/to/file.html?param=value&param2=value2#fragment
34. https://www.example.com/path/to/file.html?param=value&param2=value2#fragment=value
35. https://www.example.com/path/to/file.html?param=value&param2=value2#fragment?param3=value3
36. https://www.example.com/path/to/file.html?param=value&param2=value2#fragment&param3=value3
37. https://www.example.com/path/to/file.html?param=value&param2=value2#fragment#fragment2
38. https://www.example.com/path/to/file.html?param=value&param2=value2#fragment/path
39. https://www.example.com/path/to/file.html?param=value&param2=value2#fragment?param3=value3&param4=value4
40. https://www.example.com/path/to/file.html?param=value&param2=value2#fragment&param3=value3&param4=value4
41. data:text/html,<h1>Hello World</h1>
42. data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==
43. javascript:alert('XSS')
44. mailto:user@example.com
45. tel:+1-234-567-8901
46. sms:+1-234-567-8901?body=Hello
47. https://www.example.com/path/to/file.html?param=<script>alert('XSS')</script>
48. https://www.example.com/path/to/file.html?param=<img src="x" onerror="alert('XSS')">
49. https://www.example.com/path/to/file.html?param=javascript:alert('XSS')
50. https://www.example.com/path/to/file.html?param=data:text/html,<script>alert('XSS')</script>
51. https://www.example.com/path/to/file.html?param=data:image/svg+xml,<svg onload="alert('XSS')">
52. https://www.example.com/path/to/file.html?param=<iframe src="javascript:alert('XSS')">
53. https://www.example.com/path/to/file.html?param=<a href="javascript:alert('XSS')">Click me</a>
54. https://www.example.com/path/to/file.html?param=<img src="x" onerror="alert('XSS')">
55. https://www.example.com/path/to/file.html?param=<svg><script>alert('XSS')</script></svg>
56. https://www.example.com/path/to/file.html?param=<svg><animate onbegin="alert('XSS')" attributeName="x" />
57. https://www.example.com/path/to/file.html?param=<img src="x" onerror="alert('XSS')">
58. https://www.example.com/path/to/file.html?param=<body onload="alert('XSS')">
59. https://www.example.com/path/to/file.html?param=<input autofocus onfocus="alert('XSS')">
60. https://www.example.com/path/to/file.html?param=<video src="x" onerror="alert('XSS')">
61. https://www.example.com/path/to/file.html?param=<audio src="x" onerror="alert('XSS')">
62. https://www.example.com/path/to/file.html?param=<iframe srcdoc="<script>alert('XSS')</script>">
63. https://www.example.com/path/to/file.html?param=<math><maction actiontype="statusline#" xlink:href="javascript:alert('XSS')">Click
64. https://www.example.com/path/to/file.html?param=<form action="javascript:alert('XSS')"><input type="submit">
65. https://www.example.com/path/to/file.html?param=<isindex action="javascript:alert('XSS')" type="image">
66. https://www.example.com/path/to/file.html?param=<object data="javascript:alert('XSS')">
67. https://www.example.com/path/to/file.html?param=<embed src="javascript:alert('XSS')">
68. https://www.example.com/path/to/file.html?param=<svg><script>alert('XSS')</script>
69. https://www.example.com/path/to/file.html?param=<marquee onstart="alert('XSS')">
70. https://www.example.com/path/to/file.html?param=<div style="background-image: url(javascript:alert('XSS'))">
71. https://www.example.com/path/to/file.html?param=<link rel="stylesheet" href="javascript:alert('XSS')">
72. https://www.example.com/path/to/file.html?param=<table background="javascript:alert('XSS')">
73. https://www.example.com/path/to/file.html?param=<div style="width: expression(alert('XSS'))">
74. https://www.example.com/path/to/file.html?param=<style>@import "javascript:alert('XSS')";</style>
75. https://www.example.com/path/to/file.html?param=<meta http-equiv="refresh" content="0;url=javascript:alert('XSS')">
76. https://www.example.com/path/to/file.html?param=<iframe src="data:text/html,<script>alert('XSS')</script>">
77. https://www.example.com/path/to/file.html?param=<svg><set attributeName="onload" to="alert('XSS')" />
78. https://www.example.com/path/to/file.html?param=<script>alert('XSS')</script>
79. https://www.example.com/path/to/file.html?param=<img src="x" onerror="alert('XSS')">
80. https://www.example.com/path/to/file.html?param=<svg><animate xlink:href="#xss" attributeName="href" values="javascript:alert('XSS')" />
81. https://www.example.com/path/to/file.html?param=<svg><a><animate attributeName="href" values="javascript:alert('XSS')" />
82. https://www.example.com/path/to/file.html?param=<svg><a xlink:href="javascript:alert('XSS')"><text x="20" y="20">XSS</text></a>
83. https://www.example.com/path/to/file.html?param=<svg><a><animate attributeName="href" values="javascript:alert('XSS')" /><text x="20" y="20">XSS</text></a>
84. https://www.example.com/path/to/file.html?param=<svg><discard onbegin="alert('XSS')" />
85. https://www.example.com/path/to/file.html?param=<svg><script>alert('XSS')</script></svg>
86. https://www.example.com/path/to/file.html?param=<svg><script>alert('XSS')</script>
87. https://www.example.com/path/to/file.html?param=<svg><animate onbegin="alert('XSS')" attributeName="x" />
88. https://www.example.com/path/to/file.html?param=<svg><animate onbegin="alert('XSS')" attributeName="x" />
89. https://www.example.com/path/to/file.html?param=<svg><animate onbegin="alert('XSS')" attributeName="x" />
90. https://www.example.com/path/to/file.html?param=<svg><animate onbegin="alert('XSS')" attributeName="x" />
91. https://www.example.com/path/to/file.html?param=<svg><animate onbegin="alert('XSS')" attributeName="x" />
92. https://www.example.com/path/to/file.html?param=<svg><animate onbegin="alert('XSS')" attributeName="x" />
93. https://www.example.com/path/to/file.html?param=<svg><animate onbegin="alert('XSS')" attributeName="x" />
94. https://www.example.com/path/to/file.html?param=<svg><animate onbegin="alert('XSS')" attributeName="x" />
95. https://www.example.com/path/to/file.html?param=<svg><animate onbegin="alert('XSS')" attributeName="x" />
96. https://www.example.com/path/to/file.html?param=<svg><animate onbegin="alert('XSS')" attributeName="x" />
97. https://www.example.com/path/to/file.html?param=<svg><animate onbegin="alert('XSS')" attributeName="x" />
98. https://www.example.com/path/to/file.html?param=<svg><animate onbegin="alert('XSS')" attributeName="x" />
99. https://www.example.com/path/to/file.html?param=<svg><animate onbegin="alert('XSS')" attributeName="x" />
100. https://www.example.com/path/to/file.html?param=<svg><animate onbegin="alert('XSS')" attributeName="x" />
101. https://www.example.com/path/to/file.html?param=<svg><animate onbegin="alert('XSS')" attributeName="x" />
102. https://www.example.com/path/to/file.html?param=<svg><animate onbegin="alert('XSS')" attributeName="x" />
103. https://www.example.com/path/to/file.html?param=<svg><animate onbegin="alert('XSS')" attributeName="x" />
104. https://www.example.com/path/to/file.html?param=<svg><animate onbegin="alert('XSS')" attributeName="x" />
105. https://www.example.com/path/to/file.html?param=<svg><animate onbegin="alert('XSS')" attributeName="x" />
106. https://www.example.com/path/to/file.html?param=<svg><animate onbegin="alert('XSS')" attributeName="x" />
107. https://www.example.com/path/to/file.html?param=<svg><animate onbegin="alert('XSS')" attributeName="x" />
108. https://www.example.com/path/to/file.html?param=<svg><animate onbegin="alert('XSS')" attributeName="x" />
## Popular URLs by Popularity Test Case
1. https://www.google.com
2. https://www.blogger.com
3. https://youtube.com
4. https://linkedin.com
5. https://support.google.com
6. https://cloudflare.com
7. https://microsoft.com
8. https://apple.com
9. https://en.wikipedia.org
10. https://play.google.com
11. https://wordpress.org
12. https://docs.google.com
13. https://mozilla.org
14. https://maps.google.com
15. https://youtu.be
16. https://drive.google.com
17. https://bp.blogspot.com
18. https://sites.google.com
19. https://googleusercontent.com
20. https://accounts.google.com
21. https://t.me
22. https://europa.eu
23. https://plus.google.com
24. https://whatsapp.com
25. https://adobe.com
26. https://facebook.com
27. https://policies.google.com
28. https://uol.com.br
29. https://istockphoto.com
30. https://vimeo.com
31. https://vk.com
32. https://github.com
33. https://amazon.com
34. https://search.google.com
35. https://bbc.co.uk
36. https://google.de
37. https://live.com
38. https://gravatar.com
39. https://nih.gov
40. https://dan.com
41. https://files.wordpress.com
42. https://www.yahoo.com
43. https://cnn.com
44. https://dropbox.com
45. https://wikimedia.org
46. https://creativecommons.org
47. https://google.com.br
48. https://line.me
49. https://googleblog.com
50. https://opera.com
51. https://es.wikipedia.org
52. https://globo.com
53. https://brandbucket.com
54. https://myspace.com
55. https://slideshare.net
56. https://paypal.com
57. https://tiktok.com
58. https://netvibes.com
59. https://theguardian.com
60. https://who.int
61. https://goo.gl
62. https://medium.com
63. https://tools.google.com
64. https://draft.blogger.com
65. https://pt.wikipedia.org
66. https://fr.wikipedia.org
67. https://www.weebly.com
68. https://news.google.com
69. https://developers.google.com
70. https://w3.org
71. https://mail.google.com
72. https://gstatic.com
73. https://jimdofree.com
74. https://cpanel.net
75. https://imdb.com
76. https://wa.me
77. https://feedburner.com
78. https://enable-javascript.com
79. https://nytimes.com
80. https://workspace.google.com
81. https://ok.ru
82. https://google.es
83. https://dailymotion.com
84. https://afternic.com
85. https://bloomberg.com
86. https://amazon.de
87. https://photos.google.com
88. https://wiley.com
89. https://aliexpress.com
90. https://indiatimes.com
91. https://youronlinechoices.com
92. https://elpais.com
93. https://tinyurl.com
94. https://yadi.sk
95. https://spotify.com
96. https://huffpost.com
97. https://ru.wikipedia.org
98. https://google.fr
99. https://webmd.com
100. https://samsung.com
101. https://independent.co.uk
102. https://amazon.co.jp
103. https://get.google.com
104. https://amazon.co.uk
105. https://4shared.com
106. https://telegram.me
107. https://planalto.gov.br
108. https://businessinsider.com
109. https://ig.com.br
110. https://issuu.com
111. https://www.gov.br
112. https://wsj.com
113. https://hugedomains.com
114. https://picasaweb.google.com
115. https://usatoday.com
116. https://scribd.com
117. https://www.gov.uk
118. https://storage.googleapis.com
119. https://huffingtonpost.com
120. https://bbc.com
121. https://estadao.com.br
122. https://nature.com
123. https://mediafire.com
124. https://washingtonpost.com
125. https://forms.gle
126. https://namecheap.com
127. https://forbes.com
128. https://mirror.co.uk
129. https://soundcloud.com
130. https://fb.com
131. https://marketingplatform.google
132. https://domainmarket.com
133. https://ytimg.com
134. https://terra.com.br
135. https://google.co.uk
136. https://shutterstock.com
137. https://dailymail.co.uk
138. https://reg.ru
139. https://t.co
140. https://cdc.gov
141. https://thesun.co.uk
142. https://wp.com
143. https://cnet.com
144. https://instagram.com
145. https://researchgate.net
146. https://google.it
147. https://fandom.com
148. https://office.com
149. https://list-manage.com
150. https://msn.com
151. https://un.org
152. https://de.wikipedia.org
153. https://ovh.com
154. https://mail.ru
155. https://bing.com
156. https://news.yahoo.com
157. https://myaccount.google.com
158. https://hatena.ne.jp
159. https://shopify.com
160. https://adssettings.google.com
161. https://bit.ly
162. https://reuters.com
163. https://booking.com
164. https://discord.com
165. https://buydomains.com
166. https://nasa.gov
167. https://aboutads.info
168. https://time.com
169. https://abril.com.br
170. https://change.org
171. https://nginx.org
172. https://twitter.com
173. https://www.wikipedia.org
174. https://archive.org
175. https://cbsnews.com
176. https://networkadvertising.org
177. https://telegraph.co.uk
178. https://pinterest.com
179. https://google.co.jp
180. https://pixabay.com
181. https://zendesk.com
182. https://cpanel.com
183. https://vistaprint.com
184. https://sky.com
185. https://windows.net
186. https://alicdn.com
187. https://google.ca
188. https://lemonde.fr
189. https://newyorker.com
190. https://webnode.page
191. https://surveymonkey.com
192. https://translate.google.com
193. https://calendar.google.com
194. https://amazonaws.com
195. https://academia.edu
196. https://apache.org
197. https://imageshack.us
198. https://akamaihd.net
199. https://nginx.com
200. https://discord.gg
201. https://thetimes.co.uk
202. https://search.yahoo.com
203. https://amazon.fr
204. https://yelp.com
205. https://berkeley.edu
206. https://google.ru
207. https://sedoparking.com
208. https://cbc.ca
209. https://unesco.org
210. https://ggpht.com
211. https://privacyshield.gov
212. https://www.over-blog.com
213. https://clarin.com
214. https://www.wix.com
215. https://whitehouse.gov
216. https://icann.org
217. https://gnu.org
218. https://yandex.ru
219. https://francetvinfo.fr
220. https://gmail.com
221. https://mozilla.com
222. https://ziddu.com
223. https://guardian.co.uk
224. https://twitch.tv
225. https://sedo.com
226. https://foxnews.com
227. https://rambler.ru
228. https://books.google.com
229. https://stanford.edu
230. https://wikihow.com
231. https://it.wikipedia.org
232. https://20minutos.es
233. https://sfgate.com
234. https://liveinternet.ru
235. https://ja.wikipedia.org
236. https://000webhost.com
237. https://espn.com
238. https://eventbrite.com
239. https://disney.com
240. https://statista.com
241. https://addthis.com
242. https://pinterest.fr
243. https://lavanguardia.com
244. https://vkontakte.ru
245. https://doubleclick.net
246. https://bp2.blogger.com
247. https://skype.com
248. https://sciencedaily.com
249. https://bloglovin.com
250. https://insider.com
251. https://pl.wikipedia.org
252. https://sputniknews.com
253. https://id.wikipedia.org
254. https://doi.org
255. https://nypost.com
256. https://elmundo.es
257. https://abcnews.go.com
258. https://ipv4.google.com
259. https://deezer.com
260. https://express.co.uk
261. https://detik.com
262. https://mystrikingly.com
263. https://rakuten.co.jp
264. https://amzn.to
265. https://arxiv.org
266. https://alibaba.com
267. https://fb.me
268. https://wikia.com
269. https://t-online.de
270. https://telegra.ph
271. https://mega.nz
272. https://usnews.com
273. https://plos.org
274. https://naver.com
275. https://ibm.com
276. https://smh.com.au
277. https://dw.com
278. https://google.nl
279. https://lefigaro.fr
280. https://bp1.blogger.com
281. https://picasa.google.com
282. https://theatlantic.com
283. https://nydailynews.com
284. https://themeforest.net
285. https://rtve.es
286. https://newsweek.com
287. https://ovh.net
288. https://ca.gov
289. https://goodreads.com
290. https://economist.com
291. https://target.com
292. https://marca.com
293. https://kickstarter.com
294. https://hindustantimes.com
295. https://weibo.com
296. https://finance.yahoo.com
297. https://huawei.com
298. https://e-monsite.com
299. https://hubspot.com
300. https://npr.org
301. https://netflix.com
302. https://gizmodo.com
303. https://netlify.app
304. https://yandex.com
305. https://mashable.com
306. https://cnil.fr
307. https://latimes.com
308. https://steampowered.com
309. https://rt.com
310. https://photobucket.com
311. https://quora.com
312. https://nbcnews.com
313. https://android.com
314. https://instructables.com
315. https://www.canalblog.com
316. https://www.livejournal.com
317. https://ouest-france.fr
318. https://tripadvisor.com
319. https://ovhcloud.com
320. https://pexels.com
321. https://oracle.com
322. https://yahoo.co.jp
323. https://addtoany.com
324. https://sakura.ne.jp
325. https://cointernet.com.co
326. https://twimg.com
327. https://britannica.com
328. https://php.net
329. https://standard.co.uk
330. https://groups.google.com
331. https://cnbc.com
332. https://loc.gov
333. https://qq.com
334. https://buzzfeed.com
335. https://godaddy.com
336. https://ikea.com
337. https://disqus.com
338. https://taringa.net
339. https://ea.com
340. https://dropcatch.com
341. https://techcrunch.com
342. https://canva.com
343. https://offset.com
344. https://ebay.com
345. https://zoom.us
346. https://cambridge.org
347. https://unsplash.com
348. https://playstation.com
349. https://people.com
350. https://springer.com
351. https://psychologytoday.com
352. https://sendspace.com
353. https://home.pl
354. https://rapidshare.com
355. https://prezi.com
356. https://photos1.blogger.com
357. https://thenai.org
358. https://ftc.gov
359. https://google.pl
360. https://ted.com
361. https://secureserver.net
362. https://code.google.com
363. https://plesk.com
364. https://aol.com
365. https://biglobe.ne.jp
366. https://hp.com
367. https://canada.ca
368. https://linktr.ee
369. https://hollywoodreporter.com
370. https://ietf.org
371. https://clickbank.net
372. https://harvard.edu
373. https://amazon.es
374. https://oup.com
375. https://timeweb.ru
376. https://engadget.com
377. https://vice.com
378. https://cornell.edu
379. https://dreamstime.com
380. https://tmz.com
381. https://gofundme.com
382. https://pbs.org
383. https://stackoverflow.com
384. https://abc.net.au
385. https://sciencedirect.com
386. https://ft.com
387. https://variety.com
388. https://alexa.com
389. https://abc.es
390. https://walmart.com
391. https://gooyaabitemplates.com
392. https://redbull.com
393. https://ssl-images-amazon.com
394. https://theverge.com
395. https://spiegel.de
396. https://about.com
397. https://nationalgeographic.com
398. https://bandcamp.com
399. https://m.wikipedia.org
400. https://zippyshare.com
401. https://wired.com
402. https://freepik.com
403. https://outlook.com
404. https://mit.edu
405. https://sapo.pt
406. https://goo.ne.jp
407. https://java.com
408. https://google.co.th
409. https://scmp.com
410. https://mayoclinic.org
411. https://scholastic.com
412. https://nba.com
413. https://reverbnation.com
414. https://depositfiles.com
415. https://video.google.com
416. https://howstuffworks.com
417. https://cbslocal.com
418. https://merriam-webster.com
419. https://focus.de
420. https://admin.ch
421. https://gfycat.com
422. https://com.com
423. https://narod.ru
424. https://boston.com
425. https://sony.com
426. https://justjared.com
427. https://bitly.com
428. https://jstor.org
429. https://amebaownd.com
430. https://g.co
431. https://gsmarena.com
432. https://lexpress.fr
433. https://reddit.com
434. https://usgs.gov
435. https://bigcommerce.com
436. https://gettyimages.com
437. https://ign.com
438. https://justgiving.com
439. https://techradar.com
440. https://weather.com
441. https://amazon.ca
442. https://justice.gov
443. https://sciencemag.org
444. https://pcmag.com
445. https://theconversation.com
446. https://foursquare.com
447. https://flickr.com
448. https://giphy.com
449. https://tvtropes.org
450. https://fifa.com
451. https://upenn.edu
452. https://digg.com
453. https://bestfreecams.club
454. https://histats.com
455. https://salesforce.com
456. https://blog.google
457. https://apnews.com
458. https://theglobeandmail.com
459. https://m.me
460. https://europapress.es
461. https://washington.edu
462. https://thefreedictionary.com
463. https://jhu.edu
464. https://euronews.com
465. https://liberation.fr
466. https://ads.google.com
467. https://trustpilot.com
468. https://google.com.tw
469. https://softonic.com
470. https://kakao.com
471. https://storage.canalblog.com
472. https://interia.pl
473. https://metro.co.uk
474. https://viglink.com
475. https://last.fm
476. https://blackberry.com
477. https://public-api.wordpress.com
478. https://sina.com.cn
479. https://unicef.org
480. https://archives.gov
481. https://nps.gov
482. https://utexas.edu
483. https://biblegateway.com
484. https://usda.gov
485. https://indiegogo.com
486. https://nikkei.com
487. https://radiofrance.fr
488. https://repubblica.it
489. https://substack.com
490. https://ap.org
491. https://nicovideo.jp
492. https://joomla.org
493. https://news.com.au
494. https://allaboutcookies.org
495. https://mailchimp.com
496. https://stores.jp
497. https://intel.com
498. https://bp0.blogger.com
499. https://box.com
499. https://nhk.or.jp
@@ -43,14 +43,16 @@ import {
askSageDefaultURL,
xaiDefaultModelId,
xaiModels,
sambanovaModels,
sambanovaDefaultModelId,
} from "../../../../src/shared/api"
import { ExtensionMessage } from "../../../../src/shared/ExtensionMessage"
import { useExtensionState } from "../../context/ExtensionStateContext"
import { vscode } from "../../utils/vscode"
import { getAsVar, VSC_DESCRIPTION_FOREGROUND } from "../../utils/vscStyles"
import VSCodeButtonLink from "../common/VSCodeButtonLink"
import OpenRouterModelPicker, { ModelDescriptionMarkdown } from "./OpenRouterModelPicker"
import AccountView, { ClineAccountView } from "../account/AccountView"
import OpenRouterModelPicker, { ModelDescriptionMarkdown, OPENROUTER_MODEL_PICKER_Z_INDEX } from "./OpenRouterModelPicker"
import { ClineAccountInfoCard } from "./ClineAccountInfoCard"
interface ApiOptionsProps {
showModelOptions: boolean
@@ -60,9 +62,9 @@ interface ApiOptionsProps {
}
// This is necessary to ensure dropdown opens downward, important for when this is used in popup
const DROPDOWN_Z_INDEX = 1001 // Higher than the OpenRouterModelPicker's and ModelSelectorTooltip's z-index
const DROPDOWN_Z_INDEX = OPENROUTER_MODEL_PICKER_Z_INDEX + 2 // Higher than the OpenRouterModelPicker's and ModelSelectorTooltip's z-index
const DropdownContainer = styled.div<{ zIndex?: number }>`
export const DropdownContainer = styled.div<{ zIndex?: number }>`
position: relative;
z-index: ${(props) => props.zIndex || DROPDOWN_Z_INDEX};
@@ -90,8 +92,10 @@ const ApiOptions = ({ showModelOptions, apiErrorMessage, modelIdErrorMessage, is
const [vsCodeLmModels, setVsCodeLmModels] = useState<vscodemodels.LanguageModelChatSelector[]>([])
const [anthropicBaseUrlSelected, setAnthropicBaseUrlSelected] = useState(!!apiConfiguration?.anthropicBaseUrl)
const [azureApiVersionSelected, setAzureApiVersionSelected] = useState(!!apiConfiguration?.azureApiVersion)
const [awsEndpointSelected, setAwsEndpointSelected] = useState(!!apiConfiguration?.awsBedrockEndpoint)
const [modelConfigurationSelected, setModelConfigurationSelected] = useState(false)
const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false)
const [providerSortingSelected, setProviderSortingSelected] = useState(!!apiConfiguration?.openRouterProviderSorting)
const handleInputChange = (field: keyof ApiConfiguration) => (event: any) => {
setApiConfiguration({
@@ -207,12 +211,13 @@ const ApiOptions = ({ showModelOptions, apiErrorMessage, modelIdErrorMessage, is
<VSCodeOption value="litellm">LiteLLM</VSCodeOption>
<VSCodeOption value="asksage">AskSage</VSCodeOption>
<VSCodeOption value="xai">X AI</VSCodeOption>
<VSCodeOption value="sambanova">SambaNova</VSCodeOption>
</VSCodeDropdown>
</DropdownContainer>
{selectedProvider === "cline" && (
<div style={{ marginBottom: 8, marginTop: 4 }}>
<ClineAccountView />
<div style={{ marginBottom: 14, marginTop: 4 }}>
<ClineAccountInfoCard />
</div>
)}
@@ -563,7 +568,7 @@ const ApiOptions = ({ showModelOptions, apiErrorMessage, modelIdErrorMessage, is
<VSCodeOption value="ap-south-1">ap-south-1</VSCodeOption>
<VSCodeOption value="ap-northeast-1">ap-northeast-1</VSCodeOption>
<VSCodeOption value="ap-northeast-2">ap-northeast-2</VSCodeOption>
{/* <VSCodeOption value="ap-northeast-3">ap-northeast-3</VSCodeOption> */}
<VSCodeOption value="ap-northeast-3">ap-northeast-3</VSCodeOption>
<VSCodeOption value="ap-southeast-1">ap-southeast-1</VSCodeOption>
<VSCodeOption value="ap-southeast-2">ap-southeast-2</VSCodeOption>
<VSCodeOption value="ca-central-1">ca-central-1</VSCodeOption>
@@ -572,7 +577,7 @@ const ApiOptions = ({ showModelOptions, apiErrorMessage, modelIdErrorMessage, is
<VSCodeOption value="eu-west-1">eu-west-1</VSCodeOption>
<VSCodeOption value="eu-west-2">eu-west-2</VSCodeOption>
<VSCodeOption value="eu-west-3">eu-west-3</VSCodeOption>
{/* <VSCodeOption value="eu-north-1">eu-north-1</VSCodeOption> */}
<VSCodeOption value="eu-north-1">eu-north-1</VSCodeOption>
{/* <VSCodeOption value="me-south-1">me-south-1</VSCodeOption> */}
<VSCodeOption value="sa-east-1">sa-east-1</VSCodeOption>
<VSCodeOption value="us-gov-east-1">us-gov-east-1</VSCodeOption>
@@ -580,7 +585,33 @@ const ApiOptions = ({ showModelOptions, apiErrorMessage, modelIdErrorMessage, is
{/* <VSCodeOption value="us-gov-east-1">us-gov-east-1</VSCodeOption> */}
</VSCodeDropdown>
</DropdownContainer>
<div style={{ display: "flex", flexDirection: "column" }}>
<VSCodeCheckbox
checked={awsEndpointSelected}
onChange={(e: any) => {
const isChecked = e.target.checked === true
setAwsEndpointSelected(isChecked)
if (!isChecked) {
setApiConfiguration({
...apiConfiguration,
awsBedrockEndpoint: "",
})
}
}}>
Use custom VPC endpoint
</VSCodeCheckbox>
{awsEndpointSelected && (
<VSCodeTextField
value={apiConfiguration?.awsBedrockEndpoint || ""}
style={{ width: "100%", marginTop: 3, marginBottom: 5 }}
type="url"
onInput={handleInputChange("awsBedrockEndpoint")}
placeholder="Enter VPC Endpoint URL (optional)"
/>
)}
<VSCodeCheckbox
checked={apiConfiguration?.awsUseCrossRegionInference || false}
onChange={(e: any) => {
@@ -645,7 +676,7 @@ const ApiOptions = ({ showModelOptions, apiErrorMessage, modelIdErrorMessage, is
placeholder="Enter Project ID...">
<span style={{ fontWeight: 500 }}>Google Cloud Project ID</span>
</VSCodeTextField>
<DropdownContainer zIndex={DROPDOWN_Z_INDEX - 2} className="dropdown-container">
<DropdownContainer zIndex={DROPDOWN_Z_INDEX - 1} className="dropdown-container">
<label htmlFor="vertex-region-dropdown">
<span style={{ fontWeight: 500 }}>Google Cloud Region</span>
</label>
@@ -800,6 +831,21 @@ const ApiOptions = ({ showModelOptions, apiErrorMessage, modelIdErrorMessage, is
}}>
Supports Images
</VSCodeCheckbox>
<VSCodeCheckbox
checked={!!apiConfiguration?.openAiModelInfo?.supportsComputerUse}
onChange={(e: any) => {
const isChecked = e.target.checked === true
let modelInfo = apiConfiguration?.openAiModelInfo
? apiConfiguration.openAiModelInfo
: { ...openAiModelInfoSaneDefaults }
modelInfo = { ...modelInfo, supportsComputerUse: isChecked }
setApiConfiguration({
...apiConfiguration,
openAiModelInfo: modelInfo,
})
}}>
Supports Computer Use
</VSCodeCheckbox>
<div style={{ display: "flex", gap: 10, marginTop: "5px" }}>
<VSCodeTextField
value={
@@ -1266,6 +1312,37 @@ const ApiOptions = ({ showModelOptions, apiErrorMessage, modelIdErrorMessage, is
</div>
)}
{selectedProvider === "sambanova" && (
<div>
<VSCodeTextField
value={apiConfiguration?.sambanovaApiKey || ""}
style={{ width: "100%" }}
type="password"
onInput={handleInputChange("sambanovaApiKey")}
placeholder="Enter API Key...">
<span style={{ fontWeight: 500 }}>SambaNova API Key</span>
</VSCodeTextField>
<p
style={{
fontSize: "12px",
marginTop: 3,
color: "var(--vscode-descriptionForeground)",
}}>
This key is stored locally and only used to make API requests from this extension.
{!apiConfiguration?.sambanovaApiKey && (
<VSCodeLink
href="https://docs.sambanova.ai/cloud/docs/get-started/overview"
style={{
display: "inline",
fontSize: "inherit",
}}>
You can get a SambaNova API key by signing up here.
</VSCodeLink>
)}
</p>
</div>
)}
{apiErrorMessage && (
<p
style={{
@@ -1277,6 +1354,57 @@ const ApiOptions = ({ showModelOptions, apiErrorMessage, modelIdErrorMessage, is
</p>
)}
{(selectedProvider === "openrouter" || selectedProvider === "cline") && showModelOptions && (
<>
<VSCodeCheckbox
style={{ marginTop: -10 }}
checked={providerSortingSelected}
onChange={(e: any) => {
const isChecked = e.target.checked === true
setProviderSortingSelected(isChecked)
if (!isChecked) {
setApiConfiguration({
...apiConfiguration,
openRouterProviderSorting: "",
})
}
}}>
Sort underlying provider routing
</VSCodeCheckbox>
{providerSortingSelected && (
<div style={{ marginBottom: -6 }}>
<DropdownContainer className="dropdown-container" zIndex={OPENROUTER_MODEL_PICKER_Z_INDEX + 1}>
<VSCodeDropdown
style={{ width: "100%", marginTop: 3 }}
value={apiConfiguration?.openRouterProviderSorting}
onChange={(e: any) => {
setApiConfiguration({
...apiConfiguration,
openRouterProviderSorting: e.target.value,
})
}}>
<VSCodeOption value="">Default</VSCodeOption>
<VSCodeOption value="price">Price</VSCodeOption>
<VSCodeOption value="throughput">Throughput</VSCodeOption>
<VSCodeOption value="latency">Latency</VSCodeOption>
</VSCodeDropdown>
</DropdownContainer>
<p style={{ fontSize: "12px", marginTop: 3, color: "var(--vscode-descriptionForeground)" }}>
{!apiConfiguration?.openRouterProviderSorting &&
"Default behavior is to load balance requests across providers (like AWS, Google Vertex, Anthropic), prioritizing price while considering provider uptime"}
{apiConfiguration?.openRouterProviderSorting === "price" &&
"Sort providers by price, prioritizing the lowest cost provider"}
{apiConfiguration?.openRouterProviderSorting === "throughput" &&
"Sort providers by throughput, prioritizing the provider with the highest throughput (may increase cost)"}
{apiConfiguration?.openRouterProviderSorting === "latency" &&
"Sort providers by response time, prioritizing the provider with the lowest latency"}
</p>
</div>
)}
</>
)}
{selectedProvider !== "openrouter" &&
selectedProvider !== "cline" &&
selectedProvider !== "openai" &&
@@ -1304,6 +1432,7 @@ const ApiOptions = ({ showModelOptions, apiErrorMessage, modelIdErrorMessage, is
{selectedProvider === "mistral" && createDropdown(mistralModels)}
{selectedProvider === "asksage" && createDropdown(askSageModels)}
{selectedProvider === "xai" && createDropdown(xaiModels)}
{selectedProvider === "sambanova" && createDropdown(sambanovaModels)}
</DropdownContainer>
{((selectedProvider === "anthropic" && selectedModelId === "claude-3-7-sonnet-20250219") ||
@@ -1576,6 +1705,8 @@ export function normalizeApiConfiguration(apiConfiguration?: ApiConfiguration):
}
case "xai":
return getProviderData(xaiModels, xaiDefaultModelId)
case "sambanova":
return getProviderData(sambanovaModels, sambanovaDefaultModelId)
default:
return getProviderData(anthropicModels, anthropicDefaultModelId)
}
@@ -0,0 +1,70 @@
import { VSCodeButton } from "@vscode/webview-ui-toolkit/react"
import { useFirebaseAuth } from "../../context/FirebaseAuthContext"
import { vscode } from "../../utils/vscode"
export const ClineAccountInfoCard = () => {
const { user, handleSignOut } = useFirebaseAuth()
const handleLogin = () => {
vscode.postMessage({ type: "accountLoginClicked" })
}
const handleLogout = () => {
// First notify extension to clear API keys and state
vscode.postMessage({ type: "accountLogoutClicked" })
// Then sign out of Firebase
handleSignOut()
}
const handleShowAccount = () => {
vscode.postMessage({ type: "showAccountViewClicked" })
}
return (
<div className="max-w-[600px]">
{user ? (
<VSCodeButton appearance="secondary" onClick={handleShowAccount}>
View Billing & Usage
</VSCodeButton>
) : (
// <div className="p-2 rounded-[2px] bg-[var(--vscode-dropdown-background)]">
// <div className="flex items-center gap-3">
// {user.photoURL ? (
// <img src={user.photoURL} alt="Profile" className="w-[38px] h-[38px] rounded-full flex-shrink-0" />
// ) : (
// <div className="w-[38px] h-[38px] rounded-full bg-[var(--vscode-button-background)] flex items-center justify-center text-xl text-[var(--vscode-button-foreground)] flex-shrink-0">
// {user.displayName?.[0] || user.email?.[0] || "?"}
// </div>
// )}
// <div className="flex flex-col gap-1 flex-1 overflow-hidden">
// {user.displayName && (
// <div className="text-[13px] font-bold text-[var(--vscode-foreground)] break-words">
// {user.displayName}
// </div>
// )}
// {user.email && (
// <div className="text-[13px] text-[var(--vscode-descriptionForeground)] break-words overflow-hidden text-ellipsis">
// {user.email}
// </div>
// )}
// <div className="flex gap-2 flex-wrap mt-1">
// <VSCodeButton
// appearance="secondary"
// onClick={handleLogout}
// className="scale-[0.85] origin-left w-fit mt-0.5 mb-0 -mr-3">
// Log out
// </VSCodeButton>
// </div>
// </div>
// </div>
// </div>
<div>
<VSCodeButton onClick={handleLogin} className="mt-0">
Sign Up with Cline
</VSCodeButton>
</div>
)}
</div>
)
}
@@ -1,4 +1,4 @@
import { VSCodeLink, VSCodeTextField } from "@vscode/webview-ui-toolkit/react"
import { VSCodeCheckbox, VSCodeDropdown, VSCodeLink, VSCodeOption, VSCodeTextField } from "@vscode/webview-ui-toolkit/react"
import Fuse from "fuse.js"
import React, { KeyboardEvent, memo, useEffect, useMemo, useRef, useState } from "react"
import { useRemark } from "react-remark"
@@ -8,7 +8,7 @@ import { openRouterDefaultModelId } from "../../../../src/shared/api"
import { useExtensionState } from "../../context/ExtensionStateContext"
import { vscode } from "../../utils/vscode"
import { highlight } from "../history/HistoryView"
import { ModelInfoView, normalizeApiConfiguration } from "./ApiOptions"
import { DropdownContainer, ModelInfoView, normalizeApiConfiguration } from "./ApiOptions"
import { CODE_BLOCK_BG_COLOR } from "../common/CodeBlock"
import ThinkingBudgetSlider from "./ThinkingBudgetSlider"
@@ -117,7 +117,11 @@ const OpenRouterModelPicker: React.FC<OpenRouterModelPickerProps> = ({ isPopup }
}
const hasInfo = useMemo(() => {
return modelIds.some((id) => id.toLowerCase() === searchTerm.toLowerCase())
try {
return modelIds.some((id) => id.toLowerCase() === searchTerm.toLowerCase())
} catch {
return false
}
}, [modelIds, searchTerm])
useEffect(() => {
@@ -218,6 +222,7 @@ const OpenRouterModelPicker: React.FC<OpenRouterModelPickerProps> = ({ isPopup }
{showBudgetSlider && (
<ThinkingBudgetSlider apiConfiguration={apiConfiguration} setApiConfiguration={setApiConfiguration} />
)}
<ModelInfoView
selectedModelId={selectedModelId}
modelInfo={selectedModelInfo}

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