mirror of
https://github.com/cline/cline.git
synced 2026-09-02 07:42:19 +08:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 710612f165 | |||
| ee75b43b26 | |||
| 5e633c6ac9 | |||
| 47ad25f113 |
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": minor
|
||||
---
|
||||
|
||||
context management for file reads
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Fix auto approve state out of sync
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
TS errors
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Reduce system prompt size by letting Cline dynamically load MCP documentation only when needed
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Feature to open basic settings & scroll a section into view with a highlight animation
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Disable notifications in browser
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": minor
|
||||
---
|
||||
|
||||
Add modal for toggling MCP servers to the chat area
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Features to Relaunch browser in debug, test connection
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Added option to auto-approve all commands
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Remote browser control via devtools protocol
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
factor out servers list
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Fix tool toggle auto approve
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Added feature to detect installed versions of chromium and display them as a placeholder if not already explicitly configured by the user
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Fix for headless browser mode
|
||||
@@ -22,7 +22,7 @@ jobs:
|
||||
github.event.pull_request.base.ref == 'main' &&
|
||||
github.actor != 'github-actions'
|
||||
)
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-latest`
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
Generated
+1
-77
@@ -27,7 +27,6 @@
|
||||
"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",
|
||||
@@ -9967,24 +9966,6 @@
|
||||
"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",
|
||||
@@ -10990,6 +10971,7 @@
|
||||
"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"
|
||||
@@ -12915,21 +12897,6 @@
|
||||
"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",
|
||||
@@ -13200,18 +13167,6 @@
|
||||
"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",
|
||||
@@ -13483,31 +13438,6 @@
|
||||
"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",
|
||||
@@ -13686,12 +13616,6 @@
|
||||
"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",
|
||||
|
||||
+15
-1
@@ -214,6 +214,21 @@
|
||||
},
|
||||
"description": "Settings for VSCode Language Model API"
|
||||
},
|
||||
"cline.mcp.mode": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"full",
|
||||
"server-use-only",
|
||||
"off"
|
||||
],
|
||||
"enumDescriptions": [
|
||||
"Enable all MCP functionality (server use and build instructions)",
|
||||
"Enable MCP server use only (excludes instructions about building MCP servers)",
|
||||
"Disable all MCP functionality"
|
||||
],
|
||||
"default": "full",
|
||||
"description": "Controls MCP inclusion in prompts, reduces token usage if you only need access to certain functionality."
|
||||
},
|
||||
"cline.enableCheckpoints": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
@@ -349,7 +364,6 @@
|
||||
"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",
|
||||
|
||||
@@ -21,7 +21,6 @@ export const toolUseNames = [
|
||||
"access_mcp_resource",
|
||||
"ask_followup_question",
|
||||
"plan_mode_respond",
|
||||
"load_mcp_documentation",
|
||||
"attempt_completion",
|
||||
] as const
|
||||
|
||||
|
||||
@@ -2,119 +2,14 @@ import { Anthropic } from "@anthropic-ai/sdk"
|
||||
import { ClineApiReqInfo, ClineMessage } from "../../shared/ExtensionMessage"
|
||||
import { ApiHandler } from "../../api"
|
||||
import { OpenAiHandler } from "../../api/providers/openai"
|
||||
import { formatResponse } from "../prompts/responses"
|
||||
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 {
|
||||
// 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(
|
||||
getNewContextMessagesAndMetadata(
|
||||
apiConversationHistory: Anthropic.Messages.MessageParam[],
|
||||
clineMessages: ClineMessage[],
|
||||
api: ApiHandler,
|
||||
conversationHistoryDeletedRange: [number, number] | undefined,
|
||||
previousApiReqIndex: number,
|
||||
taskDirectory: string,
|
||||
) {
|
||||
let updatedConversationHistoryDeletedRange = false
|
||||
|
||||
@@ -122,7 +17,6 @@ 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
|
||||
@@ -149,54 +43,23 @@ export class ContextManager {
|
||||
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"
|
||||
|
||||
// we later check how many chars we trim to determine if we should still truncate history
|
||||
let [anyContextUpdates, uniqueFileReadIndices] = this.applyContextOptimizations(
|
||||
// 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 ? conversationHistoryDeletedRange[1] + 1 : 2,
|
||||
timestamp,
|
||||
conversationHistoryDeletedRange,
|
||||
keep,
|
||||
)
|
||||
|
||||
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)
|
||||
}
|
||||
updatedConversationHistoryDeletedRange = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const truncatedConversationHistory = this.getAndAlterTruncatedMessages(
|
||||
apiConversationHistory,
|
||||
conversationHistoryDeletedRange,
|
||||
)
|
||||
// conversationHistoryDeletedRange is updated only when we're close to hitting the context window, so we don't continuously break the prompt cache
|
||||
const truncatedConversationHistory = this.getTruncatedMessages(apiConversationHistory, conversationHistoryDeletedRange)
|
||||
|
||||
return {
|
||||
conversationHistoryDeletedRange: conversationHistoryDeletedRange,
|
||||
@@ -205,17 +68,14 @@ export class ContextManager {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* get truncation range
|
||||
*/
|
||||
public getNextTruncationRange(
|
||||
apiMessages: Anthropic.Messages.MessageParam[],
|
||||
currentDeletedRange: [number, number] | undefined,
|
||||
keep: "half" | "quarter",
|
||||
): [number, number] {
|
||||
// We always keep the first user-assistant pairing, and truncate an even number of messages from there
|
||||
const rangeStartIndex = 2 // index 0 and 1 are kept
|
||||
const startOfRest = currentDeletedRange ? currentDeletedRange[1] + 1 : 2 // inclusive starting index
|
||||
// Since we always keep the first message, currentDeletedRange[0] will always be 1 (for now until we have a smarter truncation algorithm)
|
||||
const rangeStartIndex = 1
|
||||
const startOfRest = currentDeletedRange ? currentDeletedRange[1] + 1 : 1
|
||||
|
||||
let messagesToRemove: number
|
||||
if (keep === "half") {
|
||||
@@ -232,11 +92,11 @@ export class ContextManager {
|
||||
messagesToRemove = Math.floor(((apiMessages.length - startOfRest) * 3) / 4 / 2) * 2
|
||||
}
|
||||
|
||||
let rangeEndIndex = startOfRest + messagesToRemove - 1 // inclusive ending index
|
||||
let rangeEndIndex = startOfRest + messagesToRemove - 1
|
||||
|
||||
// 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.
|
||||
// Make sure the last message being removed is a user message, so that the next message after the initial task message is an assistant message. This preservers the user-assistant-user-assistant structure.
|
||||
// NOTE: anthropic format messages are always user-assistant-user-assistant, while openai format messages can have multiple user messages in a row (we use anthropic format throughout cline)
|
||||
if (apiMessages[rangeEndIndex].role !== "assistant") {
|
||||
if (apiMessages[rangeEndIndex].role !== "user") {
|
||||
rangeEndIndex -= 1
|
||||
}
|
||||
|
||||
@@ -244,609 +104,17 @@ 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[] {
|
||||
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) {
|
||||
if (!deletedRange) {
|
||||
return messages
|
||||
}
|
||||
|
||||
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
|
||||
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)]
|
||||
}
|
||||
}
|
||||
|
||||
+14
-154
@@ -43,9 +43,7 @@ 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"
|
||||
|
||||
@@ -281,12 +279,6 @@ 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
|
||||
@@ -295,123 +287,6 @@ 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)
|
||||
@@ -424,11 +299,11 @@ export class Controller {
|
||||
text: message.text,
|
||||
})
|
||||
break
|
||||
case "relaunchChromeDebugMode":
|
||||
const { browserSettings } = await getAllExtensionState(this.context)
|
||||
const browserSession = new BrowserSession(this.context, browserSettings)
|
||||
await browserSession.relaunchChromeDebugMode(this)
|
||||
break
|
||||
// case "relaunchChromeDebugMode":
|
||||
// if (this.task) {
|
||||
// this.task.browserSession.relaunchChromeDebugMode()
|
||||
// }
|
||||
// break
|
||||
case "askResponse":
|
||||
this.task?.handleWebviewAskResponse(message.askResponse!, message.text, message.images)
|
||||
break
|
||||
@@ -597,7 +472,14 @@ export class Controller {
|
||||
await this.togglePlanActModeWithChatSettings({ mode: "act" })
|
||||
}
|
||||
|
||||
// 2. download MCP
|
||||
// 2. Enable MCP settings if disabled
|
||||
// Enable MCP mode if disabled
|
||||
const mcpConfig = vscode.workspace.getConfiguration("cline.mcp")
|
||||
if (mcpConfig.get<string>("mode") !== "full") {
|
||||
await mcpConfig.update("mode", "full", true)
|
||||
}
|
||||
|
||||
// 3. download MCP
|
||||
await this.downloadMcp(message.mcpId)
|
||||
}
|
||||
break
|
||||
@@ -741,13 +623,6 @@ 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)
|
||||
@@ -788,21 +663,6 @@ 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(
|
||||
@@ -839,6 +699,7 @@ export class Controller {
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case "searchFiles": {
|
||||
const workspacePath = getWorkspacePath()
|
||||
|
||||
@@ -1359,7 +1220,6 @@ export class Controller {
|
||||
|
||||
// Create task with context from README and added guidelines for MCP server installation
|
||||
const task = `Set up the MCP server from ${mcpDetails.githubUrl} while adhering to these MCP server installation rules:
|
||||
- Start by loading the MCP documentation.
|
||||
- Use "${mcpDetails.mcpId}" as the server name in cline_mcp_settings.json.
|
||||
- Create the directory for the new MCP server before starting installation.
|
||||
- Use commands aligned with the user's shell and operating system best practices.
|
||||
|
||||
@@ -1,361 +0,0 @@
|
||||
import { McpHub } from "../../services/mcp/McpHub"
|
||||
|
||||
export async function loadMcpDocumentation(mcpHub: McpHub) {
|
||||
return `## Creating an MCP Server
|
||||
|
||||
When creating MCP servers, it's important to understand that they operate in a non-interactive environment. The server cannot initiate OAuth flows, open browser windows, or prompt for user input during runtime. All credentials and authentication tokens must be provided upfront through environment variables in the MCP settings configuration. For example, Spotify's API uses OAuth to get a refresh token for the user, but the MCP server cannot initiate this flow. While you can walk the user through obtaining an application client ID and secret, you may have to create a separate one-time setup script (like get-refresh-token.js) that captures and logs the final piece of the puzzle: the user's refresh token (i.e. you might run the script using execute_command which would open a browser for authentication, and then log the refresh token so that you can see it in the command output for you to use in the MCP settings configuration).
|
||||
|
||||
Unless the user specifies otherwise, new MCP servers should be created in: ${await mcpHub.getMcpServersPath()}
|
||||
|
||||
### Example MCP Server
|
||||
|
||||
For example, if the user wanted to give you the ability to retrieve weather information, you could create an MCP server that uses the OpenWeather API to get weather information, add it to the MCP settings configuration file, and then notice that you now have access to new tools and resources in the system prompt that you might use to show the user your new capabilities.
|
||||
|
||||
The following example demonstrates how to build an MCP server that provides weather data functionality. While this example shows how to implement resources, resource templates, and tools, in practice you should prefer using tools since they are more flexible and can handle dynamic parameters. The resource and resource template implementations are included here mainly for demonstration purposes of the different MCP capabilities, but a real weather server would likely just expose tools for fetching weather data. (The following steps are for macOS)
|
||||
|
||||
1. Use the \`create-typescript-server\` tool to bootstrap a new project in the default MCP servers directory:
|
||||
|
||||
\`\`\`bash
|
||||
cd ${await mcpHub.getMcpServersPath()}
|
||||
npx @modelcontextprotocol/create-server weather-server
|
||||
cd weather-server
|
||||
# Install dependencies
|
||||
npm install axios
|
||||
\`\`\`
|
||||
|
||||
This will create a new project with the following structure:
|
||||
|
||||
\`\`\`
|
||||
weather-server/
|
||||
├── package.json
|
||||
{
|
||||
...
|
||||
"type": "module", // added by default, uses ES module syntax (import/export) rather than CommonJS (require/module.exports) (Important to know if you create additional scripts in this server repository like a get-refresh-token.js script)
|
||||
"scripts": {
|
||||
"build": "tsc && node -e \"require('fs').chmodSync('build/index.js', '755')\"",
|
||||
...
|
||||
}
|
||||
...
|
||||
}
|
||||
├── tsconfig.json
|
||||
└── src/
|
||||
└── weather-server/
|
||||
└── index.ts # Main server implementation
|
||||
\`\`\`
|
||||
|
||||
2. Replace \`src/index.ts\` with the following:
|
||||
|
||||
\`\`\`typescript
|
||||
#!/usr/bin/env node
|
||||
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
||||
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
||||
import {
|
||||
CallToolRequestSchema,
|
||||
ErrorCode,
|
||||
ListResourcesRequestSchema,
|
||||
ListResourceTemplatesRequestSchema,
|
||||
ListToolsRequestSchema,
|
||||
McpError,
|
||||
ReadResourceRequestSchema,
|
||||
} from '@modelcontextprotocol/sdk/types.js';
|
||||
import axios from 'axios';
|
||||
|
||||
const API_KEY = process.env.OPENWEATHER_API_KEY; // provided by MCP config
|
||||
if (!API_KEY) {
|
||||
throw new Error('OPENWEATHER_API_KEY environment variable is required');
|
||||
}
|
||||
|
||||
interface OpenWeatherResponse {
|
||||
main: {
|
||||
temp: number;
|
||||
humidity: number;
|
||||
};
|
||||
weather: [{ description: string }];
|
||||
wind: { speed: number };
|
||||
dt_txt?: string;
|
||||
}
|
||||
|
||||
const isValidForecastArgs = (
|
||||
args: any
|
||||
): args is { city: string; days?: number } =>
|
||||
typeof args === 'object' &&
|
||||
args !== null &&
|
||||
typeof args.city === 'string' &&
|
||||
(args.days === undefined || typeof args.days === 'number');
|
||||
|
||||
class WeatherServer {
|
||||
private server: Server;
|
||||
private axiosInstance;
|
||||
|
||||
constructor() {
|
||||
this.server = new Server(
|
||||
{
|
||||
name: 'example-weather-server',
|
||||
version: '0.1.0',
|
||||
},
|
||||
{
|
||||
capabilities: {
|
||||
resources: {},
|
||||
tools: {},
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
this.axiosInstance = axios.create({
|
||||
baseURL: 'http://api.openweathermap.org/data/2.5',
|
||||
params: {
|
||||
appid: API_KEY,
|
||||
units: 'metric',
|
||||
},
|
||||
});
|
||||
|
||||
this.setupResourceHandlers();
|
||||
this.setupToolHandlers();
|
||||
|
||||
// Error handling
|
||||
this.server.onerror = (error) => console.error('[MCP Error]', error);
|
||||
process.on('SIGINT', async () => {
|
||||
await this.server.close();
|
||||
process.exit(0);
|
||||
});
|
||||
}
|
||||
|
||||
// MCP Resources represent any kind of UTF-8 encoded data that an MCP server wants to make available to clients, such as database records, API responses, log files, and more. Servers define direct resources with a static URI or dynamic resources with a URI template that follows the format \`[protocol]://[host]/[path]\`.
|
||||
private setupResourceHandlers() {
|
||||
// For static resources, servers can expose a list of resources:
|
||||
this.server.setRequestHandler(ListResourcesRequestSchema, async () => ({
|
||||
resources: [
|
||||
// This is a poor example since you could use the resource template to get the same information but this demonstrates how to define a static resource
|
||||
{
|
||||
uri: \`weather://San Francisco/current\`, // Unique identifier for San Francisco weather resource
|
||||
name: \`Current weather in San Francisco\`, // Human-readable name
|
||||
mimeType: 'application/json', // Optional MIME type
|
||||
// Optional description
|
||||
description:
|
||||
'Real-time weather data for San Francisco including temperature, conditions, humidity, and wind speed',
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
// For dynamic resources, servers can expose resource templates:
|
||||
this.server.setRequestHandler(
|
||||
ListResourceTemplatesRequestSchema,
|
||||
async () => ({
|
||||
resourceTemplates: [
|
||||
{
|
||||
uriTemplate: 'weather://{city}/current', // URI template (RFC 6570)
|
||||
name: 'Current weather for a given city', // Human-readable name
|
||||
mimeType: 'application/json', // Optional MIME type
|
||||
description: 'Real-time weather data for a specified city', // Optional description
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
// ReadResourceRequestSchema is used for both static resources and dynamic resource templates
|
||||
this.server.setRequestHandler(
|
||||
ReadResourceRequestSchema,
|
||||
async (request) => {
|
||||
const match = request.params.uri.match(
|
||||
/^weather:\/\/([^/]+)\/current$/
|
||||
);
|
||||
if (!match) {
|
||||
throw new McpError(
|
||||
ErrorCode.InvalidRequest,
|
||||
\`Invalid URI format: \${request.params.uri}\`
|
||||
);
|
||||
}
|
||||
const city = decodeURIComponent(match[1]);
|
||||
|
||||
try {
|
||||
const response = await this.axiosInstance.get(
|
||||
'weather', // current weather
|
||||
{
|
||||
params: { q: city },
|
||||
}
|
||||
);
|
||||
|
||||
return {
|
||||
contents: [
|
||||
{
|
||||
uri: request.params.uri,
|
||||
mimeType: 'application/json',
|
||||
text: JSON.stringify(
|
||||
{
|
||||
temperature: response.data.main.temp,
|
||||
conditions: response.data.weather[0].description,
|
||||
humidity: response.data.main.humidity,
|
||||
wind_speed: response.data.wind.speed,
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
null,
|
||||
2
|
||||
),
|
||||
},
|
||||
],
|
||||
};
|
||||
} catch (error) {
|
||||
if (axios.isAxiosError(error)) {
|
||||
throw new McpError(
|
||||
ErrorCode.InternalError,
|
||||
\`Weather API error: \${
|
||||
error.response?.data.message ?? error.message
|
||||
}\`
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/* MCP Tools enable servers to expose executable functionality to the system. Through these tools, you can interact with external systems, perform computations, and take actions in the real world.
|
||||
* - Like resources, tools are identified by unique names and can include descriptions to guide their usage. However, unlike resources, tools represent dynamic operations that can modify state or interact with external systems.
|
||||
* - While resources and tools are similar, you should prefer to create tools over resources when possible as they provide more flexibility.
|
||||
*/
|
||||
private setupToolHandlers() {
|
||||
this.server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
||||
tools: [
|
||||
{
|
||||
name: 'get_forecast', // Unique identifier
|
||||
description: 'Get weather forecast for a city', // Human-readable description
|
||||
inputSchema: {
|
||||
// JSON Schema for parameters
|
||||
type: 'object',
|
||||
properties: {
|
||||
city: {
|
||||
type: 'string',
|
||||
description: 'City name',
|
||||
},
|
||||
days: {
|
||||
type: 'number',
|
||||
description: 'Number of days (1-5)',
|
||||
minimum: 1,
|
||||
maximum: 5,
|
||||
},
|
||||
},
|
||||
required: ['city'], // Array of required property names
|
||||
},
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||
if (request.params.name !== 'get_forecast') {
|
||||
throw new McpError(
|
||||
ErrorCode.MethodNotFound,
|
||||
\`Unknown tool: \${request.params.name}\`
|
||||
);
|
||||
}
|
||||
|
||||
if (!isValidForecastArgs(request.params.arguments)) {
|
||||
throw new McpError(
|
||||
ErrorCode.InvalidParams,
|
||||
'Invalid forecast arguments'
|
||||
);
|
||||
}
|
||||
|
||||
const city = request.params.arguments.city;
|
||||
const days = Math.min(request.params.arguments.days || 3, 5);
|
||||
|
||||
try {
|
||||
const response = await this.axiosInstance.get<{
|
||||
list: OpenWeatherResponse[];
|
||||
}>('forecast', {
|
||||
params: {
|
||||
q: city,
|
||||
cnt: days * 8,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(response.data.list, null, 2),
|
||||
},
|
||||
],
|
||||
};
|
||||
} catch (error) {
|
||||
if (axios.isAxiosError(error)) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: \`Weather API error: \${
|
||||
error.response?.data.message ?? error.message
|
||||
}\`,
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async run() {
|
||||
const transport = new StdioServerTransport();
|
||||
await this.server.connect(transport);
|
||||
console.error('Weather MCP server running on stdio');
|
||||
}
|
||||
}
|
||||
|
||||
const server = new WeatherServer();
|
||||
server.run().catch(console.error);
|
||||
\`\`\`
|
||||
|
||||
(Remember: This is just an example–you may use different dependencies, break the implementation up into multiple files, etc.)
|
||||
|
||||
3. Build and compile the executable JavaScript file
|
||||
|
||||
\`\`\`bash
|
||||
npm run build
|
||||
\`\`\`
|
||||
|
||||
4. Whenever you need an environment variable such as an API key to configure the MCP server, walk the user through the process of getting the key. For example, they may need to create an account and go to a developer dashboard to generate the key. Provide step-by-step instructions and URLs to make it easy for the user to retrieve the necessary information. Then use the ask_followup_question tool to ask the user for the key, in this case the OpenWeather API key.
|
||||
|
||||
5. Install the MCP Server by adding the MCP server configuration to the settings file located at '${await mcpHub.getMcpSettingsFilePath()}'. The settings file may have other MCP servers already configured, so you would read it first and then add your new server to the existing \`mcpServers\` object.
|
||||
|
||||
IMPORTANT: Regardless of what else you see in the MCP settings file, you must default any new MCP servers you create to disabled=false and autoApprove=[].
|
||||
|
||||
\`\`\`json
|
||||
{
|
||||
"mcpServers": {
|
||||
...,
|
||||
"weather": {
|
||||
"command": "node",
|
||||
"args": ["/path/to/weather-server/build/index.js"],
|
||||
"env": {
|
||||
"OPENWEATHER_API_KEY": "user-provided-api-key"
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
(Note: the user may also ask you to install the MCP server to the Claude desktop app, in which case you would read then modify \`~/Library/Application\ Support/Claude/claude_desktop_config.json\` on macOS for example. It follows the same format of a top level \`mcpServers\` object.)
|
||||
|
||||
6. After you have edited the MCP settings configuration file, the system will automatically run all the servers and expose the available tools and resources in the 'Connected MCP Servers' section. (Note: If you encounter a 'not connected' error when testing a newly installed mcp server, a common cause is an incorrect build path in your MCP settings configuration. Since compiled JavaScript files are commonly output to either 'dist/' or 'build/' directories, double-check that the build path in your MCP settings matches where your files are actually being compiled. E.g. If you assumed 'build' as the folder, check tsconfig.json to see if it's using 'dist' instead.)
|
||||
|
||||
7. Now that you have access to these new tools and resources, you may suggest ways the user can command you to invoke them - for example, with this new weather tool now available, you can invite the user to ask "what's the weather in San Francisco?"
|
||||
|
||||
## Editing MCP Servers
|
||||
|
||||
The user may ask to add tools or resources that may make sense to add to an existing MCP server (listed under 'Connected MCP Servers' below: ${
|
||||
mcpHub
|
||||
.getServers()
|
||||
.filter((server) => server.status === "connected")
|
||||
.map((server) => server.name)
|
||||
.join(", ") || "(None running currently)"
|
||||
}, e.g. if it would use the same API. This would be possible if you can locate the MCP server repository on the user's system by looking at the server arguments for a filepath. You might then use list_files and read_file to explore the files in the repository, and use replace_in_file to make changes to the files.
|
||||
|
||||
However some MCP servers may be running from installed packages rather than a local repository, in which case it may make more sense to create a new MCP server.
|
||||
|
||||
# MCP Servers Are Not Always Necessary
|
||||
|
||||
The user may not always request the use or creation of MCP servers. Instead, they might provide tasks that can be completed with existing tools. While using the MCP SDK to extend your capabilities can be useful, it's important to understand that this is just one specialized type of task you can accomplish. You should only implement MCP servers when the user explicitly requests it (e.g., "add a tool that...").
|
||||
|
||||
Remember: The MCP documentation and example provided above are to help you understand and work with existing MCP servers or create new ones when requested by the user. You already have access to tools and capabilities that can be used to accomplish a wide range of tasks.`
|
||||
}
|
||||
@@ -4,12 +4,6 @@ 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>`,
|
||||
|
||||
+404
-10
@@ -177,6 +177,9 @@ Usage:
|
||||
: ""
|
||||
}
|
||||
|
||||
${
|
||||
mcpHub.getMode() !== "off"
|
||||
? `
|
||||
## use_mcp_tool
|
||||
Description: Request to use a tool provided by a connected MCP server. Each MCP server can provide multiple tools with different capabilities. Tools have defined input schemas that specify required and optional parameters.
|
||||
Parameters:
|
||||
@@ -205,6 +208,9 @@ Usage:
|
||||
<server_name>server name here</server_name>
|
||||
<uri>resource URI here</uri>
|
||||
</access_mcp_resource>
|
||||
`
|
||||
: ""
|
||||
}
|
||||
|
||||
## ask_followup_question
|
||||
Description: Ask the user a question to gather additional information needed to complete the task. This tool should be used when you encounter ambiguities, need clarification, or require more details to proceed effectively. It allows for interactive problem-solving by enabling direct communication with the user. Use this tool judiciously to maintain a balance between gathering necessary information and avoiding excessive back-and-forth.
|
||||
@@ -246,13 +252,6 @@ 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
|
||||
@@ -318,6 +317,9 @@ return (
|
||||
>>>>>>> REPLACE
|
||||
</diff>
|
||||
</replace_in_file>
|
||||
${
|
||||
mcpHub.getMode() !== "off"
|
||||
? `
|
||||
|
||||
## Example 4: Requesting to use an MCP tool
|
||||
|
||||
@@ -332,7 +334,14 @@ return (
|
||||
</arguments>
|
||||
</use_mcp_tool>
|
||||
|
||||
## Example 5: Another example of using an MCP tool (where the server name is a unique identifier such as a URL)
|
||||
## Example 5: Requesting to access an MCP resource
|
||||
|
||||
<access_mcp_resource>
|
||||
<server_name>weather-server</server_name>
|
||||
<uri>weather://san-francisco/current</uri>
|
||||
</access_mcp_resource>
|
||||
|
||||
## Example 6: Another example of using an MCP tool (where the server name is a unique identifier such as a URL)
|
||||
|
||||
<use_mcp_tool>
|
||||
<server_name>github.com/modelcontextprotocol/servers/tree/main/src/github</server_name>
|
||||
@@ -347,7 +356,9 @@ return (
|
||||
"assignees": ["octocat"]
|
||||
}
|
||||
</arguments>
|
||||
</use_mcp_tool>
|
||||
</use_mcp_tool>`
|
||||
: ""
|
||||
}
|
||||
|
||||
# Tool Use Guidelines
|
||||
|
||||
@@ -370,6 +381,9 @@ It is crucial to proceed step-by-step, waiting for the user's message after each
|
||||
|
||||
By waiting for and carefully considering the user's response after each tool use, you can react accordingly and make informed decisions about how to proceed with the task. This iterative process helps ensure the overall success and accuracy of your work.
|
||||
|
||||
${
|
||||
mcpHub.getMode() !== "off"
|
||||
? `
|
||||
====
|
||||
|
||||
MCP SERVERS
|
||||
@@ -416,6 +430,374 @@ ${
|
||||
})
|
||||
.join("\n\n")}`
|
||||
: "(No MCP servers currently connected)"
|
||||
}`
|
||||
: ""
|
||||
}
|
||||
|
||||
${
|
||||
mcpHub.getMode() === "full"
|
||||
? `
|
||||
## Creating 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\`.
|
||||
|
||||
When creating MCP servers, it's important to understand that they operate in a non-interactive environment. The server cannot initiate OAuth flows, open browser windows, or prompt for user input during runtime. All credentials and authentication tokens must be provided upfront through environment variables in the MCP settings configuration. For example, Spotify's API uses OAuth to get a refresh token for the user, but the MCP server cannot initiate this flow. While you can walk the user through obtaining an application client ID and secret, you may have to create a separate one-time setup script (like get-refresh-token.js) that captures and logs the final piece of the puzzle: the user's refresh token (i.e. you might run the script using execute_command which would open a browser for authentication, and then log the refresh token so that you can see it in the command output for you to use in the MCP settings configuration).
|
||||
|
||||
Unless the user specifies otherwise, new MCP servers should be created in: ${await mcpHub.getMcpServersPath()}
|
||||
|
||||
### Example MCP Server
|
||||
|
||||
For example, if the user wanted to give you the ability to retrieve weather information, you could create an MCP server that uses the OpenWeather API to get weather information, add it to the MCP settings configuration file, and then notice that you now have access to new tools and resources in the system prompt that you might use to show the user your new capabilities.
|
||||
|
||||
The following example demonstrates how to build an MCP server that provides weather data functionality. While this example shows how to implement resources, resource templates, and tools, in practice you should prefer using tools since they are more flexible and can handle dynamic parameters. The resource and resource template implementations are included here mainly for demonstration purposes of the different MCP capabilities, but a real weather server would likely just expose tools for fetching weather data. (The following steps are for macOS)
|
||||
|
||||
1. Use the \`create-typescript-server\` tool to bootstrap a new project in the default MCP servers directory:
|
||||
|
||||
\`\`\`bash
|
||||
cd ${await mcpHub.getMcpServersPath()}
|
||||
npx @modelcontextprotocol/create-server weather-server
|
||||
cd weather-server
|
||||
# Install dependencies
|
||||
npm install axios
|
||||
\`\`\`
|
||||
|
||||
This will create a new project with the following structure:
|
||||
|
||||
\`\`\`
|
||||
weather-server/
|
||||
├── package.json
|
||||
{
|
||||
...
|
||||
"type": "module", // added by default, uses ES module syntax (import/export) rather than CommonJS (require/module.exports) (Important to know if you create additional scripts in this server repository like a get-refresh-token.js script)
|
||||
"scripts": {
|
||||
"build": "tsc && node -e \"require('fs').chmodSync('build/index.js', '755')\"",
|
||||
...
|
||||
}
|
||||
...
|
||||
}
|
||||
├── tsconfig.json
|
||||
└── src/
|
||||
└── weather-server/
|
||||
└── index.ts # Main server implementation
|
||||
\`\`\`
|
||||
|
||||
2. Replace \`src/index.ts\` with the following:
|
||||
|
||||
\`\`\`typescript
|
||||
#!/usr/bin/env node
|
||||
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
||||
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
||||
import {
|
||||
CallToolRequestSchema,
|
||||
ErrorCode,
|
||||
ListResourcesRequestSchema,
|
||||
ListResourceTemplatesRequestSchema,
|
||||
ListToolsRequestSchema,
|
||||
McpError,
|
||||
ReadResourceRequestSchema,
|
||||
} from '@modelcontextprotocol/sdk/types.js';
|
||||
import axios from 'axios';
|
||||
|
||||
const API_KEY = process.env.OPENWEATHER_API_KEY; // provided by MCP config
|
||||
if (!API_KEY) {
|
||||
throw new Error('OPENWEATHER_API_KEY environment variable is required');
|
||||
}
|
||||
|
||||
interface OpenWeatherResponse {
|
||||
main: {
|
||||
temp: number;
|
||||
humidity: number;
|
||||
};
|
||||
weather: [{ description: string }];
|
||||
wind: { speed: number };
|
||||
dt_txt?: string;
|
||||
}
|
||||
|
||||
const isValidForecastArgs = (
|
||||
args: any
|
||||
): args is { city: string; days?: number } =>
|
||||
typeof args === 'object' &&
|
||||
args !== null &&
|
||||
typeof args.city === 'string' &&
|
||||
(args.days === undefined || typeof args.days === 'number');
|
||||
|
||||
class WeatherServer {
|
||||
private server: Server;
|
||||
private axiosInstance;
|
||||
|
||||
constructor() {
|
||||
this.server = new Server(
|
||||
{
|
||||
name: 'example-weather-server',
|
||||
version: '0.1.0',
|
||||
},
|
||||
{
|
||||
capabilities: {
|
||||
resources: {},
|
||||
tools: {},
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
this.axiosInstance = axios.create({
|
||||
baseURL: 'http://api.openweathermap.org/data/2.5',
|
||||
params: {
|
||||
appid: API_KEY,
|
||||
units: 'metric',
|
||||
},
|
||||
});
|
||||
|
||||
this.setupResourceHandlers();
|
||||
this.setupToolHandlers();
|
||||
|
||||
// Error handling
|
||||
this.server.onerror = (error) => console.error('[MCP Error]', error);
|
||||
process.on('SIGINT', async () => {
|
||||
await this.server.close();
|
||||
process.exit(0);
|
||||
});
|
||||
}
|
||||
|
||||
// MCP Resources represent any kind of UTF-8 encoded data that an MCP server wants to make available to clients, such as database records, API responses, log files, and more. Servers define direct resources with a static URI or dynamic resources with a URI template that follows the format \`[protocol]://[host]/[path]\`.
|
||||
private setupResourceHandlers() {
|
||||
// For static resources, servers can expose a list of resources:
|
||||
this.server.setRequestHandler(ListResourcesRequestSchema, async () => ({
|
||||
resources: [
|
||||
// This is a poor example since you could use the resource template to get the same information but this demonstrates how to define a static resource
|
||||
{
|
||||
uri: \`weather://San Francisco/current\`, // Unique identifier for San Francisco weather resource
|
||||
name: \`Current weather in San Francisco\`, // Human-readable name
|
||||
mimeType: 'application/json', // Optional MIME type
|
||||
// Optional description
|
||||
description:
|
||||
'Real-time weather data for San Francisco including temperature, conditions, humidity, and wind speed',
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
// For dynamic resources, servers can expose resource templates:
|
||||
this.server.setRequestHandler(
|
||||
ListResourceTemplatesRequestSchema,
|
||||
async () => ({
|
||||
resourceTemplates: [
|
||||
{
|
||||
uriTemplate: 'weather://{city}/current', // URI template (RFC 6570)
|
||||
name: 'Current weather for a given city', // Human-readable name
|
||||
mimeType: 'application/json', // Optional MIME type
|
||||
description: 'Real-time weather data for a specified city', // Optional description
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
// ReadResourceRequestSchema is used for both static resources and dynamic resource templates
|
||||
this.server.setRequestHandler(
|
||||
ReadResourceRequestSchema,
|
||||
async (request) => {
|
||||
const match = request.params.uri.match(
|
||||
/^weather:\/\/([^/]+)\/current$/
|
||||
);
|
||||
if (!match) {
|
||||
throw new McpError(
|
||||
ErrorCode.InvalidRequest,
|
||||
\`Invalid URI format: \${request.params.uri}\`
|
||||
);
|
||||
}
|
||||
const city = decodeURIComponent(match[1]);
|
||||
|
||||
try {
|
||||
const response = await this.axiosInstance.get(
|
||||
'weather', // current weather
|
||||
{
|
||||
params: { q: city },
|
||||
}
|
||||
);
|
||||
|
||||
return {
|
||||
contents: [
|
||||
{
|
||||
uri: request.params.uri,
|
||||
mimeType: 'application/json',
|
||||
text: JSON.stringify(
|
||||
{
|
||||
temperature: response.data.main.temp,
|
||||
conditions: response.data.weather[0].description,
|
||||
humidity: response.data.main.humidity,
|
||||
wind_speed: response.data.wind.speed,
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
null,
|
||||
2
|
||||
),
|
||||
},
|
||||
],
|
||||
};
|
||||
} catch (error) {
|
||||
if (axios.isAxiosError(error)) {
|
||||
throw new McpError(
|
||||
ErrorCode.InternalError,
|
||||
\`Weather API error: \${
|
||||
error.response?.data.message ?? error.message
|
||||
}\`
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/* MCP Tools enable servers to expose executable functionality to the system. Through these tools, you can interact with external systems, perform computations, and take actions in the real world.
|
||||
* - Like resources, tools are identified by unique names and can include descriptions to guide their usage. However, unlike resources, tools represent dynamic operations that can modify state or interact with external systems.
|
||||
* - While resources and tools are similar, you should prefer to create tools over resources when possible as they provide more flexibility.
|
||||
*/
|
||||
private setupToolHandlers() {
|
||||
this.server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
||||
tools: [
|
||||
{
|
||||
name: 'get_forecast', // Unique identifier
|
||||
description: 'Get weather forecast for a city', // Human-readable description
|
||||
inputSchema: {
|
||||
// JSON Schema for parameters
|
||||
type: 'object',
|
||||
properties: {
|
||||
city: {
|
||||
type: 'string',
|
||||
description: 'City name',
|
||||
},
|
||||
days: {
|
||||
type: 'number',
|
||||
description: 'Number of days (1-5)',
|
||||
minimum: 1,
|
||||
maximum: 5,
|
||||
},
|
||||
},
|
||||
required: ['city'], // Array of required property names
|
||||
},
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||
if (request.params.name !== 'get_forecast') {
|
||||
throw new McpError(
|
||||
ErrorCode.MethodNotFound,
|
||||
\`Unknown tool: \${request.params.name}\`
|
||||
);
|
||||
}
|
||||
|
||||
if (!isValidForecastArgs(request.params.arguments)) {
|
||||
throw new McpError(
|
||||
ErrorCode.InvalidParams,
|
||||
'Invalid forecast arguments'
|
||||
);
|
||||
}
|
||||
|
||||
const city = request.params.arguments.city;
|
||||
const days = Math.min(request.params.arguments.days || 3, 5);
|
||||
|
||||
try {
|
||||
const response = await this.axiosInstance.get<{
|
||||
list: OpenWeatherResponse[];
|
||||
}>('forecast', {
|
||||
params: {
|
||||
q: city,
|
||||
cnt: days * 8,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(response.data.list, null, 2),
|
||||
},
|
||||
],
|
||||
};
|
||||
} catch (error) {
|
||||
if (axios.isAxiosError(error)) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: \`Weather API error: \${
|
||||
error.response?.data.message ?? error.message
|
||||
}\`,
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async run() {
|
||||
const transport = new StdioServerTransport();
|
||||
await this.server.connect(transport);
|
||||
console.error('Weather MCP server running on stdio');
|
||||
}
|
||||
}
|
||||
|
||||
const server = new WeatherServer();
|
||||
server.run().catch(console.error);
|
||||
\`\`\`
|
||||
|
||||
(Remember: This is just an example–you may use different dependencies, break the implementation up into multiple files, etc.)
|
||||
|
||||
3. Build and compile the executable JavaScript file
|
||||
|
||||
\`\`\`bash
|
||||
npm run build
|
||||
\`\`\`
|
||||
|
||||
4. Whenever you need an environment variable such as an API key to configure the MCP server, walk the user through the process of getting the key. For example, they may need to create an account and go to a developer dashboard to generate the key. Provide step-by-step instructions and URLs to make it easy for the user to retrieve the necessary information. Then use the ask_followup_question tool to ask the user for the key, in this case the OpenWeather API key.
|
||||
|
||||
5. Install the MCP Server by adding the MCP server configuration to the settings file located at '${await mcpHub.getMcpSettingsFilePath()}'. The settings file may have other MCP servers already configured, so you would read it first and then add your new server to the existing \`mcpServers\` object.
|
||||
|
||||
IMPORTANT: Regardless of what else you see in the MCP settings file, you must default any new MCP servers you create to disabled=false and autoApprove=[].
|
||||
|
||||
\`\`\`json
|
||||
{
|
||||
"mcpServers": {
|
||||
...,
|
||||
"weather": {
|
||||
"command": "node",
|
||||
"args": ["/path/to/weather-server/build/index.js"],
|
||||
"env": {
|
||||
"OPENWEATHER_API_KEY": "user-provided-api-key"
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
(Note: the user may also ask you to install the MCP server to the Claude desktop app, in which case you would read then modify \`~/Library/Application\ Support/Claude/claude_desktop_config.json\` on macOS for example. It follows the same format of a top level \`mcpServers\` object.)
|
||||
|
||||
6. After you have edited the MCP settings configuration file, the system will automatically run all the servers and expose the available tools and resources in the 'Connected MCP Servers' section. (Note: If you encounter a 'not connected' error when testing a newly installed mcp server, a common cause is an incorrect build path in your MCP settings configuration. Since compiled JavaScript files are commonly output to either 'dist/' or 'build/' directories, double-check that the build path in your MCP settings matches where your files are actually being compiled. E.g. If you assumed 'build' as the folder, check tsconfig.json to see if it's using 'dist' instead.)
|
||||
|
||||
7. Now that you have access to these new tools and resources, you may suggest ways the user can command you to invoke them - for example, with this new weather tool now available, you can invite the user to ask "what's the weather in San Francisco?"
|
||||
|
||||
## Editing MCP Servers
|
||||
|
||||
The user may ask to add tools or resources that may make sense to add to an existing MCP server (listed under 'Connected MCP Servers' below: ${
|
||||
mcpHub
|
||||
.getServers()
|
||||
.filter((server) => server.status === "connected")
|
||||
.map((server) => server.name)
|
||||
.join(", ") || "(None running currently)"
|
||||
}, e.g. if it would use the same API. This would be possible if you can locate the MCP server repository on the user's system by looking at the server arguments for a filepath. You might then use list_files and read_file to explore the files in the repository, and use replace_in_file to make changes to the files.
|
||||
|
||||
However some MCP servers may be running from installed packages rather than a local repository, in which case it may make more sense to create a new MCP server.
|
||||
|
||||
# MCP Servers Are Not Always Necessary
|
||||
|
||||
The user may not always request the use or creation of MCP servers. Instead, they might provide tasks that can be completed with existing tools. While using the MCP SDK to extend your capabilities can be useful, it's important to understand that this is just one specialized type of task you can accomplish. You should only implement MCP servers when the user explicitly requests it (e.g., "add a tool that...").
|
||||
|
||||
Remember: The MCP documentation and example provided above are to help you understand and work with existing MCP servers or create new ones when requested by the user. You already have access to tools and capabilities that can be used to accomplish a wide range of tasks.
|
||||
`
|
||||
: ""
|
||||
}
|
||||
|
||||
====
|
||||
@@ -530,7 +912,13 @@ CAPABILITIES
|
||||
? "\n- You can use the browser_action tool to interact with websites (including html files and locally running development servers) through a Puppeteer-controlled browser when you feel it is necessary in accomplishing the user's task. This tool is particularly useful for web development tasks as it allows you to launch a browser, navigate to pages, interact with elements through clicks and keyboard input, and capture the results through screenshots and console logs. This tool may be useful at key stages of web development tasks-such as after implementing new features, making substantial changes, when troubleshooting issues, or to verify the result of your work. You can analyze the provided screenshots to ensure correct rendering or identify errors, and review console logs for runtime issues.\n - For example, if asked to add a component to a react website, you might create the necessary files, use execute_command to run the site locally, then use browser_action to launch the browser, navigate to the local server, and verify the component renders & functions correctly before closing the browser."
|
||||
: ""
|
||||
}
|
||||
${
|
||||
mcpHub.getMode() !== "off"
|
||||
? `
|
||||
- You have access to MCP servers that may provide additional tools and resources. Each server may provide different capabilities that you can use to accomplish tasks more effectively.
|
||||
`
|
||||
: ""
|
||||
}
|
||||
|
||||
====
|
||||
|
||||
@@ -551,7 +939,7 @@ RULES
|
||||
- The user may provide a file's contents directly in their message, in which case you shouldn't use the read_file tool to get the file contents again since you already have it.
|
||||
- Your goal is to try to accomplish the user's task, NOT engage in a back and forth conversation.${
|
||||
supportsComputerUse
|
||||
? `\n- The user may ask generic non-development tasks, such as "what\'s the latest news" or "look up the weather in San Diego", in which case you might use the browser_action tool to complete the task if it makes sense to do so, rather than trying to create a website or using curl to answer the question. However, if an available MCP server tool or resource can be used instead, you should prefer to use it over browser_action.`
|
||||
? `\n- The user may ask generic non-development tasks, such as "what\'s the latest news" or "look up the weather in San Diego", in which case you might use the browser_action tool to complete the task if it makes sense to do so, rather than trying to create a website or using curl to answer the question.${mcpHub.getMode() !== "off" ? "However, if an available MCP server tool or resource can be used instead, you should prefer to use it over browser_action." : ""}`
|
||||
: ""
|
||||
}
|
||||
- NEVER end attempt_completion result with a question or request to engage in further conversation! Formulate the end of your result in a way that is final and does not require further input from the user.
|
||||
@@ -566,7 +954,13 @@ RULES
|
||||
? " Then if you want to test your work, you might use browser_action to launch the site, wait for the user's response confirming the site was launched along with a screenshot, then perhaps e.g., click a button to test functionality if needed, wait for the user's response confirming the button was clicked along with a screenshot of the new state, before finally closing the browser."
|
||||
: ""
|
||||
}
|
||||
${
|
||||
mcpHub.getMode() !== "off"
|
||||
? `
|
||||
- MCP operations should be used one at a time, similar to other tool usage. Wait for confirmation of success before proceeding with additional operations.
|
||||
`
|
||||
: ""
|
||||
}
|
||||
|
||||
====
|
||||
|
||||
|
||||
@@ -20,7 +20,6 @@ export interface TaskMetadata {
|
||||
|
||||
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",
|
||||
|
||||
@@ -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: { ...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)
|
||||
browserSettings: browserSettings || DEFAULT_BROWSER_SETTINGS,
|
||||
chatSettings: chatSettings || DEFAULT_CHAT_SETTINGS,
|
||||
userInfo,
|
||||
previousModeApiProvider,
|
||||
|
||||
+8
-68
@@ -78,7 +78,6 @@ import {
|
||||
saveClineMessages,
|
||||
GlobalFileNames,
|
||||
} from "../storage/disk"
|
||||
import { loadMcpDocumentation } from "../prompts/loadMcpDocumentation"
|
||||
|
||||
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
|
||||
|
||||
@@ -182,9 +181,6 @@ export class Task {
|
||||
taskId: this.taskId,
|
||||
})
|
||||
|
||||
// Set taskId on browserSession for telemetry tracking
|
||||
this.browserSession.setTaskId(this.taskId)
|
||||
|
||||
// Continue with task initialization
|
||||
if (historyItem) {
|
||||
this.resumeTaskFromHistory()
|
||||
@@ -212,6 +208,7 @@ 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)
|
||||
@@ -327,12 +324,6 @@ 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)))
|
||||
@@ -864,9 +855,6 @@ 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()
|
||||
@@ -996,7 +984,7 @@ export class Task {
|
||||
this.abort = true // will stop any autonomously running promises
|
||||
this.terminalManager.disposeAll()
|
||||
this.urlContentFetcher.closeBrowser()
|
||||
await this.browserSession.dispose()
|
||||
this.browserSession.closeBrowser()
|
||||
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
|
||||
@@ -1154,9 +1142,7 @@ export class Task {
|
||||
}
|
||||
}
|
||||
|
||||
// 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] {
|
||||
shouldAutoApproveTool(toolName: ToolUseName): boolean {
|
||||
if (this.autoApprovalSettings.enabled) {
|
||||
switch (toolName) {
|
||||
case "read_file":
|
||||
@@ -1168,10 +1154,7 @@ export class Task {
|
||||
case "replace_in_file":
|
||||
return this.autoApprovalSettings.actions.editFiles
|
||||
case "execute_command":
|
||||
return [
|
||||
this.autoApprovalSettings.actions.executeSafeCommands,
|
||||
this.autoApprovalSettings.actions.executeAllCommands,
|
||||
]
|
||||
return this.autoApprovalSettings.actions.executeCommands
|
||||
case "browser_action":
|
||||
return this.autoApprovalSettings.actions.useBrowser
|
||||
case "access_mcp_resource":
|
||||
@@ -1269,13 +1252,12 @@ export class Task {
|
||||
preferredLanguageInstructions,
|
||||
)
|
||||
}
|
||||
const contextManagementMetadata = await this.contextManager.getNewContextMessagesAndMetadata(
|
||||
const contextManagementMetadata = this.contextManager.getNewContextMessagesAndMetadata(
|
||||
this.apiConversationHistory,
|
||||
this.clineMessages,
|
||||
this.api,
|
||||
this.conversationHistoryDeletedRange,
|
||||
previousApiReqIndex,
|
||||
await ensureTaskDirectoryExists(this.getContext(), this.taskId),
|
||||
)
|
||||
|
||||
if (contextManagementMetadata.updatedConversationHistoryDeletedRange) {
|
||||
@@ -1471,8 +1453,6 @@ export class Task {
|
||||
return `[${block.name} for '${block.params.question}']`
|
||||
case "plan_mode_respond":
|
||||
return `[${block.name}]`
|
||||
case "load_mcp_documentation":
|
||||
return `[${block.name}]`
|
||||
case "attempt_completion":
|
||||
return `[${block.name}]`
|
||||
}
|
||||
@@ -2264,14 +2244,6 @@ 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 {
|
||||
@@ -2364,7 +2336,7 @@ export class Task {
|
||||
case "execute_command": {
|
||||
let command: string | undefined = block.params.command
|
||||
const requiresApprovalRaw: string | undefined = block.params.requires_approval
|
||||
const requiresApprovalPerLLM = requiresApprovalRaw?.toLowerCase() === "true"
|
||||
const requiresApproval = requiresApprovalRaw?.toLowerCase() === "true"
|
||||
|
||||
try {
|
||||
if (block.partial) {
|
||||
@@ -2415,17 +2387,7 @@ export class Task {
|
||||
|
||||
let didAutoApprove = false
|
||||
|
||||
// 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)
|
||||
) {
|
||||
if (!requiresApproval && this.shouldAutoApproveTool(block.name)) {
|
||||
this.removeLastPartialMessageIfExistsWithType("ask", "command")
|
||||
await this.say("command", command, undefined, false)
|
||||
this.consecutiveAutoApprovedRequestsCount++
|
||||
@@ -2438,7 +2400,7 @@ export class Task {
|
||||
const didApprove = await askApproval(
|
||||
"command",
|
||||
command +
|
||||
`${this.shouldAutoApproveTool(block.name) && requiresApprovalPerLLM ? COMMAND_REQ_APP_STRING : ""}`, // ugly hack until we refactor combineCommandSequences
|
||||
`${this.shouldAutoApproveTool(block.name) && requiresApproval ? COMMAND_REQ_APP_STRING : ""}`, // ugly hack until we refactor combineCommandSequences
|
||||
)
|
||||
if (!didApprove) {
|
||||
break
|
||||
@@ -2827,28 +2789,6 @@ export class Task {
|
||||
break
|
||||
}
|
||||
}
|
||||
case "load_mcp_documentation": {
|
||||
try {
|
||||
if (block.partial) {
|
||||
// shouldn't happen
|
||||
break
|
||||
} else {
|
||||
await this.say("load_mcp_documentation", "", undefined, false)
|
||||
|
||||
const mcpHub = this.controllerRef.deref()?.mcpHub
|
||||
if (!mcpHub) {
|
||||
throw new Error("MCP hub not available")
|
||||
}
|
||||
|
||||
pushToolResult(await loadMcpDocumentation(mcpHub))
|
||||
|
||||
break
|
||||
}
|
||||
} catch (error) {
|
||||
await handleError("loading MCP documentation", error)
|
||||
break
|
||||
}
|
||||
}
|
||||
case "attempt_completion": {
|
||||
/*
|
||||
this.consecutiveMistakeCount = 0
|
||||
|
||||
@@ -1,102 +0,0 @@
|
||||
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,95 +1,36 @@
|
||||
import * as vscode from "vscode"
|
||||
import * as fs from "fs/promises"
|
||||
import * as path from "path"
|
||||
import { exec, spawn } from "child_process"
|
||||
import { Browser, Page, ScreenshotOptions, TimeoutError, launch, connect } from "puppeteer-core"
|
||||
import { Browser, Page, ScreenshotOptions, TimeoutError, launch } 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 { discoverChromeInstances, testBrowserConnection, isPortOpen } from "./BrowserDiscovery"
|
||||
import * as chromeLauncher from "chrome-launcher"
|
||||
import { Controller } from "../../core/controller"
|
||||
import { telemetryService } from "../../services/telemetry/TelemetryService"
|
||||
// import * as chromeLauncher from "chrome-launcher"
|
||||
|
||||
interface PCRStats {
|
||||
puppeteer: { launch: typeof launch }
|
||||
executablePath: string
|
||||
}
|
||||
|
||||
// Define browser connection info interface
|
||||
export interface BrowserConnectionInfo {
|
||||
isConnected: boolean
|
||||
isRemote: boolean
|
||||
host?: string
|
||||
}
|
||||
|
||||
const DEBUG_PORT = 9222 // Chrome's default debugging port
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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> {
|
||||
private async ensureChromiumExists(): Promise<PCRStats> {
|
||||
const globalStoragePath = this.context?.globalStorageUri?.fsPath
|
||||
if (!globalStoragePath) {
|
||||
throw new Error("Global storage uri is invalid")
|
||||
@@ -101,342 +42,130 @@ export class BrowserSession {
|
||||
await fs.mkdir(puppeteerDir, { recursive: true })
|
||||
}
|
||||
|
||||
// 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 })
|
||||
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 })
|
||||
|
||||
return stats
|
||||
}
|
||||
|
||||
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",
|
||||
)
|
||||
// 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
|
||||
// }
|
||||
// }
|
||||
|
||||
if (result !== "Yes") {
|
||||
controller?.postMessageToWebview({
|
||||
type: "browserRelaunchResult",
|
||||
success: false,
|
||||
text: "Operation cancelled by user",
|
||||
})
|
||||
return
|
||||
}
|
||||
// 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",
|
||||
// )
|
||||
|
||||
try {
|
||||
// Chrome-launcher's killAll only kills instances it launched
|
||||
// We need to handle system Chrome processes separately
|
||||
await this.killAllChromeBrowsers()
|
||||
// if (result !== "Yes") {
|
||||
// return
|
||||
// }
|
||||
|
||||
// Wait a moment for Chrome to fully shut down
|
||||
await new Promise((resolve) => setTimeout(resolve, 500))
|
||||
// // // Kill any existing Chrome instances
|
||||
// // await chromeLauncher.killAll()
|
||||
|
||||
// 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
|
||||
]
|
||||
// // // 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"],
|
||||
// // })
|
||||
|
||||
const installation = chromeLauncher.Launcher.getFirstInstallation()
|
||||
if (!installation) {
|
||||
throw new Error("Could not find Chrome installation on this system")
|
||||
}
|
||||
console.info("chrome installation", installation)
|
||||
// // 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)
|
||||
// }
|
||||
|
||||
// Prepare the command arguments
|
||||
const args = [`--remote-debugging-port=${DEBUG_PORT}`, "--disable-notifications", "chrome://newtab"]
|
||||
// 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
|
||||
// }
|
||||
|
||||
// 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
|
||||
}
|
||||
// /**
|
||||
// * 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")
|
||||
// }
|
||||
// }
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// 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({
|
||||
const stats = await this.ensureChromiumExists()
|
||||
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: path,
|
||||
executablePath: stats.executablePath,
|
||||
defaultViewport: this.browserSettings.viewport,
|
||||
headless: "shell", // Always use headless mode for local connections
|
||||
headless: this.browserSettings.headless,
|
||||
})
|
||||
this.isConnectedToRemote = false
|
||||
}
|
||||
|
||||
async launchRemoteBrowser() {
|
||||
let remoteBrowserHost = this.browserSettings.remoteBrowserHost
|
||||
let browserWSEndpoint: string | undefined = this.cachedWebSocketEndpoint
|
||||
let reconnectionAttempted = 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,
|
||||
// })
|
||||
// }
|
||||
|
||||
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)
|
||||
}
|
||||
// (latest version of puppeteer does not add headless to user agent)
|
||||
this.page = await this.browser?.newPage()
|
||||
}
|
||||
|
||||
async closeBrowser(): Promise<BrowserActionResult> {
|
||||
if (this.browser || this.page) {
|
||||
// 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...")
|
||||
}
|
||||
|
||||
console.log("closing browser...")
|
||||
await this.browser?.close().catch(() => {})
|
||||
this.browser = undefined
|
||||
this.page = undefined
|
||||
this.currentMousePosition = undefined
|
||||
this.isConnectedToRemote = false
|
||||
|
||||
// Reset tracking properties
|
||||
this.sessionStartTime = 0
|
||||
this.browserActions = []
|
||||
}
|
||||
return {}
|
||||
}
|
||||
@@ -472,18 +201,8 @@ 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] ${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],
|
||||
})
|
||||
}
|
||||
logs.push(`[Error] ${err.toString()}`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -511,7 +230,7 @@ export class BrowserSession {
|
||||
let screenshot = `data:image/webp;base64,${screenshotBase64}`
|
||||
|
||||
if (!screenshotBase64) {
|
||||
console.info("webp screenshot failed, trying png")
|
||||
console.log("webp screenshot failed, trying png")
|
||||
screenshotBase64 = await this.page.screenshot({
|
||||
...options,
|
||||
type: "png",
|
||||
@@ -520,13 +239,6 @@ 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.")
|
||||
}
|
||||
|
||||
@@ -543,8 +255,6 @@ 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, {
|
||||
@@ -571,7 +281,7 @@ export class BrowserSession {
|
||||
let currentHTMLSize = html.length
|
||||
|
||||
// let bodyHTMLSize = await page.evaluate(() => document.body.innerHTML.length)
|
||||
console.info("last: ", lastHTMLSize, " <> curr: ", currentHTMLSize)
|
||||
console.log("last: ", lastHTMLSize, " <> curr: ", currentHTMLSize)
|
||||
|
||||
if (lastHTMLSize !== 0 && currentHTMLSize === lastHTMLSize) {
|
||||
countStableSizeIterations++
|
||||
@@ -580,7 +290,7 @@ export class BrowserSession {
|
||||
}
|
||||
|
||||
if (countStableSizeIterations >= minStableSizeIterations) {
|
||||
console.info("Page rendered fully...")
|
||||
console.log("Page rendered fully...")
|
||||
break
|
||||
}
|
||||
|
||||
@@ -590,8 +300,6 @@ 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
|
||||
@@ -625,16 +333,12 @@ 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({
|
||||
@@ -647,8 +351,6 @@ export class BrowserSession {
|
||||
}
|
||||
|
||||
async scrollUp(): Promise<BrowserActionResult> {
|
||||
this.browserActions.push("scrollUp")
|
||||
|
||||
return this.doAction(async (page) => {
|
||||
await page.evaluate(() => {
|
||||
window.scrollBy({
|
||||
@@ -659,8 +361,4 @@ export class BrowserSession {
|
||||
await setTimeoutPromise(300)
|
||||
})
|
||||
}
|
||||
|
||||
async dispose() {
|
||||
await this.closeBrowser()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,6 +94,10 @@ export class McpHub {
|
||||
return this.connections.filter((conn) => !conn.server.disabled).map((conn) => conn.server)
|
||||
}
|
||||
|
||||
getMode(): McpMode {
|
||||
return vscode.workspace.getConfiguration("cline.mcp").get<McpMode>("mode", "full")
|
||||
}
|
||||
|
||||
async getMcpServersPath(): Promise<string> {
|
||||
const provider = this.controllerRef.deref()
|
||||
if (!provider) {
|
||||
@@ -623,12 +627,7 @@ export class McpHub {
|
||||
|
||||
// Update the tools list to reflect the change
|
||||
const connection = this.connections.find((conn) => conn.server.name === serverName)
|
||||
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),
|
||||
}))
|
||||
if (connection) {
|
||||
await this.notifyWebviewOfServerChanges()
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
@@ -42,12 +42,6 @@ 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: {
|
||||
@@ -467,79 +461,6 @@ 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,8 +5,7 @@ export interface AutoApprovalSettings {
|
||||
actions: {
|
||||
readFiles: boolean // Read files and directories
|
||||
editFiles: boolean // Edit files
|
||||
executeSafeCommands: boolean // Execute safe commands
|
||||
executeAllCommands: boolean // Execute all commands
|
||||
executeCommands: boolean // Execute safe commands
|
||||
useBrowser: boolean // Use browser
|
||||
useMcp: boolean // Use MCP servers
|
||||
}
|
||||
@@ -20,8 +19,7 @@ export const DEFAULT_AUTO_APPROVAL_SETTINGS: AutoApprovalSettings = {
|
||||
actions: {
|
||||
readFiles: false,
|
||||
editFiles: false,
|
||||
executeSafeCommands: false,
|
||||
executeAllCommands: false,
|
||||
executeCommands: 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,8 +15,7 @@ export const DEFAULT_BROWSER_SETTINGS: BrowserSettings = {
|
||||
width: 900,
|
||||
height: 600,
|
||||
},
|
||||
remoteBrowserEnabled: false,
|
||||
remoteBrowserHost: "http://localhost:9222",
|
||||
headless: true,
|
||||
// chromeType: "chromium",
|
||||
}
|
||||
|
||||
|
||||
@@ -41,11 +41,6 @@ export interface ExtensionMessage {
|
||||
| "userCreditsPayments"
|
||||
| "totalTasksSize"
|
||||
| "addToInput"
|
||||
| "browserConnectionResult"
|
||||
| "browserConnectionInfo"
|
||||
| "detectedChromePath"
|
||||
| "scrollToSettings"
|
||||
| "browserRelaunchResult"
|
||||
| "relativePathsResponse" // Handles single and multiple path responses
|
||||
| "fileSearchResults"
|
||||
text?: string
|
||||
@@ -89,12 +84,6 @@ 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
|
||||
@@ -118,7 +107,6 @@ export interface ExtensionState {
|
||||
apiConfiguration?: ApiConfiguration
|
||||
autoApprovalSettings: AutoApprovalSettings
|
||||
browserSettings: BrowserSettings
|
||||
remoteBrowserHost?: string
|
||||
chatSettings: ChatSettings
|
||||
checkpointTrackerErrorMessage?: string
|
||||
clineMessages: ClineMessage[]
|
||||
@@ -195,7 +183,6 @@ export type ClineSay =
|
||||
| "deleted_api_reqs"
|
||||
| "clineignore_error"
|
||||
| "checkpoint_created"
|
||||
| "load_mcp_documentation"
|
||||
|
||||
export interface ClineSayTool {
|
||||
tool:
|
||||
@@ -230,12 +217,6 @@ 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"
|
||||
|
||||
@@ -35,10 +35,6 @@ export interface WebviewMessage {
|
||||
| "deleteMcpServer"
|
||||
| "autoApprovalSettings"
|
||||
| "browserSettings"
|
||||
| "discoverBrowser"
|
||||
| "testBrowserConnection"
|
||||
| "browserConnectionResult"
|
||||
| "browserRelaunchResult"
|
||||
| "togglePlanActMode"
|
||||
| "checkpointDiff"
|
||||
| "checkpointRestore"
|
||||
@@ -70,12 +66,7 @@ export interface WebviewMessage {
|
||||
| "fetchUserCreditsData"
|
||||
| "optionsResponse"
|
||||
| "requestTotalTasksSize"
|
||||
| "relaunchChromeDebugMode"
|
||||
| "taskFeedback"
|
||||
| "getBrowserConnectionInfo"
|
||||
| "getDetectedChromePath"
|
||||
| "detectedChromePath"
|
||||
| "scrollToSettings"
|
||||
| "getRelativePaths" // Handles single and multiple URI resolution
|
||||
| "searchFiles"
|
||||
// | "relaunchChromeDebugMode"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { VSCodeButton, VSCodeCheckbox, VSCodeDropdown, VSCodeOption } from "@vscode/webview-ui-toolkit/react"
|
||||
import React, { useEffect, useRef, useState } from "react"
|
||||
import React, { useRef, useState } from "react"
|
||||
import { useClickAway } from "react-use"
|
||||
import styled from "styled-components"
|
||||
import { BROWSER_VIEWPORT_PRESETS } from "@shared/BrowserSettings"
|
||||
@@ -7,213 +7,229 @@ import { useExtensionState } from "@/context/ExtensionStateContext"
|
||||
import { vscode } from "@/utils/vscode"
|
||||
import { CODE_BLOCK_BG_COLOR } from "../common/CodeBlock"
|
||||
|
||||
interface ConnectionInfo {
|
||||
isConnected: boolean
|
||||
isRemote: boolean
|
||||
host?: string
|
||||
interface BrowserSettingsMenuProps {
|
||||
disabled?: boolean
|
||||
maxWidth?: number
|
||||
}
|
||||
|
||||
export const BrowserSettingsMenu = () => {
|
||||
export const BrowserSettingsMenu: React.FC<BrowserSettingsMenuProps> = ({ disabled = false, maxWidth }) => {
|
||||
const { browserSettings } = useExtensionState()
|
||||
const [showMenu, setShowMenu] = useState(false)
|
||||
const [hasMouseEntered, setHasMouseEntered] = useState(false)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const [showInfoPopover, setShowInfoPopover] = useState(false)
|
||||
const [connectionInfo, setConnectionInfo] = useState<ConnectionInfo>({
|
||||
isConnected: false,
|
||||
isRemote: !!browserSettings.remoteBrowserEnabled,
|
||||
host: browserSettings.remoteBrowserHost,
|
||||
const menuRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useClickAway(containerRef, () => {
|
||||
if (showMenu) {
|
||||
setShowMenu(false)
|
||||
setHasMouseEntered(false)
|
||||
}
|
||||
})
|
||||
const popoverRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Get actual connection info from the browser session
|
||||
useEffect(() => {
|
||||
// Request connection info when component mounts
|
||||
vscode.postMessage({
|
||||
type: "getBrowserConnectionInfo",
|
||||
})
|
||||
const handleMouseEnter = () => {
|
||||
setHasMouseEntered(true)
|
||||
}
|
||||
|
||||
// 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,
|
||||
})
|
||||
}
|
||||
const handleMouseLeave = () => {
|
||||
if (hasMouseEntered) {
|
||||
setShowMenu(false)
|
||||
setHasMouseEntered(false)
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("message", handleMessage)
|
||||
return () => {
|
||||
window.removeEventListener("message", handleMessage)
|
||||
}
|
||||
}, [browserSettings.remoteBrowserHost, browserSettings.remoteBrowserEnabled])
|
||||
const handleControlsMouseLeave = (e: React.MouseEvent) => {
|
||||
const menuElement = menuRef.current
|
||||
|
||||
// Close popover when clicking outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (menuElement && showMenu) {
|
||||
const menuRect = menuElement.getBoundingClientRect()
|
||||
|
||||
// If mouse is moving towards the menu, don't close it
|
||||
if (
|
||||
popoverRef.current &&
|
||||
!popoverRef.current.contains(event.target as Node) &&
|
||||
!event.composedPath().some((el) => (el as HTMLElement).classList?.contains("browser-info-icon"))
|
||||
e.clientY >= menuRect.top &&
|
||||
e.clientY <= menuRect.bottom &&
|
||||
e.clientX >= menuRect.left &&
|
||||
e.clientX <= menuRect.right
|
||||
) {
|
||||
setShowInfoPopover(false)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (showInfoPopover) {
|
||||
document.addEventListener("mousedown", handleClickOutside)
|
||||
}
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleClickOutside)
|
||||
}
|
||||
}, [showInfoPopover])
|
||||
setShowMenu(false)
|
||||
setHasMouseEntered(false)
|
||||
}
|
||||
|
||||
const openBrowserSettings = () => {
|
||||
// First open the settings panel
|
||||
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 updateHeadless = (headless: boolean) => {
|
||||
vscode.postMessage({
|
||||
type: "openSettings",
|
||||
type: "browserSettings",
|
||||
browserSettings: {
|
||||
...browserSettings,
|
||||
headless,
|
||||
},
|
||||
})
|
||||
|
||||
// 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 toggleInfoPopover = () => {
|
||||
setShowInfoPopover(!showInfoPopover)
|
||||
// const updateChromeType = (chromeType: BrowserSettings["chromeType"]) => {
|
||||
// vscode.postMessage({
|
||||
// type: "browserSettings",
|
||||
// browserSettings: {
|
||||
// ...browserSettings,
|
||||
// chromeType,
|
||||
// },
|
||||
// })
|
||||
// }
|
||||
|
||||
// Request updated connection info when opening the popover
|
||||
if (!showInfoPopover) {
|
||||
vscode.postMessage({
|
||||
type: "getBrowserConnectionInfo",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Determine icon based on connection state
|
||||
const getIconClass = () => {
|
||||
if (connectionInfo.isRemote) {
|
||||
return "codicon-remote"
|
||||
} else {
|
||||
return connectionInfo.isConnected ? "codicon-vm-running" : "codicon-info"
|
||||
}
|
||||
}
|
||||
|
||||
// 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)"
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
}, [])
|
||||
// const relaunchChromeDebugMode = () => {
|
||||
// vscode.postMessage({
|
||||
// type: "relaunchChromeDebugMode",
|
||||
// })
|
||||
// }
|
||||
|
||||
return (
|
||||
<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}>
|
||||
<div ref={containerRef} style={{ position: "relative", marginTop: "-1px" }} onMouseLeave={handleControlsMouseLeave}>
|
||||
<VSCodeButton appearance="icon" onClick={() => setShowMenu(!showMenu)} disabled={disabled}>
|
||||
<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 InfoPopover = styled.div`
|
||||
const SettingsMenu = styled.div<{ maxWidth?: number }>`
|
||||
position: absolute;
|
||||
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;
|
||||
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
|
||||
}
|
||||
`
|
||||
|
||||
const InfoRow = styled.div`
|
||||
display: flex;
|
||||
margin-bottom: 4px;
|
||||
flex-wrap: wrap;
|
||||
white-space: nowrap;
|
||||
const SettingsGroup = styled.div`
|
||||
&:not(:last-child) {
|
||||
margin-bottom: 8px;
|
||||
// padding-bottom: 8px;
|
||||
border-bottom: 1px solid var(--vscode-editorGroup-border);
|
||||
}
|
||||
`
|
||||
|
||||
const InfoLabel = styled.div`
|
||||
flex: 0 0 90px;
|
||||
font-weight: 500;
|
||||
const SettingsHeader = styled.div`
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 6px;
|
||||
color: var(--vscode-foreground);
|
||||
`
|
||||
|
||||
const InfoValue = styled.div`
|
||||
flex: 1;
|
||||
word-break: break-word;
|
||||
const SettingsDescription = styled.div<{ isLast?: boolean }>`
|
||||
font-size: 11px;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
margin-bottom: ${(props) => (props.isLast ? "0" : "8px")};
|
||||
`
|
||||
|
||||
export default BrowserSettingsMenu
|
||||
|
||||
@@ -10,15 +10,6 @@ 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
|
||||
@@ -38,18 +29,12 @@ const ACTION_METADATA: {
|
||||
description: "Allows modification of any files on your computer.",
|
||||
},
|
||||
{
|
||||
id: "executeSafeCommands",
|
||||
id: "executeCommands",
|
||||
label: "Execute safe commands",
|
||||
shortName: "Safe Commands",
|
||||
shortName: "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",
|
||||
@@ -68,26 +53,11 @@ 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 = (() => {
|
||||
// "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 enabledActionsList = enabledActions.map((action) => action.shortName).join(", ")
|
||||
const hasEnabledActions = enabledActions.length > 0
|
||||
|
||||
const updateEnabled = useCallback(
|
||||
@@ -250,61 +220,26 @@ 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) => {
|
||||
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={{
|
||||
margin: "6px 0",
|
||||
{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)
|
||||
}}>
|
||||
<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>
|
||||
{action.label}
|
||||
</VSCodeCheckbox>
|
||||
<div
|
||||
style={{
|
||||
marginLeft: "28px",
|
||||
color: getAsVar(VSC_DESCRIPTION_FOREGROUND),
|
||||
fontSize: "12px",
|
||||
}}>
|
||||
{action.description}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
<div
|
||||
style={{
|
||||
height: "0.5px",
|
||||
|
||||
@@ -365,7 +365,7 @@ const BrowserSessionRow = memo((props: BrowserSessionRowProps) => {
|
||||
}}>
|
||||
<div style={urlTextStyle}>{displayState.url || "http"}</div>
|
||||
</div>
|
||||
<BrowserSettingsMenu />
|
||||
<BrowserSettingsMenu disabled={!shouldShowSettings} maxWidth={maxWidth} />
|
||||
</div>
|
||||
|
||||
{/* Screenshot Area */}
|
||||
|
||||
@@ -989,21 +989,6 @@ export const ChatRowContent = ({ message, isExpanded, onToggleExpand, lastModifi
|
||||
<CheckmarkControl messageTs={message.ts} isCheckpointCheckedOut={message.isCheckpointCheckedOut} />
|
||||
</>
|
||||
)
|
||||
case "load_mcp_documentation":
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
color: "var(--vscode-foreground)",
|
||||
opacity: 0.7,
|
||||
fontSize: 12,
|
||||
padding: "4px 0",
|
||||
}}>
|
||||
<i className="codicon codicon-book" style={{ marginRight: 6 }} />
|
||||
Loading MCP documentation
|
||||
</div>
|
||||
)
|
||||
case "completion_result":
|
||||
const hasChanges = message.text?.endsWith(COMPLETION_RESULT_CHANGES_FLAG) ?? false
|
||||
const text = hasChanges ? message.text?.slice(0, -COMPLETION_RESULT_CHANGES_FLAG.length) : message.text
|
||||
|
||||
@@ -25,7 +25,6 @@ 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
|
||||
@@ -1191,9 +1190,7 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
|
||||
onClick={handleContextButtonClick}
|
||||
style={{ padding: "0px 0px", height: "20px" }}>
|
||||
<ButtonContainer>
|
||||
<span className="flex items-center" style={{ fontSize: "13px", marginBottom: 1 }}>
|
||||
@
|
||||
</span>
|
||||
<span style={{ fontSize: "13px", marginBottom: 1 }}>@</span>
|
||||
{/* {showButtonText && <span style={{ fontSize: "10px" }}>Context</span>} */}
|
||||
</ButtonContainer>
|
||||
</VSCodeButton>
|
||||
@@ -1210,14 +1207,10 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
|
||||
}}
|
||||
style={{ padding: "0px 0px", height: "20px" }}>
|
||||
<ButtonContainer>
|
||||
<span
|
||||
className="codicon codicon-device-camera flex items-center"
|
||||
style={{ fontSize: "14px", marginBottom: -3 }}
|
||||
/>
|
||||
<span className="codicon codicon-device-camera" style={{ fontSize: "14px", marginBottom: -3 }} />
|
||||
{/* {showButtonText && <span style={{ fontSize: "10px" }}>Images</span>} */}
|
||||
</ButtonContainer>
|
||||
</VSCodeButton>
|
||||
<ServersToggleModal />
|
||||
|
||||
<ModelContainer ref={modelSelectorRef}>
|
||||
<ModelButtonWrapper ref={buttonRef}>
|
||||
|
||||
@@ -224,7 +224,6 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
|
||||
case "mcp_server_response":
|
||||
case "completion_result":
|
||||
case "tool":
|
||||
case "load_mcp_documentation":
|
||||
break
|
||||
}
|
||||
break
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
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,4 +1,3 @@
|
||||
import React from "react"
|
||||
import { render, screen, fireEvent } from "@testing-library/react"
|
||||
import { describe, it, expect, vi } from "vitest"
|
||||
import Announcement from "../Announcement"
|
||||
@@ -23,9 +22,18 @@ describe("Announcement", () => {
|
||||
expect(hideAnnouncement).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("renders the enhanced MCP support announcement", () => {
|
||||
it("renders the mcp server improvements announcement", () => {
|
||||
render(<Announcement version="2.0.0" hideAnnouncement={hideAnnouncement} />)
|
||||
// Updated text based on actual component output
|
||||
expect(screen.getByText(/Enhanced MCP Support:/)).toBeInTheDocument()
|
||||
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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -29,7 +29,7 @@ const InstalledServersView = () => {
|
||||
</VSCodeLink>
|
||||
</div>
|
||||
|
||||
<ServersToggleList servers={servers} isExpandable={true} hasTrashIcon={false} />
|
||||
<ServersToggleList servers={servers} />
|
||||
|
||||
{/* Settings Section */}
|
||||
<div style={{ marginBottom: "20px", marginTop: 10 }}>
|
||||
|
||||
@@ -1,29 +1,11 @@
|
||||
import { McpServer } from "@shared/mcp"
|
||||
import ServerRow from "./server-row/ServerRow"
|
||||
|
||||
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]
|
||||
|
||||
const ServersToggleList = ({ servers }: { servers: McpServer[] }) => {
|
||||
return servers.length > 0 ? (
|
||||
<div className={`flex flex-col ${gapClass}`}>
|
||||
<div className="flex flex-col gap-2.5">
|
||||
{servers.map((server) => (
|
||||
<ServerRow key={server.name} server={server} isExpandable={isExpandable} hasTrashIcon={hasTrashIcon} />
|
||||
<ServerRow key={server.name} server={server} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
|
||||
+1
-4
@@ -14,10 +14,7 @@ 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) {
|
||||
return
|
||||
}
|
||||
if (!serverName || !event.isTrusted) return
|
||||
|
||||
vscode.postMessage({
|
||||
type: "toggleToolAutoApprove",
|
||||
|
||||
+11
-21
@@ -17,15 +17,7 @@ import McpToolRow from "./McpToolRow"
|
||||
import McpResourceRow from "./McpResourceRow"
|
||||
import { useExtensionState } from "@/context/ExtensionStateContext"
|
||||
|
||||
const ServerRow = ({
|
||||
server,
|
||||
isExpandable = true,
|
||||
hasTrashIcon = true,
|
||||
}: {
|
||||
server: McpServer
|
||||
isExpandable?: boolean
|
||||
hasTrashIcon?: boolean
|
||||
}) => {
|
||||
const ServerRow = ({ server, isExpandable = true }: { server: McpServer; isExpandable?: boolean }) => {
|
||||
const { mcpMarketplaceCatalog, autoApprovalSettings } = useExtensionState()
|
||||
|
||||
const [isExpanded, setIsExpanded] = useState(false)
|
||||
@@ -146,18 +138,16 @@ const ServerRow = ({
|
||||
disabled={server.status === "connecting"}>
|
||||
<span className="codicon codicon-sync"></span>
|
||||
</VSCodeButton>
|
||||
{hasTrashIcon && (
|
||||
<VSCodeButton
|
||||
appearance="icon"
|
||||
title="Delete Server"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleDelete()
|
||||
}}
|
||||
disabled={isDeleting}>
|
||||
<span className="codicon codicon-trash"></span>
|
||||
</VSCodeButton>
|
||||
)}
|
||||
<VSCodeButton
|
||||
appearance="icon"
|
||||
title="Delete Server"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleDelete()
|
||||
}}
|
||||
disabled={isDeleting}>
|
||||
<span className="codicon codicon-trash"></span>
|
||||
</VSCodeButton>
|
||||
</div>
|
||||
)}
|
||||
{/* Toggle Switch */}
|
||||
|
||||
@@ -1,347 +0,0 @@
|
||||
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,8 +8,6 @@ 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 = {
|
||||
@@ -107,24 +105,6 @@ 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],
|
||||
@@ -291,26 +271,6 @@ 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>
|
||||
@@ -328,6 +288,22 @@ 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",
|
||||
@@ -335,7 +311,6 @@ 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,7 +25,6 @@ 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
|
||||
})
|
||||
|
||||
@@ -53,7 +52,7 @@ describe("ApiOptions Component", () => {
|
||||
vi.mock("../../../context/ExtensionStateContext", async (importOriginal) => {
|
||||
const actual = await importOriginal()
|
||||
return {
|
||||
...(actual || {}),
|
||||
...actual,
|
||||
// your mocked methods
|
||||
useExtensionState: vi.fn(() => ({
|
||||
apiConfiguration: {
|
||||
@@ -72,7 +71,6 @@ 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
|
||||
})
|
||||
|
||||
@@ -100,7 +98,8 @@ describe("ApiOptions Component", () => {
|
||||
vi.mock("../../../context/ExtensionStateContext", async (importOriginal) => {
|
||||
const actual = await importOriginal()
|
||||
return {
|
||||
...(actual || {}),
|
||||
...actual,
|
||||
// your mocked methods
|
||||
useExtensionState: vi.fn(() => ({
|
||||
apiConfiguration: {
|
||||
apiProvider: "openai",
|
||||
@@ -118,7 +117,6 @@ describe("OpenApiInfoOptions", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
//@ts-expect-error - vscode is not defined in the global namespace in test environment
|
||||
global.vscode = { postMessage: mockPostMessage }
|
||||
})
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"types": ["vitest/globals", "@testing-library/jest-dom"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
@@ -31,5 +30,6 @@
|
||||
"@utils/*": ["src/utils/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"]
|
||||
"include": ["src"],
|
||||
"exclude": ["src/**/__tests__/**"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user