mirror of
https://github.com/cline/cline.git
synced 2026-09-02 15:52:29 +08:00
Compare commits
34 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1671a4ae91 | |||
| fab8fe0c2c | |||
| cbcf89d634 | |||
| 6a10e30436 | |||
| 2c0afbc3be | |||
| 2ef4e56bca | |||
| e26d001585 | |||
| 6fc2cb128e | |||
| 8cc64f5e7e | |||
| a4412e8014 | |||
| 36f7abb8ec | |||
| 080a79bd7d | |||
| 0208fdf555 | |||
| 226f20f28f | |||
| fdc76c8802 | |||
| 7099a00674 | |||
| b470229a97 | |||
| e37f6e3b88 | |||
| 4c72bd96ab | |||
| be120e85be | |||
| fdd04bc942 | |||
| b7c03af9ac | |||
| 1961583eb6 | |||
| 35dd137c36 | |||
| 867a69777a | |||
| b67afb84a7 | |||
| abca4cc76a | |||
| 73c64d9ab5 | |||
| 989eeb2a87 | |||
| d5524e747a | |||
| 521258239a | |||
| dd25195b4d | |||
| 0b95ad3bae | |||
| 95120bb050 |
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": minor
|
||||
---
|
||||
|
||||
update ordering of messages during task restore
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": minor
|
||||
---
|
||||
|
||||
added IS_TEST build flag
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
allow enabling prompt caching for LiteLLM + Claude
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Include model and apiProvider in metadata for cline to read
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": minor
|
||||
---
|
||||
|
||||
Drag and drop of file/folders into cline chat
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
Vendored
+13
@@ -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}"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Vendored
+51
@@ -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"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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**
|
||||
|
||||
@@ -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 */
|
||||
|
||||
Generated
+155
-3
@@ -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
@@ -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
@@ -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") {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
+8
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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: () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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
@@ -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")
|
||||
|
||||
@@ -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)}`,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 user’s 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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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}`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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",
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
first–preserving 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={() => {
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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>
|
||||
) : (
|
||||
|
||||
+4
-1
@@ -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",
|
||||
|
||||
+21
-11
@@ -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 }
|
||||
})
|
||||
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user