Compare commits

..

56 Commits

Author SHA1 Message Date
0xtoshii 48ffdc2c2c support old interface 2025-03-23 19:59:12 -07:00
0xtoshii c9c2e93812 merge, make functions public 2025-03-23 19:51:07 -07:00
Toshii d54146623f Merge branch 'main' into ft/move-context-logic-out 2025-03-23 14:28:04 +09: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
0xtoshii 652034232e changeset 2025-03-21 20:47:00 -07:00
0xtoshii 8d57cc6bfa updated move context logic out of Cline 2025-03-21 20:45:29 -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
0xtoshii 4d35585392 changeset 2025-03-21 13:17:43 -07:00
0xtoshii 0941c31381 stateless adjustment of context window on truncation 2025-03-21 13:14:41 -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
0xtoshii 57a1deea22 move context management out 2025-03-20 17:37:19 -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
61 changed files with 5487 additions and 992 deletions
+7
View File
@@ -0,0 +1,7 @@
---
"claude-dev": minor
---
Add Bedrock prompt caching support (optional).
This feature protected under checkbox because it is not yet rolled out to everyone, and if you will try to send cache headers, and its not enabled for you, you will get error.
@@ -2,4 +2,4 @@
"claude-dev": minor
---
Test Minor
update context on truncation
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
updated move context management out of cline
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
Test Patch
+1 -1
View File
@@ -1 +1 @@
* @saoudrizwan @ocasta181 @NightTrek @pashpashpash @dcbartlett
* @saoudrizwan @ocasta181 @NightTrek @pashpashpash @dcbartlett @saito-sv
@@ -26,65 +26,39 @@ import sys
CHANGELOG_PATH = os.environ.get("CHANGELOG_PATH", "CHANGELOG.md")
VERSION = os.environ['VERSION']
PREV_VERSION = os.environ.get("PREV_VERSION", "")
NEW_CONTENT = os.environ.get("NEW_CONTENT", "")
# Find the section for the specified version
version_index = -1
version_pattern = f"## {VERSION}\n"
bracketed_version_pattern = f"## [{VERSION}]\n"
header_end_index = 0
print(f"latest version: {VERSION}")
def overwrite_changelog_section(changelog_text: str, new_content: str):
# Find the section for the specified version
version_pattern = f"## {VERSION}\n"
bracketed_version_pattern = f"## [{VERSION}]\n"
prev_version_pattern = f"## [{PREV_VERSION}]\n"
print(f"latest version: {VERSION}")
print(f"prev_version: {PREV_VERSION}")
def fetch_changelog_header(changelog_text: str):
global version_pattern, version_index, bracketed_version_pattern, header_end_index
header = ""
print(f"Starting fetch_changelog_header")
# Try both unbracketed and bracketed version patterns
version_index = changelog_text.find(version_pattern)
if version_index == -1:
print("Version not found, trying bracketed version pattern")
version_index = changelog_text.find(bracketed_version_pattern)
if version_index == -1:
print("Bracketed version not found, adding new version header")
# If version not found, add it at the top (after the first line)
first_newline = changelog_text.find('\n')
print(f"First newline index: {first_newline}")
if first_newline == -1:
print("No newline found, prepending new version header")
# If no newline found, just prepend
header = f"## [{VERSION}]\n\n"
header = f"{changelog_text[:first_newline + 1]}\n## [{VERSION}]\n\n"
return f"## [{VERSION}]\n\n{changelog_text}"
return f"{changelog_text[:first_newline + 1]}## [{VERSION}]\n\n{changelog_text[first_newline + 1:]}"
else:
# Using bracketed version
version_pattern = bracketed_version_pattern
header = changelog_text[:version_index]
else:
header = changelog_text[:version_index]
header_end_index = len(header)
return header
def generate_changelog_section(changelog_text: str, new_content: str):
global version_pattern, version_index, header_end_index
print(f"Starting generate_changelog_section")
print(f"Version index: {version_index}")
print(f"Version pattern: {version_pattern} {len(version_pattern)}")
print(f"Header end index: {header_end_index}")
prev_version_pattern = "## ["
prev_version_index = changelog_text[header_end_index:].find(prev_version_pattern)
print(f"Previous version index: {prev_version_index}")
notes_start_index = version_index + len(version_pattern)
notes_end_index = changelog_text.find(prev_version_pattern, notes_start_index) if PREV_VERSION and prev_version_pattern in changelog_text else len(changelog_text)
if new_content:
print("Detected new content, overwriting existing changeset")
return f"{new_content}\n" + changelog_text[prev_version_index:]
return changelog_text[:notes_start_index] + f"{new_content}\n" + changelog_text[notes_end_index:]
else:
print("No new content provided, reformatting existing changeset")
changeset_lines = changelog_text[header_end_index:prev_version_index].split("\n")
print(f"Changeset lines: {changeset_lines}")
changeset_lines = changelog_text[notes_start_index:notes_end_index].split("\n")
# Ensure we have at least 2 lines before removing them
if len(changeset_lines) < 2:
print("Warning: Changeset content has fewer than 2 lines")
@@ -92,22 +66,11 @@ def generate_changelog_section(changelog_text: str, new_content: str):
else:
# Remove the first two lines from the regular changeset format, ex: \n### Patch Changes
parsed_lines = "\n".join(changeset_lines[2:])
# Reconstruct the changelog with the new content
updated_changelog = parsed_lines + changelog_text[prev_version_index:]
updated_changelog = changelog_text[:notes_start_index] + parsed_lines + changelog_text[notes_end_index:]
# Ensure version number is bracketed
updated_changelog = updated_changelog.replace(f"## {VERSION}", f"## [{VERSION}]")
return updated_changelog
def overwrite_changelog_section(changelog_text: str, new_content: str):
print(f"Starting overwrite_changelog_section")
header = fetch_changelog_header(changelog_text)
body = generate_changelog_section(changelog_text, new_content)
print(f"Header: {header}")
return header + body
try:
print(f"Reading changelog from: {CHANGELOG_PATH}")
with open(CHANGELOG_PATH, 'r') as f:
@@ -122,7 +85,7 @@ try:
print("New changelog content:")
print("----------------------------------------------------------------------------------")
# print(new_changelog)
print(new_changelog)
print("----------------------------------------------------------------------------------")
print(f"Writing updated changelog back to: {CHANGELOG_PATH}")
@@ -18,7 +18,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4
with:
fetch-depth: 0
ref: ${{ github.event.pull_request.head.sha }}
+26 -36
View File
@@ -6,7 +6,7 @@ on:
release-type:
description: "Choose release type (release or pre-release)"
required: true
default: "pre-release"
default: "release"
type: choice
options:
- pre-release
@@ -19,11 +19,11 @@ permissions:
pull-requests: write
jobs:
# test:
# uses: ./.github/workflows/test.yml
test:
uses: ./.github/workflows/test.yml
publish:
# needs: test
needs: test
name: Publish Extension
runs-on: ubuntu-latest
environment: publish
@@ -75,8 +75,8 @@ jobs:
VERSION=v${{ steps.get_version.outputs.version }}
echo "tag=$VERSION" >> $GITHUB_OUTPUT
echo "Tagging with $VERSION"
# git tag "$VERSION"
# git push origin "$VERSION"
git tag "$VERSION"
git push origin "$VERSION"
- name: Package and Publish Extension
env:
@@ -87,39 +87,29 @@ jobs:
vsce package --out "cline-${{ steps.get_version.outputs.version }}.vsix"
if [ "${{ github.event.inputs.release-type }}" = "pre-release" ]; then
# npm run publish:marketplace:prerelease
npm run publish:marketplace:prerelease
echo "Successfully published pre-release version ${{ steps.get_version.outputs.version }} to VS Code Marketplace and Open VSX Registry"
else
# npm run publish:marketplace
npm run publish:marketplace
echo "Successfully published release version ${{ steps.get_version.outputs.version }} to VS Code Marketplace and Open VSX Registry"
fi
# - name: Create Changelog Entry
# id: changesets
# uses: changesets/action@v1
# env:
# GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Create Changelog Entry
id: changesets
env:
VERSION: ${{ steps.get_version.outputs.version }}
run: |
python .github/scripts/overwrite_changeset_changelog.py
- name: Get Changelog Entry
id: changelog
uses: mindsers/changelog-reader-action@v2
with:
version: ${{ steps.get_version.outputs.version }}
# - name: Create GitHub Release
# uses: softprops/action-gh-release@v1
# - name: Get Changelog Entry
# id: changelog
# uses: mindsers/changelog-reader-action@v2
# with:
# tag_name: ${{ steps.create_tag.outputs.tag }}
# files: "*.vsix"
# # body: ${{ steps.fetch-changelog.outputs.content }}
# generate_release_notes: true
# prerelease: ${{ github.event.inputs.release-type == 'pre-release' }}
# env:
# GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# # This expects a standard Keep a Changelog format
# # "latest" means it will read whichever is the most recent version
# # set in "## [1.2.3] - 2025-01-28" style
# version: latest
- name: Create GitHub Release
uses: softprops/action-gh-release@v1
with:
tag_name: ${{ steps.create_tag.outputs.tag }}
files: "*.vsix"
# body: ${{ steps.changelog.outputs.content }}
generate_release_notes: true
prerelease: ${{ github.event.inputs.release-type == 'pre-release' }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+16 -3
View File
@@ -1,10 +1,23 @@
# Changelog
## [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]
- Tests
- Tests
- Tests
- 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]
+1769 -67
View File
File diff suppressed because it is too large Load Diff
+58 -8
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.7.0",
"version": "3.8.0",
"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,26 @@
"command": "cline.openInNewTab",
"title": "Open In New Tab",
"category": "Cline"
},
{
"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 +140,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"
}
]
},
@@ -271,7 +315,13 @@
"@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",
+20 -3
View File
@@ -30,8 +30,11 @@ export class ClineHandler implements ApiHandler {
this.getModel(),
this.options.o3MiniReasoningEffort,
this.options.thinkingBudgetTokens,
this.options.openRouterProviderSorting,
)
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) {
@@ -62,11 +65,25 @@ export class ClineHandler implements ApiHandler {
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
}
}
const apiStreamUsage = await this.getApiStreamUsage()
if (apiStreamUsage) {
yield apiStreamUsage
// Fallback to generation endpoint if usage chunk not returned
if (!didOutputUsage) {
const apiStreamUsage = await this.getApiStreamUsage()
if (apiStreamUsage) {
yield apiStreamUsage
}
}
}
+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,
+20 -3
View File
@@ -37,8 +37,11 @@ export class OpenRouterHandler implements ApiHandler {
this.getModel(),
this.options.o3MiniReasoningEffort,
this.options.thinkingBudgetTokens,
this.options.openRouterProviderSorting,
)
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) {
@@ -69,11 +72,25 @@ export class OpenRouterHandler implements ApiHandler {
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
}
}
const apiStreamUsage = await this.getApiStreamUsage()
if (apiStreamUsage) {
yield apiStreamUsage
// Fallback to generation endpoint if usage chunk not returned
if (!didOutputUsage) {
const apiStreamUsage = await this.getApiStreamUsage()
if (apiStreamUsage) {
yield apiStreamUsage
}
}
}
+3
View File
@@ -13,6 +13,7 @@ export async function createOpenRouterStream(
model: { id: string; info: ModelInfo },
o3MiniReasoningEffort?: string,
thinkingBudgetTokens?: number,
openRouterProviderSorting?: string,
) {
// Convert Anthropic messages to OpenAI format
let openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [
@@ -141,10 +142,12 @@ export async function createOpenRouterStream(
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 } } : {}),
})
return stream
+149 -47
View File
@@ -57,14 +57,17 @@ 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 { checkIsOpenRouterContextWindowError } from "./context-management/context-error-handling"
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
@@ -78,6 +81,7 @@ export class Cline {
private terminalManager: TerminalManager
private urlContentFetcher: UrlContentFetcher
browserSession: BrowserSession
contextManager: ContextManager
private didEditFile: boolean = false
customInstructions?: string
autoApprovalSettings: AutoApprovalSettings
@@ -139,6 +143,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
@@ -1346,58 +1351,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]()
@@ -1409,20 +1394,48 @@ export class Cline {
this.isWaitingForFirstChunk = false
} catch (error) {
const isOpenRouter = this.api instanceof OpenRouterHandler || this.api instanceof ClineHandler
const isOpenRouterContextWindowError = checkIsOpenRouterContextWindowError(error) && isOpenRouter
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)
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) {
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
@@ -3162,6 +3175,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({
@@ -3239,6 +3285,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
}
@@ -3388,6 +3464,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)
@@ -0,0 +1,139 @@
import { Anthropic } from "@anthropic-ai/sdk"
import { ClineApiReqInfo, ClineMessage } from "../../shared/ExtensionMessage"
import { ApiHandler } from "../../api"
import { OpenAiHandler } from "../../api/providers/openai"
import { formatResponse } from "../prompts/responses"
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.getAndAlterTruncatedMessages(
apiConversationHistory,
conversationHistoryDeletedRange,
)
return {
conversationHistoryDeletedRange: conversationHistoryDeletedRange,
updatedConversationHistoryDeletedRange: updatedConversationHistoryDeletedRange,
truncatedConversationHistory: truncatedConversationHistory,
}
}
public getNextTruncationRange(
apiMessages: Anthropic.Messages.MessageParam[],
currentDeletedRange: [number, number] | undefined,
keep: "half" | "quarter",
): [number, number] {
// We always keep the first user-assistant pairing, and truncate an even number of messages from there
const rangeStartIndex = 2 // index 0 and 1 are kept
const startOfRest = currentDeletedRange ? currentDeletedRange[1] + 1 : 2 // inclusive starting index
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 // inclusive ending index
// Make sure that the last message being removed is a assistant message, so the next message after the initial user-assistant pair 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 !== "assistant") {
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[] {
return this.getAndAlterTruncatedMessages(messages, deletedRange)
}
private getAndAlterTruncatedMessages(
messages: Anthropic.Messages.MessageParam[],
deletedRange: [number, number] | undefined,
): Anthropic.Messages.MessageParam[] {
if (!deletedRange) {
return messages
}
const [start, end] = deletedRange // inclusive range to ignore
// need a deep copy
const firstMessageChunk = JSON.parse(JSON.stringify(messages.slice(0, start)))
if (Array.isArray(firstMessageChunk[1].content)) {
// should always be the case
firstMessageChunk[1].content[0].text = formatResponse.contextTruncationNotice()
}
// 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 [...firstMessageChunk, ...messages.slice(end + 1)]
}
}
@@ -0,0 +1,3 @@
export function checkIsOpenRouterContextWindowError(error: any): boolean {
return error.code === 400 && error.message?.includes("context length")
}
+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) {
+3
View File
@@ -4,6 +4,9 @@ import * as path from "path"
import { ClineIgnoreController, LOCK_TEXT_SYMBOL } from "../ignore/ClineIgnoreController"
export const formatResponse = {
contextTruncationNotice: () =>
`[NOTE] Some previous conversation history with the user has been removed to maintain optimal context window length. The initial user task and the most recent exchanges have been retained for continuity, while intermediate conversation history has been removed. Please keep this in mind as you continue assisting the user.`,
toolDenied: () => `The user denied this operation.`,
toolError: (error?: string) => `The tool execution failed with the following error:\n<error>\n${error}\n</error>`,
-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)]
}
+194 -47
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 delay from "delay"
/*
https://github.com/microsoft/vscode-webview-ui-toolkit-samples/blob/main/default/weather-webview/src/providers/WeatherViewProvider.ts
@@ -89,6 +94,7 @@ type GlobalStateKey =
| "azureApiVersion"
| "openRouterModelId"
| "openRouterModelInfo"
| "openRouterProviderSorting"
| "autoApprovalSettings"
| "browserSettings"
| "chatSettings"
@@ -109,14 +115,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"
@@ -126,7 +124,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,
@@ -136,6 +136,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) => {
@@ -166,6 +168,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)
}
@@ -726,6 +730,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
@@ -816,6 +828,10 @@ export class ClineProvider implements vscode.WebviewViewProvider {
}
break
}
case "requestTotalTasksSize": {
this.refreshTotalTasksSize()
break
}
case "restartMcpServer": {
try {
await this.mcpHub?.restartConnection(message.text!)
@@ -917,6 +933,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
case "clearAllTaskHistory": {
await this.deleteAllTaskHistory()
await this.postStateToWebview()
this.refreshTotalTasksSize()
this.postMessageToWebview({ type: "relinquishControl" })
break
}
@@ -1138,6 +1155,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
azureApiVersion,
openRouterModelId,
openRouterModelInfo,
openRouterProviderSorting,
vsCodeLmModelSelector,
liteLlmBaseUrl,
liteLlmModelId,
@@ -1187,6 +1205,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)
@@ -1308,6 +1327,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> {
@@ -1346,7 +1379,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")
@@ -1716,6 +1749,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 delay(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 delay(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 delay(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<{
@@ -1788,46 +1919,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) {
@@ -1838,6 +1979,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() {
@@ -1976,6 +2119,7 @@ Here is the project's README to help you get started:\n\n${mcpDetails.readmeCont
azureApiVersion,
openRouterModelId,
openRouterModelInfo,
openRouterProviderSorting,
lastShownAnnouncementId,
customInstructions,
taskHistory,
@@ -2038,6 +2182,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>,
@@ -2140,8 +2285,10 @@ Here is the project's README to help you get started:\n\n${mcpDetails.readmeCont
qwenApiLine,
mistralApiKey,
azureApiVersion,
openRouterModelId,
// Fixes bug where switching to plan/act would result in setting this model id to previousModeModelId which may have been a non-string value by default, causing a type error in the webview when calling .toLowerCase() on it.
openRouterModelId: openRouterModelId ? String(openRouterModelId) : undefined,
openRouterModelInfo,
openRouterProviderSorting,
vsCodeLmModelSelector,
o3MiniReasoningEffort,
thinkingBudgetTokens,
@@ -2161,7 +2308,7 @@ Here is the project's README to help you get started:\n\n${mcpDetails.readmeCont
chatSettings: chatSettings || DEFAULT_CHAT_SETTINGS,
userInfo,
previousModeApiProvider,
previousModeModelId,
previousModeModelId: previousModeModelId ? String(previousModeModelId) : undefined,
previousModeModelInfo,
previousModeThinkingBudgetTokens,
mcpMarketplaceEnabled,
+153 -2
View File
@@ -115,14 +115,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 +193,151 @@ export function activate(context: vscode.ExtensionContext) {
}
context.subscriptions.push(vscode.window.registerUriHandler({ handleUri }))
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",
}
@@ -164,6 +164,7 @@ 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.`)
+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
}
}
}
+2 -1
View File
@@ -14,7 +14,7 @@ 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
@@ -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()
}
}
}
+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
}
+22 -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
}
+3
View File
@@ -45,6 +45,7 @@ export interface WebviewMessage {
| "getLatestState"
| "accountLoginClicked"
| "accountLogoutClicked"
| "showAccountViewClicked"
| "authStateChanged"
| "authCallback"
| "fetchMcpMarketplace"
@@ -61,7 +62,9 @@ export interface WebviewMessage {
| "invoke"
| "updateSettings"
| "clearAllTaskHistory"
| "fetchUserCreditsData"
| "optionsResponse"
| "requestTotalTasksSize"
// | "relaunchChromeDebugMode"
text?: string
disabled?: boolean
+9
View File
@@ -31,6 +31,7 @@ export interface ApiHandlerOptions {
openRouterApiKey?: string
openRouterModelId?: string
openRouterModelInfo?: ModelInfo
openRouterProviderSorting?: string
awsAccessKey?: string
awsSecretKey?: string
awsSessionToken?: string
@@ -1088,6 +1089,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,
+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
}
}
+23 -4
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",
@@ -49,7 +50,7 @@
"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"
}
},
@@ -4177,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",
@@ -7095,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",
@@ -8359,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": {
+2 -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",
@@ -53,7 +54,7 @@
"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
+4 -25
View File
@@ -22,13 +22,14 @@ import { CheckpointControls, CheckpointOverlay } from "../common/CheckpointContr
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 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;
@@ -792,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>
@@ -1041,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
@@ -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",
@@ -94,19 +94,20 @@ const TaskHeader: React.FC<TaskHeaderProps> = ({
}, [isTextExpanded, windowHeight])
useEffect(() => {
if (textRef.current && textContainerRef.current) {
if (isTaskExpanded && 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)
}
}, [task.text, windowWidth])
}, [task.text, windowWidth, isTaskExpanded])
const isCostAvailable = useMemo(() => {
const openAiCompatHasPricing =
@@ -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
@@ -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
}
@@ -387,6 +387,15 @@ const ServerRow = ({ server }: { server: McpServer }) => {
}}>
{server.status === "connecting" ? "Retrying..." : "Retry Connection"}
</VSCodeButton>
<DangerButton
onClick={handleDelete}
disabled={isDeleting}
style={{
width: "calc(100% - 20px)",
margin: "0 10px 10px 10px",
}}>
{isDeleting ? "Deleting..." : "Delete Server"}
</DangerButton>
</div>
) : (
isExpanded && (
@@ -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
@@ -51,8 +51,8 @@ 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
@@ -62,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};
@@ -95,6 +95,7 @@ const ApiOptions = ({ showModelOptions, apiErrorMessage, modelIdErrorMessage, is
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({
@@ -215,8 +216,8 @@ const ApiOptions = ({ showModelOptions, apiErrorMessage, modelIdErrorMessage, is
</DropdownContainer>
{selectedProvider === "cline" && (
<div style={{ marginBottom: 8, marginTop: 4 }}>
<ClineAccountView />
<div style={{ marginBottom: 14, marginTop: 4 }}>
<ClineAccountInfoCard />
</div>
)}
@@ -675,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>
@@ -1353,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" &&
@@ -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"
@@ -222,6 +222,7 @@ const OpenRouterModelPicker: React.FC<OpenRouterModelPickerProps> = ({ isPopup }
{showBudgetSlider && (
<ThinkingBudgetSlider apiConfiguration={apiConfiguration} setApiConfiguration={setApiConfiguration} />
)}
<ModelInfoView
selectedModelId={selectedModelId}
modelInfo={selectedModelInfo}
@@ -6,6 +6,7 @@ import { useExtensionState } from "../../context/ExtensionStateContext"
import { validateApiConfiguration } from "../../utils/validate"
import { vscode } from "../../utils/vscode"
import ApiOptions from "../settings/ApiOptions"
import ClineLogoWhite from "../../assets/ClineLogoWhite"
const WelcomeView = () => {
const { apiConfiguration } = useExtensionState()
@@ -46,7 +47,7 @@ const WelcomeView = () => {
}}>
<h2>Hi, I'm Cline</h2>
<div style={{ display: "flex", justifyContent: "center", margin: "20px 0" }}>
<ClineLogo />
<ClineLogoWhite className="size-16" />
</div>
<p>
I can do all kinds of tasks thanks to breakthroughs in{" "}
@@ -91,22 +92,4 @@ const WelcomeView = () => {
)
}
const ClineLogo: React.FC<{ style?: React.CSSProperties }> = ({ style }) => {
// (can't use svgs in vsc extensions)
const logoBase64 =
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADoAAAA8CAYAAAA34qk1AAAAAXNSR0IArs4c6QAAAERlWElmTU0AKgAAAAgAAYdpAAQAAAABAAAAGgAAAAAAA6ABAAMAAAABAAEAAKACAAQAAAABAAAAOqADAAQAAAABAAAAPAAAAAAs615UAAAGuElEQVRoBd1aW2hcRRj+5yRp4242m0tbSqMitSLaaFGLUXvDB0HUmlYRFXxQQRG0iFXxwctDfWgDKlgREYRWkaJPGsULUjX6IFRbkwZaaL1AUWI1JnvJ7ibpJmf8ZrNnc86ZmXPJZtM9DhzOzH+b/zszZ+af/xxGS1A4543pPD3OON1LjK5Al8txnWFEXzYQ9bW0sLO1dgN91baMTfD1DYzeRS/XaXoawwN4IplgH2j4i0KuKdDUJL+EzdIwPE34ecuJHmpvYQf95BbKrxlQTFeWydPXcOzmgM5lZg3q7oyxPwPKhxIzQkmHEE7naCfEg4IUlpMNJu0J0UUo0ZoBZYzuCOXJnPD2BegEUqkZUPS+NpAHTqEVo6Pc9312qgRr1RLobDAXnFLFFbQgPacVuVU7oJx+kbvzpmBlHFnDWMFbamHcqoFidTVwNbkvZtAnYV3ijD522ym3EVdUV0JtL+jUSE3QpgaDdmDf24qrCwZWwYWqHQkAIyVG3CQaQoDRf26Svli1iuUC6JVEAgMdz/Pt6GAfFK4MarzGcmnY35uM037G2JRfX75Az3Ieby7Qe8TpLj9j54XP6Ffsv72JBDvp1b8n0FyOr55B4A0DG7yM1AEvCx/ubmthh3W+aIHifVyeydEAThs36JTrjJ5t4HSjbmS1qy5Avh4hkOKZtyJW7v+D8wtUA6AEOp7jVwHkIyqFuqZxWpfI05MqH5VTNzPB+7Gn3alSiAAtMztNF3d2MvHeVoo0oqkUbwPI2yoS0askG5fJ/ktAjSa6Hdgao4dv3mMMVO98a64mAQX5WrdQBNtS2kYCirBuTQSBuV2WMEhAoSFi16iX+AjnMTsIFdClCNDtPtSk3vSv86ChAhq24wLSJi+ZJm3Bsp5EdLIe0/9B7FsnwhqC/CET21oTozVFRl2iDtqipEGlfTSd4wMwvg1XkDJIM3RPWxv7zS0sQshsnvoAWrmBO+QZ5SD3QHuc9Tvo5UaqwHcyk95H0zEdVbIWrThJrStXsgmrrRrRZovpc8/rQAo9HJ2mW+P0FKoi5elXntWBFIrtMfYRjojP+Rmx85ubyYHDATSV59dA+Hq7gq6O6bpPNZJ2eYDl2JEftdMU9WPJGL2toDtIrS30JghDDqJHA3GvI4R1AMWZcw90pemssocM/LcqupvW1sx+B+2Mm25rD5QeiI2gqgoZOPadiqekcdptzyhWgGYKvAeGAudicwkaVHagInK9LPr8WaWioiGNErxPos6mGO2y7FSAcpNetogB7jxMtg5Doc3tYGXV8iQ/OFUWF4mnInB62hrVEtDxLN8CuVtUshGndVijWgJqGKFGM1rYy6NqlA7ZwffNaIGc81aM6v0GIpmgwUEUQZZ85py2GlgM/hexrdcoYGVHzj3M3uRlrZ55nL438Dl9CMv/D/XsZ5W+pc0iHZrbR016vkpjdauOUPW1jg6WKQFtT7ABeBok+F4oILwm2jL3sLXseQaMBJYta6VmppCfRqko4jNfmFFl6Sm+dt4F7xocXKeV4HSpludmsBCyQpfRq1baswI0GWNH4NCnbtvadpE2ank2Bs6lIqO4wUZyVNFnIDslJRZClmisWKD9VmcVoIKAoPlF3HAG9i+YAbvE91I/yWyOHoOM42zo0tkeZHZkMvwyeBYm3/yK9uCNFfg4jB1xOaJsYhPejP+InlEyy8Rsll+OFb3PSwa8OA7wB/DQmnRy4C0zG+kg+IEzDIZJ79jtySPCaNou4FPvy+T4gfFxnnTL4RD/sGnQj6AHcW4rHtpPqRyXprhIBoB3FEf4m9x9eLWnp5048Io4S8ickaWcxzI+iDk/jNzOhVgENqIu5VYtYY873h46CaeOlWWEHfGTpDwgHkYEy50zWqxPD3ExlWF/s0gDVFEEoG6A667ChlJV9aRmlZIRI7r/V1IB/TtimGR3kbVwZ0AkoJh5f8maEaNwGnF7LAGFwFG3UATb1mJWcV0COnuOPge3WJGIYgU/XLndloCKSB9Bw2duwQi1U8Wp0mA5XJaACi6W3RdwE3taFMtee+hnAVAC7UywE9gP37KEInQ/jV/m3lD5q93eRXyZLtA3CL02qRTrkJZBfNvT2spOqXxTjqgQxLeOc41m6f8/aQVTGTrPtDQisx06kMI3LVDBxO9m/0zEaQuqH4p2nZZTYiTLWRKti55AhdZFjE3iZ8L7MMdvRXNYa2npGeOIiXfjnbwaI3nar3vtO6pSxHvLspPUgw9SvdiCtuGU0gW51biWqeQXkSZ2gFFcI3D4OHLR/ZMx+sod5nn19x8Bu+YF5eP/fAAAAABJRU5ErkJggg=="
return (
<img
src={logoBase64}
style={{
width: "57px",
height: "60px",
...style,
}}
alt="Cline Logo"
/>
)
}
export default WelcomeView
@@ -20,6 +20,7 @@ interface ExtensionStateContextType extends ExtensionState {
mcpServers: McpServer[]
mcpMarketplaceCatalog: McpMarketplaceCatalog
filePaths: string[]
totalTasksSize: number | null
setApiConfiguration: (config: ApiConfiguration) => void
setCustomInstructions: (value?: string) => void
setTelemetrySetting: (value: TelemetrySetting) => void
@@ -52,6 +53,7 @@ export const ExtensionStateContextProvider: React.FC<{
const [openRouterModels, setOpenRouterModels] = useState<Record<string, ModelInfo>>({
[openRouterDefaultModelId]: openRouterDefaultModelInfo,
})
const [totalTasksSize, setTotalTasksSize] = useState<number | null>(null)
const [openAiModels, setOpenAiModels] = useState<string[]>([])
const [mcpServers, setMcpServers] = useState<McpServer[]>([])
@@ -137,6 +139,10 @@ export const ExtensionStateContextProvider: React.FC<{
}
break
}
case "totalTasksSize": {
setTotalTasksSize(message.totalTasksSize ?? null)
break
}
}
}, [])
@@ -156,6 +162,7 @@ export const ExtensionStateContextProvider: React.FC<{
mcpServers,
mcpMarketplaceCatalog,
filePaths,
totalTasksSize,
setApiConfiguration: (value) =>
setState((prevState) => ({
...prevState,
+24
View File
@@ -10,3 +10,27 @@ export function formatLargeNumber(num: number): string {
}
return num.toString()
}
// Helper to format cents as dollars with 2 decimal places
export function formatDollars(cents?: number): string {
if (cents === undefined) {
return ""
}
return (cents / 100).toFixed(2)
}
export function formatTimestamp(timestamp: string): string {
const date = new Date(timestamp)
const dateFormatter = new Intl.DateTimeFormat("en-US", {
month: "2-digit",
day: "2-digit",
year: "2-digit",
hour: "numeric",
minute: "2-digit",
hour12: true,
})
return dateFormatter.format(date)
}