Compare commits

...

34 Commits

Author SHA1 Message Date
Dennis Bartlett 1671a4ae91 Move Grok 3 to the top 2025-04-09 17:11:35 -07:00
arafatkatze fab8fe0c2c Remove stream options from XAIHandler and add new Grok-3 model variants to API configuration 2025-04-09 16:25:53 -07:00
Dennis Bartlett cbcf89d634 Add sentry to extension (#2766)
* Add Sentry package

* Setup Logger to use Error Service

* Update src/services/error/ErrorService.ts

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

---------

Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>
2025-04-09 16:01:10 -07:00
pashpashpash 6a10e30436 linebreak (#2765) 2025-04-09 15:33:42 -07:00
pashpashpash 2c0afbc3be added IS_TEST build flag (#2770)
* added IS_TEST build flag

* removed cross-env
2025-04-09 15:32:08 -07:00
Ara 2ef4e56bca ENG-377 Changing Checkpoint UI to take less real space on the chat interface (#2752)
* Enhance chat component interactivity by adding row index and hover state management. Updated BrowserSessionRow, ChatRow, and CheckmarkControl to support row-specific hover effects and state tracking, improving user experience during interactions.

* Adding hovered row index

* Adding hovered row index

* Adding hovered row index
2025-04-09 13:53:10 -07:00
Toshii e26d001585 ENG-422 (#2768)
* task restore user message block structure

* changeset

* check str
2025-04-09 13:19:58 -07:00
Trevor Hudson 6fc2cb128e Trevor/eng 253 system for users to report errors to GitHub (#2756)
* add metadata for model and apiProvider so it's available to Cline

* fix test

* check for existing array in case of old task

* only write if the metadata changes
2025-04-09 12:12:32 -07:00
Saoud Rizwan 8cc64f5e7e Revert "split user text during task resumption (#2699)"
This reverts commit fdd04bc942.
2025-04-09 02:48:05 -07:00
Saoud Rizwan a4412e8014 Update new task tool component styles 2025-04-09 02:44:26 -07:00
Saoud Rizwan 36f7abb8ec Prepare for release 2025-04-09 02:30:20 -07:00
Saoud Rizwan 080a79bd7d Remove mcp docs tool 2025-04-09 02:19:28 -07:00
pashpashpash 0208fdf555 fixed test (#2755) 2025-04-08 19:51:09 -07:00
Dennis Bartlett 226f20f28f Add new types to PR template (#2750)
* Update scripts so that test runs all tests by default

* Update PR Template to include new types
2025-04-08 19:20:02 -07:00
Dennis Bartlett fdc76c8802 Update scripts so that test runs all tests by default (#2749) 2025-04-08 19:19:12 -07:00
Saoud Rizwan 7099a00674 Add info about smarter context management (#2754)
* Add info about smarter context management

* Create nice-toys-help.md
2025-04-08 19:18:13 -07:00
monotykamary b470229a97 feat: add tiered pricing for gemini-2.5-pro (#2741)
* feat: add tiered pricing for gemini-2.5-pro

* fix: ensure price tiers are sorted before lookup

* refactor: remove old prices

* refactor(settings): improve model tier pricing display clarity

- Clarify token limit display using full numbers instead of 'k'.
- Specify price unit as '/million tokens' for better understanding.
2025-04-08 19:06:25 -07:00
pashpashpash e37f6e3b88 Context in context (#2745)
* context in context

* keeping comments

* minimal context window line

* nit
2025-04-08 17:55:27 -07:00
pashpashpash 4c72bd96ab New Horizons (#2747)
* new task tool added

* small fix

* fixed numbering

* system prompt
2025-04-08 17:20:30 -07:00
Evan be120e85be Keybinding to quick-add context to Cline chat (#2748)
* add cmd + quote keybinding to add to cline chat

* changeset

* auto focus and start cursor on new line for easy UX

* changeset

* Remove extra new line character

---------

Co-authored-by: Dennis Bartlett <bartlett.dc.1@gmail.com>
2025-04-08 16:45:34 -07:00
Toshii fdd04bc942 split user text during task resumption (#2699)
* task restore user message block structure

* changeset
2025-04-08 16:13:07 -07:00
Shlomo b7c03af9ac fix: can't open as image diagrams with non-Latin1 characters (#2402) 2025-04-08 16:12:21 -07:00
github-actions[bot] 1961583eb6 v3.10.0 Release Notes and Banner
v3.10.0 Release Notes and Banner
2025-04-08 16:05:52 -07:00
Evan 35dd137c36 ApiOptions TS Errors (#2746)
* unblock apioptions spec errors

* changeset
2025-04-08 13:49:04 -07:00
Toshii 867a69777a context management (#2731)
* base context manager

* responses

* changeset

* Disable unit tests until runner is updated (#2733)

---------

Co-authored-by: Dennis Bartlett <bartlett.dc.1@gmail.com>
2025-04-08 10:11:29 -07:00
Dennis Bartlett b67afb84a7 Update runner name 2025-04-07 22:54:07 -07:00
Dennis Bartlett abca4cc76a Add dispatch trigger and restrictions to changeset converter. (#2735)
* Add dispatch trigger and restrictions to changeset converter.

* Update .github/workflows/changeset-converter.yml

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

* Update action to specific version

* Fix format, Update package-lock version

---------

Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>
2025-04-07 22:42:16 -07:00
Evan 73c64d9ab5 Add Toggle MCP Servers Modal (#2723)
* make ServerRow not optionally not expandable

* changeset

* factor out servers toggle list

* changeset

* add servers modal

* changest

* Reduce padding in modal

* separate fetch useEffect for more efficient rendering

---------

Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2025-04-07 22:22:20 -07:00
Suvarchal Kumar Cheedela 989eeb2a87 Fix failing webview UI tests for version 3.2 (#2707)
* Fix failing webview UI tests for Announcement component

* Remove unnecessary comments

* Fix Format

---------

Co-authored-by: Dennis Bartlett <bartlett.dc.1@gmail.com>
2025-04-07 22:20:36 -07:00
Evan d5524e747a Fix MCP auto approve toggle [2/2] (#2729)
* fix state out of sync

* changeset

* remove event.isTrusted check

* changeset
2025-04-07 22:19:19 -07:00
canvrno 521258239a ENG-315/Add execute all commands toggle nested under "safe commands" (#2677)
* initial

* Menu

* small clarity change

* cleanup

* Tests

* Update .changeset/ninety-pots-rescue.md

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

* Update src/test/TerminalCommandApprovalSettings.test.ts

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

* indentation

* smooth transition

* cleanup

* Consolidated enabledActionsList for display, fixed unchecking issue

* Updated shouldAutoApproveTool to better manage sub-options for future auto approve changes

* removed test

* cleanup

* comments and extra bool check

* cleanup

* change to compare ids isntead of label strings

* test in progress

* test in progress

* no tests for now

* type safety change

* cleanup

* rename

* comments

* Copy

---------

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-04-07 22:17:34 -07:00
Evan dd25195b4d Fix MCP Auto-Approve - State out of Sync [1/2] (#2727)
* fix state out of sync

* changeset
2025-04-07 22:13:55 -07:00
Saoud Rizwan 0b95ad3bae Add load_mcp_documentation tool (#2703)
* Add load_mcp_documentation tool

* Create fluffy-toys-punch.md
2025-04-07 22:10:16 -07:00
Andrei Edell 95120bb050 Remote browser control using devtools protocol (#2423)
* manual port

* successfully open remote chrome

* clean up auto-detect vs specified path

* move the browser settings into regular settings

* changeset & prettier

* correct chrome path description, remove some old comments, and rename headless mode to local mode

* rename incorrect headless mode to 'local mode'

* Sub-PR of hugelung/remote_browser: clicking browser widget's gear opens basic settings & scrolls down with a highlight (#2439)

* first version of scrolling to browser settings

* really nice generic scroll to settings & highlight

* formatting & changeset

---------

Co-authored-by: Andrei Edell <andrei@nugbase.com>

* added feature to detect and display chrome path as placeholder in browser settings (#2442)

Co-authored-by: Andrei Edell <garoth@gmail.com>

* Features to relaunch browser in debug, test connection (#2440)

* Features to Relaunch browser in debug, test connection

* Update src/services/browser/BrowserSession.ts

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

* Update webview-ui/src/components/browser/BrowserSettingsMenu.tsx

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

---------

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

* fix a merge conflict resolution error

* fix linter issue

* clarify settings descriptions

* Remove sketchy network scanning code

* respect viewport size in remote host

* headless browser fix (#2451)

* Disable notifications in browser

* start of info panel popover (#2453)

* start of info panel popover

* remove duplicated message & prettier fix

* Revert "remove duplicated message & prettier fix"

This reverts commit dcefef35aa.

* info styling, close browser tab, hide headless info

---------

Co-authored-by: Andrei Edell <andrei@nugbase.com>

* remove headless checkbox

* settings layout rework & more auto

* new chrome flags experiments

* make headless choice automatic & phrasing & visual cleanups

* auto-recheck chrome connection every second

- while we are looking at settings
- while we have remote debugging enabled

* continuous remote connection testing & ux cleanup

* remove advanced settings from package.json

* format fixes

* dont display connection type after dc to smooth over ui of reloading tasks

* seems we need package-lock now for ci

* Revert "remove advanced settings from package.json"

This reverts commit 5defe4a8ca.

* relaunch correctly with default session

* prevent about:blank opening on relaunch

* Resolve merge conflicts with refactor

* add browser tool telemetry

* try launching chrome using node spawn_child to detach it

* browser settings update

* do async dispose for browsersession

* remove duplicated message implementation

* Remove remote browser settings from configuration, and enhance browser settings UI with an advanced settings button.

* Remove updateBrowserSettings

* Fix text with chrome path

* fix arafat's pr note about multiple timers

* fix saoud's note about require use

* Remote browser logging (#2682)

* logging

* reduce logging levels

---------

Co-authored-by: Andrei Edell <andrei@nugbase.com>

* Make browser status popup adapt to viewport width

* remove requires for exec/spawn

* remove unneeded comments

* error telemetry

* remove headless mode / settings everywhere

* migrate values list to simple endpoint string

* fix log spam and clean up a comment

* Fixes; copy

* Remove local state since we're already using extension state

* Remove unnecessary remoteBrowserHost and remoteBrowserEnabled states

* Fix status wrapping

---------

Co-authored-by: Andrei Edell <andrei@nugbase.com>
Co-authored-by: canvrno <46584286+canvrno@users.noreply.github.com>
Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>
Co-authored-by: canvrno <kevin@cline.bot>
Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
Co-authored-by: frostbournesb <frostbournesb@protonmail.com>
Co-authored-by: Dennis Bartlett <bartlett.dc.1@gmail.com>
2025-04-07 21:57:45 -07:00
67 changed files with 3550 additions and 544 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": minor
---
update ordering of messages during task restore
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": minor
---
added IS_TEST build flag
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
allow enabling prompt caching for LiteLLM + Claude
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
Include model and apiProvider in metadata for cline to read
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": minor
---
Drag and drop of file/folders into cline chat
+3
View File
@@ -13,7 +13,10 @@
- [ ] 🐛 Bug fix (non-breaking change which fixes an issue)
- [ ] ✨ New feature (non-breaking change which adds functionality)
- [ ] 💥 Breaking change (fix or feature that would cause existing functionality to not work as expected)
- [ ] ♻️ Refactor Changes
- [ ] 💅 Cosmetic Changes
- [ ] 📚 Documentation update
- [ ] 🏃 Workflow Changes
### Pre-flight Checklist
+26 -5
View File
@@ -2,6 +2,7 @@ name: Changeset Converter
run-name: Changeset Conversion
on:
workflow_dispatch:
pull_request:
types: [closed]
@@ -13,16 +14,36 @@ env:
jobs:
# Job 1: Create version bump PR when changesets are merged to main
changeset-pr-version-bump:
if: >
github.event_name == 'pull_request' &&
github.event.pull_request.merged == true &&
github.event.pull_request.base.ref == 'main' &&
github.actor != 'github-actions'
if: |
github.event_name == 'workflow_dispatch' ||
(
github.event_name == 'pull_request' &&
github.event.pull_request.merged == true &&
github.event.pull_request.base.ref == 'main' &&
github.actor != 'github-actions'
)
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
steps:
- name: Check user for team affiliation
id: team_check
if: github.event_name == 'workflow_dispatch'
uses: morfien101/actions-authorized-user@4a3cfbf0bcb3cafe4a71710a278920c5d94bb38b
with:
username: ${{ github.actor }}
team: "deployer"
github_token: ${{ secrets.GITHUB_TOKEN }}
- name: Check if user is authorized
if: github.event_name == 'workflow_dispatch'
run: |
if [ "${{ steps.team_check.outputs.authorized }}" != "true" ]; then
echo "User is not authorized to run this workflow."
exit 1
fi
- name: Git Checkout
uses: actions/checkout@v4
with:
+13
View File
@@ -16,6 +16,19 @@
"IS_DEV": "true",
"DEV_WORKSPACE_FOLDER": "${workspaceFolder}"
}
},
{
"name": "Run Extension (Test Mode)",
"type": "extensionHost",
"request": "launch",
"args": ["--extensionDevelopmentPath=${workspaceFolder}"],
"outFiles": ["${workspaceFolder}/dist/**/*.js"],
"preLaunchTask": "${defaultBuildTask}",
"env": {
"IS_DEV": "true",
"IS_TEST": "true",
"DEV_WORKSPACE_FOLDER": "${workspaceFolder}"
}
}
]
}
+51
View File
@@ -14,6 +14,14 @@
"isDefault": true
}
},
{
"label": "watch:test",
"dependsOn": ["npm: build:webview:test", "npm: dev:webview", "npm: watch:tsc", "npm: watch:esbuild:test"],
"presentation": {
"reveal": "never"
},
"group": "build"
},
{
"type": "npm",
"script": "build:webview",
@@ -32,6 +40,25 @@
}
}
},
{
"type": "npm",
"script": "build:webview:test",
"group": "build",
"problemMatcher": [],
"isBackground": true,
"label": "npm: build:webview:test",
"presentation": {
"group": "watch",
"reveal": "never",
"close": true
},
"options": {
"env": {
"IS_DEV": "true",
"IS_TEST": "true"
}
}
},
{
"type": "npm",
"script": "dev:webview",
@@ -77,6 +104,30 @@
"group": "watch",
"reveal": "never",
"close": true
},
"options": {
"env": {
"IS_DEV": "true"
}
}
},
{
"type": "npm",
"script": "watch:esbuild:test",
"group": "build",
"problemMatcher": "$esbuild-watch",
"isBackground": true,
"label": "npm: watch:esbuild:test",
"presentation": {
"group": "watch",
"reveal": "never",
"close": true
},
"options": {
"env": {
"IS_DEV": "true",
"IS_TEST": "true"
}
}
},
{
+18
View File
@@ -1,5 +1,23 @@
# Changelog
## [3.10.1]
- Add CMD+' keyboard shortcut to add selected text to Cline
- Cline now auto focuses the text field when using 'Add to Cline' shortcut
- Add new 'Create New Task' tool to let Cline start a new task autonomously!
- Fix Mermaid diagram issues
- Fix Gemini provider cost calculation to take new tiered pricing structure into account
## [3.10.0]
- Add setting to let browser tool use local Chrome via remote debugging, enabling session-based browsing. Replaces sessionless Chromium, unlocking debugging and productivity workflows tied to your real browser state.
- Add new auto-approve option to approve _ALL_ commands (use at your own risk!)
- Add modal in the chat area to more easily enable or disable MCP servers
- Add drag and drop of file/folders into cline chat (Thanks eljapi!)
- Add prompt caching for LiteLLM + Claude (Thanks sammcj!)
- Add Improved context management
- Fix MCP auto approve toggle issues being out of sync with settings
## [3.9.2]
- Add recommended models for Cline provider
+16
View File
@@ -82,6 +82,7 @@ Cline has access to the following tools for various tasks:
4. **Interaction Tools**
- `ask_followup_question`: Ask user for clarification
- `attempt_completion`: Present final results
- `new_task`: Start a new task with preloaded context
Each tool has specific parameters and usage patterns. Here are some examples:
@@ -114,6 +115,21 @@ Each tool has specific parameters and usage patterns. Here are some examples:
</execute_command>
```
- Start a new task with context (new_task):
```xml
<new_task>
<context>
We've completed the backend API with these endpoints:
- GET /api/tasks
- POST /api/tasks
- PUT /api/tasks/:id
- DELETE /api/tasks/:id
Now we need to implement the React frontend.
</context>
</new_task>
```
## Common Tasks
1. **Create a New Component**
+5
View File
@@ -4,6 +4,7 @@ const path = require("path")
const production = process.argv.includes("--production")
const watch = process.argv.includes("--watch")
const test = process.env.IS_TEST === "true"
/**
* @type {import('esbuild').Plugin}
@@ -68,6 +69,10 @@ const extensionConfig = {
minify: production,
sourcemap: !production,
logLevel: "silent",
define: {
"process.env.IS_DEV": JSON.stringify(!production),
"process.env.IS_TEST": JSON.stringify(test),
},
plugins: [
copyWasmFiles,
/* add to the end of plugins array */
+155 -3
View File
@@ -1,12 +1,12 @@
{
"name": "claude-dev",
"version": "3.9.1",
"version": "3.10.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "claude-dev",
"version": "3.9.1",
"version": "3.10.0",
"license": "Apache-2.0",
"dependencies": {
"@anthropic-ai/bedrock-sdk": "^0.12.4",
@@ -23,10 +23,12 @@
"@opentelemetry/sdk-node": "^0.39.1",
"@opentelemetry/sdk-trace-node": "^1.30.1",
"@opentelemetry/semantic-conventions": "^1.30.0",
"@sentry/browser": "^9.12.0",
"@vscode/codicons": "^0.0.36",
"axios": "^1.8.2",
"cheerio": "^1.0.0",
"chokidar": "^4.0.1",
"chrome-launcher": "^1.1.2",
"clone-deep": "^4.0.1",
"default-shell": "^2.2.0",
"diff": "^5.2.0",
@@ -6855,6 +6857,81 @@
"integrity": "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==",
"license": "MIT"
},
"node_modules/@sentry-internal/browser-utils": {
"version": "9.12.0",
"resolved": "https://registry.npmjs.org/@sentry-internal/browser-utils/-/browser-utils-9.12.0.tgz",
"integrity": "sha512-GXuDEG2Ix8DmVtTkjsItWdusk2CvJ6EPWKYVqFKifxt+IAT3ZbhGZd99Rg3wdRmt9xhCNuS4QrDzDTPMPgfdCw==",
"license": "MIT",
"dependencies": {
"@sentry/core": "9.12.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@sentry-internal/feedback": {
"version": "9.12.0",
"resolved": "https://registry.npmjs.org/@sentry-internal/feedback/-/feedback-9.12.0.tgz",
"integrity": "sha512-3+UxoT97QIXNSUQS4ATL1FFws0RkUb6PeaQN8CPndI6mFlqTW5tuVVLNg9Eo1seNg7R/dfk6WHCWrYN1NbFFKQ==",
"license": "MIT",
"dependencies": {
"@sentry/core": "9.12.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@sentry-internal/replay": {
"version": "9.12.0",
"resolved": "https://registry.npmjs.org/@sentry-internal/replay/-/replay-9.12.0.tgz",
"integrity": "sha512-njEQosFeO/UX+gG+DMRANkPUuz6OIJLb+A1GVylhq9adUgFQydQ9Ay3v7/x1gMhdfHVP6Jeb27qkti0BWYbzBQ==",
"license": "MIT",
"dependencies": {
"@sentry-internal/browser-utils": "9.12.0",
"@sentry/core": "9.12.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@sentry-internal/replay-canvas": {
"version": "9.12.0",
"resolved": "https://registry.npmjs.org/@sentry-internal/replay-canvas/-/replay-canvas-9.12.0.tgz",
"integrity": "sha512-p8LuKZgWT/CoQBbDOXkSGjWWnc8WsnAayWgna8M/ZFWNITCNEM2rCuqZOyWOElIlrni+M7qoEA3jS7MZe8Ejxw==",
"license": "MIT",
"dependencies": {
"@sentry-internal/replay": "9.12.0",
"@sentry/core": "9.12.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@sentry/browser": {
"version": "9.12.0",
"resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-9.12.0.tgz",
"integrity": "sha512-4xQYoZqi+VVhNvlhWiwRd57+SMr3Og4sLjuayAA+zIp1Wx/bDcIld697cugLwml/BR+mVJI2eokkgh1CBl6zag==",
"license": "MIT",
"dependencies": {
"@sentry-internal/browser-utils": "9.12.0",
"@sentry-internal/feedback": "9.12.0",
"@sentry-internal/replay": "9.12.0",
"@sentry-internal/replay-canvas": "9.12.0",
"@sentry/core": "9.12.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@sentry/core": {
"version": "9.12.0",
"resolved": "https://registry.npmjs.org/@sentry/core/-/core-9.12.0.tgz",
"integrity": "sha512-jOqQK/90uzHmsBvkPTj/DAEFvA5poX4ZRyC7LE1zjg4F5jdOp3+M4W3qCy0CkSTu88Zu5VWBoppCU2Bs34XEqg==",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/@sindresorhus/merge-streams": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-4.0.0.tgz",
@@ -9966,6 +10043,24 @@
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/chrome-launcher": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/chrome-launcher/-/chrome-launcher-1.1.2.tgz",
"integrity": "sha512-YclTJey34KUm5jB1aEJCq807bSievi7Nb/TU4Gu504fUYi3jw3KCIaH6L7nFWQhdEgH3V+wCh+kKD1P5cXnfxw==",
"license": "Apache-2.0",
"dependencies": {
"@types/node": "*",
"escape-string-regexp": "^4.0.0",
"is-wsl": "^2.2.0",
"lighthouse-logger": "^2.0.1"
},
"bin": {
"print-chrome-path": "bin/print-chrome-path.js"
},
"engines": {
"node": ">=12.13.0"
}
},
"node_modules/chromium-bidi": {
"version": "0.6.5",
"resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-0.6.5.tgz",
@@ -10971,7 +11066,6 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
"integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10"
@@ -12897,6 +12991,21 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/is-docker": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz",
"integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==",
"license": "MIT",
"bin": {
"is-docker": "cli.js"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/is-extglob": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
@@ -13167,6 +13276,18 @@
"node": ">=0.10.0"
}
},
"node_modules/is-wsl": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz",
"integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==",
"license": "MIT",
"dependencies": {
"is-docker": "^2.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/isarray": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
@@ -13438,6 +13559,31 @@
"immediate": "~3.0.5"
}
},
"node_modules/lighthouse-logger": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/lighthouse-logger/-/lighthouse-logger-2.0.1.tgz",
"integrity": "sha512-ioBrW3s2i97noEmnXxmUq7cjIcVRjT5HBpAYy8zE11CxU9HqlWHHeRxfeN1tn8F7OEMVPIC9x1f8t3Z7US9ehQ==",
"license": "Apache-2.0",
"dependencies": {
"debug": "^2.6.9",
"marky": "^1.2.2"
}
},
"node_modules/lighthouse-logger/node_modules/debug": {
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
"license": "MIT",
"dependencies": {
"ms": "2.0.0"
}
},
"node_modules/lighthouse-logger/node_modules/ms": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT"
},
"node_modules/load-json-file": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/load-json-file/-/load-json-file-4.0.0.tgz",
@@ -13616,6 +13762,12 @@
"sprintf-js": "~1.0.2"
}
},
"node_modules/marky": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/marky/-/marky-1.2.5.tgz",
"integrity": "sha512-q9JtQJKjpsVxCRVgQ+WapguSbKC3SQ5HEzFGPAJMStgh3QjCawp00UKv3MTTAArTmGmmPUvllHZoNbZ3gs0I+Q==",
"license": "Apache-2.0"
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+19 -2
View File
@@ -2,7 +2,7 @@
"name": "claude-dev",
"displayName": "Cline",
"description": "Autonomous coding agent right in your IDE, capable of creating/editing files, running commands, using the browser, and more with your permission every step of the way.",
"version": "3.9.2",
"version": "3.10.1",
"icon": "assets/icons/icon.png",
"engines": {
"vscode": "^1.84.0"
@@ -118,6 +118,16 @@
"category": "Cline"
}
],
"keybindings": [
{
"command": "cline.addToChat",
"key": "cmd+'",
"mac": "cmd+'",
"win": "ctrl+'",
"linux": "ctrl+'",
"when": "editorHasSelection"
}
],
"menus": {
"view/title": [
{
@@ -292,8 +302,12 @@
"compile": "npm run check-types && npm run lint && node esbuild.js",
"watch": "npm-run-all -p watch:*",
"watch:esbuild": "node esbuild.js --watch",
"watch:esbuild:test": "IS_TEST=true node esbuild.js --watch",
"watch:tsc": "tsc --noEmit --watch --project tsconfig.json",
"package": "npm run build:webview && npm run check-types && npm run lint && node esbuild.js --production",
"package:test": "IS_TEST=true npm run build:webview:test && npm run check-types && npm run lint && IS_TEST=true node esbuild.js --production",
"build:webview:test": "cd webview-ui && IS_TEST=true npm run build",
"watch:test": "IS_TEST=true npm-run-all -p watch:tsc watch:esbuild:test",
"compile-tests": "tsc -p ./tsconfig.test.json --outDir out",
"watch-tests": "tsc -p . -w --outDir out",
"pretest": "npm run compile-tests && npm run compile && npm run lint",
@@ -301,8 +315,9 @@
"lint": "eslint src --ext ts && eslint webview-ui/src --ext ts",
"format": "prettier . --check",
"format:fix": "prettier . --write",
"test": "vscode-test",
"test": "npm-run-all test:unit test:integration",
"test:ci": "node scripts/test-ci.js",
"test:integration": "vscode-test",
"test:unit": "TS_NODE_PROJECT='./tsconfig.unit-test.json' mocha",
"test:coverage": "vscode-test --coverage",
"install:all": "npm install && cd webview-ui && npm install",
@@ -360,10 +375,12 @@
"@opentelemetry/sdk-node": "^0.39.1",
"@opentelemetry/sdk-trace-node": "^1.30.1",
"@opentelemetry/semantic-conventions": "^1.30.0",
"@sentry/browser": "^9.12.0",
"@vscode/codicons": "^0.0.36",
"axios": "^1.8.2",
"cheerio": "^1.0.0",
"chokidar": "^4.0.1",
"chrome-launcher": "^1.1.2",
"clone-deep": "^4.0.1",
"default-shell": "^2.2.0",
"diff": "^5.2.0",
+2 -2
View File
@@ -9,10 +9,10 @@ try {
execSync("which xvfb-run", { stdio: "ignore" })
console.log("xvfb-run is installed. Running tests with xvfb-run...")
execSync("xvfb-run -a npm run test", { stdio: "inherit" })
execSync("xvfb-run -a npm run test:integration", { stdio: "inherit" })
} else {
console.log("Non-Linux environment detected. Running tests normally.")
execSync("npm run test", { stdio: "inherit" })
execSync("npm run test:integration", { stdio: "inherit" })
}
} catch (error) {
if (process.platform === "linux") {
-1
View File
@@ -24,7 +24,6 @@ export class XAIHandler implements ApiHandler {
temperature: 0,
messages: [{ role: "system", content: systemPrompt }, ...convertToOpenAiMessages(messages)],
stream: true,
stream_options: { include_usage: true },
})
for await (const chunk of stream) {
+2
View File
@@ -22,6 +22,7 @@ export const toolUseNames = [
"ask_followup_question",
"plan_mode_respond",
"attempt_completion",
"new_task",
] as const
// Converts array of tool call names into a union type ("execute_command" | "read_file" | ...)
@@ -48,6 +49,7 @@ export const toolParamNames = [
"options",
"response",
"result",
"context",
] as const
export type ToolParamName = (typeof toolParamNames)[number]
+754 -40
View File
@@ -1,15 +1,120 @@
import { Anthropic } from "@anthropic-ai/sdk"
import { ClineApiReqInfo, ClineMessage } from "../../shared/ExtensionMessage"
import { ApiHandler } from "../../api"
import { OpenAiHandler } from "../../api/providers/openai"
import { getContextWindowInfo } from "./context-window-utils"
import { formatResponse } from "../prompts/responses"
import { GlobalFileNames } from "../storage/disk"
import { fileExistsAtPath } from "../../utils/fs"
import * as path from "path"
import fs from "fs/promises"
import cloneDeep from "clone-deep"
enum EditType {
UNDEFINED = 0,
NO_FILE_READ = 1,
READ_FILE_TOOL = 2,
ALTER_FILE_TOOL = 3,
FILE_MENTION = 4,
}
// array of string values allows us to cover all changes for message types currently supported
type MessageContent = string[]
type MessageMetadata = string[][]
// Type for a single context update
type ContextUpdate = [number, string, MessageContent, MessageMetadata] // [timestamp, updateType, update, metadata]
// Type for the serialized format of our nested maps
type SerializedContextHistory = Array<
[
number, // messageIndex
[
number, // EditType (message type)
Array<
[
number, // blockIndex
ContextUpdate[], // updates array (now with 4 elements including metadata)
]
>,
],
]
>
export class ContextManager {
getNewContextMessagesAndMetadata(
// mapping from the apiMessages outer index to the inner message index to a list of actual changes, ordered by timestamp
// timestamp is required in order to support full checkpointing, where the changes we apply need to be able to be undone when
// moving to an earlier conversation history checkpoint - this ordering intuitively allows for binary search on truncation
// there is also a number stored for each (EditType) which defines which message type it is, for custom handling
// format: { outerIndex => [EditType, { innerIndex => [[timestamp, updateType, update], ...] }] }
// example: { 1 => { [0, 0 => [[<timestamp>, "text", "[NOTE] Some previous conversation history with the user has been removed ..."], ...] }] }
// the above example would be how we update the first assistant message to indicate we truncated text
private contextHistoryUpdates: Map<number, [number, Map<number, ContextUpdate[]>]>
constructor() {
this.contextHistoryUpdates = new Map()
}
/**
* public function for loading contextHistoryUpdates from disk, if it exists
*/
async initializeContextHistory(taskDirectory: string) {
this.contextHistoryUpdates = await this.getSavedContextHistory(taskDirectory)
}
/**
* get the stored context history updates from disk
*/
private async getSavedContextHistory(taskDirectory: string): Promise<Map<number, [number, Map<number, ContextUpdate[]>]>> {
try {
const filePath = path.join(taskDirectory, GlobalFileNames.contextHistory)
if (await fileExistsAtPath(filePath)) {
const data = await fs.readFile(filePath, "utf8")
const serializedUpdates = JSON.parse(data) as SerializedContextHistory
// Update to properly reconstruct the tuple structure
return new Map(
serializedUpdates.map(([messageIndex, [numberValue, innerMapArray]]) => [
messageIndex,
[numberValue, new Map(innerMapArray)],
]),
)
}
} catch (error) {
console.error("Failed to load context history:", error)
}
return new Map()
}
/**
* save the context history updates to disk
*/
private async saveContextHistory(taskDirectory: string) {
try {
const serializedUpdates: SerializedContextHistory = Array.from(this.contextHistoryUpdates.entries()).map(
([messageIndex, [numberValue, innerMap]]) => [messageIndex, [numberValue, Array.from(innerMap.entries())]],
)
await fs.writeFile(
path.join(taskDirectory, GlobalFileNames.contextHistory),
JSON.stringify(serializedUpdates),
"utf8",
)
} catch (error) {
console.error("Failed to save context history:", error)
}
}
/**
* primary entry point for getting up to date context & truncating when required
*/
async getNewContextMessagesAndMetadata(
apiConversationHistory: Anthropic.Messages.MessageParam[],
clineMessages: ClineMessage[],
api: ApiHandler,
conversationHistoryDeletedRange: [number, number] | undefined,
previousApiReqIndex: number,
taskDirectory: string,
) {
let updatedConversationHistoryDeletedRange = false
@@ -17,49 +122,63 @@ export class ContextManager {
if (previousApiReqIndex >= 0) {
const previousRequest = clineMessages[previousApiReqIndex]
if (previousRequest && previousRequest.text) {
const timestamp = previousRequest.ts
const { tokensIn, tokensOut, cacheWrites, cacheReads }: ClineApiReqInfo = JSON.parse(previousRequest.text)
const totalTokens = (tokensIn || 0) + (tokensOut || 0) + (cacheWrites || 0) + (cacheReads || 0)
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.
}
const { maxAllowedSize } = getContextWindowInfo(api)
// This is the most reliable way to know when we're close to hitting the context window.
if (totalTokens >= maxAllowedSize) {
// Since the user may switch between models with different context windows, truncating half may not be enough (ie if switching from claude 200k to deepseek 64k, half truncation will only remove 100k tokens, but we need to remove much more)
// So if totalTokens/2 is greater than maxAllowedSize, we truncate 3/4 instead of 1/2
// 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(
// we later check how many chars we trim to determine if we should still truncate history
let [anyContextUpdates, uniqueFileReadIndices] = this.applyContextOptimizations(
apiConversationHistory,
conversationHistoryDeletedRange,
keep,
conversationHistoryDeletedRange ? conversationHistoryDeletedRange[1] + 1 : 2,
timestamp,
)
updatedConversationHistoryDeletedRange = true
let needToTruncate = true
if (anyContextUpdates) {
// determine whether we've saved enough chars to not truncate
const charactersSavedPercentage = this.calculateContextOptimizationMetrics(
apiConversationHistory,
conversationHistoryDeletedRange,
uniqueFileReadIndices,
)
if (charactersSavedPercentage >= 0.3) {
needToTruncate = false
}
}
if (needToTruncate) {
// go ahead with truncation
anyContextUpdates = this.applyStandardContextTruncationNoticeChange(timestamp) || anyContextUpdates
// NOTE: it's okay that we overwriteConversationHistory in resume task since we're only ever removing the last user message and not anything in the middle which would affect this range
conversationHistoryDeletedRange = this.getNextTruncationRange(
apiConversationHistory,
conversationHistoryDeletedRange,
keep,
)
updatedConversationHistoryDeletedRange = true
}
// if we alter the context history, save the updated version to disk
if (anyContextUpdates) {
await this.saveContextHistory(taskDirectory)
}
}
}
}
// conversationHistoryDeletedRange is updated only when we're close to hitting the context window, so we don't continuously break the prompt cache
const truncatedConversationHistory = this.getTruncatedMessages(apiConversationHistory, conversationHistoryDeletedRange)
const truncatedConversationHistory = this.getAndAlterTruncatedMessages(
apiConversationHistory,
conversationHistoryDeletedRange,
)
return {
conversationHistoryDeletedRange: conversationHistoryDeletedRange,
@@ -68,14 +187,17 @@ export class ContextManager {
}
}
/**
* get truncation range
*/
public getNextTruncationRange(
apiMessages: Anthropic.Messages.MessageParam[],
currentDeletedRange: [number, number] | undefined,
keep: "half" | "quarter",
): [number, number] {
// Since we always keep the first message, currentDeletedRange[0] will always be 1 (for now until we have a smarter truncation algorithm)
const rangeStartIndex = 1
const startOfRest = currentDeletedRange ? currentDeletedRange[1] + 1 : 1
// 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") {
@@ -92,11 +214,11 @@ export class ContextManager {
messagesToRemove = Math.floor(((apiMessages.length - startOfRest) * 3) / 4 / 2) * 2
}
let rangeEndIndex = startOfRest + messagesToRemove - 1
let rangeEndIndex = startOfRest + messagesToRemove - 1 // inclusive ending index
// 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.
// 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 preserves the user-assistant-user-assistant structure.
// NOTE: anthropic format messages are always user-assistant-user-assistant, while openai format messages can have multiple user messages in a row (we use anthropic format throughout cline)
if (apiMessages[rangeEndIndex].role !== "user") {
if (apiMessages[rangeEndIndex].role !== "assistant") {
rangeEndIndex -= 1
}
@@ -104,17 +226,609 @@ export class ContextManager {
return [rangeStartIndex, rangeEndIndex]
}
/**
* external interface to support old calls
*/
public getTruncatedMessages(
messages: Anthropic.Messages.MessageParam[],
deletedRange: [number, number] | undefined,
): Anthropic.Messages.MessageParam[] {
if (!deletedRange) {
return this.getAndAlterTruncatedMessages(messages, deletedRange)
}
/**
* apply all required truncation methods to the messages in context
*/
private getAndAlterTruncatedMessages(
messages: Anthropic.Messages.MessageParam[],
deletedRange: [number, number] | undefined,
): Anthropic.Messages.MessageParam[] {
if (messages.length <= 1) {
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)]
const updatedMessages = this.applyContextHistoryUpdates(messages, deletedRange ? deletedRange[1] + 1 : 2)
// OLD 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 updatedMessages
}
/**
* applies deletedRange truncation and other alterations based on changes in this.contextHistoryUpdates
*/
private applyContextHistoryUpdates(
messages: Anthropic.Messages.MessageParam[],
startFromIndex: number,
): Anthropic.Messages.MessageParam[] {
// runtime is linear in length of user messages, if expecting a limited number of alterations, could be more optimal to loop over alterations
const firstChunk = messages.slice(0, 2) // get first user-assistant pair
const secondChunk = messages.slice(startFromIndex) // get remaining messages within context
const messagesToUpdate = [...firstChunk, ...secondChunk]
// we need the mapping from the local indices in messagesToUpdate to the global array of updates in this.contextHistoryUpdates
const originalIndices = [
...Array(2).keys(),
...Array(secondChunk.length)
.fill(0)
.map((_, i) => i + startFromIndex),
]
for (let arrayIndex = 0; arrayIndex < messagesToUpdate.length; arrayIndex++) {
const messageIndex = originalIndices[arrayIndex]
const innerTuple = this.contextHistoryUpdates.get(messageIndex)
if (!innerTuple) {
continue
}
// because we are altering this, we need a deep copy
messagesToUpdate[arrayIndex] = cloneDeep(messagesToUpdate[arrayIndex])
// Extract the map from the tuple
const innerMap = innerTuple[1]
for (const [blockIndex, changes] of innerMap) {
// apply the latest change among n changes - [timestamp, updateType, update]
const latestChange = changes[changes.length - 1]
if (latestChange[1] === "text") {
// only altering text for now
const message = messagesToUpdate[arrayIndex]
if (Array.isArray(message.content)) {
const block = message.content[blockIndex]
if (block && block.type === "text") {
block.text = latestChange[2][0]
}
}
}
}
}
return messagesToUpdate
}
/**
* removes all context history updates that occurred after the specified timestamp and saves to disk
*/
async truncateContextHistory(timestamp: number, taskDirectory: string): Promise<void> {
this.truncateContextHistoryAtTimestamp(this.contextHistoryUpdates, timestamp)
// save the modified context history to disk
await this.saveContextHistory(taskDirectory)
}
/**
* alters the context history to remove all alterations after a given timestamp
* removes the index if there are no alterations there anymore, both outer and inner indices
*/
private truncateContextHistoryAtTimestamp(
contextHistory: Map<number, [number, Map<number, ContextUpdate[]>]>,
timestamp: number,
): void {
for (const [messageIndex, [_, innerMap]] of contextHistory) {
// track which blockIndices to delete
const blockIndicesToDelete: number[] = []
// loop over the innerIndices of the messages in this block
for (const [blockIndex, updates] of innerMap) {
// updates ordered by timestamp, so find cutoff point by iterating from right to left
let cutoffIndex = updates.length - 1
while (cutoffIndex >= 0 && updates[cutoffIndex][0] > timestamp) {
cutoffIndex--
}
// If we found updates to remove
if (cutoffIndex < updates.length - 1) {
// Modify the array in place to keep only updates up to cutoffIndex
updates.length = cutoffIndex + 1
// If no updates left after truncation, mark this block for deletion
if (updates.length === 0) {
blockIndicesToDelete.push(blockIndex)
}
}
}
// Remove empty blocks from inner map
for (const blockIndex of blockIndicesToDelete) {
innerMap.delete(blockIndex)
}
// If inner map is now empty, remove the message index from outer map
if (innerMap.size === 0) {
contextHistory.delete(messageIndex)
}
}
}
/**
* applies the context optimization steps and returns whether any changes were made
*/
private applyContextOptimizations(
apiMessages: Anthropic.Messages.MessageParam[],
startFromIndex: number,
timestamp: number,
): [boolean, Set<number>] {
const [fileReadUpdatesBool, uniqueFileReadIndices] = this.findAndPotentiallySaveFileReadContextHistoryUpdates(
apiMessages,
startFromIndex,
timestamp,
)
// true if any context optimization steps alter state
const contextHistoryUpdated = fileReadUpdatesBool
return [contextHistoryUpdated, uniqueFileReadIndices]
}
/**
* if there is any truncation and there is no other alteration already set, alter the assistant message to indicate this occurred
*/
private applyStandardContextTruncationNoticeChange(timestamp: number): boolean {
if (!this.contextHistoryUpdates.has(1)) {
// first assistant message always at index 1
const innerMap = new Map<number, ContextUpdate[]>()
innerMap.set(0, [[timestamp, "text", [formatResponse.contextTruncationNotice()], []]])
this.contextHistoryUpdates.set(1, [0, innerMap]) // EditType is undefined for first assistant message
return true
}
return false
}
/**
* wraps the logic for determining file reads to overwrite, and altering state
* returns whether any updates were made (bool) and indices where updates were made
*/
private findAndPotentiallySaveFileReadContextHistoryUpdates(
apiMessages: Anthropic.Messages.MessageParam[],
startFromIndex: number,
timestamp: number,
): [boolean, Set<number>] {
const [fileReadIndices, messageFilePaths] = this.getPossibleDuplicateFileReads(apiMessages, startFromIndex)
return this.applyFileReadContextHistoryUpdates(fileReadIndices, messageFilePaths, apiMessages, timestamp)
}
/**
* generate a mapping from unique file reads from multiple tool calls to their outer index position(s)
* also return additional metadata to support multiple file reads in file mention text blocks
*/
private getPossibleDuplicateFileReads(
apiMessages: Anthropic.Messages.MessageParam[],
startFromIndex: number,
): [Map<string, [number, number, string, string][]>, Map<number, string[]>] {
// fileReadIndices: { fileName => [outerIndex, EditType, searchText, replaceText] }
// messageFilePaths: { outerIndex => [fileRead1, fileRead2, ..] }
// searchText in fileReadIndices is only required for file mention file-reads since there can be more than one file in the text
// searchText will be the empty string "" in the case that it's not required, for non-file mentions
// messageFilePaths is only used for file mentions as there can be multiple files read in the same text chunk
// for all text blocks per file, has info for updating the block
const fileReadIndices = new Map<string, [number, number, string, string][]>()
// for file mention text blocks, track all the unique files read
const messageFilePaths = new Map<number, string[]>()
for (let i = startFromIndex; i < apiMessages.length; i++) {
let thisExistingFileReads: string[] = []
if (this.contextHistoryUpdates.has(i)) {
const innerTuple = this.contextHistoryUpdates.get(i)
if (innerTuple) {
// safety check
const editType = innerTuple[0]
if (editType === EditType.FILE_MENTION) {
const innerMap = innerTuple[1]
const blockIndex = 1 // file mention blocks assumed to be at index 1
const blockUpdates = innerMap.get(blockIndex)
// if we have updated this text previously, we want to check whether the lists of files in the metadata are the same
if (blockUpdates && blockUpdates.length > 0) {
// the first list indicates the files we have replaced in this text, second list indicates all unique files in this text
// if they are equal then we have replaced all the files in this text already, and can ignore further processing
if (
blockUpdates[blockUpdates.length - 1][3][0].length ===
blockUpdates[blockUpdates.length - 1][3][1].length
) {
continue
}
// otherwise there are still file reads here we can overwrite, so still need to process this text chunk
// to do so we need to keep track of which files we've already replaced so we don't replace them again
else {
thisExistingFileReads = blockUpdates[blockUpdates.length - 1][3][0]
}
}
} else {
// for all other cases we can assume that we dont need to check this again
continue
}
}
}
const message = apiMessages[i]
if (message.role === "user" && Array.isArray(message.content) && message.content.length > 0) {
const firstBlock = message.content[0]
if (firstBlock.type === "text") {
const matchTup = this.parsePotentialToolCall(firstBlock.text)
let foundNormalFileRead = false
if (matchTup) {
if (matchTup[0] === "read_file") {
this.handleReadFileToolCall(i, matchTup[1], fileReadIndices)
foundNormalFileRead = true
} else if (matchTup[0] === "replace_in_file" || matchTup[0] === "write_to_file") {
if (message.content.length > 1) {
const secondBlock = message.content[1]
if (secondBlock.type === "text") {
this.handlePotentialFileChangeToolCalls(i, matchTup[1], secondBlock.text, fileReadIndices)
foundNormalFileRead = true
}
}
}
}
// file mentions can happen in most other user message blocks
if (!foundNormalFileRead) {
if (message.content.length > 1) {
const secondBlock = message.content[1]
if (secondBlock.type === "text") {
const [hasFileRead, filePaths] = this.handlePotentialFileMentionCalls(
i,
secondBlock.text,
fileReadIndices,
thisExistingFileReads, // file reads we've already replaced in this text in the latest version of this updated text
)
if (hasFileRead) {
messageFilePaths.set(i, filePaths) // all file paths in this string
}
}
}
}
}
}
}
return [fileReadIndices, messageFilePaths]
}
/**
* handles potential file content mentions in text blocks
* there will not be more than one of the same file read in a text block
*/
private handlePotentialFileMentionCalls(
i: number,
secondBlockText: string,
fileReadIndices: Map<string, [number, number, string, string][]>,
thisExistingFileReads: string[],
): [boolean, string[]] {
const pattern = new RegExp(`<file_content path="([^"]*)">([\\s\\S]*?)</file_content>`, "g")
let foundMatch = false
const filePaths: string[] = []
let match
while ((match = pattern.exec(secondBlockText)) !== null) {
foundMatch = true
const filePath = match[1]
filePaths.push(filePath) // we will record all unique paths from file mentions in this text
// we can assume that thisExistingFileReads does not have many entries
if (!thisExistingFileReads.includes(filePath)) {
// meaning we havent already replaced this file read
const entireMatch = match[0] // The entire matched string
// Create the replacement text - keep the tags but replace the content
const replacementText = `<file_content path="${filePath}">${formatResponse.duplicateFileReadNotice()}</file_content>`
const indices = fileReadIndices.get(filePath) || []
indices.push([i, EditType.FILE_MENTION, entireMatch, replacementText])
fileReadIndices.set(filePath, indices)
}
}
return [foundMatch, filePaths]
}
/**
* parses specific tool call formats, returns null if no acceptable format is found
*/
private parsePotentialToolCall(text: string): [string, string] | null {
const match = text.match(/^\[([^\s]+) for '([^']+)'\] Result:$/)
if (!match) {
return null
}
return [match[1], match[2]]
}
/**
* file_read tool call always pastes the file, so this is always a hit
*/
private handleReadFileToolCall(
i: number,
filePath: string,
fileReadIndices: Map<string, [number, number, string, string][]>,
) {
const indices = fileReadIndices.get(filePath) || []
indices.push([i, EditType.READ_FILE_TOOL, "", formatResponse.duplicateFileReadNotice()])
fileReadIndices.set(filePath, indices)
}
/**
* write_to_file and replace_in_file tool output are handled similarly
*/
private handlePotentialFileChangeToolCalls(
i: number,
filePath: string,
secondBlockText: string,
fileReadIndices: Map<string, [number, number, string, string][]>,
) {
const pattern = new RegExp(`(<final_file_content path="[^"]*">)[\\s\\S]*?(</final_file_content>)`)
// check if this exists in the text, it wont exist if the user rejects the file change for example
if (pattern.test(secondBlockText)) {
const replacementText = secondBlockText.replace(pattern, `$1 ${formatResponse.duplicateFileReadNotice()} $2`)
const indices = fileReadIndices.get(filePath) || []
indices.push([i, EditType.ALTER_FILE_TOOL, "", replacementText])
fileReadIndices.set(filePath, indices)
}
}
/**
* alter all occurrences of file read operations and track which messages were updated
* returns the outer index of messages we alter, to count number of changes
*/
private applyFileReadContextHistoryUpdates(
fileReadIndices: Map<string, [number, number, string, string][]>,
messageFilePaths: Map<number, string[]>,
apiMessages: Anthropic.Messages.MessageParam[],
timestamp: number,
): [boolean, Set<number>] {
let didUpdate = false
const updatedMessageIndices = new Set<number>() // track which messages we update on this round
const fileMentionUpdates = new Map<number, [string, string[]]>()
for (const [filePath, indices] of fileReadIndices.entries()) {
// Only process if there are multiple reads of the same file, else we will want to keep the latest read of the file
if (indices.length > 1) {
// Process all but the last index, as we will keep that instance of the file read
for (let i = 0; i < indices.length - 1; i++) {
const messageIndex = indices[i][0]
const messageType = indices[i][1] // EditType value
const searchText = indices[i][2] // search text (for file mentions, else empty string)
const messageString = indices[i][3] // what we will replace the string with
didUpdate = true
updatedMessageIndices.add(messageIndex)
// for single-fileread text we can set the updates here
// for potential multi-fileread text we need to determine all changes & iteratively update the text prior to saving the final change
if (messageType === EditType.FILE_MENTION) {
if (!fileMentionUpdates.has(messageIndex)) {
// Get base text either from existing updates or from apiMessages
let baseText = ""
let prevFilesReplaced: string[] = []
const innerTuple = this.contextHistoryUpdates.get(messageIndex)
if (innerTuple) {
const blockUpdates = innerTuple[1].get(1) // assumed index=1 for file mention filereads
if (blockUpdates && blockUpdates.length > 0) {
baseText = blockUpdates[blockUpdates.length - 1][2][0] // index 0 of MessageContent
prevFilesReplaced = blockUpdates[blockUpdates.length - 1][3][0] // previously overwritten file reads in this text
}
}
// can assume that this content will exist, otherwise it would not have been in fileReadIndices
const messageContent = apiMessages[messageIndex]?.content
if (!baseText && Array.isArray(messageContent) && messageContent.length > 1) {
const contentBlock = messageContent[1] // assume index=1 for all text to replace for file mention filereads
if (contentBlock.type === "text") {
baseText = contentBlock.text
}
}
// prevFilesReplaced keeps track of the previous file reads we've replace in this string, empty array if none
fileMentionUpdates.set(messageIndex, [baseText, prevFilesReplaced])
}
// Replace searchText with messageString for all file reads we need to replace in this text
if (searchText) {
const currentTuple = fileMentionUpdates.get(messageIndex) || ["", []]
if (currentTuple[0]) {
// safety check
// replace this text chunk
const updatedText = currentTuple[0].replace(searchText, messageString)
// add the newly added filePath read
const updatedFileReads = currentTuple[1]
updatedFileReads.push(filePath)
fileMentionUpdates.set(messageIndex, [updatedText, updatedFileReads])
}
}
} else {
let innerTuple = this.contextHistoryUpdates.get(messageIndex)
let innerMap: Map<number, ContextUpdate[]>
if (!innerTuple) {
innerMap = new Map<number, ContextUpdate[]>()
this.contextHistoryUpdates.set(messageIndex, [messageType, innerMap])
} else {
innerMap = innerTuple[1]
}
// block index for file reads from read_file, write_to_file, replace_in_file tools is 1
const blockIndex = 1
const updates = innerMap.get(blockIndex) || []
// metadata array is empty for non-file mention occurrences
updates.push([timestamp, "text", [messageString], []])
innerMap.set(blockIndex, updates)
}
}
}
}
// apply file mention updates to contextHistoryUpdates
// in fileMentionUpdates, filePathsUpdated includes all the file paths which are updated in the latest version of this altered text
for (const [messageIndex, [updatedText, filePathsUpdated]] of fileMentionUpdates.entries()) {
let innerTuple = this.contextHistoryUpdates.get(messageIndex)
let innerMap: Map<number, ContextUpdate[]>
if (!innerTuple) {
innerMap = new Map<number, ContextUpdate[]>()
this.contextHistoryUpdates.set(messageIndex, [EditType.FILE_MENTION, innerMap])
} else {
innerMap = innerTuple[1]
}
const blockIndex = 1 // we only consider the block index of 1 for file mentions
const updates = innerMap.get(blockIndex) || []
// filePathsUpdated includes changes done previously to this timestamp, and right now
if (messageFilePaths.has(messageIndex)) {
const allFileReads = messageFilePaths.get(messageIndex)
if (allFileReads) {
// safety check
// we gather all the file reads possible in this text from messageFilePaths
// filePathsUpdated from fileMentionUpdates stores all the files reads we have replaced now & previously
updates.push([timestamp, "text", [updatedText], [filePathsUpdated, allFileReads]])
innerMap.set(blockIndex, updates)
}
}
}
return [didUpdate, updatedMessageIndices]
}
/**
* count total characters in messages and total savings within this range
*/
private countCharactersAndSavingsInRange(
apiMessages: Anthropic.Messages.MessageParam[],
startIndex: number,
endIndex: number,
uniqueFileReadIndices: Set<number>,
): { totalCharacters: number; charactersSaved: number } {
let totalCharCount = 0
let totalCharactersSaved = 0
for (let i = startIndex; i < endIndex; i++) {
// looping over the outer indicies of messages
const message = apiMessages[i]
if (!message.content) {
continue
}
// hasExistingAlterations checks whether the outer idnex has any changes
// hasExistingAlterations will also include the alterations we just made
const hasExistingAlterations = this.contextHistoryUpdates.has(i)
const hasNewAlterations = uniqueFileReadIndices.has(i)
if (Array.isArray(message.content)) {
for (let blockIndex = 0; blockIndex < message.content.length; blockIndex++) {
// looping over inner indices of messages
const block = message.content[blockIndex]
if (block.type === "text" && block.text) {
// true if we just altered it, or it was altered before
if (hasExistingAlterations) {
const innerTuple = this.contextHistoryUpdates.get(i)
const updates = innerTuple?.[1].get(blockIndex) // updated text for this inner index
if (updates && updates.length > 0) {
// exists if we have an update for the message at this index
const latestUpdate = updates[updates.length - 1]
// if block was just altered, then calculate savings
if (hasNewAlterations) {
let originalTextLength
if (updates.length > 1) {
originalTextLength = updates[updates.length - 2][2][0].length // handles case if we have multiple updates for same text block
} else {
originalTextLength = block.text.length
}
const newTextLength = latestUpdate[2][0].length // replacement text
totalCharactersSaved += originalTextLength - newTextLength
totalCharCount += originalTextLength
} else {
// meaning there was an update to this text previously, but we didnt just alter it
totalCharCount += latestUpdate[2][0].length
}
} else {
// reach here if there was one inner index with an update, but now we are at a different index, so updates is not defined
totalCharCount += block.text.length
}
} else {
// reach here if there's no alterations for this outer index, meaning each inner index wont have any changes either
totalCharCount += block.text.length
}
} else if (block.type === "image" && block.source) {
if (block.source.type === "base64" && block.source.data) {
totalCharCount += block.source.data.length
}
}
}
}
}
return { totalCharacters: totalCharCount, charactersSaved: totalCharactersSaved }
}
/**
* count total percentage character savings across in-range conversation
*/
private calculateContextOptimizationMetrics(
apiMessages: Anthropic.Messages.MessageParam[],
conversationHistoryDeletedRange: [number, number] | undefined,
uniqueFileReadIndices: Set<number>,
): number {
// count for first user-assistant message pair
const firstChunkResult = this.countCharactersAndSavingsInRange(apiMessages, 0, 2, uniqueFileReadIndices)
// count for the remaining in-range messages
const secondChunkResult = this.countCharactersAndSavingsInRange(
apiMessages,
conversationHistoryDeletedRange ? conversationHistoryDeletedRange[1] + 1 : 2,
apiMessages.length,
uniqueFileReadIndices,
)
const totalCharacters = firstChunkResult.totalCharacters + secondChunkResult.totalCharacters
const totalCharactersSaved = firstChunkResult.charactersSaved + secondChunkResult.charactersSaved
const percentCharactersSaved = totalCharacters === 0 ? 0 : totalCharactersSaved / totalCharacters
return percentCharactersSaved
}
}
@@ -0,0 +1,35 @@
import { ApiHandler } from "../../api"
import { OpenAiHandler } from "../../api/providers/openai"
/**
* Gets context window information for the given API handler
*
* @param api The API handler to get context window information for
* @returns An object containing the raw context window size and the effective max allowed size
*/
export function getContextWindowInfo(api: ApiHandler) {
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
// Handle special cases like DeepSeek
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.
}
return { contextWindow, maxAllowedSize }
}
@@ -10,8 +10,16 @@ export interface FileMetadataEntry {
user_edit_date?: number | null
}
export interface ModelMetadataEntry {
ts: number
model_id: string
model_provider_id: string
mode: string
}
export interface TaskMetadata {
files_in_context: FileMetadataEntry[]
model_usage: ModelMetadataEntry[]
}
// Interface for the controller to avoid direct dependency
@@ -5,7 +5,7 @@ import * as vscode from "vscode"
import * as path from "path"
import { FileContextTracker } from "./FileContextTracker"
import * as diskModule from "../storage/disk"
import type { TaskMetadata, ControllerLike, FileMetadataEntry } from "./FileContextTrackerTypes"
import type { TaskMetadata, ControllerLike, FileMetadataEntry } from "./ContextTrackerTypes"
describe("FileContextTracker", () => {
let sandbox: sinon.SinonSandbox
@@ -53,7 +53,7 @@ describe("FileContextTracker", () => {
}
// Mock disk module functions
mockTaskMetadata = { files_in_context: [] }
mockTaskMetadata = { files_in_context: [], model_usage: [] }
getTaskMetadataStub = sandbox.stub(diskModule, "getTaskMetadata").resolves(mockTaskMetadata)
saveTaskMetadataStub = sandbox.stub(diskModule, "saveTaskMetadata").resolves()
@@ -1,7 +1,7 @@
import * as path from "path"
import * as vscode from "vscode"
import { getTaskMetadata, saveTaskMetadata } from "../storage/disk"
import type { FileMetadataEntry, ControllerLike } from "./FileContextTrackerTypes"
import type { FileMetadataEntry, ControllerLike } from "./ContextTrackerTypes"
// This class is responsible for tracking file operations that may result in stale context.
// If a user modifies a file outside of Cline, the context may become stale and need to be updated.
@@ -0,0 +1,211 @@
import { describe, it, beforeEach, afterEach } from "mocha"
import { expect } from "chai"
import * as sinon from "sinon"
import * as vscode from "vscode"
import { ModelContextTracker } from "./ModelContextTracker"
import * as diskModule from "../storage/disk"
import type { TaskMetadata, ControllerLike } from "./ContextTrackerTypes"
describe("ModelContextTracker", () => {
let sandbox: sinon.SinonSandbox
let mockController: ControllerLike
let mockContext: vscode.ExtensionContext
let tracker: ModelContextTracker
let taskId: string
let mockTaskMetadata: TaskMetadata
let getTaskMetadataStub: sinon.SinonStub
let saveTaskMetadataStub: sinon.SinonStub
beforeEach(() => {
sandbox = sinon.createSandbox()
// Mock controller and context
mockContext = {
globalStorageUri: { fsPath: "/mock/storage" },
} as unknown as vscode.ExtensionContext
mockController = {
context: mockContext,
}
// Mock disk module functions
mockTaskMetadata = { files_in_context: [], model_usage: [] }
getTaskMetadataStub = sandbox.stub(diskModule, "getTaskMetadata").resolves(mockTaskMetadata)
saveTaskMetadataStub = sandbox.stub(diskModule, "saveTaskMetadata").resolves()
// Create tracker instance
taskId = "test-task-id"
tracker = new ModelContextTracker(mockController, taskId)
})
afterEach(() => {
sandbox.restore()
})
it("should record model usage with correct data", async () => {
// Test data
const apiProviderId = "anthropic"
const modelId = "claude-3-opus"
const mode = "act"
// Use a fake timer to have a predictable timestamp
const fakeNow = 1617293940000 // Some fixed timestamp
const clock = sandbox.useFakeTimers(fakeNow)
try {
// Call the method being tested
await tracker.recordModelUsage(apiProviderId, modelId, mode)
// Verify getTaskMetadata was called with correct parameters
expect(getTaskMetadataStub.calledOnce).to.be.true
expect(getTaskMetadataStub.firstCall.args[1]).to.equal(taskId)
// Verify saveTaskMetadata was called with the correct data
expect(saveTaskMetadataStub.calledOnce).to.be.true
// Extract the saved metadata from the call arguments
const savedMetadata = saveTaskMetadataStub.firstCall.args[2]
// Verify model_usage array has one entry
expect(savedMetadata.model_usage.length).to.equal(1)
// Verify the entry has the correct properties
const modelUsageEntry = savedMetadata.model_usage[0]
expect(modelUsageEntry.ts).to.equal(fakeNow)
expect(modelUsageEntry.model_id).to.equal(modelId)
expect(modelUsageEntry.model_provider_id).to.equal(apiProviderId)
expect(modelUsageEntry.mode).to.equal(mode)
} finally {
// Restore the clock
clock.restore()
}
})
it("should throw an error when controller is dereferenced", async () => {
// Create a new tracker with a controller that will be garbage collected
const weakMockController = { context: mockContext }
const weakTracker = new ModelContextTracker(weakMockController, taskId)
// Force the WeakRef to return null by overriding the deref method
const weakRef = { deref: sandbox.stub().returns(null) }
sandbox.stub(WeakRef.prototype, "deref").callsFake(() => weakRef.deref())
try {
// Try to call the method - this should throw
await weakTracker.recordModelUsage("any-provider", "any-model", "any-mode")
// If we get here, the test should fail
expect.fail("Expected an error to be thrown")
} catch (error) {
// Verify the error message
expect(error.message).to.equal("Unable to access extension context")
}
})
it("should append model usage to existing entries", async () => {
// Add an existing model usage entry
const existingTimestamp = 1617200000000
mockTaskMetadata.model_usage = [
{
ts: existingTimestamp,
model_id: "existing-model",
model_provider_id: "existing-provider",
mode: "plan",
},
]
// Test data for new entry
const apiProviderId = "anthropic"
const modelId = "claude-3-sonnet"
const mode = "act"
// Use a fake timer
const newTimestamp = 1617300000000
const clock = sandbox.useFakeTimers(newTimestamp)
try {
// Call the method being tested
await tracker.recordModelUsage(apiProviderId, modelId, mode)
// Verify saveTaskMetadata was called
expect(saveTaskMetadataStub.calledOnce).to.be.true
// Extract the saved metadata
const savedMetadata = saveTaskMetadataStub.firstCall.args[2]
// Verify model_usage array now has two entries
expect(savedMetadata.model_usage.length).to.equal(2)
// Verify the existing entry is preserved
expect(savedMetadata.model_usage[0]).to.deep.equal({
ts: existingTimestamp,
model_id: "existing-model",
model_provider_id: "existing-provider",
mode: "plan",
})
// Verify the new entry has correct data
expect(savedMetadata.model_usage[1]).to.deep.equal({
ts: newTimestamp,
model_id: modelId,
model_provider_id: apiProviderId,
mode: mode,
})
} finally {
clock.restore()
}
})
it("should handle multiple model usages in sequence", async () => {
// Test data for sequential calls
const usages = [
{ provider: "anthropic", model: "claude-3-opus", mode: "plan" },
{ provider: "openai", model: "gpt-4", mode: "act" },
{ provider: "anthropic", model: "claude-3-haiku", mode: "plan" },
]
// Use a fake timer that advances with each call
const startTime = 1617300000000
const clock = sandbox.useFakeTimers(startTime)
try {
// Record multiple model usages
for (let i = 0; i < usages.length; i++) {
const { provider, model, mode } = usages[i]
// Advance time by 1 second for each call
clock.tick(1000)
const expectedTime = startTime + (i + 1) * 1000
// Reset history between calls to check individual call behavior
getTaskMetadataStub.resetHistory()
saveTaskMetadataStub.resetHistory()
// Reset mock metadata for each iteration to avoid accumulation
mockTaskMetadata.model_usage = []
// Call the method
await tracker.recordModelUsage(provider, model, mode)
// Verify interaction with disk module
expect(getTaskMetadataStub.calledOnce).to.be.true
expect(saveTaskMetadataStub.calledOnce).to.be.true
// Get the saved metadata
const savedMetadata = saveTaskMetadataStub.firstCall.args[2]
// Since we reset the array for each call, we should always have 1 entry
expect(savedMetadata.model_usage.length).to.equal(1)
// Check the entry
const entry = savedMetadata.model_usage[0]
expect(entry.ts).to.equal(expectedTime)
expect(entry.model_id).to.equal(model)
expect(entry.model_provider_id).to.equal(provider)
expect(entry.mode).to.equal(mode)
}
} finally {
clock.restore()
}
})
})
@@ -0,0 +1,52 @@
import * as vscode from "vscode"
import { getTaskMetadata, saveTaskMetadata } from "../storage/disk"
import type { ControllerLike } from "./ContextTrackerTypes"
export class ModelContextTracker {
readonly taskId: string
private controllerRef: WeakRef<ControllerLike>
constructor(controller: ControllerLike, taskId: string) {
this.controllerRef = new WeakRef(controller)
this.taskId = taskId
}
// While a task is ref'd by a controller, it will always have access to the extension context
// This error is thrown if the controller derefs the task after e.g., aborting the task
private context(): vscode.ExtensionContext {
const context = this.controllerRef.deref()?.context
if (!context) {
throw new Error("Unable to access extension context")
}
return context
}
async recordModelUsage(apiProviderId: string, modelId: string, mode: string) {
const context = this.context()
const metadata = await getTaskMetadata(context, this.taskId)
if (!metadata.model_usage) {
metadata.model_usage = []
}
// check to see if the last entry is the same as the new one
const lastEntry = metadata.model_usage[metadata.model_usage.length - 1]
if (
lastEntry &&
lastEntry.model_id === modelId &&
lastEntry.model_provider_id === apiProviderId &&
lastEntry.mode === mode
) {
return
}
metadata.model_usage.push({
ts: Date.now(),
model_id: modelId,
model_provider_id: apiProviderId,
mode: mode,
})
await saveTaskMetadata(context, this.taskId, metadata)
}
}
+153 -7
View File
@@ -43,7 +43,9 @@ import {
updateGlobalState,
} from "../storage/state"
import { WebviewProvider } from "../webview"
import { BrowserSession } from "../../services/browser/BrowserSession"
import { GlobalFileNames } from "../storage/disk"
import { discoverChromeInstances } from "../../services/browser/BrowserDiscovery"
import { searchWorkspaceFiles } from "../../services/search/file-search"
import { getWorkspacePath } from "../../utils/path"
@@ -59,7 +61,7 @@ export class Controller {
workspaceTracker?: WorkspaceTracker
mcpHub?: McpHub
accountService?: ClineAccountService
private latestAnnouncementId = "march-22-2025" // update to some unique identifier when we add a new announcement
private latestAnnouncementId = "april-7-2025" // update to some unique identifier when we add a new announcement
private webviewProviderRef: WeakRef<WebviewProvider>
constructor(
@@ -279,6 +281,12 @@ export class Controller {
break
case "browserSettings":
if (message.browserSettings) {
// remoteBrowserEnabled now means "enable remote browser connection"
// commenting out since this is being done in BrowserSettingsSection updateRemoteBrowserEnabled
// if (!message.browserSettings.remoteBrowserEnabled) {
// // If disabling remote browser connection, clear the remoteBrowserHost
// message.browserSettings.remoteBrowserHost = undefined
// }
await updateGlobalState(this.context, "browserSettings", message.browserSettings)
if (this.task) {
this.task.browserSettings = message.browserSettings
@@ -287,6 +295,123 @@ export class Controller {
await this.postStateToWebview()
}
break
case "getBrowserConnectionInfo":
try {
// Get the current browser session from Cline if it exists
if (this.task?.browserSession) {
const connectionInfo = this.task.browserSession.getConnectionInfo()
await this.postMessageToWebview({
type: "browserConnectionInfo",
isConnected: connectionInfo.isConnected,
isRemote: connectionInfo.isRemote,
host: connectionInfo.host,
})
} else {
// If no active browser session, just return the settings
const { browserSettings } = await getAllExtensionState(this.context)
await this.postMessageToWebview({
type: "browserConnectionInfo",
isConnected: false,
isRemote: !!browserSettings.remoteBrowserEnabled,
host: browserSettings.remoteBrowserHost,
})
}
} catch (error) {
console.error("Error getting browser connection info:", error)
await this.postMessageToWebview({
type: "browserConnectionInfo",
isConnected: false,
isRemote: false,
})
}
break
case "testBrowserConnection":
try {
const { browserSettings } = await getAllExtensionState(this.context)
const browserSession = new BrowserSession(this.context, browserSettings)
// If no text is provided, try auto-discovery
if (!message.text) {
try {
const discoveredHost = await discoverChromeInstances()
if (discoveredHost) {
// Test the connection to the discovered host
const result = await browserSession.testConnection(discoveredHost)
// Send the result back to the webview
await this.postMessageToWebview({
type: "browserConnectionResult",
success: result.success,
text: `Auto-discovered and tested connection to Chrome at ${discoveredHost}: ${result.message}`,
endpoint: result.endpoint,
})
} else {
await this.postMessageToWebview({
type: "browserConnectionResult",
success: false,
text: "No Chrome instances found on the network. Make sure Chrome is running with remote debugging enabled (--remote-debugging-port=9222).",
})
}
} catch (error) {
await this.postMessageToWebview({
type: "browserConnectionResult",
success: false,
text: `Error during auto-discovery: ${error instanceof Error ? error.message : String(error)}`,
})
}
} else {
// Test the provided URL
const result = await browserSession.testConnection(message.text)
// Send the result back to the webview
await this.postMessageToWebview({
type: "browserConnectionResult",
success: result.success,
text: result.message,
endpoint: result.endpoint,
})
}
} catch (error) {
await this.postMessageToWebview({
type: "browserConnectionResult",
success: false,
text: `Error testing connection: ${error instanceof Error ? error.message : String(error)}`,
})
}
break
case "discoverBrowser":
try {
const discoveredHost = await discoverChromeInstances()
if (discoveredHost) {
// Don't update the remoteBrowserHost state when auto-discovering
// This way we don't override the user's preference
// Test the connection to get the endpoint
const { browserSettings } = await getAllExtensionState(this.context)
const browserSession = new BrowserSession(this.context, browserSettings)
const result = await browserSession.testConnection(discoveredHost)
// Send the result back to the webview
await this.postMessageToWebview({
type: "browserConnectionResult",
success: true,
text: `Successfully discovered and connected to Chrome at ${discoveredHost}`,
endpoint: result.endpoint,
})
} else {
await this.postMessageToWebview({
type: "browserConnectionResult",
success: false,
text: "No Chrome instances found on the network. Make sure Chrome is running with remote debugging enabled (--remote-debugging-port=9222).",
})
}
} catch (error) {
await this.postMessageToWebview({
type: "browserConnectionResult",
success: false,
text: `Error discovering browser: ${error instanceof Error ? error.message : String(error)}`,
})
}
break
case "togglePlanActMode":
if (message.chatSettings) {
await this.togglePlanActModeWithChatSettings(message.chatSettings, message.chatContent)
@@ -299,11 +424,11 @@ export class Controller {
text: message.text,
})
break
// case "relaunchChromeDebugMode":
// if (this.task) {
// this.task.browserSession.relaunchChromeDebugMode()
// }
// break
case "relaunchChromeDebugMode":
const { browserSettings } = await getAllExtensionState(this.context)
const browserSession = new BrowserSession(this.context, browserSettings)
await browserSession.relaunchChromeDebugMode(this)
break
case "askResponse":
this.task?.handleWebviewAskResponse(message.askResponse!, message.text, message.images)
break
@@ -623,6 +748,13 @@ export class Controller {
})
break
}
case "scrollToSettings": {
await this.postMessageToWebview({
type: "scrollToSettings",
text: message.text,
})
break
}
case "telemetrySetting": {
if (message.telemetrySetting) {
await this.updateTelemetrySetting(message.telemetrySetting)
@@ -663,6 +795,21 @@ export class Controller {
this.postMessageToWebview({ type: "relinquishControl" })
break
}
case "getDetectedChromePath": {
try {
const { browserSettings } = await getAllExtensionState(this.context)
const browserSession = new BrowserSession(this.context, browserSettings)
const { path, isBundled } = await browserSession.getDetectedChromePath()
await this.postMessageToWebview({
type: "detectedChromePath",
text: path,
isBundled,
})
} catch (error) {
console.error("Error getting detected Chrome path:", error)
}
break
}
case "getRelativePaths": {
if (message.uris && message.uris.length > 0) {
const resolvedPaths = await Promise.all(
@@ -699,7 +846,6 @@ export class Controller {
}
break
}
case "searchFiles": {
const workspacePath = getWorkspacePath()
+14 -4
View File
@@ -4,6 +4,12 @@ import * as path from "path"
import { ClineIgnoreController, LOCK_TEXT_SYMBOL } from "../ignore/ClineIgnoreController"
export const formatResponse = {
duplicateFileReadNotice: () =>
`[[NOTE] This file read has been removed to save space in the context window. Refer to the latest file read for the most up to date version of this file.]`,
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>`,
@@ -124,8 +130,8 @@ Otherwise, if you have not completed the task and do not need additional informa
cwd: string,
wasRecent: boolean | 0 | undefined,
responseText?: string,
) => {
return `[TASK RESUMPTION] ${
): [string, string] => {
const taskResumptionMessage = `[TASK RESUMPTION] ${
mode === "plan"
? `This task was interrupted ${agoText}. The conversation may have been incomplete. Be aware that the project state may have changed since then. The current working directory is now '${cwd.toPosix()}'.\n\nNote: If you previously attempted a tool use that the user did not provide a result for, you should assume the tool use was not successful. However you are in PLAN MODE, so rather than continuing the task, you must respond to the user's message.`
: `This task was interrupted ${agoText}. It may or may not be complete, so please reassess the task context. Be aware that the project state may have changed since then. The current working directory is now '${cwd.toPosix()}'. If the task has not been completed, retry the last step before interruption and proceed with completing the task.\n\nNote: If you previously attempted a tool use that the user did not provide a result for, you should assume the tool use was not successful and assess whether you should retry. If the last tool was a browser_action, the browser has been closed and you must launch a new browser if needed.`
@@ -133,13 +139,17 @@ Otherwise, if you have not completed the task and do not need additional informa
wasRecent
? "\n\nIMPORTANT: If the last tool use was a replace_in_file or write_to_file that was interrupted, the file was reverted back to its original state before the interrupted edit, and you do NOT need to re-read the file as you already have its up-to-date contents."
: ""
}${
}`
const userResponseMessage = `${
responseText
? `\n\n${mode === "plan" ? "New message to respond to with plan_mode_respond tool (be sure to provide your response in the <response> parameter)" : "New instructions for task continuation"}:\n<user_message>\n${responseText}\n</user_message>`
? `${mode === "plan" ? "New message to respond to with plan_mode_respond tool (be sure to provide your response in the <response> parameter)" : "New instructions for task continuation"}:\n<user_message>\n${responseText}\n</user_message>`
: mode === "plan"
? "(The user did not provide a new message. Consider asking them how they'd like you to proceed, or to switch to Act mode to continue with the task.)"
: ""
}`
return [taskResumptionMessage, userResponseMessage]
},
planModeInstructions: () => {
+21
View File
@@ -239,6 +239,20 @@ Your final result description here
<command>Command to demonstrate result (optional)</command>
</attempt_completion>
## new_task
Description: Request to create a new task with preloaded context. The user will be presented with a preview of the context and can choose to create a new task or keep chatting in the current conversation. The user may choose to start a new task at any point.
Parameters:
- context: (required) The context to preload the new task with. This should include:
* Comprehensively explain what has been accomplished in the current task - mention specific file names that are relevant
* The specific next steps or focus for the new task - mention specific file names that are relevant
* Any critical information needed to continue the work
* Clear indication of how this new task relates to the overall workflow
* This should be akin to a long handoff file, enough for a totally new developer to be able to pick up where you left off and know exactly what to do next and which files to look at.
Usage:
<new_task>
<context>context to preload new task with</context>
</new_task>
## plan_mode_respond
Description: Respond to the user's inquiry in an effort to plan a solution to the user's task. This tool should be used when you need to provide a response to a question or statement from the user about how you plan to accomplish the task. This tool is only available in PLAN MODE. The environment_details will specify the current mode, if it is not PLAN MODE then you should not use this tool. Depending on the user's message, you may ask questions to get clarification about the user's request, architect a solution to the task, and to brainstorm ideas with the user. For example, if the user's task is to create a website, you may start by asking some clarifying questions, then present a detailed plan for how you will accomplish the task given the context, and perhaps engage in a back and forth to finalize the details before the user switches you to ACT MODE to implement the solution.
Parameters:
@@ -252,6 +266,13 @@ Array of options here (optional), e.g. ["Option 1", "Option 2", "Option 3"]
</options>
</plan_mode_respond>
## load_mcp_documentation
Description: Load documentation about creating MCP servers. This tool should be used when the user requests to create or install an MCP server (the user may ask you something along the lines of "add a tool" that does some function, in other words to create an MCP server that provides tools and resources that may connect to external APIs for example. You have the ability to create an MCP server and add it to a configuration file that will then expose the tools and resources for you to use with \`use_mcp_tool\` and \`access_mcp_resource\`). The documentation provides detailed information about the MCP server creation process, including setup instructions, best practices, and examples.
Parameters: None
Usage:
<load_mcp_documentation>
</load_mcp_documentation>
# Tool Use Examples
## Example 1: Requesting to execute a command
+3 -14
View File
@@ -4,22 +4,11 @@ import fs from "fs/promises"
import { Anthropic } from "@anthropic-ai/sdk"
import { fileExistsAtPath } from "../../utils/fs"
import { ClineMessage } from "../../shared/ExtensionMessage"
export interface FileMetadataEntry {
path: string
record_state: "active" | "stale"
record_source: "read_tool" | "user_edited" | "cline_edited" | "file_mentioned"
cline_read_date: number | null
cline_edit_date: number | null
user_edit_date?: number | null
}
export interface TaskMetadata {
files_in_context: FileMetadataEntry[]
}
import type { TaskMetadata } from "../context-tracking/ContextTrackerTypes"
export const GlobalFileNames = {
apiConversationHistory: "api_conversation_history.json",
contextHistory: "context_history.json",
uiMessages: "ui_messages.json",
openRouterModels: "openrouter_models.json",
mcpSettings: "cline_mcp_settings.json",
@@ -95,7 +84,7 @@ export async function getTaskMetadata(context: vscode.ExtensionContext, taskId:
} catch (error) {
console.error("Failed to read task metadata:", error)
}
return { files_in_context: [] }
return { files_in_context: [], model_usage: [] }
}
export async function saveTaskMetadata(context: vscode.ExtensionContext, taskId: string, metadata: TaskMetadata) {
+1 -1
View File
@@ -280,7 +280,7 @@ export async function getAllExtensionState(context: vscode.ExtensionContext) {
customInstructions,
taskHistory,
autoApprovalSettings: autoApprovalSettings || DEFAULT_AUTO_APPROVAL_SETTINGS, // default value can be 0 or empty string
browserSettings: browserSettings || DEFAULT_BROWSER_SETTINGS,
browserSettings: { ...DEFAULT_BROWSER_SETTINGS, ...browserSettings }, // this will ensure that older versions of browserSettings (e.g. before remoteBrowserEnabled was added) are merged with the default values (false for remoteBrowserEnabled)
chatSettings: chatSettings || DEFAULT_CHAT_SETTINGS,
userInfo,
previousModeApiProvider,
+152 -20
View File
@@ -13,6 +13,7 @@ import { ApiHandler, buildApiHandler } from "../../api"
import { AnthropicHandler } from "../../api/providers/anthropic"
import { ClineHandler } from "../../api/providers/cline"
import { OpenRouterHandler } from "../../api/providers/openrouter"
import { getContextWindowInfo } from "../context-management/context-window-utils"
import { ApiStream } from "../../api/transform/stream"
import CheckpointTracker from "../../integrations/checkpoints/CheckpointTracker"
import { DIFF_VIEW_URI_SCHEME, DiffViewProvider } from "../../integrations/editor/DiffViewProvider"
@@ -65,6 +66,8 @@ import { parseMentions } from ".././mentions"
import { formatResponse } from ".././prompts/responses"
import { addUserInstructions, SYSTEM_PROMPT } from ".././prompts/system"
import { FileContextTracker } from "../context-tracking/FileContextTracker"
import { ModelContextTracker } from "../context-tracking/ModelContextTracker"
import {
checkIsAnthropicContextWindowError,
checkIsOpenRouterContextWindowError,
@@ -77,6 +80,7 @@ import {
saveApiConversationHistory,
saveClineMessages,
GlobalFileNames,
getTaskMetadata,
} from "../storage/disk"
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
@@ -118,8 +122,9 @@ export class Task {
isAwaitingPlanResponse = false
didRespondToPlanAskBySwitchingMode = false
// File tracking
// Metadata tracking
private fileContextTracker: FileContextTracker
private modelContextTracker: ModelContextTracker
// streaming
isWaitingForFirstChunk = false
@@ -174,13 +179,16 @@ export class Task {
// Initialize file context tracker
this.fileContextTracker = new FileContextTracker(controller, this.taskId)
this.modelContextTracker = new ModelContextTracker(controller, this.taskId)
// Now that taskId is initialized, we can build the API handler
this.api = buildApiHandler({
...apiConfiguration,
taskId: this.taskId,
})
// Set taskId on browserSession for telemetry tracking
this.browserSession.setTaskId(this.taskId)
// Continue with task initialization
if (historyItem) {
this.resumeTaskFromHistory()
@@ -208,7 +216,6 @@ export class Task {
}
// Storing task to disk for history
private async addToApiConversationHistory(message: Anthropic.MessageParam) {
this.apiConversationHistory.push(message)
await saveApiConversationHistory(this.getContext(), this.taskId, this.apiConversationHistory)
@@ -324,6 +331,12 @@ export class Task {
) // +1 since this index corresponds to the last user message, and another +1 since slice end index is exclusive
await this.overwriteApiConversationHistory(newConversationHistory)
// update the context history state
await this.contextManager.truncateContextHistory(
message.ts,
await ensureTaskDirectoryExists(this.getContext(), this.taskId),
)
// aggregate deleted api reqs info so we don't lose costs/tokens
const deletedMessages = this.clineMessages.slice(messageIndex + 1)
const deletedApiReqsMetrics = getApiMetrics(combineApiRequests(combineCommandSequences(deletedMessages)))
@@ -855,6 +868,9 @@ export class Task {
// This is important in case the user deletes messages without resuming the task first
this.apiConversationHistory = await getSavedApiConversationHistory(this.getContext(), this.taskId)
// load the context history state
await this.contextManager.initializeContextHistory(await ensureTaskDirectoryExists(this.getContext(), this.taskId))
const lastClineMessage = this.clineMessages
.slice()
.reverse()
@@ -930,16 +946,27 @@ export class Task {
const wasRecent = lastClineMessage?.ts && Date.now() - lastClineMessage.ts < 30_000
newUserContent.push({
type: "text",
text: formatResponse.taskResumption(
this.chatSettings?.mode === "plan" ? "plan" : "act",
agoText,
cwd,
wasRecent,
responseText,
),
})
const [taskResumptionMessage, userResponseMessage] = formatResponse.taskResumption(
this.chatSettings?.mode === "plan" ? "plan" : "act",
agoText,
cwd,
wasRecent,
responseText,
)
if (taskResumptionMessage !== "") {
newUserContent.push({
type: "text",
text: taskResumptionMessage,
})
}
if (userResponseMessage !== "") {
newUserContent.push({
type: "text",
text: userResponseMessage,
})
}
if (responseImages && responseImages.length > 0) {
newUserContent.push(...formatResponse.imageBlocks(responseImages))
@@ -984,7 +1011,7 @@ export class Task {
this.abort = true // will stop any autonomously running promises
this.terminalManager.disposeAll()
this.urlContentFetcher.closeBrowser()
this.browserSession.closeBrowser()
await this.browserSession.dispose()
this.clineIgnoreController.dispose()
this.fileContextTracker.dispose()
await this.diffViewProvider.revertChanges() // need to await for when we want to make sure directories/files are reverted before re-starting the task from a checkpoint
@@ -1142,7 +1169,9 @@ export class Task {
}
}
shouldAutoApproveTool(toolName: ToolUseName): boolean {
// Check if the tool should be auto-approved based on the settings
// Returns bool for most tools, tuple for execute_command (and future nested auto appoved settings)
shouldAutoApproveTool(toolName: ToolUseName): boolean | [boolean, boolean] {
if (this.autoApprovalSettings.enabled) {
switch (toolName) {
case "read_file":
@@ -1154,7 +1183,10 @@ export class Task {
case "replace_in_file":
return this.autoApprovalSettings.actions.editFiles
case "execute_command":
return this.autoApprovalSettings.actions.executeCommands
return [
this.autoApprovalSettings.actions.executeSafeCommands,
this.autoApprovalSettings.actions.executeAllCommands,
]
case "browser_action":
return this.autoApprovalSettings.actions.useBrowser
case "access_mcp_resource":
@@ -1252,12 +1284,13 @@ export class Task {
preferredLanguageInstructions,
)
}
const contextManagementMetadata = this.contextManager.getNewContextMessagesAndMetadata(
const contextManagementMetadata = await this.contextManager.getNewContextMessagesAndMetadata(
this.apiConversationHistory,
this.clineMessages,
this.api,
this.conversationHistoryDeletedRange,
previousApiReqIndex,
await ensureTaskDirectoryExists(this.getContext(), this.taskId),
)
if (contextManagementMetadata.updatedConversationHistoryDeletedRange) {
@@ -1455,6 +1488,8 @@ export class Task {
return `[${block.name}]`
case "attempt_completion":
return `[${block.name}]`
case "new_task":
return `[${block.name} for creating a new task]`
}
}
@@ -2244,6 +2279,14 @@ export class Task {
// await this.say("inspect_site_result", "") // no result, starts the loading spinner waiting for result
await this.say("browser_action_result", "") // starts loading spinner
// Re-make browserSession to make sure latest settings apply
const localContext = this.controllerRef.deref()?.context
if (localContext) {
await this.browserSession.dispose()
this.browserSession = new BrowserSession(localContext, this.browserSettings)
} else {
console.warn("no controller context available for browserSession")
}
await this.browserSession.launchBrowser()
browserActionResult = await this.browserSession.navigateToUrl(url)
} else {
@@ -2336,7 +2379,7 @@ export class Task {
case "execute_command": {
let command: string | undefined = block.params.command
const requiresApprovalRaw: string | undefined = block.params.requires_approval
const requiresApproval = requiresApprovalRaw?.toLowerCase() === "true"
const requiresApprovalPerLLM = requiresApprovalRaw?.toLowerCase() === "true"
try {
if (block.partial) {
@@ -2387,7 +2430,17 @@ export class Task {
let didAutoApprove = false
if (!requiresApproval && this.shouldAutoApproveTool(block.name)) {
// If the model says this command is safe and auto aproval for safe commands is true, execute the command
// If the model says the command is risky, but *BOTH* auto approve settings are true, execute the command
const autoApproveResult = this.shouldAutoApproveTool(block.name)
const [autoApproveSafe, autoApproveAll] = Array.isArray(autoApproveResult)
? autoApproveResult
: [autoApproveResult, false]
if (
(!requiresApprovalPerLLM && autoApproveSafe) ||
(requiresApprovalPerLLM && autoApproveSafe && autoApproveAll)
) {
this.removeLastPartialMessageIfExistsWithType("ask", "command")
await this.say("command", command, undefined, false)
this.consecutiveAutoApprovedRequestsCount++
@@ -2400,7 +2453,7 @@ export class Task {
const didApprove = await askApproval(
"command",
command +
`${this.shouldAutoApproveTool(block.name) && requiresApproval ? COMMAND_REQ_APP_STRING : ""}`, // ugly hack until we refactor combineCommandSequences
`${this.shouldAutoApproveTool(block.name) && requiresApprovalPerLLM ? COMMAND_REQ_APP_STRING : ""}`, // ugly hack until we refactor combineCommandSequences
)
if (!didApprove) {
break
@@ -2705,6 +2758,51 @@ export class Task {
break
}
}
case "new_task": {
const context: string | undefined = block.params.context
try {
if (block.partial) {
await this.ask("new_task", removeClosingTag("context", context), block.partial).catch(() => {})
break
} else {
if (!context) {
this.consecutiveMistakeCount++
pushToolResult(await this.sayAndCreateMissingParamError("new_task", "context"))
break
}
this.consecutiveMistakeCount = 0
if (this.autoApprovalSettings.enabled && this.autoApprovalSettings.enableNotifications) {
showSystemNotification({
subtitle: "Cline wants to start a new task...",
message: `Cline is suggesting to start a new task with: ${context}`,
})
}
const { text, images } = await this.ask("new_task", context, false)
// If the user provided a response, treat it as feedback
if (text || images?.length) {
await this.say("user_feedback", text ?? "", images)
pushToolResult(
formatResponse.toolResult(
`The user provided feedback instead of creating a new task:\n<feedback>\n${text}\n</feedback>`,
images,
),
)
} else {
// If no response, the user clicked the "Create New Task" button
pushToolResult(
formatResponse.toolResult(`The user has created a new task with the provided context.`),
)
}
break
}
} catch (error) {
await handleError("creating new task", error)
break
}
}
case "plan_mode_respond": {
const response: string | undefined = block.params.response
const optionsRaw: string | undefined = block.params.options
@@ -2989,6 +3087,10 @@ export class Task {
throw new Error("Cline instance aborted")
}
if (this.apiProvider && this.api.getModel().id) {
await this.modelContextTracker.recordModelUsage(this.apiProvider, this.api.getModel().id, this.chatSettings.mode)
}
if (this.consecutiveMistakeCount >= 3) {
if (this.autoApprovalSettings.enabled && this.autoApprovalSettings.enableNotifications) {
showSystemNotification({
@@ -3568,6 +3670,36 @@ export class Task {
}
}
// Add context window usage information
const { contextWindow, maxAllowedSize } = getContextWindowInfo(this.api)
// Get the token count from the most recent API request to accurately reflect context management
const getTotalTokensFromApiReqMessage = (msg: ClineMessage) => {
if (!msg.text) {
return 0
}
try {
const { tokensIn, tokensOut, cacheWrites, cacheReads } = JSON.parse(msg.text)
return (tokensIn || 0) + (tokensOut || 0) + (cacheWrites || 0) + (cacheReads || 0)
} catch (e) {
return 0
}
}
const modifiedMessages = combineApiRequests(combineCommandSequences(this.clineMessages.slice(1)))
const lastApiReqMessage = findLast(modifiedMessages, (msg) => {
if (msg.say !== "api_req_started") {
return false
}
return getTotalTokensFromApiReqMessage(msg) > 0
})
const lastApiReqTotalTokens = lastApiReqMessage ? getTotalTokensFromApiReqMessage(lastApiReqMessage) : 0
const usagePercentage = Math.round((lastApiReqTotalTokens / contextWindow) * 100)
details += "\n\n# Context Window Usage"
details += `\n${lastApiReqTotalTokens.toLocaleString()} / ${(contextWindow / 1000).toLocaleString()}K tokens used (${usagePercentage}%)`
details += "\n\n# Current Mode"
if (this.chatSettings.mode === "plan") {
details += "\nPLAN MODE\n" + formatResponse.planModeInstructions()
+2 -1
View File
@@ -33,6 +33,7 @@ export function activate(context: vscode.ExtensionContext) {
const sidebarWebview = new WebviewProvider(context, outputChannel)
vscode.commands.executeCommand("setContext", "cline.isDevMode", IS_DEV && IS_DEV === "true")
vscode.commands.executeCommand("setContext", "cline.isTestMode", IS_TEST && IS_TEST === "true")
context.subscriptions.push(
vscode.window.registerWebviewViewProvider(WebviewProvider.sideBarId, sidebarWebview, {
@@ -408,7 +409,7 @@ export function deactivate() {
//
// This is a workaround to reload the extension when the source code changes
// since vscode doesn't support hot reload for extensions
const { IS_DEV, DEV_WORKSPACE_FOLDER } = process.env
const { IS_DEV, DEV_WORKSPACE_FOLDER, IS_TEST } = process.env
if (IS_DEV && IS_DEV !== "false") {
assert(DEV_WORKSPACE_FOLDER, "DEV_WORKSPACE_FOLDER must be set in development")
+102
View File
@@ -0,0 +1,102 @@
import * as net from "net"
import axios from "axios"
/**
* Check if a port is open on a given host
*/
export async function isPortOpen(host: string, port: number, timeout = 1000): Promise<boolean> {
return new Promise((resolve) => {
const socket = new net.Socket()
let status = false
// Set timeout
socket.setTimeout(timeout)
// Handle successful connection
socket.on("connect", () => {
status = true
socket.destroy()
})
// Handle any errors
socket.on("error", () => {
socket.destroy()
})
// Handle timeout
socket.on("timeout", () => {
socket.destroy()
})
// Handle close
socket.on("close", () => {
resolve(status)
})
// Attempt to connect
socket.connect(port, host)
})
}
/**
* Try to connect to Chrome at a specific IP address
*/
export async function tryConnect(ipAddress: string): Promise<{ endpoint: string; ip: string } | null> {
try {
const response = await axios.get(`http://${ipAddress}:9222/json/version`, { timeout: 1000 })
const data = response.data
return { endpoint: data.webSocketDebuggerUrl, ip: ipAddress }
} catch (error) {
return null
}
}
/**
* Discover Chrome instances (localhost only)
*/
export async function discoverChromeInstances(): Promise<string | null> {
// Only try localhost
const ipAddresses = ["localhost", "127.0.0.1"]
// Try connecting to each IP address
for (const ip of ipAddresses) {
const connection = await tryConnect(ip)
if (connection) {
return `http://${connection.ip}:9222`
}
}
return null
}
/**
* Test connection to a remote browser
*/
export async function testBrowserConnection(host: string): Promise<{ success: boolean; message: string; endpoint?: string }> {
try {
// Fetch the WebSocket endpoint from the Chrome DevTools Protocol
const versionUrl = `${host.replace(/\/$/, "")}/json/version`
const response = await axios.get(versionUrl, { timeout: 3000 })
const browserWSEndpoint = response.data.webSocketDebuggerUrl
if (!browserWSEndpoint) {
return {
success: false,
message: "Could not find webSocketDebuggerUrl in the response",
}
}
return {
success: true,
message: "Successfully connected to Chrome browser",
endpoint: browserWSEndpoint,
}
} catch (error) {
console.error(`Failed to connect to remote browser: ${error}`)
return {
success: false,
message: `Failed to connect: ${error instanceof Error ? error.message : String(error)}`,
}
}
}
+404 -102
View File
@@ -1,36 +1,95 @@
import * as vscode from "vscode"
import * as fs from "fs/promises"
import * as path from "path"
import { Browser, Page, ScreenshotOptions, TimeoutError, launch } from "puppeteer-core"
import { exec, spawn } from "child_process"
import { Browser, Page, ScreenshotOptions, TimeoutError, launch, connect } from "puppeteer-core"
// @ts-ignore
import PCR from "puppeteer-chromium-resolver"
import pWaitFor from "p-wait-for"
import { setTimeout as setTimeoutPromise } from "node:timers/promises"
import axios from "axios"
import { fileExistsAtPath } from "../../utils/fs"
import { BrowserActionResult } from "../../shared/ExtensionMessage"
import { BrowserSettings } from "../../shared/BrowserSettings"
// import * as chromeLauncher from "chrome-launcher"
import { discoverChromeInstances, testBrowserConnection, isPortOpen } from "./BrowserDiscovery"
import * as chromeLauncher from "chrome-launcher"
import { Controller } from "../../core/controller"
import { telemetryService } from "../../services/telemetry/TelemetryService"
interface PCRStats {
puppeteer: { launch: typeof launch }
executablePath: string
}
// const DEBUG_PORT = 9222 // Chrome's default debugging port
// Define browser connection info interface
export interface BrowserConnectionInfo {
isConnected: boolean
isRemote: boolean
host?: string
}
const DEBUG_PORT = 9222 // Chrome's default debugging port
export class BrowserSession {
private context: vscode.ExtensionContext
private browser?: Browser
private page?: Page
private currentMousePosition?: string
private cachedWebSocketEndpoint?: string
private lastConnectionAttempt: number = 0
browserSettings: BrowserSettings
private isConnectedToRemote: boolean = false
// Telemetry tracking properties
private sessionStartTime: number = 0
private browserActions: string[] = []
private taskId?: string
constructor(context: vscode.ExtensionContext, browserSettings: BrowserSettings) {
this.context = context
this.browserSettings = browserSettings
}
private async ensureChromiumExists(): Promise<PCRStats> {
// Tests remote browser connection
async testConnection(host: string): Promise<{ success: boolean; message: string; endpoint?: string }> {
return testBrowserConnection(host)
}
/**
* Get current browser connection information
*/
getConnectionInfo(): BrowserConnectionInfo {
return {
isConnected: !!this.browser,
isRemote: this.isConnectedToRemote,
host: this.isConnectedToRemote ? this.browserSettings.remoteBrowserHost : undefined,
}
}
async getDetectedChromePath(): Promise<{ path: string; isBundled: boolean }> {
// First check VSCode config
const configPath = vscode.workspace.getConfiguration("cline").get<string>("chromeExecutablePath")
if (configPath && (await fileExistsAtPath(configPath))) {
return { path: configPath, isBundled: false }
}
// Then try to find system Chrome
try {
const systemPath = chromeLauncher.Launcher.getFirstInstallation()
// Add validation to ensure path is not in Trash - This can happen on Mac OS due to the way the chrome-launcher library works
if (systemPath && !systemPath.includes(".Trash") && (await fileExistsAtPath(systemPath))) {
return { path: systemPath, isBundled: false }
}
} catch (error) {
console.info("Could not find system Chrome:", error)
}
// Finally fall back to PCR's bundled version
const stats = await this.ensureChromiumExists()
return { path: stats.executablePath, isBundled: true }
}
async ensureChromiumExists(): Promise<PCRStats> {
const globalStoragePath = this.context?.globalStorageUri?.fsPath
if (!globalStoragePath) {
throw new Error("Global storage uri is invalid")
@@ -42,130 +101,342 @@ export class BrowserSession {
await fs.mkdir(puppeteerDir, { recursive: true })
}
const chromeExecutablePath = vscode.workspace.getConfiguration("cline").get<string>("chromeExecutablePath")
if (chromeExecutablePath && !(await fileExistsAtPath(chromeExecutablePath))) {
throw new Error(`Chrome executable not found at path: ${chromeExecutablePath}`)
}
const stats: PCRStats = chromeExecutablePath
? { puppeteer: require("puppeteer-core"), executablePath: chromeExecutablePath }
: // if chromium doesn't exist, this will download it to path.join(puppeteerDir, ".chromium-browser-snapshots")
// if it does exist it will return the path to existing chromium
await PCR({ downloadPath: puppeteerDir })
// if chromium doesn't exist, this will download it to path.join(puppeteerDir, ".chromium-browser-snapshots")
// if it does exist it will return the path to existing chromium
const stats = await PCR({ downloadPath: puppeteerDir })
return stats
}
// private async checkExistingChromeDebugger(): Promise<boolean> {
// try {
// // Try to connect to existing debugger
// const response = await fetch(`http://localhost:${DEBUG_PORT}/json/version`)
// return response.ok
// } catch {
// return false
// }
// }
async relaunchChromeDebugMode(controller: Controller) {
const result = await vscode.window.showWarningMessage(
"This will close your existing Chrome tabs and relaunch Chrome in debug mode. Are you sure?",
{ modal: true },
"Yes",
)
// async relaunchChromeDebugMode() {
// const result = await vscode.window.showWarningMessage(
// "This will close your existing Chrome tabs and relaunch Chrome in debug mode. Are you sure?",
// { modal: true },
// "Yes",
// )
if (result !== "Yes") {
controller?.postMessageToWebview({
type: "browserRelaunchResult",
success: false,
text: "Operation cancelled by user",
})
return
}
// if (result !== "Yes") {
// return
// }
try {
// Chrome-launcher's killAll only kills instances it launched
// We need to handle system Chrome processes separately
await this.killAllChromeBrowsers()
// // // Kill any existing Chrome instances
// // await chromeLauncher.killAll()
// Wait a moment for Chrome to fully shut down
await new Promise((resolve) => setTimeout(resolve, 500))
// // // Launch Chrome with debug port
// // const launcher = new chromeLauncher.Launcher({
// // port: DEBUG_PORT,
// // chromeFlags: ["--remote-debugging-port=" + DEBUG_PORT, "--no-first-run", "--no-default-browser-check"],
// // })
// Instead of using any default flags, use a minimal set to ensure session persistence
// This closely mimics running "google-chrome-stable --remote-debugging-port=9222" from the CLI
const chromeFlags = [
"--remote-debugging-port=" + DEBUG_PORT,
"--disable-notifications",
// Do not add any flags that might interfere with profile data
]
// // await launcher.launch()
// const installation = chromeLauncher.Launcher.getFirstInstallation()
// if (!installation) {
// throw new Error("Could not find Chrome installation on this system")
// }
// console.log("chrome installation", installation)
// }
const installation = chromeLauncher.Launcher.getFirstInstallation()
if (!installation) {
throw new Error("Could not find Chrome installation on this system")
}
console.info("chrome installation", installation)
// private async getSystemChromeExecutablePath(): Promise<string> {
// // Find installed Chrome
// const installation = chromeLauncher.Launcher.getFirstInstallation()
// if (!installation) {
// throw new Error("Could not find Chrome installation on this system")
// }
// console.log("chrome installation", installation)
// return installation
// }
// Prepare the command arguments
const args = [`--remote-debugging-port=${DEBUG_PORT}`, "--disable-notifications", "chrome://newtab"]
// /**
// * Helper to detect users default Chrome data dir.
// * Adjust for OS if needed.
// */
// private getDefaultChromeUserDataDir(): string {
// const homedir = require("os").homedir()
// switch (process.platform) {
// case "win32":
// return path.join(homedir, "AppData", "Local", "Google", "Chrome", "User Data")
// case "darwin":
// return path.join(homedir, "Library", "Application Support", "Google", "Chrome")
// default:
// return path.join(homedir, ".config", "google-chrome")
// }
// }
// Spawn Chrome as a detached process
const chromeProcess = spawn(installation, args, {
detached: true, // This is key - makes the process independent of parent
stdio: "ignore", // Detach stdio to prevent hanging
shell: false, // Don't run in a shell
})
// Unref the process to allow Node to exit independently
chromeProcess.unref()
// Wait a moment to ensure Chrome has time to start
await new Promise((resolve) => setTimeout(resolve, 1000))
// Test if Chrome is actually running with debug port
const isRunning = await isPortOpen("localhost", DEBUG_PORT, 2000)
if (!isRunning) {
throw new Error("Chrome was launched but debug port is not responding")
}
controller?.postMessageToWebview({
type: "browserRelaunchResult",
success: true,
text: `Browser successfully launched with debug mode\nUsing: ${installation}`,
})
} catch (error) {
controller?.postMessageToWebview({
type: "browserRelaunchResult",
success: false,
text: `Failed to relaunch Chrome: ${error instanceof Error ? error.message : String(error)}`,
})
}
}
/**
* Set the task ID for telemetry tracking
* @param taskId The task ID to associate with browser actions
*/
setTaskId(taskId: string) {
this.taskId = taskId
}
async launchBrowser() {
console.log("launch browser called")
if (this.browser) {
// throw new Error("Browser already launched")
await this.closeBrowser() // this may happen when the model launches a browser again after having used it already before
}
const stats = await this.ensureChromiumExists()
this.browser = await stats.puppeteer.launch({
// Reset tracking properties
this.sessionStartTime = Date.now()
this.browserActions = []
// Reset remote connection status
this.isConnectedToRemote = false
if (this.browserSettings.remoteBrowserEnabled) {
console.log(`launch browser called -- remote host mode (non-headless)`)
try {
await this.launchRemoteBrowser()
// Don't create a new page here, as we'll create it in launchRemoteBrowser
// Send telemetry for browser tool start
if (this.taskId) {
telemetryService.captureBrowserToolStart(this.taskId, this.browserSettings)
}
return
} catch (error) {
console.error("Failed to launch remote browser, falling back to local mode:", error)
// Capture error telemetry
if (this.taskId) {
telemetryService.captureBrowserError(
this.taskId,
"remote_browser_launch_error",
error instanceof Error ? error.message : String(error),
{
isRemote: true,
remoteBrowserHost: this.browserSettings.remoteBrowserHost,
},
)
}
await this.launchLocalBrowser()
}
} else {
console.log(`launch browser called -- local mode (headless)`)
await this.launchLocalBrowser()
}
this.page = await this.browser?.newPage()
// Send telemetry for browser tool start
if (this.taskId) {
telemetryService.captureBrowserToolStart(this.taskId, this.browserSettings)
}
}
async launchLocalBrowser() {
const { path } = await this.getDetectedChromePath()
this.browser = await launch({
args: [
"--user-agent=Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36",
],
executablePath: stats.executablePath,
executablePath: path,
defaultViewport: this.browserSettings.viewport,
headless: this.browserSettings.headless,
headless: "shell", // Always use headless mode for local connections
})
this.isConnectedToRemote = false
}
// if (this.browserSettings.chromeType === "system") {
// const userDataDir = this.getDefaultChromeUserDataDir()
// this.browser = await stats.puppeteer.launch({
// args: [`--user-data-dir=${userDataDir}`, "--profile-directory=Default"],
// executablePath: await this.getSystemChromeExecutablePath(),
// defaultViewport: this.browserSettings.viewport,
// headless: this.browserSettings.headless,
// })
// } else {
// this.browser = await stats.puppeteer.launch({
// args: [
// "--user-agent=Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36",
// ],
// executablePath: stats.executablePath,
// defaultViewport: this.browserSettings.viewport,
// headless: this.browserSettings.headless,
// })
// }
async launchRemoteBrowser() {
let remoteBrowserHost = this.browserSettings.remoteBrowserHost
let browserWSEndpoint: string | undefined = this.cachedWebSocketEndpoint
let reconnectionAttempted = false
// (latest version of puppeteer does not add headless to user agent)
this.page = await this.browser?.newPage()
const getViewport = () => {
return this.browserSettings.viewport
}
// First try auto-discovery if no host is provided
if (!remoteBrowserHost) {
try {
console.info("No remote browser host provided, trying auto-discovery")
const discoveredHost = await discoverChromeInstances()
if (discoveredHost) {
console.info(`Auto-discovered Chrome at ${discoveredHost}`)
remoteBrowserHost = discoveredHost
}
} catch (error) {
console.log(`Auto-discovery failed: ${error}`)
}
}
// Try to connect with cached endpoint first if it exists and is recent (less than 1 hour old)
if (browserWSEndpoint && Date.now() - this.lastConnectionAttempt < 3600000) {
try {
console.info(`Attempting to connect using cached WebSocket endpoint: ${browserWSEndpoint}`)
this.browser = await connect({
browserWSEndpoint,
defaultViewport: getViewport(),
})
this.page = await this.browser?.newPage()
this.isConnectedToRemote = true
return
} catch (error) {
console.log(`Failed to connect using cached endpoint: ${error}`)
// Capture error telemetry
if (this.taskId) {
telemetryService.captureBrowserError(
this.taskId,
"cached_endpoint_connection_error",
error instanceof Error ? error.message : String(error),
{
isRemote: true,
endpoint: browserWSEndpoint,
},
)
}
// Clear the cached endpoint since it's no longer valid
this.cachedWebSocketEndpoint = undefined
// User wants to give up after one reconnection attempt
if (remoteBrowserHost) {
reconnectionAttempted = true
}
}
}
// Try to connect with host (either user-provided or auto-discovered)
if (remoteBrowserHost) {
try {
// Fetch the WebSocket endpoint from the Chrome DevTools Protocol
const versionUrl = `${remoteBrowserHost.replace(/\/$/, "")}/json/version`
console.info(`Fetching WebSocket endpoint from ${versionUrl}`)
const response = await axios.get(versionUrl)
browserWSEndpoint = response.data.webSocketDebuggerUrl
if (!browserWSEndpoint) {
throw new Error("Could not find webSocketDebuggerUrl in the response")
}
console.info(`Found WebSocket browser endpoint: ${browserWSEndpoint}`)
// Cache the successful endpoint
this.cachedWebSocketEndpoint = browserWSEndpoint
this.lastConnectionAttempt = Date.now()
this.browser = await connect({
browserWSEndpoint,
defaultViewport: getViewport(),
})
this.page = await this.browser?.newPage()
this.isConnectedToRemote = true
return
} catch (error) {
console.log(`Failed to connect to remote browser: ${error}`)
// Capture error telemetry
if (this.taskId) {
telemetryService.captureBrowserError(
this.taskId,
"remote_host_connection_error",
error instanceof Error ? error.message : String(error),
{
isRemote: true,
remoteBrowserHost,
},
)
}
}
}
// If we get here, all connection attempts failed
throw new Error(
"Failed to connect to remote browser. Make sure Chrome is running with remote debugging enabled (--remote-debugging-port=9222).",
)
}
/**
* Kill all Chrome instances, including those not launched by chrome-launcher
*/
private async killAllChromeBrowsers(): Promise<void> {
// First try chrome-launcher's killAll to handle instances it launched
try {
await chromeLauncher.killAll()
} catch (err: unknown) {
console.log("Error in chrome-launcher killAll:", err)
}
// Then kill other Chrome instances using platform-specific commands
try {
if (process.platform === "win32") {
// Windows: Use taskkill to forcefully terminate Chrome processes
await new Promise<void>((resolve, reject) => {
exec("taskkill /F /IM chrome.exe /T", () => resolve())
})
} else if (process.platform === "darwin") {
// macOS: Use pkill to terminate Chrome processes
await new Promise<void>((resolve) => {
exec('pkill -x "Google Chrome"', () => resolve())
})
} else {
// Linux: Use pkill for Chrome and chromium
await new Promise<void>((resolve) => {
exec('pkill -f "chrome|chromium"', () => resolve())
})
}
} catch (error) {
console.error("Error killing Chrome processes:", error)
}
}
async closeBrowser(): Promise<BrowserActionResult> {
if (this.browser || this.page) {
console.log("closing browser...")
await this.browser?.close().catch(() => {})
// Send telemetry for browser tool end if we have a task ID and session was started
if (this.taskId && this.sessionStartTime > 0) {
const sessionDuration = Date.now() - this.sessionStartTime
telemetryService.captureBrowserToolEnd(this.taskId, {
actionCount: this.browserActions.length,
duration: sessionDuration,
actions: this.browserActions,
})
}
if (this.isConnectedToRemote && this.browser) {
// Close the page/tab first if it exists
if (this.page) {
await this.page.close().catch(() => {})
console.info("closed remote browser tab...")
}
await this.browser.disconnect().catch(() => {})
console.info("disconnected from remote browser...")
// do not close the browser
} else if (this.isConnectedToRemote === false) {
await this.browser?.close().catch(() => {})
console.info("closed local browser...")
}
this.browser = undefined
this.page = undefined
this.currentMousePosition = undefined
this.isConnectedToRemote = false
// Reset tracking properties
this.sessionStartTime = 0
this.browserActions = []
}
return {}
}
@@ -201,8 +472,18 @@ export class BrowserSession {
try {
await action(this.page)
} catch (err) {
const errorMessage = err instanceof Error ? err.message : String(err)
if (!(err instanceof TimeoutError)) {
logs.push(`[Error] ${err.toString()}`)
logs.push(`[Error] ${errorMessage}`)
// Capture error telemetry
if (this.taskId) {
telemetryService.captureBrowserError(this.taskId, "browser_action_error", errorMessage, {
isRemote: this.isConnectedToRemote,
action: this.browserActions[this.browserActions.length - 1],
})
}
}
}
@@ -230,7 +511,7 @@ export class BrowserSession {
let screenshot = `data:image/webp;base64,${screenshotBase64}`
if (!screenshotBase64) {
console.log("webp screenshot failed, trying png")
console.info("webp screenshot failed, trying png")
screenshotBase64 = await this.page.screenshot({
...options,
type: "png",
@@ -239,6 +520,13 @@ export class BrowserSession {
}
if (!screenshotBase64) {
// Capture error telemetry
if (this.taskId) {
telemetryService.captureBrowserError(this.taskId, "screenshot_error", "Failed to take screenshot", {
isRemote: this.isConnectedToRemote,
action: this.browserActions[this.browserActions.length - 1],
})
}
throw new Error("Failed to take screenshot.")
}
@@ -255,6 +543,8 @@ export class BrowserSession {
}
async navigateToUrl(url: string): Promise<BrowserActionResult> {
this.browserActions.push(`navigate: url`)
return this.doAction(async (page) => {
// networkidle2 isn't good enough since page may take some time to load. we can assume locally running dev sites will reach networkidle0 in a reasonable amount of time
await page.goto(url, {
@@ -281,7 +571,7 @@ export class BrowserSession {
let currentHTMLSize = html.length
// let bodyHTMLSize = await page.evaluate(() => document.body.innerHTML.length)
console.log("last: ", lastHTMLSize, " <> curr: ", currentHTMLSize)
console.info("last: ", lastHTMLSize, " <> curr: ", currentHTMLSize)
if (lastHTMLSize !== 0 && currentHTMLSize === lastHTMLSize) {
countStableSizeIterations++
@@ -290,7 +580,7 @@ export class BrowserSession {
}
if (countStableSizeIterations >= minStableSizeIterations) {
console.log("Page rendered fully...")
console.info("Page rendered fully...")
break
}
@@ -300,6 +590,8 @@ export class BrowserSession {
}
async click(coordinate: string): Promise<BrowserActionResult> {
this.browserActions.push(`click: coordinate`)
const [x, y] = coordinate.split(",").map(Number)
return this.doAction(async (page) => {
// Set up network request monitoring
@@ -333,12 +625,16 @@ export class BrowserSession {
}
async type(text: string): Promise<BrowserActionResult> {
this.browserActions.push(`type:${text.length} chars`)
return this.doAction(async (page) => {
await page.keyboard.type(text)
})
}
async scrollDown(): Promise<BrowserActionResult> {
this.browserActions.push("scrollDown")
return this.doAction(async (page) => {
await page.evaluate(() => {
window.scrollBy({
@@ -351,6 +647,8 @@ export class BrowserSession {
}
async scrollUp(): Promise<BrowserActionResult> {
this.browserActions.push("scrollUp")
return this.doAction(async (page) => {
await page.evaluate(() => {
window.scrollBy({
@@ -361,4 +659,8 @@ export class BrowserSession {
await setTimeoutPromise(300)
})
}
async dispose() {
await this.closeBrowser()
}
}
+18
View File
@@ -0,0 +1,18 @@
import * as Sentry from "@sentry/browser"
// Initialize sentry
Sentry.init({
dsn: "https://7936780e3f0f0290fcf8d4a395c249b7@o4509028819664896.ingest.us.sentry.io/4509052955983872",
})
export class ErrorService {
static logException(error: Error): void {
// Log the error to Sentry
Sentry.captureException(error)
}
static logMessage(message: string, level: "error" | "warning" | "log" | "debug" | "info" = "log"): void {
// Log a message to Sentry
Sentry.captureMessage(message, { level })
}
}
+20 -1
View File
@@ -1,4 +1,5 @@
import type { OutputChannel } from "vscode"
import { ErrorService } from "../error/ErrorService"
/**
* Simple logging utility for the extension's backend code.
@@ -12,7 +13,25 @@ export class Logger {
Logger.outputChannel = outputChannel
}
static error(message: string, exception?: Error) {
Logger.outputChannel.appendLine(`ERROR: ${message}`)
ErrorService.logMessage(message, "error")
exception && ErrorService.logException(exception)
}
static warn(message: string) {
Logger.outputChannel.appendLine(`WARN: ${message}`)
ErrorService.logMessage(message, "warning")
}
static log(message: string) {
Logger.outputChannel.appendLine(message)
Logger.outputChannel.appendLine(`LOG: ${message}`)
}
static debug(message: string) {
Logger.outputChannel.appendLine(`DEBUG: ${message}`)
}
static info(message: string) {
Logger.outputChannel.appendLine(`INFO: ${message}`)
}
static trace(message: string) {
Logger.outputChannel.appendLine(`TRACE: ${message}`)
}
}
+6 -1
View File
@@ -627,7 +627,12 @@ export class McpHub {
// Update the tools list to reflect the change
const connection = this.connections.find((conn) => conn.server.name === serverName)
if (connection) {
if (connection && connection.server.tools) {
// Update the autoApprove property of each tool in the in-memory server object
connection.server.tools = connection.server.tools.map((tool) => ({
...tool,
autoApprove: autoApprove.includes(tool.name),
}))
await this.notifyWebviewOfServerChanges()
}
} catch (error) {
@@ -42,6 +42,12 @@ class PostHogClient {
RETRY_CLICKED: "task.retry_clicked",
// Tracks when a diff edit (replace_in_file) operation fails
DIFF_EDIT_FAILED: "task.diff_edit_failed",
// Tracks when the browser tool is started
BROWSER_TOOL_START: "task.browser_tool_start",
// Tracks when the browser tool is completed
BROWSER_TOOL_END: "task.browser_tool_end",
// Tracks when browser errors occur
BROWSER_ERROR: "task.browser_error",
},
// UI interaction events for tracking user engagement
UI: {
@@ -461,6 +467,79 @@ class PostHogClient {
})
}
/**
* Records when the browser tool is started
* @param taskId Unique identifier for the task
* @param browserSettings The browser settings being used
*/
public captureBrowserToolStart(taskId: string, browserSettings: any) {
this.capture({
event: PostHogClient.EVENTS.TASK.BROWSER_TOOL_START,
properties: {
taskId,
viewport: browserSettings.viewport,
isRemote: !!browserSettings.remoteBrowserEnabled,
remoteBrowserHost: browserSettings.remoteBrowserHost,
timestamp: new Date().toISOString(),
},
})
}
/**
* Records when the browser tool is completed
* @param taskId Unique identifier for the task
* @param stats Statistics about the browser session
*/
public captureBrowserToolEnd(
taskId: string,
stats: {
actionCount: number
duration: number
actions?: string[]
},
) {
this.capture({
event: PostHogClient.EVENTS.TASK.BROWSER_TOOL_END,
properties: {
taskId,
actionCount: stats.actionCount,
duration: stats.duration,
actions: stats.actions,
timestamp: new Date().toISOString(),
},
})
}
/**
* Records when browser errors occur during a task
* @param taskId Unique identifier for the task
* @param errorType Type of error that occurred (e.g., "launch_error", "connection_error", "navigation_error")
* @param errorMessage The error message
* @param context Additional context about where the error occurred
*/
public captureBrowserError(
taskId: string,
errorType: string,
errorMessage: string,
context?: {
action?: string
url?: string
isRemote?: boolean
[key: string]: any
},
) {
this.capture({
event: PostHogClient.EVENTS.TASK.BROWSER_ERROR,
properties: {
taskId,
errorType,
errorMessage,
context,
timestamp: new Date().toISOString(),
},
})
}
/**
* Records when a user selects an option from AI-generated followup questions
* @param taskId Unique identifier for the task
+4 -2
View File
@@ -5,7 +5,8 @@ export interface AutoApprovalSettings {
actions: {
readFiles: boolean // Read files and directories
editFiles: boolean // Edit files
executeCommands: boolean // Execute safe commands
executeSafeCommands: boolean // Execute safe commands
executeAllCommands: boolean // Execute all commands
useBrowser: boolean // Use browser
useMcp: boolean // Use MCP servers
}
@@ -19,7 +20,8 @@ export const DEFAULT_AUTO_APPROVAL_SETTINGS: AutoApprovalSettings = {
actions: {
readFiles: false,
editFiles: false,
executeCommands: false,
executeSafeCommands: false,
executeAllCommands: false,
useBrowser: false,
useMcp: false,
},
+4 -3
View File
@@ -4,10 +4,10 @@ export interface BrowserSettings {
width: number
height: number
}
// Browser mode settings
headless: boolean
// Chrome installation to use
// chromeType: "chromium" | "system"
remoteBrowserHost?: string
remoteBrowserEnabled?: boolean
}
export const DEFAULT_BROWSER_SETTINGS: BrowserSettings = {
@@ -15,7 +15,8 @@ export const DEFAULT_BROWSER_SETTINGS: BrowserSettings = {
width: 900,
height: 600,
},
headless: true,
remoteBrowserEnabled: false,
remoteBrowserHost: "http://localhost:9222",
// chromeType: "chromium",
}
+23
View File
@@ -41,6 +41,11 @@ export interface ExtensionMessage {
| "userCreditsPayments"
| "totalTasksSize"
| "addToInput"
| "browserConnectionResult"
| "browserConnectionInfo"
| "detectedChromePath"
| "scrollToSettings"
| "browserRelaunchResult"
| "relativePathsResponse" // Handles single and multiple path responses
| "fileSearchResults"
text?: string
@@ -84,6 +89,12 @@ export interface ExtensionMessage {
userCreditsUsage?: UsageTransaction[]
userCreditsPayments?: PaymentTransaction[]
totalTasksSize?: number | null
success?: boolean
endpoint?: string
isBundled?: boolean
isConnected?: boolean
isRemote?: boolean
host?: string
mentionsRequestId?: string
results?: Array<{
path: string
@@ -107,6 +118,7 @@ export interface ExtensionState {
apiConfiguration?: ApiConfiguration
autoApprovalSettings: AutoApprovalSettings
browserSettings: BrowserSettings
remoteBrowserHost?: string
chatSettings: ChatSettings
checkpointTrackerErrorMessage?: string
clineMessages: ClineMessage[]
@@ -157,6 +169,7 @@ export type ClineAsk =
| "auto_approval_max_req_reached"
| "browser_action_launch"
| "use_mcp_server"
| "new_task"
export type ClineSay =
| "task"
@@ -217,6 +230,12 @@ export type BrowserActionResult = {
currentMousePosition?: string
}
export interface BrowserConnectionInfo {
isConnected: boolean
isRemote: boolean
host?: string
}
export interface ClineAskUseMcpServer {
serverName: string
type: "use_mcp_tool" | "access_mcp_resource"
@@ -237,6 +256,10 @@ export interface ClineAskQuestion {
selected?: string
}
export interface ClineAskNewTask {
context: string
}
export interface ClineApiReqInfo {
request?: string
tokensIn?: number
+9
View File
@@ -35,6 +35,10 @@ export interface WebviewMessage {
| "deleteMcpServer"
| "autoApprovalSettings"
| "browserSettings"
| "discoverBrowser"
| "testBrowserConnection"
| "browserConnectionResult"
| "browserRelaunchResult"
| "togglePlanActMode"
| "checkpointDiff"
| "checkpointRestore"
@@ -66,7 +70,12 @@ export interface WebviewMessage {
| "fetchUserCreditsData"
| "optionsResponse"
| "requestTotalTasksSize"
| "relaunchChromeDebugMode"
| "taskFeedback"
| "getBrowserConnectionInfo"
| "getDetectedChromePath"
| "detectedChromePath"
| "scrollToSettings"
| "getRelativePaths" // Handles single and multiple URI resolution
| "searchFiles"
// | "relaunchChromeDebugMode"
+63 -6
View File
@@ -82,14 +82,21 @@ export type ApiConfiguration = ApiHandlerOptions & {
// Models
interface PriceTier {
tokenLimit: number // Upper limit (inclusive) of *input* tokens for this price. Use Infinity for the highest tier.
price: number // Price per million tokens for this tier.
}
export interface ModelInfo {
maxTokens?: number
contextWindow?: number
supportsImages?: boolean
supportsComputerUse?: boolean
supportsPromptCache: boolean // this value is hardcoded for now
inputPrice?: number
outputPrice?: number
inputPrice?: number // Keep for non-tiered input models
inputPriceTiers?: PriceTier[] // Add for tiered input pricing
outputPrice?: number // Keep for non-tiered output models
outputPriceTiers?: PriceTier[] // Add for tiered output pricing
cacheWritesPrice?: number
cacheReadsPrice?: number
description?: string
@@ -384,8 +391,16 @@ export const vertexModels = {
contextWindow: 1_048_576,
supportsImages: true,
supportsPromptCache: false,
inputPrice: 1.25,
outputPrice: 10,
// inputPrice: 1.25, // Removed
// outputPrice: 10, // Removed
inputPriceTiers: [
{ tokenLimit: 200000, price: 1.25 }, // Input price for <= 200k input tokens
{ tokenLimit: Infinity, price: 2.5 }, // Input price for > 200k input tokens
],
outputPriceTiers: [
{ tokenLimit: 200000, price: 10.0 }, // Output price for <= 200k input tokens
{ tokenLimit: Infinity, price: 15.0 }, // Output price for > 200k input tokens
],
},
"gemini-2.0-flash-thinking-exp-01-21": {
maxTokens: 65_536,
@@ -474,8 +489,14 @@ export const geminiModels = {
contextWindow: 1_048_576,
supportsImages: true,
supportsPromptCache: false,
inputPrice: 1.25,
outputPrice: 10,
inputPriceTiers: [
{ tokenLimit: 200000, price: 1.25 }, // Input price for <= 200k input tokens
{ tokenLimit: Infinity, price: 2.5 }, // Input price for > 200k input tokens
],
outputPriceTiers: [
{ tokenLimit: 200000, price: 10.0 }, // Output price for <= 200k input tokens
{ tokenLimit: Infinity, price: 15.0 }, // Output price for > 200k input tokens
],
},
"gemini-2.0-flash-001": {
maxTokens: 8192,
@@ -1300,6 +1321,42 @@ export const askSageModels = {
export type XAIModelId = keyof typeof xaiModels
export const xaiDefaultModelId: XAIModelId = "grok-2-latest"
export const xaiModels = {
"grok-3-beta": {
maxTokens: 8192,
contextWindow: 131072,
supportsImages: false,
supportsPromptCache: false,
inputPrice: 3.0,
outputPrice: 15.0,
description: "X AI's Grok-3 beta model with 131K context window",
},
"grok-3-fast-beta": {
maxTokens: 8192,
contextWindow: 131072,
supportsImages: false,
supportsPromptCache: false,
inputPrice: 5.0,
outputPrice: 25.0,
description: "X AI's Grok-3 fast beta model with 131K context window",
},
"grok-3-mini-beta": {
maxTokens: 8192,
contextWindow: 131072,
supportsImages: false,
supportsPromptCache: false,
inputPrice: 0.3,
outputPrice: 0.5,
description: "X AI's Grok-3 mini beta model with 131K context window",
},
"grok-3-mini-fast-beta": {
maxTokens: 8192,
contextWindow: 131072,
supportsImages: false,
supportsPromptCache: false,
inputPrice: 0.6,
outputPrice: 4.0,
description: "X AI's Grok-3 mini fast beta model with 131K context window",
},
"grok-2-latest": {
maxTokens: 8192,
contextWindow: 131072,
+49 -5
View File
@@ -2,15 +2,48 @@ import { ModelInfo } from "../shared/api"
function calculateApiCostInternal(
modelInfo: ModelInfo,
inputTokens: number,
inputTokens: number, // Note: For OpenAI-style, this is non-cached tokens. For Anthropic-style, this is total input tokens.
outputTokens: number,
cacheCreationInputTokens: number,
cacheReadInputTokens: number,
totalInputTokensForPricing?: number, // The *total* input tokens, used for tiered pricing lookup
): number {
// Determine effective input price
let effectiveInputPrice = modelInfo.inputPrice || 0
if (modelInfo.inputPriceTiers && modelInfo.inputPriceTiers.length > 0 && totalInputTokensForPricing !== undefined) {
// Ensure tiers are sorted by tokenLimit ascending before finding
const sortedInputTiers = [...modelInfo.inputPriceTiers].sort((a, b) => a.tokenLimit - b.tokenLimit)
// Find the first tier where the total input tokens are less than or equal to the limit
const tier = sortedInputTiers.find((t) => totalInputTokensForPricing! <= t.tokenLimit)
if (tier) {
effectiveInputPrice = tier.price
} else {
// Should ideally not happen if Infinity is used for the last tier, but fallback just in case
effectiveInputPrice = sortedInputTiers[sortedInputTiers.length - 1]?.price || 0
}
}
// Determine effective output price (based on total *input* tokens for pricing)
let effectiveOutputPrice = modelInfo.outputPrice || 0
if (modelInfo.outputPriceTiers && modelInfo.outputPriceTiers.length > 0 && totalInputTokensForPricing !== undefined) {
// Ensure tiers are sorted by tokenLimit ascending before finding
const sortedOutputTiers = [...modelInfo.outputPriceTiers].sort((a, b) => a.tokenLimit - b.tokenLimit)
const tier = sortedOutputTiers.find((t) => totalInputTokensForPricing! <= t.tokenLimit)
if (tier) {
effectiveOutputPrice = tier.price
} else {
// Should ideally not happen if Infinity is used for the last tier, but fallback just in case
effectiveOutputPrice = sortedOutputTiers[sortedOutputTiers.length - 1]?.price || 0
}
}
const cacheWritesCost = ((modelInfo.cacheWritesPrice || 0) / 1_000_000) * cacheCreationInputTokens
const cacheReadsCost = ((modelInfo.cacheReadsPrice || 0) / 1_000_000) * cacheReadInputTokens
const baseInputCost = ((modelInfo.inputPrice || 0) / 1_000_000) * inputTokens
const outputCost = ((modelInfo.outputPrice || 0) / 1_000_000) * outputTokens
// Use effectiveInputPrice for baseInputCost. Note: 'inputTokens' here is the potentially adjusted count (e.g., non-cached for OpenAI)
const baseInputCost = (effectiveInputPrice / 1_000_000) * inputTokens
// Use effectiveOutputPrice for outputCost
const outputCost = (effectiveOutputPrice / 1_000_000) * outputTokens
const totalCost = cacheWritesCost + cacheReadsCost + baseInputCost + outputCost
return totalCost
}
@@ -25,7 +58,15 @@ export function calculateApiCostAnthropic(
): number {
const cacheCreationInputTokensNum = cacheCreationInputTokens || 0
const cacheReadInputTokensNum = cacheReadInputTokens || 0
return calculateApiCostInternal(modelInfo, inputTokens, outputTokens, cacheCreationInputTokensNum, cacheReadInputTokensNum)
// Anthropic style doesn't need totalInputTokensForPricing as its inputTokens already represents the total
return calculateApiCostInternal(
modelInfo,
inputTokens,
outputTokens,
cacheCreationInputTokensNum,
cacheReadInputTokensNum,
undefined, // Pass undefined for totalInputTokensForPricing
)
}
// For OpenAI compliant usage, the input tokens count INCLUDES the cached tokens
@@ -38,12 +79,15 @@ export function calculateApiCostOpenAI(
): number {
const cacheCreationInputTokensNum = cacheCreationInputTokens || 0
const cacheReadInputTokensNum = cacheReadInputTokens || 0
// Calculate non-cached tokens for the internal function's 'inputTokens' parameter
const nonCachedInputTokens = Math.max(0, inputTokens - cacheCreationInputTokensNum - cacheReadInputTokensNum)
// Pass the original 'inputTokens' as 'totalInputTokensForPricing' for tier lookup
return calculateApiCostInternal(
modelInfo,
nonCachedInputTokens,
nonCachedInputTokens, // Pass the adjusted token count here
outputTokens,
cacheCreationInputTokensNum,
cacheReadInputTokensNum,
inputTokens, // Pass the original total input tokens for pricing tier lookup
)
}
+1
View File
@@ -6,6 +6,7 @@
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"build:test": "tsc -b && vite build",
"preview": "vite preview",
"lint": "eslint .",
"test": "vitest run",
@@ -1,5 +1,5 @@
import { VSCodeButton, VSCodeCheckbox, VSCodeDropdown, VSCodeOption } from "@vscode/webview-ui-toolkit/react"
import React, { useRef, useState } from "react"
import React, { useEffect, useRef, useState } from "react"
import { useClickAway } from "react-use"
import styled from "styled-components"
import { BROWSER_VIEWPORT_PRESETS } from "@shared/BrowserSettings"
@@ -7,229 +7,213 @@ import { useExtensionState } from "@/context/ExtensionStateContext"
import { vscode } from "@/utils/vscode"
import { CODE_BLOCK_BG_COLOR } from "../common/CodeBlock"
interface BrowserSettingsMenuProps {
disabled?: boolean
maxWidth?: number
interface ConnectionInfo {
isConnected: boolean
isRemote: boolean
host?: string
}
export const BrowserSettingsMenu: React.FC<BrowserSettingsMenuProps> = ({ disabled = false, maxWidth }) => {
export const BrowserSettingsMenu = () => {
const { browserSettings } = useExtensionState()
const [showMenu, setShowMenu] = useState(false)
const [hasMouseEntered, setHasMouseEntered] = useState(false)
const containerRef = useRef<HTMLDivElement>(null)
const menuRef = useRef<HTMLDivElement>(null)
useClickAway(containerRef, () => {
if (showMenu) {
setShowMenu(false)
setHasMouseEntered(false)
}
const [showInfoPopover, setShowInfoPopover] = useState(false)
const [connectionInfo, setConnectionInfo] = useState<ConnectionInfo>({
isConnected: false,
isRemote: !!browserSettings.remoteBrowserEnabled,
host: browserSettings.remoteBrowserHost,
})
const popoverRef = useRef<HTMLDivElement>(null)
const handleMouseEnter = () => {
setHasMouseEntered(true)
}
// Get actual connection info from the browser session
useEffect(() => {
// Request connection info when component mounts
vscode.postMessage({
type: "getBrowserConnectionInfo",
})
const handleMouseLeave = () => {
if (hasMouseEntered) {
setShowMenu(false)
setHasMouseEntered(false)
}
}
const handleControlsMouseLeave = (e: React.MouseEvent) => {
const menuElement = menuRef.current
if (menuElement && showMenu) {
const menuRect = menuElement.getBoundingClientRect()
// If mouse is moving towards the menu, don't close it
if (
e.clientY >= menuRect.top &&
e.clientY <= menuRect.bottom &&
e.clientX >= menuRect.left &&
e.clientX <= menuRect.right
) {
return
// Listen for connection info updates
const handleMessage = (event: MessageEvent) => {
const message = event.data
if (message.type === "browserConnectionInfo") {
setConnectionInfo({
isConnected: message.isConnected,
isRemote: message.isRemote,
host: message.host,
})
}
}
setShowMenu(false)
setHasMouseEntered(false)
window.addEventListener("message", handleMessage)
return () => {
window.removeEventListener("message", handleMessage)
}
}, [browserSettings.remoteBrowserHost, browserSettings.remoteBrowserEnabled])
// Close popover when clicking outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
popoverRef.current &&
!popoverRef.current.contains(event.target as Node) &&
!event.composedPath().some((el) => (el as HTMLElement).classList?.contains("browser-info-icon"))
) {
setShowInfoPopover(false)
}
}
if (showInfoPopover) {
document.addEventListener("mousedown", handleClickOutside)
}
return () => {
document.removeEventListener("mousedown", handleClickOutside)
}
}, [showInfoPopover])
const openBrowserSettings = () => {
// First open the settings panel
vscode.postMessage({
type: "openSettings",
})
// After a short delay, send a message to scroll to browser settings
setTimeout(() => {
vscode.postMessage({
type: "scrollToSettings",
text: "browser-settings-section",
})
}, 300) // Give the settings panel time to open
}
const handleViewportChange = (event: Event) => {
const target = event.target as HTMLSelectElement
const selectedSize = BROWSER_VIEWPORT_PRESETS[target.value as keyof typeof BROWSER_VIEWPORT_PRESETS]
if (selectedSize) {
const toggleInfoPopover = () => {
setShowInfoPopover(!showInfoPopover)
// Request updated connection info when opening the popover
if (!showInfoPopover) {
vscode.postMessage({
type: "browserSettings",
browserSettings: {
...browserSettings,
viewport: selectedSize,
},
type: "getBrowserConnectionInfo",
})
}
}
const updateHeadless = (headless: boolean) => {
vscode.postMessage({
type: "browserSettings",
browserSettings: {
...browserSettings,
headless,
},
})
// Determine icon based on connection state
const getIconClass = () => {
if (connectionInfo.isRemote) {
return "codicon-remote"
} else {
return connectionInfo.isConnected ? "codicon-vm-running" : "codicon-info"
}
}
// const updateChromeType = (chromeType: BrowserSettings["chromeType"]) => {
// vscode.postMessage({
// type: "browserSettings",
// browserSettings: {
// ...browserSettings,
// chromeType,
// },
// })
// }
// Determine icon color based on connection state
const getIconColor = () => {
if (connectionInfo.isRemote) {
return connectionInfo.isConnected ? "var(--vscode-charts-blue)" : "var(--vscode-foreground)"
} else if (connectionInfo.isConnected) {
return "var(--vscode-charts-green)"
} else {
return "var(--vscode-foreground)"
}
}
// const relaunchChromeDebugMode = () => {
// vscode.postMessage({
// type: "relaunchChromeDebugMode",
// })
// }
// Check connection status every second to keep icon in sync
useEffect(() => {
// Request connection info immediately
vscode.postMessage({
type: "getBrowserConnectionInfo",
})
// Set up interval to refresh every second
const intervalId = setInterval(() => {
vscode.postMessage({
type: "getBrowserConnectionInfo",
})
}, 1000)
return () => clearInterval(intervalId)
}, [])
return (
<div ref={containerRef} style={{ position: "relative", marginTop: "-1px" }} onMouseLeave={handleControlsMouseLeave}>
<VSCodeButton appearance="icon" onClick={() => setShowMenu(!showMenu)} disabled={disabled}>
<div ref={containerRef} style={{ position: "relative", marginTop: "-1px", display: "flex" }}>
<VSCodeButton
appearance="icon"
className="browser-info-icon"
onClick={toggleInfoPopover}
title="Browser connection info"
style={{ marginRight: "4px" }}>
<i
className={`codicon ${getIconClass()}`}
style={{
fontSize: "14.5px",
color: getIconColor(),
}}
/>
</VSCodeButton>
{showInfoPopover && (
<InfoPopover ref={popoverRef}>
<h4 style={{ margin: "0 0 8px 0" }}>Browser Connection</h4>
<InfoRow>
<InfoLabel>Status:</InfoLabel>
<InfoValue
style={{
color: connectionInfo.isConnected
? "var(--vscode-charts-green)"
: "var(--vscode-errorForeground)",
}}>
{connectionInfo.isConnected ? "Connected" : "Disconnected"}
</InfoValue>
</InfoRow>
{connectionInfo.isConnected && (
<InfoRow>
<InfoLabel>Type:</InfoLabel>
<InfoValue>{connectionInfo.isRemote ? "Remote" : "Local"}</InfoValue>
</InfoRow>
)}
{connectionInfo.isConnected && connectionInfo.isRemote && connectionInfo.host && (
<InfoRow>
<InfoLabel>Remote Host:</InfoLabel>
<InfoValue>{connectionInfo.host}</InfoValue>
</InfoRow>
)}
</InfoPopover>
)}
<VSCodeButton appearance="icon" onClick={openBrowserSettings}>
<i className="codicon codicon-settings-gear" style={{ fontSize: "14.5px" }} />
</VSCodeButton>
{showMenu && (
<SettingsMenu ref={menuRef} maxWidth={maxWidth} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave}>
<SettingsGroup>
{/* <SettingsHeader>Headless Mode</SettingsHeader> */}
<VSCodeCheckbox
style={{ marginBottom: "8px", marginTop: -1 }}
checked={browserSettings.headless}
onChange={(e) => updateHeadless((e.target as HTMLInputElement).checked)}>
Run in headless mode
</VSCodeCheckbox>
<SettingsDescription>When enabled, Chrome will run in the background.</SettingsDescription>
</SettingsGroup>
{/* <SettingsGroup>
<SettingsHeader>Chrome Executable</SettingsHeader>
<VSCodeDropdown
style={{ width: "100%", marginBottom: "8px" }}
value={browserSettings.chromeType}
onChange={(e) =>
updateChromeType((e.target as HTMLSelectElement).value as BrowserSettings["chromeType"])
}>
<VSCodeOption value="chromium">Chromium (Auto-downloaded)</VSCodeOption>
<VSCodeOption value="system">System Chrome</VSCodeOption>
</VSCodeDropdown>
<SettingsDescription>
{browserSettings.chromeType === "system" ? (
<>
Cline will use your personal browser. You must{" "}
<VSCodeLink
href="#"
style={{ fontSize: "inherit" }}
onClick={(e: React.MouseEvent) => {
e.preventDefault()
relaunchChromeDebugMode()
}}>
relaunch Chrome in debug mode
</VSCodeLink>{" "}
to use this setting.
</>
) : (
"Cline will use a Chromium browser bundled with the extension."
)}
</SettingsDescription>
</SettingsGroup> */}
<SettingsGroup>
<SettingsHeader>Viewport Size</SettingsHeader>
<VSCodeDropdown
style={{ width: "100%" }}
value={
Object.entries(BROWSER_VIEWPORT_PRESETS).find(
([_, size]) =>
size.width === browserSettings.viewport.width &&
size.height === browserSettings.viewport.height,
)?.[0]
}
onChange={(event) => handleViewportChange(event as Event)}>
{Object.entries(BROWSER_VIEWPORT_PRESETS).map(([name]) => (
<VSCodeOption key={name} value={name}>
{name}
</VSCodeOption>
))}
</VSCodeDropdown>
</SettingsGroup>
</SettingsMenu>
)}
</div>
)
}
const SettingsMenu = styled.div<{ maxWidth?: number }>`
const InfoPopover = styled.div`
position: absolute;
top: calc(100% + 8px);
right: -2px;
background: ${CODE_BLOCK_BG_COLOR};
border: 1px solid var(--vscode-editorGroup-border);
padding: 8px;
border-radius: 3px;
z-index: 1000;
width: calc(100vw - 57px);
min-width: 0px;
max-width: ${(props) => (props.maxWidth ? `${props.maxWidth - 23}px` : "100vw")};
// Add invisible padding to create a safe hover zone
&::before {
content: "";
position: absolute;
top: -14px; // Same as margin-top in the parent's top property
left: 0;
right: -6px;
height: 14px;
}
&::after {
content: "";
position: absolute;
top: -6px;
right: 6px;
width: 10px;
height: 10px;
background: ${CODE_BLOCK_BG_COLOR};
border-left: 1px solid var(--vscode-editorGroup-border);
border-top: 1px solid var(--vscode-editorGroup-border);
transform: rotate(45deg);
z-index: 1; // Ensure arrow stays above the padding
}
top: 30px;
right: 0;
background-color: var(--vscode-editorWidget-background);
border: 1px solid var(--vscode-widget-border);
border-radius: 4px;
padding: 10px;
z-index: 100;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
width: 60dvw;
max-width: 250px;
`
const SettingsGroup = styled.div`
&:not(:last-child) {
margin-bottom: 8px;
// padding-bottom: 8px;
border-bottom: 1px solid var(--vscode-editorGroup-border);
}
const InfoRow = styled.div`
display: flex;
margin-bottom: 4px;
flex-wrap: wrap;
white-space: nowrap;
`
const SettingsHeader = styled.div`
font-size: 11px;
font-weight: 600;
margin-bottom: 6px;
color: var(--vscode-foreground);
const InfoLabel = styled.div`
flex: 0 0 90px;
font-weight: 500;
`
const SettingsDescription = styled.div<{ isLast?: boolean }>`
font-size: 11px;
color: var(--vscode-descriptionForeground);
margin-bottom: ${(props) => (props.isLast ? "0" : "8px")};
const InfoValue = styled.div`
flex: 1;
word-break: break-word;
`
export default BrowserSettingsMenu
@@ -43,20 +43,21 @@ const Announcement = ({ version, hideAnnouncement }: AnnouncementProps) => {
</h3>
<ul style={ulStyle}>
<li>
<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.
<b>Browser Tool Upgrades:</b> Use your local Chrome browser for session-based browsing, enabling debugging and
productivity workflows tied to your actual browser state!
</li>
<li>
<b>Billing Dashboard:</b> Track your remaining credits and transaction history right in the extension with a{" "}
<span className="codicon codicon-account" style={accountIconStyle}></span> Cline account!
<b>Auto-Approve Commands:</b> New option to automatically approve <b>ALL</b> commands (use at your own risk!)
</li>
<li>
<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).
<b>Easily Toggle MCP's:</b> New modal in the chat area to easily enable/disable MCP servers.
</li>
<li>
<b>Enhanced MCP Support:</b> Dynamic image loading with GIF support, and a new delete button to clean up
failed servers.
<b>Smarter Context Management:</b> When hitting context window limits, old file contents are removed
firstpreserving narrative integrity and reducing Cline getting stuck in loops.
</li>
<li>
Drag and drop files/folders into chat by holding <code>Shift</code> while dragging it into the chat field.
</li>
</ul>
{/*<ul style={{ margin: "0 0 8px", paddingLeft: "12px" }}>
@@ -10,6 +10,15 @@ interface AutoApproveMenuProps {
style?: React.CSSProperties
}
const SubOptionAnimateIn = styled.div<{ show: boolean }>`
max-height: ${(props) => (props.show ? "100px" : "0")};
opacity: ${(props) => (props.show ? "1" : "0")};
overflow: hidden;
transition:
max-height 0.2s ease-in-out,
opacity 0.2s ease-in-out;
`
const ACTION_METADATA: {
id: keyof AutoApprovalSettings["actions"]
label: string
@@ -29,12 +38,18 @@ const ACTION_METADATA: {
description: "Allows modification of any files on your computer.",
},
{
id: "executeCommands",
id: "executeSafeCommands",
label: "Execute safe commands",
shortName: "Commands",
shortName: "Safe Commands",
description:
"Allows execution of safe terminal commands. If the model determines a command is potentially destructive, it will still require approval.",
},
{
id: "executeAllCommands",
label: "Execute all commands",
shortName: "All Commands",
description: "Allows execution of all terminal commands. Use at your own risk.",
},
{
id: "useBrowser",
label: "Use the browser",
@@ -53,11 +68,26 @@ const AutoApproveMenu = ({ style }: AutoApproveMenuProps) => {
const { autoApprovalSettings } = useExtensionState()
const [isExpanded, setIsExpanded] = useState(false)
const [isHoveringCollapsibleSection, setIsHoveringCollapsibleSection] = useState(false)
// Careful not to use partials to mutate since spread operator only does shallow copy
const enabledActions = ACTION_METADATA.filter((action) => autoApprovalSettings.actions[action.id])
const enabledActionsList = enabledActions.map((action) => action.shortName).join(", ")
const enabledActionsList = (() => {
// "All Commands" is the only label displayed if both are set
const safeCommandsEnabled = enabledActions.some((action) => action.id === "executeSafeCommands")
const allCommandsEnabled = enabledActions.some((action) => action.id === "executeAllCommands")
const otherActions = enabledActions
.filter((action) => action.id !== "executeSafeCommands" && action.id !== "executeAllCommands")
.map((action) => action.shortName)
if (allCommandsEnabled) {
return ["All Commands", ...otherActions].join(", ")
} else if (safeCommandsEnabled) {
return ["Safe Commands", ...otherActions].join(", ")
} else {
return otherActions.join(", ")
}
})()
const hasEnabledActions = enabledActions.length > 0
const updateEnabled = useCallback(
@@ -220,26 +250,61 @@ const AutoApproveMenu = ({ style }: AutoApproveMenuProps) => {
Auto-approve allows Cline to perform the following actions without asking for permission. Please use with
caution and only enable if you understand the risks.
</div>
{ACTION_METADATA.map((action) => (
<div key={action.id} style={{ margin: "6px 0" }}>
<VSCodeCheckbox
checked={autoApprovalSettings.actions[action.id]}
onChange={(e) => {
const checked = (e.target as HTMLInputElement).checked
updateAction(action.id, checked)
}}>
{action.label}
</VSCodeCheckbox>
{ACTION_METADATA.map((action) => {
if (action.id === "executeAllCommands") {
return (
// Option to make the "Approve All" option animate into the menu when "Approve Safe" is enabled
<SubOptionAnimateIn key={action.id} show={autoApprovalSettings.actions.executeSafeCommands}>
<div
style={{
margin: "6px 0",
marginLeft: "28px",
}}>
<VSCodeCheckbox
checked={autoApprovalSettings.actions[action.id]}
onChange={(e) => {
const checked = (e.target as HTMLInputElement).checked
updateAction(action.id, checked)
}}>
{action.label}
</VSCodeCheckbox>
<div
style={{
marginLeft: "28px",
color: getAsVar(VSC_DESCRIPTION_FOREGROUND),
fontSize: "12px",
}}>
{action.description}
</div>
</div>
</SubOptionAnimateIn>
)
}
return (
<div
key={action.id}
style={{
marginLeft: "28px",
color: getAsVar(VSC_DESCRIPTION_FOREGROUND),
fontSize: "12px",
margin: "6px 0",
}}>
{action.description}
<VSCodeCheckbox
checked={autoApprovalSettings.actions[action.id]}
onChange={(e) => {
const checked = (e.target as HTMLInputElement).checked
updateAction(action.id, checked)
}}>
{action.label}
</VSCodeCheckbox>
<div
style={{
marginLeft: "28px",
color: getAsVar(VSC_DESCRIPTION_FOREGROUND),
fontSize: "12px",
}}>
{action.description}
</div>
</div>
</div>
))}
)
})}
<div
style={{
height: "0.5px",
@@ -275,11 +275,19 @@ const BrowserSessionRow = memo((props: BrowserSessionRowProps) => {
consoleLogs: currentPage?.currentState.consoleLogs,
screenshot: currentPage?.currentState.screenshot,
}
const [rowIndex, setRowIndex] = useState<number>(0)
const [hoveredRowIndex, setHoveredRowIndex] = useState<number | null>(null)
const [actionContent, { height: actionHeight }] = useSize(
<div>
{currentPage?.nextAction?.messages.map((message) => (
<BrowserSessionRowContent key={message.ts} {...props} message={message} setMaxActionHeight={setMaxActionHeight} />
<BrowserSessionRowContent
key={message.ts}
{...props}
message={message}
setMaxActionHeight={setMaxActionHeight}
rowIndex={rowIndex}
hoveredRowIndex={hoveredRowIndex}
/>
))}
{!isBrowsing && messages.some((m) => m.say === "browser_action_result") && currentPageIndex === 0 && (
<BrowserActionBox action={"launch"} text={initialUrl} />
@@ -365,7 +373,7 @@ const BrowserSessionRow = memo((props: BrowserSessionRowProps) => {
}}>
<div style={urlTextStyle}>{displayState.url || "http"}</div>
</div>
<BrowserSettingsMenu disabled={!shouldShowSettings} maxWidth={maxWidth} />
<BrowserSettingsMenu />
</div>
{/* Screenshot Area */}
@@ -473,6 +481,8 @@ const BrowserSessionRow = memo((props: BrowserSessionRowProps) => {
interface BrowserSessionRowContentProps extends Omit<BrowserSessionRowProps, "messages"> {
message: ClineMessage
setMaxActionHeight: (height: number) => void
rowIndex: number
hoveredRowIndex: number | null
}
const BrowserSessionRowContent = ({
@@ -482,6 +492,8 @@ const BrowserSessionRowContent = ({
lastModifiedMessage,
isLast,
setMaxActionHeight,
rowIndex,
hoveredRowIndex,
}: BrowserSessionRowContentProps) => {
if (message.ask === "browser_action_launch" || message.say === "browser_action_launch") {
return (
@@ -504,6 +516,8 @@ const BrowserSessionRowContent = ({
return (
<div style={chatRowContentContainerStyle}>
<ChatRowContent
rowIndex={rowIndex}
hoveredRowIndex={hoveredRowIndex}
message={message}
isExpanded={isExpanded(message.ts)}
onToggleExpand={() => {
+50 -24
View File
@@ -17,6 +17,7 @@ import { COMMAND_OUTPUT_STRING, COMMAND_REQ_APP_STRING } from "@shared/combineCo
import { useExtensionState } from "@/context/ExtensionStateContext"
import { findMatchingResourceOrTemplate, getMcpServerDisplayName } from "@/utils/mcp"
import { vscode } from "@/utils/vscode"
import { useChatRowStyles } from "@/hooks/useChatRowStyles"
import { CheckmarkControl } from "@/components/common/CheckmarkControl"
import { CheckpointControls, CheckpointOverlay } from "../common/CheckpointControls"
import CodeAccordian, { cleanPathPrefix } from "../common/CodeAccordian"
@@ -30,6 +31,7 @@ import { OptionsButtons } from "@/components/chat/OptionsButtons"
import { highlightMentions } from "./TaskHeader"
import SuccessButton from "@/components/common/SuccessButton"
import TaskFeedbackButtons from "@/components/chat/TaskFeedbackButtons"
import NewTaskPreview from "./NewTaskPreview"
import McpResourceRow from "@/components/mcp/configuration/tabs/installed/server-row/McpResourceRow"
const ChatRowContainer = styled.div`
@@ -48,9 +50,16 @@ interface ChatRowProps {
lastModifiedMessage?: ClineMessage
isLast: boolean
onHeightChange: (isTaller: boolean) => void
rowIndex: number
hoveredRowIndex: number | null
setHoveredRowIndex: React.Dispatch<React.SetStateAction<number | null>>
}
interface ChatRowContentProps extends Omit<ChatRowProps, "onHeightChange"> {}
interface ChatRowContentProps
extends Omit<ChatRowProps, "onHeightChange" | "rowIndex" | "hoveredRowIndex" | "setHoveredRowIndex"> {
rowIndex: number
hoveredRowIndex: number | null
}
export const ProgressIndicator = () => (
<div
@@ -83,32 +92,19 @@ const Markdown = memo(({ markdown }: { markdown?: string }) => {
const ChatRow = memo(
(props: ChatRowProps) => {
const { isLast, onHeightChange, message, lastModifiedMessage } = props
const { isLast, onHeightChange, message, lastModifiedMessage, rowIndex, hoveredRowIndex, setHoveredRowIndex } = props
// Store the previous height to compare with the current height
// This allows us to detect changes without causing re-renders
const prevHeightRef = useRef(0)
// NOTE: for tools that are interrupted and not responded to (approved or rejected) there won't be a checkpoint hash
let shouldShowCheckpoints =
message.lastCheckpointHash != null &&
(message.say === "tool" ||
message.ask === "tool" ||
message.say === "command" ||
message.ask === "command" ||
// message.say === "completion_result" ||
// message.ask === "completion_result" ||
message.say === "use_mcp_server" ||
message.ask === "use_mcp_server")
if (shouldShowCheckpoints && isLast) {
shouldShowCheckpoints =
lastModifiedMessage?.ask === "resume_completed_task" || lastModifiedMessage?.ask === "resume_task"
}
// Calculate dynamic styles using the custom hook
const { padding, minHeight } = useChatRowStyles(message, hoveredRowIndex, rowIndex)
const [chatrow, { height }] = useSize(
<ChatRowContainer>
<ChatRowContent {...props} />
{shouldShowCheckpoints && <CheckpointOverlay messageTs={message.ts} />}
<ChatRowContainer
style={{ padding, minHeight }}
onMouseEnter={() => setHoveredRowIndex(rowIndex)}
onMouseLeave={() => setHoveredRowIndex(null)}>
<ChatRowContent {...props} rowIndex={rowIndex} hoveredRowIndex={hoveredRowIndex} />
</ChatRowContainer>,
)
@@ -134,7 +130,15 @@ const ChatRow = memo(
export default ChatRow
export const ChatRowContent = ({ message, isExpanded, onToggleExpand, lastModifiedMessage, isLast }: ChatRowContentProps) => {
export const ChatRowContent = ({
message,
isExpanded,
onToggleExpand,
lastModifiedMessage,
isLast,
rowIndex,
hoveredRowIndex,
}: ChatRowContentProps) => {
const { mcpServers, mcpMarketplaceCatalog } = useExtensionState()
const [seeNewChangesDisabled, setSeeNewChangesDisabled] = useState(false)
@@ -984,9 +988,16 @@ export const ChatRowContent = ({ message, isExpanded, onToggleExpand, lastModifi
</>
)
case "checkpoint_created":
// Determine if the hover is near the checkpoint marker's visual position (either on the preceding row or the checkpoint row itself)
const isHoveredNearCheckpoint = hoveredRowIndex === rowIndex - 1 || hoveredRowIndex === rowIndex
return (
<>
<CheckmarkControl messageTs={message.ts} isCheckpointCheckedOut={message.isCheckpointCheckedOut} />
<CheckmarkControl
messageTs={message.ts}
isCheckpointCheckedOut={message.isCheckpointCheckedOut}
isHoveredNearCheckpoint={isHoveredNearCheckpoint}
/>
</>
)
case "completion_result":
@@ -1233,6 +1244,21 @@ export const ChatRowContent = ({ message, isExpanded, onToggleExpand, lastModifi
</div>
</>
)
case "new_task":
return (
<>
<div style={headerStyle}>
<span
className="codicon codicon-new-file"
style={{
color: normalColor,
marginBottom: "-1.5px",
}}></span>
<span style={{ color: normalColor, fontWeight: "bold" }}>Cline wants to start a new task:</span>
</div>
<NewTaskPreview context={message.text || ""} />
</>
)
case "plan_mode_respond": {
let response: string | undefined
let options: string[] | undefined
@@ -25,6 +25,7 @@ import ApiOptions, { normalizeApiConfiguration } from "@/components/settings/Api
import { MAX_IMAGES_PER_MESSAGE } from "@/components/chat/ChatView"
import ContextMenu from "@/components/chat/ContextMenu"
import { ChatSettings } from "@shared/ChatSettings"
import ServersToggleModal from "./ServersToggleModal"
interface ChatTextAreaProps {
inputValue: string
@@ -1190,7 +1191,9 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
onClick={handleContextButtonClick}
style={{ padding: "0px 0px", height: "20px" }}>
<ButtonContainer>
<span style={{ fontSize: "13px", marginBottom: 1 }}>@</span>
<span className="flex items-center" style={{ fontSize: "13px", marginBottom: 1 }}>
@
</span>
{/* {showButtonText && <span style={{ fontSize: "10px" }}>Context</span>} */}
</ButtonContainer>
</VSCodeButton>
@@ -1207,10 +1210,14 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
}}
style={{ padding: "0px 0px", height: "20px" }}>
<ButtonContainer>
<span className="codicon codicon-device-camera" style={{ fontSize: "14px", marginBottom: -3 }} />
<span
className="codicon codicon-device-camera flex items-center"
style={{ fontSize: "14px", marginBottom: -3 }}
/>
{/* {showButtonText && <span style={{ fontSize: "10px" }}>Images</span>} */}
</ButtonContainer>
</VSCodeButton>
<ServersToggleModal />
<ModelContainer ref={modelSelectorRef}>
<ModelButtonWrapper ref={buttonRef}>
+33 -3
View File
@@ -77,6 +77,7 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
const disableAutoScrollRef = useRef(false)
const [showScrollToBottom, setShowScrollToBottom] = useState(false)
const [isAtBottom, setIsAtBottom] = useState(false)
const [hoveredRowIndex, setHoveredRowIndex] = useState<number | null>(null)
// UI layout depends on the last 2 messages
// (since it relies on the content of these messages, we are deep comparing. i.e. the button state after hitting button sets enableButtons to false, and this effect otherwise would have to true again even if messages didn't change
@@ -195,6 +196,13 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
setSecondaryButtonText(undefined)
setDidClickCancel(false)
break
case "new_task":
setTextAreaDisabled(isPartial)
setClineAsk("new_task")
setEnableButtons(!isPartial)
setPrimaryButtonText("Start New Task with Context")
setSecondaryButtonText(undefined)
break
}
break
case "say":
@@ -295,6 +303,7 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
case "resume_task":
case "resume_completed_task":
case "mistake_limit_reached":
case "new_task": // user can provide feedback or reject the new task suggestion
vscode.postMessage({
type: "askResponse",
askResponse: "messageResponse",
@@ -360,6 +369,13 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
// extension waiting for feedback. but we can just present a new task button
startNewTask()
break
case "new_task":
console.info("new task button clicked!", { lastMessage, messages, clineAsk, text })
vscode.postMessage({
type: "newTask",
text: lastMessage?.text,
})
break
}
setTextAreaDisabled(true)
setClineAsk(undefined)
@@ -368,7 +384,7 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
// setSecondaryButtonText(undefined)
disableAutoScrollRef.current = false
},
[clineAsk, startNewTask],
[clineAsk, startNewTask, lastMessage],
)
const handleSecondaryButtonClick = useCallback(
@@ -456,12 +472,15 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
case "addToInput":
setInputValue((prevValue) => {
const newText = message.text ?? ""
return prevValue ? `${prevValue}\n${newText}` : newText
const newTextWithNewline = newText + "\n"
return prevValue ? `${prevValue}\n${newTextWithNewline}` : newTextWithNewline
})
// Add scroll to bottom after state update
// Auto focus the input and start the cursor on a new linefor easy typing
setTimeout(() => {
if (textAreaRef.current) {
textAreaRef.current.scrollTop = textAreaRef.current.scrollHeight
textAreaRef.current.focus()
}
}, 0)
break
@@ -762,10 +781,21 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
lastModifiedMessage={modifiedMessages.at(-1)}
isLast={index === groupedMessages.length - 1}
onHeightChange={handleRowHeightChange}
rowIndex={index}
hoveredRowIndex={hoveredRowIndex}
setHoveredRowIndex={setHoveredRowIndex}
/>
)
},
[expandedRows, modifiedMessages, groupedMessages.length, toggleRowExpansion, handleRowHeightChange],
[
expandedRows,
modifiedMessages,
groupedMessages.length,
toggleRowExpansion,
handleRowHeightChange,
hoveredRowIndex,
setHoveredRowIndex,
],
)
return (
@@ -0,0 +1,17 @@
import React from "react"
import MarkdownBlock from "../common/MarkdownBlock"
interface NewTaskPreviewProps {
context: string
}
const NewTaskPreview: React.FC<NewTaskPreviewProps> = ({ context }) => {
return (
<div className="bg-[var(--vscode-badge-background)] text-[var(--vscode-badge-foreground)] rounded-[3px] p-[14px] pb-[6px]">
<span style={{ fontWeight: "bold" }}>Task</span>
<MarkdownBlock markdown={context} />
</div>
)
}
export default NewTaskPreview
@@ -0,0 +1,86 @@
import React, { useRef, useState, useEffect } from "react"
import { useClickAway, useWindowSize } from "react-use"
import { useExtensionState } from "@/context/ExtensionStateContext"
import { CODE_BLOCK_BG_COLOR } from "@/components/common/CodeBlock"
import ServersToggleList from "@/components/mcp/configuration/tabs/installed/ServersToggleList"
import { vscode } from "@/utils/vscode"
import { VSCodeButton } from "@vscode/webview-ui-toolkit/react"
const ServersToggleModal: React.FC = () => {
const { mcpServers } = useExtensionState()
const [isVisible, setIsVisible] = useState(false)
const buttonRef = useRef<HTMLDivElement>(null)
const modalRef = useRef<HTMLDivElement>(null)
const { width: viewportWidth, height: viewportHeight } = useWindowSize()
const [arrowPosition, setArrowPosition] = useState(0)
const [menuPosition, setMenuPosition] = useState(0)
// Close modal when clicking outside
useClickAway(modalRef, () => {
setIsVisible(false)
})
// Calculate positions for modal and arrow
useEffect(() => {
if (isVisible && buttonRef.current) {
const buttonRect = buttonRef.current.getBoundingClientRect()
const buttonCenter = buttonRect.left + buttonRect.width / 2
const rightPosition = document.documentElement.clientWidth - buttonCenter - 5
setArrowPosition(rightPosition)
setMenuPosition(buttonRect.top + 1)
}
}, [isVisible, viewportWidth, viewportHeight])
useEffect(() => {
if (isVisible) {
vscode.postMessage({ type: "fetchLatestMcpServersFromHub" })
}
}, [isVisible])
return (
<div ref={modalRef}>
<div ref={buttonRef} className="inline-flex min-w-0 max-w-full">
<VSCodeButton
appearance="icon"
aria-label="MCP Servers"
onClick={() => setIsVisible(!isVisible)}
style={{ padding: "0px 0px", height: "20px" }}>
<div className="flex items-center gap-1 text-xs whitespace-nowrap min-w-0 w-full">
<span
className="codicon codicon-server flex items-center"
style={{ fontSize: "12.5px", marginBottom: 1 }}
/>
</div>
</VSCodeButton>
</div>
{isVisible && (
<div
className="fixed left-[15px] right-[15px] border border-[var(--vscode-editorGroup-border)] p-3 rounded z-[1000] overflow-y-auto"
style={{
bottom: `calc(100vh - ${menuPosition}px + 6px)`,
background: CODE_BLOCK_BG_COLOR,
maxHeight: "calc(100vh - 100px)",
overscrollBehavior: "contain",
}}>
<div
className="fixed w-[10px] h-[10px] z-[-1] rotate-45 border-r border-b border-[var(--vscode-editorGroup-border)]"
style={{
bottom: `calc(100vh - ${menuPosition}px)`,
right: arrowPosition,
background: CODE_BLOCK_BG_COLOR,
}}
/>
<div className="m-0 mb-2.5">MCP Servers</div>
<div style={{ marginBottom: "-10px" }}>
<ServersToggleList servers={mcpServers} isExpandable={false} hasTrashIcon={false} listGap="small" />
</div>
</div>
)}
</div>
)
}
export default ServersToggleModal
@@ -1,3 +1,4 @@
import React from "react"
import { render, screen, fireEvent } from "@testing-library/react"
import { describe, it, expect, vi } from "vitest"
import Announcement from "../Announcement"
@@ -22,18 +23,9 @@ describe("Announcement", () => {
expect(hideAnnouncement).toHaveBeenCalled()
})
it("renders the mcp server improvements announcement", () => {
it("renders the enhanced MCP support announcement", () => {
render(<Announcement version="2.0.0" hideAnnouncement={hideAnnouncement} />)
expect(screen.getByText(/MCP server improvements:/)).toBeInTheDocument()
})
it("renders the 'See new changes' button feature", () => {
render(<Announcement version="2.0.0" hideAnnouncement={hideAnnouncement} />)
expect(screen.getByText(/See it in action here./)).toBeInTheDocument()
})
it("renders the demo link", () => {
render(<Announcement version="2.0.0" hideAnnouncement={hideAnnouncement} />)
expect(screen.getByText(/See a demo here./)).toBeInTheDocument()
// Updated text based on actual component output
expect(screen.getByText(/Enhanced MCP Support:/)).toBeInTheDocument()
})
})
@@ -11,9 +11,11 @@ import { useFloating, offset, flip, shift } from "@floating-ui/react"
interface CheckmarkControlProps {
messageTs?: number
isCheckpointCheckedOut?: boolean
/** Determines if the hover is near the checkpoint marker's visual position (either on the preceding row or the checkpoint row itself) */
isHoveredNearCheckpoint: boolean
}
export const CheckmarkControl = ({ messageTs, isCheckpointCheckedOut }: CheckmarkControlProps) => {
export const CheckmarkControl = ({ messageTs, isCheckpointCheckedOut, isHoveredNearCheckpoint }: CheckmarkControlProps) => {
const [compareDisabled, setCompareDisabled] = useState(false)
const [restoreTaskDisabled, setRestoreTaskDisabled] = useState(false)
const [restoreWorkspaceDisabled, setRestoreWorkspaceDisabled] = useState(false)
@@ -119,6 +121,13 @@ export const CheckmarkControl = ({ messageTs, isCheckpointCheckedOut }: Checkmar
useEvent("message", handleMessage)
// Hide checkpoint if it is not the currently restored one AND the user is not hovering near it.
// This keeps the UI clean but ensures the checkpoint appear on hover for interaction.
const shouldHideCheckpoint = !isCheckpointCheckedOut && !isHoveredNearCheckpoint
if (shouldHideCheckpoint) {
return null
}
return (
<Container isMenuOpen={showRestoreConfirm} $isCheckedOut={isCheckpointCheckedOut} onMouseLeave={handleControlsMouseLeave}>
<i
@@ -174,7 +174,10 @@ async function svgToPng(svgEl: SVGElement): Promise<string> {
const serializer = new XMLSerializer()
const svgString = serializer.serializeToString(svgClone)
const svgDataUrl = "data:image/svg+xml;base64," + btoa(decodeURIComponent(encodeURIComponent(svgString)))
const encoder = new TextEncoder()
const bytes = encoder.encode(svgString)
const base64 = btoa(Array.from(bytes, (byte) => String.fromCharCode(byte)).join(""))
const svgDataUrl = `data:image/svg+xml;base64,${base64}`
return new Promise((resolve, reject) => {
const img = new Image()
@@ -29,7 +29,7 @@ const InstalledServersView = () => {
</VSCodeLink>
</div>
<ServersToggleList servers={servers} />
<ServersToggleList servers={servers} isExpandable={true} hasTrashIcon={false} />
{/* Settings Section */}
<div style={{ marginBottom: "20px", marginTop: 10 }}>
@@ -1,11 +1,29 @@
import { McpServer } from "@shared/mcp"
import ServerRow from "./server-row/ServerRow"
const ServersToggleList = ({ servers }: { servers: McpServer[] }) => {
const ServersToggleList = ({
servers,
isExpandable,
hasTrashIcon,
listGap = "medium",
}: {
servers: McpServer[]
isExpandable: boolean
hasTrashIcon: boolean
listGap?: "small" | "medium" | "large"
}) => {
const gapClasses = {
small: "gap-0",
medium: "gap-2.5",
large: "gap-5",
}
const gapClass = gapClasses[listGap]
return servers.length > 0 ? (
<div className="flex flex-col gap-2.5">
<div className={`flex flex-col ${gapClass}`}>
{servers.map((server) => (
<ServerRow key={server.name} server={server} />
<ServerRow key={server.name} server={server} isExpandable={isExpandable} hasTrashIcon={hasTrashIcon} />
))}
</div>
) : (
@@ -14,7 +14,10 @@ const McpToolRow = ({ tool, serverName }: McpToolRowProps) => {
// Accept the event object
const handleAutoApproveChange = (event: any) => {
// Only proceed if the event was triggered by a direct user interaction
if (!serverName || !event.isTrusted) return
if (!serverName) {
return
}
vscode.postMessage({
type: "toggleToolAutoApprove",
@@ -17,7 +17,15 @@ import McpToolRow from "./McpToolRow"
import McpResourceRow from "./McpResourceRow"
import { useExtensionState } from "@/context/ExtensionStateContext"
const ServerRow = ({ server, isExpandable = true }: { server: McpServer; isExpandable?: boolean }) => {
const ServerRow = ({
server,
isExpandable = true,
hasTrashIcon = true,
}: {
server: McpServer
isExpandable?: boolean
hasTrashIcon?: boolean
}) => {
const { mcpMarketplaceCatalog, autoApprovalSettings } = useExtensionState()
const [isExpanded, setIsExpanded] = useState(false)
@@ -138,16 +146,18 @@ const ServerRow = ({ server, isExpandable = true }: { server: McpServer; isExpan
disabled={server.status === "connecting"}>
<span className="codicon codicon-sync"></span>
</VSCodeButton>
<VSCodeButton
appearance="icon"
title="Delete Server"
onClick={(e) => {
e.stopPropagation()
handleDelete()
}}
disabled={isDeleting}>
<span className="codicon codicon-trash"></span>
</VSCodeButton>
{hasTrashIcon && (
<VSCodeButton
appearance="icon"
title="Delete Server"
onClick={(e) => {
e.stopPropagation()
handleDelete()
}}
disabled={isDeleting}>
<span className="codicon codicon-trash"></span>
</VSCodeButton>
)}
</div>
)}
{/* Toggle Switch */}
@@ -1574,6 +1574,21 @@ export const formatPrice = (price: number) => {
}).format(price)
}
// Returns an array of formatted tier strings
const formatTiers = (tiers: ModelInfo["inputPriceTiers"]): string[] => {
if (!tiers || tiers.length === 0) {
return []
}
return tiers.map((tier, index, arr) => {
const prevLimit = index > 0 ? arr[index - 1].tokenLimit : 0
const limitText =
tier.tokenLimit === Infinity
? `> ${prevLimit.toLocaleString()}` // Assumes sorted and Infinity is last
: `<= ${tier.tokenLimit.toLocaleString()}`
return `${formatPrice(tier.price)}/million tokens (${limitText} tokens)`
})
}
export const ModelInfoView = ({
selectedModelId,
modelInfo,
@@ -1589,6 +1604,42 @@ export const ModelInfoView = ({
}) => {
const isGemini = Object.keys(geminiModels).includes(selectedModelId)
// Create elements for tiered pricing separately
const inputPriceElement = modelInfo.inputPriceTiers ? (
<Fragment key="inputPriceTiers">
<span style={{ fontWeight: 500 }}>Input price:</span>
<br />
{formatTiers(modelInfo.inputPriceTiers).map((tierString, i, arr) => (
<Fragment key={`inputTierFrag${i}`}>
<span style={{ paddingLeft: "15px" }}>{tierString}</span>
{i < arr.length - 1 && <br />}
</Fragment>
))}
</Fragment>
) : modelInfo.inputPrice !== undefined && modelInfo.inputPrice > 0 ? (
<span key="inputPrice">
<span style={{ fontWeight: 500 }}>Input price:</span> {formatPrice(modelInfo.inputPrice)}/million tokens
</span>
) : null
const outputPriceElement = modelInfo.outputPriceTiers ? (
<Fragment key="outputPriceTiers">
<span style={{ fontWeight: 500 }}>Output price:</span>
<span style={{ fontStyle: "italic" }}> (based on input tokens)</span>
<br />
{formatTiers(modelInfo.outputPriceTiers).map((tierString, i, arr) => (
<Fragment key={`outputTierFrag${i}`}>
<span style={{ paddingLeft: "15px" }}>{tierString}</span>
{i < arr.length - 1 && <br />}
</Fragment>
))}
</Fragment>
) : modelInfo.outputPrice !== undefined && modelInfo.outputPrice > 0 ? (
<span key="outputPrice">
<span style={{ fontWeight: 500 }}>Output price:</span> {formatPrice(modelInfo.outputPrice)}/million tokens
</span>
) : null
const infoItems = [
modelInfo.description && (
<ModelDescriptionMarkdown
@@ -1624,11 +1675,7 @@ export const ModelInfoView = ({
<span style={{ fontWeight: 500 }}>Max output:</span> {modelInfo.maxTokens?.toLocaleString()} tokens
</span>
),
modelInfo.inputPrice !== undefined && modelInfo.inputPrice > 0 && (
<span key="inputPrice">
<span style={{ fontWeight: 500 }}>Input price:</span> {formatPrice(modelInfo.inputPrice)}/million tokens
</span>
),
inputPriceElement, // Add the generated input price block
modelInfo.supportsPromptCache && modelInfo.cacheWritesPrice && (
<span key="cacheWritesPrice">
<span style={{ fontWeight: 500 }}>Cache writes price:</span> {formatPrice(modelInfo.cacheWritesPrice || 0)}
@@ -1641,11 +1688,7 @@ export const ModelInfoView = ({
tokens
</span>
),
modelInfo.outputPrice !== undefined && modelInfo.outputPrice > 0 && (
<span key="outputPrice">
<span style={{ fontWeight: 500 }}>Output price:</span> {formatPrice(modelInfo.outputPrice)}/million tokens
</span>
),
outputPriceElement, // Add the generated output price block
isGemini && (
<span key="geminiInfo" style={{ fontStyle: "italic" }}>
* Free up to {selectedModelId && selectedModelId.includes("flash") ? "15" : "2"} requests per minute. After that,
@@ -0,0 +1,347 @@
import React, { useState, useEffect, useCallback } from "react"
import { VSCodeButton, VSCodeCheckbox, VSCodeDropdown, VSCodeOption, VSCodeTextField } from "@vscode/webview-ui-toolkit/react"
import debounce from "debounce"
import { BROWSER_VIEWPORT_PRESETS } from "../../../../src/shared/BrowserSettings"
import { useExtensionState } from "../../context/ExtensionStateContext"
import { vscode } from "../../utils/vscode"
import styled from "styled-components"
const ConnectionStatusIndicator = ({
isChecking,
isConnected,
remoteBrowserEnabled,
}: {
isChecking: boolean
isConnected: boolean | null
remoteBrowserEnabled?: boolean
}) => {
if (!remoteBrowserEnabled) return null
return (
<StatusContainer>
{isChecking ? (
<>
<Spinner />
<StatusText>Checking connection...</StatusText>
</>
) : isConnected === true ? (
<>
<CheckIcon className="codicon codicon-check" />
<StatusText style={{ color: "var(--vscode-terminal-ansiGreen)" }}>Connected</StatusText>
</>
) : isConnected === false ? (
<StatusText style={{ color: "var(--vscode-errorForeground)" }}>Not connected</StatusText>
) : null}
</StatusContainer>
)
}
export const BrowserSettingsSection: React.FC = () => {
const { browserSettings } = useExtensionState()
const [isCheckingConnection, setIsCheckingConnection] = useState(false)
const [connectionStatus, setConnectionStatus] = useState<boolean | null>(null)
const [relaunchResult, setRelaunchResult] = useState<{ success: boolean; message: string } | null>(null)
const [debugMode, setDebugMode] = useState(false)
const [isBundled, setIsBundled] = useState(false)
const [detectedChromePath, setDetectedChromePath] = useState<string | null>(null)
// Listen for browser connection test results and relaunch results
useEffect(() => {
const handleMessage = (event: MessageEvent) => {
const message = event.data
if (message.type === "browserConnectionResult") {
setConnectionStatus(message.success)
setIsCheckingConnection(false)
} else if (message.type === "browserRelaunchResult") {
setRelaunchResult({
success: message.success,
message: message.text,
})
setDebugMode(false)
} else if (message.type === "detectedChromePath") {
setDetectedChromePath(message.text)
setIsBundled(message.isBundled)
}
}
window.addEventListener("message", handleMessage)
return () => window.removeEventListener("message", handleMessage)
}, [])
// Auto-clear relaunch result message after 15 seconds
useEffect(() => {
if (relaunchResult) {
const timer = setTimeout(() => {
setRelaunchResult(null)
}, 15000)
// Clear timeout if component unmounts or relaunchResult changes
return () => clearTimeout(timer)
}
}, [relaunchResult])
// Request detected Chrome path on mount
useEffect(() => {
vscode.postMessage({
type: "getDetectedChromePath",
})
}, [])
// Debounced connection check function
const debouncedCheckConnection = useCallback(
debounce(() => {
if (browserSettings.remoteBrowserEnabled) {
setIsCheckingConnection(true)
setConnectionStatus(null)
vscode.postMessage({
type: browserSettings.remoteBrowserHost ? "testBrowserConnection" : "discoverBrowser",
text: browserSettings.remoteBrowserHost,
})
}
}, 1000),
[browserSettings.remoteBrowserEnabled, browserSettings.remoteBrowserHost],
)
// Check connection when component mounts or when remote settings change
useEffect(() => {
if (browserSettings.remoteBrowserEnabled) {
debouncedCheckConnection()
} else {
setConnectionStatus(null)
}
}, [browserSettings.remoteBrowserEnabled, browserSettings.remoteBrowserHost, debouncedCheckConnection])
const handleViewportChange = (event: Event) => {
const target = event.target as HTMLSelectElement
const selectedSize = BROWSER_VIEWPORT_PRESETS[target.value as keyof typeof BROWSER_VIEWPORT_PRESETS]
if (selectedSize) {
vscode.postMessage({
type: "browserSettings",
browserSettings: {
...browserSettings,
viewport: selectedSize,
},
})
}
}
const updateRemoteBrowserEnabled = (enabled: boolean) => {
// Also update browserSettings to ensure task settings are updated
vscode.postMessage({
type: "browserSettings",
browserSettings: {
...browserSettings,
remoteBrowserEnabled: enabled,
// If disabling, also clear the host in browserSettings
...(enabled ? {} : { remoteBrowserHost: undefined }),
},
})
}
const updateRemoteBrowserHost = (host: string | undefined) => {
// Also update browserSettings to ensure task settings are updated
vscode.postMessage({
type: "browserSettings",
browserSettings: {
...browserSettings,
remoteBrowserHost: host,
},
})
}
// Function to check connection once without changing UI state immediately
const checkConnectionOnce = useCallback(() => {
// Don't show the spinner for every check to avoid UI flicker
// We'll rely on the response to update the connectionStatus
vscode.postMessage({
type: browserSettings.remoteBrowserHost ? "testBrowserConnection" : "discoverBrowser",
text: browserSettings.remoteBrowserHost,
})
}, [browserSettings.remoteBrowserHost])
// Setup continuous polling for connection status when remote browser is enabled
useEffect(() => {
// Only poll if remote browser mode is enabled
if (!browserSettings.remoteBrowserEnabled) {
// Make sure we're not showing checking state when disabled
setIsCheckingConnection(false)
return
}
// Check immediately when enabled
checkConnectionOnce()
// Then check every second
const pollInterval = setInterval(() => {
checkConnectionOnce()
}, 1000)
// Cleanup the interval if the component unmounts or remote browser is disabled
return () => clearInterval(pollInterval)
}, [browserSettings.remoteBrowserEnabled, checkConnectionOnce])
const relaunchChromeDebugMode = () => {
setDebugMode(true)
setRelaunchResult(null)
// The connection status will be automatically updated by our polling
vscode.postMessage({
type: "relaunchChromeDebugMode",
})
}
// Determine if we should show the relaunch button
const isRemoteEnabled = Boolean(browserSettings.remoteBrowserEnabled)
const shouldShowRelaunchButton = isRemoteEnabled && connectionStatus === false
return (
<div
id="browser-settings-section"
style={{ marginBottom: 20, borderTop: "1px solid var(--vscode-panel-border)", paddingTop: 15 }}>
<h3 style={{ color: "var(--vscode-foreground)", margin: "0 0 10px 0", fontSize: "14px" }}>Browser Settings</h3>
<div style={{ marginBottom: 15 }}>
<div style={{ marginBottom: 8 }}>
<label style={{ fontWeight: "500", display: "block", marginBottom: 5 }}>Viewport size</label>
<VSCodeDropdown
style={{ width: "100%" }}
value={
Object.entries(BROWSER_VIEWPORT_PRESETS).find(([_, size]) => {
const typedSize = size as { width: number; height: number }
return (
typedSize.width === browserSettings.viewport.width &&
typedSize.height === browserSettings.viewport.height
)
})?.[0]
}
onChange={(event) => handleViewportChange(event as Event)}>
{Object.entries(BROWSER_VIEWPORT_PRESETS).map(([name]) => (
<VSCodeOption key={name} value={name}>
{name}
</VSCodeOption>
))}
</VSCodeDropdown>
</div>
<p
style={{
fontSize: "12px",
color: "var(--vscode-descriptionForeground)",
margin: 0,
}}>
Set the size of the browser viewport for screenshots and interactions.
</p>
</div>
<div style={{ marginBottom: 0 }}>
<div style={{ marginBottom: 4, display: "flex", alignItems: "center", justifyContent: "space-between" }}>
<VSCodeCheckbox
checked={browserSettings.remoteBrowserEnabled}
onChange={(e) => updateRemoteBrowserEnabled((e.target as HTMLInputElement).checked)}>
Use remote browser connection
</VSCodeCheckbox>
<ConnectionStatusIndicator
isChecking={isCheckingConnection}
isConnected={connectionStatus}
remoteBrowserEnabled={browserSettings.remoteBrowserEnabled}
/>
</div>
<p
style={{
fontSize: "12px",
color: "var(--vscode-descriptionForeground)",
margin: "0 0 6px 0px",
}}>
Enable Cline to use your Chrome
{isBundled ? "(not detected on your machine)" : detectedChromePath ? ` (${detectedChromePath})` : ""}. This
requires starting Chrome in debug mode
{browserSettings.remoteBrowserEnabled ? (
<>
{" "}
manually (<code>--remote-debugging-port=9222</code>) or using the button below. Enter the host address
or leave it blank for automatic discovery.
</>
) : (
"."
)}
</p>
{browserSettings.remoteBrowserEnabled && (
<div style={{ marginLeft: 0 }}>
<VSCodeTextField
value={browserSettings.remoteBrowserHost || ""}
placeholder="http://localhost:9222"
style={{ width: "100%", marginBottom: 8 }}
onChange={(e: any) => updateRemoteBrowserHost(e.target.value || undefined)}
/>
{shouldShowRelaunchButton && (
<div style={{ display: "flex", gap: "10px", marginBottom: 8, justifyContent: "center" }}>
<VSCodeButton style={{ flex: 1 }} disabled={debugMode} onClick={relaunchChromeDebugMode}>
{debugMode ? "Relaunching Browser..." : "Relaunch Browser with Debug Mode"}
</VSCodeButton>
</div>
)}
{relaunchResult && (
<div
style={{
padding: "8px",
marginBottom: "8px",
backgroundColor: relaunchResult.success ? "rgba(0, 128, 0, 0.1)" : "rgba(255, 0, 0, 0.1)",
color: relaunchResult.success
? "var(--vscode-terminal-ansiGreen)"
: "var(--vscode-terminal-ansiRed)",
borderRadius: "3px",
fontSize: "11px",
whiteSpace: "pre-wrap",
wordBreak: "break-word",
}}>
{relaunchResult.message}
</div>
)}
<p
style={{
fontSize: "12px",
color: "var(--vscode-descriptionForeground)",
margin: 0,
}}></p>
</div>
)}
</div>
</div>
)
}
const StatusContainer = styled.div`
display: flex;
align-items: center;
margin-left: 12px;
height: 20px;
`
const StatusText = styled.span`
font-size: 12px;
margin-left: 4px;
`
const CheckIcon = styled.i`
color: var(--vscode-terminal-ansiGreen);
font-size: 14px;
`
const Spinner = styled.div`
width: 14px;
height: 14px;
border: 2px solid rgba(255, 255, 255, 0.3);
border-radius: 50%;
border-top-color: var(--vscode-progressBar-background);
animation: spin 1s ease-in-out infinite;
@keyframes spin {
to {
transform: rotate(360deg);
}
}
`
export default BrowserSettingsSection
@@ -8,6 +8,8 @@ import ApiOptions from "./ApiOptions"
import { TabButton } from "../mcp/configuration/McpConfigurationView"
import { useEvent } from "react-use"
import { ExtensionMessage } from "@shared/ExtensionMessage"
import BrowserSettingsSection from "./BrowserSettingsSection"
const { IS_DEV } = process.env
type SettingsViewProps = {
@@ -105,6 +107,24 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
setPendingTabChange(null)
}
break
case "scrollToSettings":
setTimeout(() => {
const elementId = message.text
if (elementId) {
const element = document.getElementById(elementId)
if (element) {
element.scrollIntoView({ behavior: "smooth" })
element.style.transition = "background-color 0.5s ease"
element.style.backgroundColor = "var(--vscode-textPreformat-background)"
setTimeout(() => {
element.style.backgroundColor = "transparent"
}, 1200)
}
}
}, 300)
break
}
},
[pendingTabChange],
@@ -271,6 +291,26 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
</p>
</div>
{/* Browser Settings Section */}
<BrowserSettingsSection />
<div
style={{
marginTop: "auto",
paddingRight: 8,
display: "flex",
justifyContent: "center",
}}>
<SettingsButton
onClick={() => vscode.postMessage({ type: "openExtensionSettings" })}
style={{
margin: "0 0 16px 0",
}}>
<i className="codicon codicon-settings-gear" />
Advanced Settings
</SettingsButton>
</div>
{IS_DEV && (
<>
<div style={{ marginTop: "10px", marginBottom: "4px" }}>Debug</div>
@@ -288,22 +328,6 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
</>
)}
<div
style={{
marginTop: "auto",
paddingRight: 8,
display: "flex",
justifyContent: "center",
}}>
<SettingsButton
onClick={() => vscode.postMessage({ type: "openExtensionSettings" })}
style={{
margin: "0 0 16px 0",
}}>
<i className="codicon codicon-settings-gear" />
Advanced Settings
</SettingsButton>
</div>
<div
style={{
textAlign: "center",
@@ -311,6 +335,7 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
fontSize: "12px",
lineHeight: "1.2",
padding: "0 8px 15px 0",
marginTop: "auto",
}}>
<p
style={{
@@ -6,7 +6,7 @@ import { ExtensionStateContextProvider } from "@/context/ExtensionStateContext"
vi.mock("../../../context/ExtensionStateContext", async (importOriginal) => {
const actual = await importOriginal()
return {
...actual,
...(actual || {}),
// your mocked methods
useExtensionState: vi.fn(() => ({
apiConfiguration: {
@@ -25,6 +25,7 @@ describe("ApiOptions Component", () => {
const mockPostMessage = vi.fn()
beforeEach(() => {
//@ts-expect-error - vscode is not defined in the global namespace in test environment
global.vscode = { postMessage: mockPostMessage } as any
})
@@ -52,7 +53,7 @@ describe("ApiOptions Component", () => {
vi.mock("../../../context/ExtensionStateContext", async (importOriginal) => {
const actual = await importOriginal()
return {
...actual,
...(actual || {}),
// your mocked methods
useExtensionState: vi.fn(() => ({
apiConfiguration: {
@@ -71,6 +72,7 @@ describe("ApiOptions Component", () => {
const mockPostMessage = vi.fn()
beforeEach(() => {
//@ts-expect-error - vscode is not defined in the global namespace in test environment
global.vscode = { postMessage: mockPostMessage } as any
})
@@ -98,8 +100,7 @@ describe("ApiOptions Component", () => {
vi.mock("../../../context/ExtensionStateContext", async (importOriginal) => {
const actual = await importOriginal()
return {
...actual,
// your mocked methods
...(actual || {}),
useExtensionState: vi.fn(() => ({
apiConfiguration: {
apiProvider: "openai",
@@ -117,6 +118,7 @@ describe("OpenApiInfoOptions", () => {
beforeEach(() => {
vi.clearAllMocks()
//@ts-expect-error - vscode is not defined in the global namespace in test environment
global.vscode = { postMessage: mockPostMessage }
})
+40
View File
@@ -0,0 +1,40 @@
import { useMemo } from "react"
import { ClineMessage } from "@shared/ExtensionMessage"
/**
* Custom hook to determine the dynamic styles for a ChatRowContainer.
*
* This hook calculates the padding and minimum height for a chat row based on
* whether it represents a checkpoint message and its current hover state.
* The goal is to visually collapse checkpoint markers when they are not checked out
* and not being hovered over, while ensuring they remain interactable.
*
* @param message - The chat message object for the current row.
* @param hoveredRowIndex - The index of the currently hovered row, or null if none.
* @param rowIndex - The index of the current row being rendered.
* @returns An object containing the calculated style properties (padding and minHeight).
*/
export const useChatRowStyles = (
message: ClineMessage,
hoveredRowIndex: number | null,
rowIndex: number,
): { padding: number | undefined; minHeight: number | undefined } => {
return useMemo(() => {
// Check if the current message is a checkpoint creation message.
const isCheckpointMessage = message.say === "checkpoint_created"
// Determine if the hover state is relevant to this row or the one immediately preceding it.
// This is because the checkpoint marker is visually associated with the row *before* the checkpoint message,
// but its visibility is controlled by the hover state of *both* the preceding row and the checkpoint message row itself.
const isHoverRelevant = hoveredRowIndex === rowIndex - 1 || hoveredRowIndex === rowIndex
// Calculate styles based on checkpoint status and hover relevance.
// If it's a checkpoint message, not currently checked out, and not relevantly hovered,
// reset padding to 0 and set minHeight to 1px to visually collapse it.
// Otherwise, use default styles (undefined, letting CSS handle it).
const padding = isCheckpointMessage && !message.isCheckpointCheckedOut && !isHoverRelevant ? 0 : undefined
const minHeight = isCheckpointMessage && !message.isCheckpointCheckedOut && !isHoverRelevant ? 1 : undefined
return { padding, minHeight }
}, [message.say, message.isCheckpointCheckedOut, hoveredRowIndex, rowIndex])
}
+2 -2
View File
@@ -4,6 +4,7 @@
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"types": ["vitest/globals", "@testing-library/jest-dom"],
"module": "ESNext",
"skipLibCheck": true,
@@ -30,6 +31,5 @@
"@utils/*": ["src/utils/*"]
}
},
"include": ["src"],
"exclude": ["src/**/__tests__/**"]
"include": ["src"]
}
+1
View File
@@ -44,6 +44,7 @@ export default defineConfig({
"process.env": {
NODE_ENV: JSON.stringify(process.env.IS_DEV ? "development" : "production"),
IS_DEV: JSON.stringify(process.env.IS_DEV),
IS_TEST: JSON.stringify(process.env.IS_TEST),
},
},
resolve: {