Roo-Code/src/core/webview/ClineProvider.ts

2197 lines
76 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { Anthropic } from "@anthropic-ai/sdk"
import axios from "axios"
import crypto from "crypto"
import { execa } from "execa"
import fs from "fs/promises"
import os from "os"
import pWaitFor from "p-wait-for"
import * as path from "path"
import * as vscode from "vscode"
import { buildApiHandler } from "../../api"
import CheckpointTracker from "../../integrations/checkpoints/CheckpointTracker"
import { downloadTask } from "../../integrations/misc/export-markdown"
import { openFile, openImage } from "../../integrations/misc/open-file"
import { fetchOpenGraphData, isImageUrl } from "../../integrations/misc/link-preview"
import { selectImages } from "../../integrations/misc/process-images"
import { getTheme } from "../../integrations/theme/getTheme"
import WorkspaceTracker from "../../integrations/workspace/WorkspaceTracker"
import { FirebaseAuthManager, UserInfo } from "../../services/auth/FirebaseAuthManager"
import { McpHub } from "../../services/mcp/McpHub"
import { ApiProvider, ModelInfo } from "../../shared/api"
import { findLast } from "../../shared/array"
import { AutoApprovalSettings, DEFAULT_AUTO_APPROVAL_SETTINGS } from "../../shared/AutoApprovalSettings"
import { BrowserSettings, DEFAULT_BROWSER_SETTINGS } from "../../shared/BrowserSettings"
import { ChatContent } from "../../shared/ChatContent"
import { ChatSettings, DEFAULT_CHAT_SETTINGS } from "../../shared/ChatSettings"
import { ExtensionMessage, ExtensionState, Platform } from "../../shared/ExtensionMessage"
import { HistoryItem } from "../../shared/HistoryItem"
import { McpDownloadResponse, McpMarketplaceCatalog, McpServer } from "../../shared/mcp"
import { ClineCheckpointRestore, WebviewMessage } from "../../shared/WebviewMessage"
import { fileExistsAtPath } from "../../utils/fs"
import { searchCommits } from "../../utils/git"
import { Cline } from "../Cline"
import { openMention } from "../mentions"
import { getNonce } from "./getNonce"
import { getUri } from "./getUri"
import { telemetryService } from "../../services/telemetry/TelemetryService"
import { TelemetrySetting } from "../../shared/TelemetrySetting"
import { validateThinkingBudget } from "../../utils/validation"
/*
https://github.com/microsoft/vscode-webview-ui-toolkit-samples/blob/main/default/weather-webview/src/providers/WeatherViewProvider.ts
https://github.com/KumarVariable/vscode-extension-sidebar-html/blob/master/src/customSidebarViewProvider.ts
*/
type SecretKey =
| "apiKey"
| "openRouterApiKey"
| "awsAccessKey"
| "awsSecretKey"
| "awsSessionToken"
| "openAiApiKey"
| "geminiApiKey"
| "openAiNativeApiKey"
| "deepSeekApiKey"
| "requestyApiKey"
| "togetherApiKey"
| "qwenApiKey"
| "mistralApiKey"
| "liteLlmApiKey"
| "authToken"
| "authNonce"
| "xaiApiKey"
type GlobalStateKey =
| "apiProvider"
| "apiModelId"
| "awsRegion"
| "awsUseCrossRegionInference"
| "awsProfile"
| "awsUseProfile"
| "vertexProjectId"
| "vertexRegion"
| "lastShownAnnouncementId"
| "customInstructions"
| "taskHistory"
| "openAiBaseUrl"
| "openAiModelId"
| "openAiModelInfo"
| "ollamaModelId"
| "ollamaBaseUrl"
| "lmStudioModelId"
| "lmStudioBaseUrl"
| "anthropicBaseUrl"
| "azureApiVersion"
| "openRouterModelId"
| "openRouterModelInfo"
| "autoApprovalSettings"
| "browserSettings"
| "chatSettings"
| "vsCodeLmModelSelector"
| "userInfo"
| "previousModeApiProvider"
| "previousModeModelId"
| "previousModeModelInfo"
| "liteLlmBaseUrl"
| "liteLlmModelId"
| "qwenApiLine"
| "requestyModelId"
| "togetherModelId"
| "mcpMarketplaceCatalog"
| "telemetrySetting"
export const GlobalFileNames = {
apiConversationHistory: "api_conversation_history.json",
uiMessages: "ui_messages.json",
openRouterModels: "openrouter_models.json",
mcpSettings: "cline_mcp_settings.json",
clineRules: ".clinerules",
}
export class ClineProvider implements vscode.WebviewViewProvider {
public static readonly sideBarId = "claude-dev.SidebarProvider" // used in package.json as the view's id. This value cannot be changed due to how vscode caches views based on their id, and updating the id would break existing instances of the extension.
public static readonly tabPanelId = "claude-dev.TabPanelProvider"
private static activeInstances: Set<ClineProvider> = new Set()
private disposables: vscode.Disposable[] = []
private view?: vscode.WebviewView | vscode.WebviewPanel
private cline?: Cline
workspaceTracker?: WorkspaceTracker
mcpHub?: McpHub
private authManager: FirebaseAuthManager
private latestAnnouncementId = "feb-19-2025" // update to some unique identifier when we add a new announcement
constructor(
readonly context: vscode.ExtensionContext,
private readonly outputChannel: vscode.OutputChannel,
) {
this.outputChannel.appendLine("ClineProvider instantiated")
ClineProvider.activeInstances.add(this)
this.workspaceTracker = new WorkspaceTracker(this)
this.mcpHub = new McpHub(this)
this.authManager = new FirebaseAuthManager(this)
}
/*
VSCode extensions use the disposable pattern to clean up resources when the sidebar/editor tab is closed by the user or system. This applies to event listening, commands, interacting with the UI, etc.
- https://vscode-docs.readthedocs.io/en/stable/extensions/patterns-and-principles/
- https://github.com/microsoft/vscode-extension-samples/blob/main/webview-sample/src/extension.ts
*/
async dispose() {
this.outputChannel.appendLine("Disposing ClineProvider...")
await this.clearTask()
this.outputChannel.appendLine("Cleared task")
if (this.view && "dispose" in this.view) {
this.view.dispose()
this.outputChannel.appendLine("Disposed webview")
}
while (this.disposables.length) {
const x = this.disposables.pop()
if (x) {
x.dispose()
}
}
this.workspaceTracker?.dispose()
this.workspaceTracker = undefined
this.mcpHub?.dispose()
this.mcpHub = undefined
this.authManager.dispose()
this.outputChannel.appendLine("Disposed all disposables")
ClineProvider.activeInstances.delete(this)
}
// Auth methods
async handleSignOut() {
try {
await this.authManager.signOut()
vscode.window.showInformationMessage("Successfully logged out of Cline")
} catch (error) {
vscode.window.showErrorMessage("Logout failed")
}
}
async setAuthToken(token?: string) {
await this.storeSecret("authToken", token)
}
async setUserInfo(info?: { displayName: string | null; email: string | null; photoURL: string | null }) {
await this.updateGlobalState("userInfo", info)
}
public static getVisibleInstance(): ClineProvider | undefined {
return findLast(Array.from(this.activeInstances), (instance) => instance.view?.visible === true)
}
async resolveWebviewView(webviewView: vscode.WebviewView | vscode.WebviewPanel) {
this.outputChannel.appendLine("Resolving webview view")
this.view = webviewView
webviewView.webview.options = {
// Allow scripts in the webview
enableScripts: true,
localResourceRoots: [this.context.extensionUri],
}
webviewView.webview.html =
this.context.extensionMode === vscode.ExtensionMode.Development
? await this.getHMRHtmlContent(webviewView.webview)
: this.getHtmlContent(webviewView.webview)
// Sets up an event listener to listen for messages passed from the webview view context
// and executes code based on the message that is received
this.setWebviewMessageListener(webviewView.webview)
// Logs show up in bottom panel > Debug Console
//console.log("registering listener")
// Listen for when the panel becomes visible
// https://github.com/microsoft/vscode-discussions/discussions/840
if ("onDidChangeViewState" in webviewView) {
// WebviewView and WebviewPanel have all the same properties except for this visibility listener
// panel
webviewView.onDidChangeViewState(
() => {
if (this.view?.visible) {
this.postMessageToWebview({
type: "action",
action: "didBecomeVisible",
})
}
},
null,
this.disposables,
)
} else if ("onDidChangeVisibility" in webviewView) {
// sidebar
webviewView.onDidChangeVisibility(
() => {
if (this.view?.visible) {
this.postMessageToWebview({
type: "action",
action: "didBecomeVisible",
})
}
},
null,
this.disposables,
)
}
// Listen for when the view is disposed
// This happens when the user closes the view or when the view is closed programmatically
webviewView.onDidDispose(
async () => {
await this.dispose()
},
null,
this.disposables,
)
// Listen for configuration changes
vscode.workspace.onDidChangeConfiguration(
async (e) => {
if (e && e.affectsConfiguration("workbench.colorTheme")) {
// Sends latest theme name to webview
await this.postMessageToWebview({
type: "theme",
text: JSON.stringify(await getTheme()),
})
}
if (e && e.affectsConfiguration("cline.mcpMarketplace.enabled")) {
// Update state when marketplace tab setting changes
await this.postStateToWebview()
}
if (e && e.affectsConfiguration("cline.modelSettings.anthropic.thinkingBudgetTokens")) {
const config = vscode.workspace.getConfiguration("cline.modelSettings.anthropic")
const thinkingBudget = config.get<number>("thinkingBudgetTokens", 0)
const validatedValue = validateThinkingBudget(thinkingBudget)
// Only update if the value changed
if (validatedValue !== thinkingBudget) {
await config.update("thinkingBudgetTokens", validatedValue, true)
}
await this.postStateToWebview()
}
},
null,
this.disposables,
)
// if the extension is starting a new session, clear previous task state
this.clearTask()
this.outputChannel.appendLine("Webview view resolved")
}
async initClineWithTask(task?: string, images?: string[]) {
await this.clearTask() // ensures that an existing task doesn't exist before starting a new one, although this shouldn't be possible since user must clear task before starting a new one
const { apiConfiguration, customInstructions, autoApprovalSettings, browserSettings, chatSettings } =
await this.getState()
this.cline = new Cline(
this,
apiConfiguration,
autoApprovalSettings,
browserSettings,
chatSettings,
customInstructions,
task,
images,
)
// New task started
if (telemetryService.isTelemetryEnabled()) {
telemetryService.capture({
event: "New task started",
properties: {
apiProvider: apiConfiguration.apiProvider,
},
})
}
}
async initClineWithHistoryItem(historyItem: HistoryItem) {
await this.clearTask()
const { apiConfiguration, customInstructions, autoApprovalSettings, browserSettings, chatSettings } =
await this.getState()
this.cline = new Cline(
this,
apiConfiguration,
autoApprovalSettings,
browserSettings,
chatSettings,
customInstructions,
undefined,
undefined,
historyItem,
)
// Open task from history
if (telemetryService.isTelemetryEnabled()) {
telemetryService.capture({
event: "Open task from history",
properties: {
apiProvider: apiConfiguration.apiProvider,
},
})
}
}
// Send any JSON serializable data to the react app
async postMessageToWebview(message: ExtensionMessage) {
await this.view?.webview.postMessage(message)
}
/**
* Defines and returns the HTML that should be rendered within the webview panel.
*
* @remarks This is also the place where references to the React webview build files
* are created and inserted into the webview HTML.
*
* @param webview A reference to the extension webview
* @param extensionUri The URI of the directory containing the extension
* @returns A template string literal containing the HTML that should be
* rendered within the webview panel
*/
private getHtmlContent(webview: vscode.Webview): string {
// Get the local path to main script run in the webview,
// then convert it to a uri we can use in the webview.
// The CSS file from the React build output
const stylesUri = getUri(webview, this.context.extensionUri, ["webview-ui", "build", "assets", "index.css"])
// The JS file from the React build output
const scriptUri = getUri(webview, this.context.extensionUri, ["webview-ui", "build", "assets", "index.js"])
// The codicon font from the React build output
// https://github.com/microsoft/vscode-extension-samples/blob/main/webview-codicons-sample/src/extension.ts
// we installed this package in the extension so that we can access it how its intended from the extension (the font file is likely bundled in vscode), and we just import the css fileinto our react app we don't have access to it
// don't forget to add font-src ${webview.cspSource};
const codiconsUri = getUri(webview, this.context.extensionUri, [
"node_modules",
"@vscode",
"codicons",
"dist",
"codicon.css",
])
// const scriptUri = webview.asWebviewUri(vscode.Uri.joinPath(this._extensionUri, "assets", "main.js"))
// const styleResetUri = webview.asWebviewUri(vscode.Uri.joinPath(this._extensionUri, "assets", "reset.css"))
// const styleVSCodeUri = webview.asWebviewUri(vscode.Uri.joinPath(this._extensionUri, "assets", "vscode.css"))
// // Same for stylesheet
// const stylesheetUri = webview.asWebviewUri(vscode.Uri.joinPath(this._extensionUri, "assets", "main.css"))
// Use a nonce to only allow a specific script to be run.
/*
content security policy of your webview to only allow scripts that have a specific nonce
create a content security policy meta tag so that only loading scripts with a nonce is allowed
As your extension grows you will likely want to add custom styles, fonts, and/or images to your webview. If you do, you will need to update the content security policy meta tag to explicity allow for these resources. E.g.
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src ${webview.cspSource}; font-src ${webview.cspSource}; img-src ${webview.cspSource} https:; script-src 'nonce-${nonce}';">
- 'unsafe-inline' is required for styles due to vscode-webview-toolkit's dynamic style injection
- since we pass base64 images to the webview, we need to specify img-src ${webview.cspSource} data:;
in meta tag we add nonce attribute: A cryptographic nonce (only used once) to allow scripts. The server must generate a unique nonce value each time it transmits a policy. It is critical to provide a nonce that cannot be guessed as bypassing a resource's policy is otherwise trivial.
*/
const nonce = getNonce()
// Tip: Install the es6-string-html VS Code extension to enable code highlighting below
return /*html*/ `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,shrink-to-fit=no">
<meta name="theme-color" content="#000000">
<link rel="stylesheet" type="text/css" href="${stylesUri}">
<link href="${codiconsUri}" rel="stylesheet" />
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; connect-src https://*.posthog.com; font-src ${webview.cspSource}; style-src ${webview.cspSource} 'unsafe-inline'; img-src ${webview.cspSource} https: data:; script-src 'nonce-${nonce}' https://*.posthog.com;">
<title>Cline</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<script type="module" nonce="${nonce}" src="${scriptUri}"></script>
</body>
</html>
`
}
/**
* Connects to the local Vite dev server to allow HMR, with fallback to the bundled assets
*
* @param webview A reference to the extension webview
* @returns A template string literal containing the HTML that should be
* rendered within the webview panel
*/
private async getHMRHtmlContent(webview: vscode.Webview): Promise<string> {
const localPort = 25463
const localServerUrl = `localhost:${localPort}`
// Check if local dev server is running.
try {
await axios.get(`http://${localServerUrl}`)
} catch (error) {
vscode.window.showErrorMessage(
"Cline: Local webview dev server is not running, HMR will not work. Please run 'npm run dev:webview' before launching the extension to enable HMR. Using bundled assets.",
)
return this.getHtmlContent(webview)
}
const nonce = getNonce()
const stylesUri = getUri(webview, this.context.extensionUri, ["webview-ui", "build", "assets", "index.css"])
const codiconsUri = getUri(webview, this.context.extensionUri, [
"node_modules",
"@vscode",
"codicons",
"dist",
"codicon.css",
])
const scriptEntrypoint = "src/main.tsx"
const scriptUri = `http://${localServerUrl}/${scriptEntrypoint}`
const reactRefresh = /*html*/ `
<script nonce="${nonce}" type="module">
import RefreshRuntime from "http://${localServerUrl}/@react-refresh"
RefreshRuntime.injectIntoGlobalHook(window)
window.$RefreshReg$ = () => {}
window.$RefreshSig$ = () => (type) => type
window.__vite_plugin_react_preamble_installed__ = true
</script>
`
const csp = [
"default-src 'none'",
`font-src ${webview.cspSource}`,
`style-src ${webview.cspSource} 'unsafe-inline' https://* http://${localServerUrl} http://0.0.0.0:${localPort}`,
`img-src ${webview.cspSource} https: data:`,
`script-src 'unsafe-eval' https://* http://${localServerUrl} http://0.0.0.0:${localPort} 'nonce-${nonce}'`,
`connect-src https://* ws://${localServerUrl} ws://0.0.0.0:${localPort} http://${localServerUrl} http://0.0.0.0:${localPort}`,
]
return /*html*/ `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,shrink-to-fit=no">
<meta http-equiv="Content-Security-Policy" content="${csp.join("; ")}">
<link rel="stylesheet" type="text/css" href="${stylesUri}">
<link href="${codiconsUri}" rel="stylesheet" />
<title>Cline</title>
</head>
<body>
<div id="root"></div>
${reactRefresh}
<script type="module" src="${scriptUri}"></script>
</body>
</html>
`
}
/**
* Sets up an event listener to listen for messages passed from the webview context and
* executes code based on the message that is received.
*
* @param webview A reference to the extension webview
*/
private setWebviewMessageListener(webview: vscode.Webview) {
webview.onDidReceiveMessage(
async (message: WebviewMessage) => {
switch (message.type) {
case "webviewDidLaunch":
this.postStateToWebview()
this.workspaceTracker?.populateFilePaths() // don't await
getTheme().then((theme) =>
this.postMessageToWebview({
type: "theme",
text: JSON.stringify(theme),
}),
)
// post last cached models in case the call to endpoint fails
this.readOpenRouterModels().then((openRouterModels) => {
if (openRouterModels) {
this.postMessageToWebview({
type: "openRouterModels",
openRouterModels,
})
}
})
// gui relies on model info to be up-to-date to provide the most accurate pricing, so we need to fetch the latest details on launch.
// we do this for all users since many users switch between api providers and if they were to switch back to openrouter it would be showing outdated model info if we hadn't retrieved the latest at this point
// (see normalizeApiConfiguration > openrouter)
// Prefetch marketplace and OpenRouter models
this.getGlobalState("mcpMarketplaceCatalog").then((mcpMarketplaceCatalog) => {
if (mcpMarketplaceCatalog) {
this.postMessageToWebview({
type: "mcpMarketplaceCatalog",
mcpMarketplaceCatalog: mcpMarketplaceCatalog as McpMarketplaceCatalog,
})
}
})
this.silentlyRefreshMcpMarketplace()
this.refreshOpenRouterModels().then(async (openRouterModels) => {
if (openRouterModels) {
// update model info in state (this needs to be done here since we don't want to update state while settings is open, and we may refresh models there)
const { apiConfiguration } = await this.getState()
if (apiConfiguration.openRouterModelId) {
await this.updateGlobalState(
"openRouterModelInfo",
openRouterModels[apiConfiguration.openRouterModelId],
)
await this.postStateToWebview()
}
}
})
// If user already opted in to telemetry, enable telemetry service
this.getStateToPostToWebview().then((state) => {
const { telemetrySetting } = state
const isOptedIn = telemetrySetting === "enabled"
telemetryService.updateTelemetryState(isOptedIn)
})
break
case "newTask":
// Code that should run in response to the hello message command
//vscode.window.showInformationMessage(message.text!)
// Send a message to our webview.
// You can send any JSON serializable data.
// Could also do this in extension .ts
//this.postMessageToWebview({ type: "text", text: `Extension: ${Date.now()}` })
// initializing new instance of Cline will make sure that any agentically running promises in old instance don't affect our new task. this essentially creates a fresh slate for the new task
await this.initClineWithTask(message.text, message.images)
break
case "apiConfiguration":
if (message.apiConfiguration) {
const {
apiProvider,
apiModelId,
apiKey,
openRouterApiKey,
awsAccessKey,
awsSecretKey,
awsSessionToken,
awsRegion,
awsUseCrossRegionInference,
awsProfile,
awsUseProfile,
vertexProjectId,
vertexRegion,
openAiBaseUrl,
openAiApiKey,
openAiModelId,
openAiModelInfo,
ollamaModelId,
ollamaBaseUrl,
lmStudioModelId,
lmStudioBaseUrl,
anthropicBaseUrl,
geminiApiKey,
openAiNativeApiKey,
deepSeekApiKey,
requestyApiKey,
requestyModelId,
togetherApiKey,
togetherModelId,
qwenApiKey,
mistralApiKey,
azureApiVersion,
openRouterModelId,
openRouterModelInfo,
vsCodeLmModelSelector,
liteLlmBaseUrl,
liteLlmModelId,
liteLlmApiKey,
qwenApiLine,
xaiApiKey,
} = message.apiConfiguration
await this.updateGlobalState("apiProvider", apiProvider)
await this.updateGlobalState("apiModelId", apiModelId)
await this.storeSecret("apiKey", apiKey)
await this.storeSecret("openRouterApiKey", openRouterApiKey)
await this.storeSecret("awsAccessKey", awsAccessKey)
await this.storeSecret("awsSecretKey", awsSecretKey)
await this.storeSecret("awsSessionToken", awsSessionToken)
await this.updateGlobalState("awsRegion", awsRegion)
await this.updateGlobalState("awsUseCrossRegionInference", awsUseCrossRegionInference)
await this.updateGlobalState("awsProfile", awsProfile)
await this.updateGlobalState("awsUseProfile", awsUseProfile)
await this.updateGlobalState("vertexProjectId", vertexProjectId)
await this.updateGlobalState("vertexRegion", vertexRegion)
await this.updateGlobalState("openAiBaseUrl", openAiBaseUrl)
await this.storeSecret("openAiApiKey", openAiApiKey)
await this.updateGlobalState("openAiModelId", openAiModelId)
await this.updateGlobalState("openAiModelInfo", openAiModelInfo)
await this.updateGlobalState("ollamaModelId", ollamaModelId)
await this.updateGlobalState("ollamaBaseUrl", ollamaBaseUrl)
await this.updateGlobalState("lmStudioModelId", lmStudioModelId)
await this.updateGlobalState("lmStudioBaseUrl", lmStudioBaseUrl)
await this.updateGlobalState("anthropicBaseUrl", anthropicBaseUrl)
await this.storeSecret("geminiApiKey", geminiApiKey)
await this.storeSecret("openAiNativeApiKey", openAiNativeApiKey)
await this.storeSecret("deepSeekApiKey", deepSeekApiKey)
await this.storeSecret("requestyApiKey", requestyApiKey)
await this.storeSecret("togetherApiKey", togetherApiKey)
await this.storeSecret("qwenApiKey", qwenApiKey)
await this.storeSecret("mistralApiKey", mistralApiKey)
await this.storeSecret("liteLlmApiKey", liteLlmApiKey)
await this.storeSecret("xaiApiKey", xaiApiKey)
await this.updateGlobalState("azureApiVersion", azureApiVersion)
await this.updateGlobalState("openRouterModelId", openRouterModelId)
await this.updateGlobalState("openRouterModelInfo", openRouterModelInfo)
await this.updateGlobalState("vsCodeLmModelSelector", vsCodeLmModelSelector)
await this.updateGlobalState("liteLlmBaseUrl", liteLlmBaseUrl)
await this.updateGlobalState("liteLlmModelId", liteLlmModelId)
await this.updateGlobalState("qwenApiLine", qwenApiLine)
await this.updateGlobalState("requestyModelId", requestyModelId)
await this.updateGlobalState("togetherModelId", togetherModelId)
if (this.cline) {
this.cline.api = buildApiHandler(message.apiConfiguration)
}
}
await this.postStateToWebview()
break
case "customInstructions":
await this.updateCustomInstructions(message.text)
break
case "autoApprovalSettings":
if (message.autoApprovalSettings) {
await this.updateGlobalState("autoApprovalSettings", message.autoApprovalSettings)
if (this.cline) {
this.cline.autoApprovalSettings = message.autoApprovalSettings
}
await this.postStateToWebview()
}
break
case "browserSettings":
if (message.browserSettings) {
await this.updateGlobalState("browserSettings", message.browserSettings)
if (this.cline) {
this.cline.updateBrowserSettings(message.browserSettings)
}
await this.postStateToWebview()
}
break
case "togglePlanActMode":
if (message.chatSettings) {
await this.togglePlanActModeWithChatSettings(message.chatSettings, message.chatContent)
}
break
// case "relaunchChromeDebugMode":
// if (this.cline) {
// this.cline.browserSession.relaunchChromeDebugMode()
// }
// break
case "askResponse":
this.cline?.handleWebviewAskResponse(message.askResponse!, message.text, message.images)
break
case "clearTask":
// newTask will start a new task with a given task text, while clear task resets the current session and allows for a new task to be started
await this.clearTask()
await this.postStateToWebview()
break
case "didShowAnnouncement":
await this.updateGlobalState("lastShownAnnouncementId", this.latestAnnouncementId)
await this.postStateToWebview()
break
case "selectImages":
const images = await selectImages()
await this.postMessageToWebview({
type: "selectedImages",
images,
})
break
case "exportCurrentTask":
const currentTaskId = this.cline?.taskId
if (currentTaskId) {
this.exportTaskWithId(currentTaskId)
}
break
case "showTaskWithId":
this.showTaskWithId(message.text!)
break
case "deleteTaskWithId":
this.deleteTaskWithId(message.text!)
break
case "exportTaskWithId":
this.exportTaskWithId(message.text!)
break
case "resetState":
await this.resetState()
break
case "requestOllamaModels":
const ollamaModels = await this.getOllamaModels(message.text)
this.postMessageToWebview({
type: "ollamaModels",
ollamaModels,
})
break
case "requestLmStudioModels":
const lmStudioModels = await this.getLmStudioModels(message.text)
this.postMessageToWebview({
type: "lmStudioModels",
lmStudioModels,
})
break
case "requestVsCodeLmModels":
const vsCodeLmModels = await this.getVsCodeLmModels()
this.postMessageToWebview({ type: "vsCodeLmModels", vsCodeLmModels })
break
case "refreshOpenRouterModels":
await this.refreshOpenRouterModels()
break
case "refreshOpenAiModels":
const { apiConfiguration } = await this.getState()
const openAiModels = await this.getOpenAiModels(
apiConfiguration.openAiBaseUrl,
apiConfiguration.openAiApiKey,
)
this.postMessageToWebview({ type: "openAiModels", openAiModels })
break
case "openImage":
openImage(message.text!)
break
case "openInBrowser":
if (message.url) {
vscode.env.openExternal(vscode.Uri.parse(message.url))
}
break
case "fetchOpenGraphData":
this.fetchOpenGraphData(message.text!)
break
case "checkIsImageUrl":
this.checkIsImageUrl(message.text!)
break
case "openFile":
openFile(message.text!)
break
case "openMention":
openMention(message.text)
break
case "checkpointDiff": {
if (message.number) {
await this.cline?.presentMultifileDiff(message.number, false)
}
break
}
case "checkpointRestore": {
await this.cancelTask() // we cannot alter message history say if the task is active, as it could be in the middle of editing a file or running a command, which expect the ask to be responded to rather than being superceded by a new message eg add deleted_api_reqs
// cancel task waits for any open editor to be reverted and starts a new cline instance
if (message.number) {
// wait for messages to be loaded
await pWaitFor(() => this.cline?.isInitialized === true, {
timeout: 3_000,
}).catch(() => {
console.error("Failed to init new cline instance")
})
// NOTE: cancelTask awaits abortTask, which awaits diffViewProvider.revertChanges, which reverts any edited files, allowing us to reset to a checkpoint rather than running into a state where the revertChanges function is called alongside or after the checkpoint reset
await this.cline?.restoreCheckpoint(message.number, message.text! as ClineCheckpointRestore)
}
break
}
case "taskCompletionViewChanges": {
if (message.number) {
await this.cline?.presentMultifileDiff(message.number, true)
}
break
}
case "cancelTask":
this.cancelTask()
break
case "getLatestState":
await this.postStateToWebview()
break
case "subscribeEmail":
this.subscribeEmail(message.text)
break
case "accountLoginClicked": {
// Generate nonce for state validation
const nonce = crypto.randomBytes(32).toString("hex")
await this.storeSecret("authNonce", nonce)
// Open browser for authentication with state param
console.log("Login button clicked in account page")
console.log("Opening auth page with state param")
const uriScheme = vscode.env.uriScheme
const authUrl = vscode.Uri.parse(
`https://app.cline.bot/auth?state=${encodeURIComponent(nonce)}&callback_url=${encodeURIComponent(`${uriScheme || "vscode"}://saoudrizwan.claude-dev/auth`)}`,
)
vscode.env.openExternal(authUrl)
break
}
case "accountLogoutClicked": {
await this.handleSignOut()
break
}
case "showMcpView": {
await this.postMessageToWebview({ type: "action", action: "mcpButtonClicked" })
break
}
case "openMcpSettings": {
const mcpSettingsFilePath = await this.mcpHub?.getMcpSettingsFilePath()
if (mcpSettingsFilePath) {
openFile(mcpSettingsFilePath)
}
break
}
case "fetchMcpMarketplace": {
await this.fetchMcpMarketplace(message.bool)
break
}
case "downloadMcp": {
if (message.mcpId) {
// 1. Toggle to act mode if we are in plan mode
const { chatSettings } = await this.getStateToPostToWebview()
if (chatSettings.mode === "plan") {
await this.togglePlanActModeWithChatSettings({ mode: "act" })
}
// 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
}
case "silentlyRefreshMcpMarketplace": {
await this.silentlyRefreshMcpMarketplace()
break
}
// case "openMcpMarketplaceServerDetails": {
// if (message.text) {
// const response = await fetch(`https://api.cline.bot/v1/mcp/marketplace/item?mcpId=${message.mcpId}`)
// const details: McpDownloadResponse = await response.json()
// if (details.readmeContent) {
// // Disable markdown preview markers
// const config = vscode.workspace.getConfiguration("markdown")
// await config.update("preview.markEditorSelection", false, true)
// // Create URI with base64 encoded markdown content
// const uri = vscode.Uri.parse(
// `${DIFF_VIEW_URI_SCHEME}:${details.name} README?${Buffer.from(details.readmeContent).toString("base64")}`,
// )
// // close existing
// const tabs = vscode.window.tabGroups.all
// .flatMap((tg) => tg.tabs)
// .filter((tab) => tab.label && tab.label.includes("README") && tab.label.includes("Preview"))
// for (const tab of tabs) {
// await vscode.window.tabGroups.close(tab)
// }
// // Show only the preview
// await vscode.commands.executeCommand("markdown.showPreview", uri, {
// sideBySide: true,
// preserveFocus: true,
// })
// }
// }
// this.postMessageToWebview({ type: "relinquishControl" })
// break
// }
case "toggleMcpServer": {
try {
await this.mcpHub?.toggleServerDisabled(message.serverName!, message.disabled!)
} catch (error) {
console.error(`Failed to toggle MCP server ${message.serverName}:`, error)
}
break
}
case "toggleToolAutoApprove": {
try {
await this.mcpHub?.toggleToolAutoApprove(message.serverName!, message.toolName!, message.autoApprove!)
} catch (error) {
console.error(`Failed to toggle auto-approve for tool ${message.toolName}:`, error)
}
break
}
case "restartMcpServer": {
try {
await this.mcpHub?.restartConnection(message.text!)
} catch (error) {
console.error(`Failed to retry connection for ${message.text}:`, error)
}
break
}
case "deleteMcpServer": {
if (message.serverName) {
this.mcpHub?.deleteServer(message.serverName)
}
break
}
case "fetchLatestMcpServersFromHub": {
this.mcpHub?.sendLatestMcpServers()
break
}
case "searchCommits": {
const cwd = vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath).at(0)
if (cwd) {
try {
const commits = await searchCommits(message.text || "", cwd)
await this.postMessageToWebview({
type: "commitSearchResults",
commits,
})
} catch (error) {
console.error(`Error searching commits: ${JSON.stringify(error)}`)
}
}
break
}
case "updateThinkingBudgetTokens": {
if (message.number !== undefined) {
const validatedValue = validateThinkingBudget(message.number)
const config = vscode.workspace.getConfiguration("cline.modelSettings.anthropic")
await config.update("thinkingBudgetTokens", validatedValue, true)
}
break
}
case "openExtensionSettings": {
const settingsFilter = message.text || ""
await vscode.commands.executeCommand(
"workbench.action.openSettings",
`@ext:saoudrizwan.claude-dev ${settingsFilter}`.trim(), // trim whitespace if no settings filter
)
break
}
// telemetry
case "openSettings": {
await this.postMessageToWebview({
type: "action",
action: "settingsButtonClicked",
})
break
}
case "telemetrySetting": {
const telemetrySetting = message.text as TelemetrySetting
await this.updateGlobalState("telemetrySetting", telemetrySetting)
const isOptedIn = telemetrySetting === "enabled"
telemetryService.updateTelemetryState(isOptedIn)
await this.postStateToWebview()
break
}
// Add more switch case statements here as more webview message commands
// are created within the webview context (i.e. inside media/main.js)
}
},
null,
this.disposables,
)
}
async togglePlanActModeWithChatSettings(chatSettings: ChatSettings, chatContent?: ChatContent) {
const didSwitchToActMode = chatSettings.mode === "act"
// Get previous model info that we will revert to after saving current mode api info
const {
apiConfiguration,
previousModeApiProvider: newApiProvider,
previousModeModelId: newModelId,
previousModeModelInfo: newModelInfo,
} = await this.getState()
// Save the last model used in this mode
await this.updateGlobalState("previousModeApiProvider", apiConfiguration.apiProvider)
switch (apiConfiguration.apiProvider) {
case "anthropic":
case "bedrock":
case "vertex":
case "gemini":
await this.updateGlobalState("previousModeModelId", apiConfiguration.apiModelId)
break
case "openrouter":
await this.updateGlobalState("previousModeModelId", apiConfiguration.openRouterModelId)
await this.updateGlobalState("previousModeModelInfo", apiConfiguration.openRouterModelInfo)
break
case "vscode-lm":
await this.updateGlobalState("previousModeModelId", apiConfiguration.vsCodeLmModelSelector)
break
case "openai":
await this.updateGlobalState("previousModeModelId", apiConfiguration.openAiModelId)
await this.updateGlobalState("previousModeModelInfo", apiConfiguration.openAiModelInfo)
break
case "ollama":
await this.updateGlobalState("previousModeModelId", apiConfiguration.ollamaModelId)
break
case "lmstudio":
await this.updateGlobalState("previousModeModelId", apiConfiguration.lmStudioModelId)
break
case "litellm":
await this.updateGlobalState("previousModeModelId", apiConfiguration.liteLlmModelId)
break
case "requesty":
await this.updateGlobalState("previousModeModelId", apiConfiguration.requestyModelId)
break
}
// Restore the model used in previous mode
if (newApiProvider && newModelId) {
await this.updateGlobalState("apiProvider", newApiProvider)
switch (newApiProvider) {
case "anthropic":
case "bedrock":
case "vertex":
case "gemini":
await this.updateGlobalState("apiModelId", newModelId)
break
case "openrouter":
await this.updateGlobalState("openRouterModelId", newModelId)
await this.updateGlobalState("openRouterModelInfo", newModelInfo)
break
case "vscode-lm":
await this.updateGlobalState("vsCodeLmModelSelector", newModelId)
break
case "openai":
await this.updateGlobalState("openAiModelId", newModelId)
await this.updateGlobalState("openAiModelInfo", newModelInfo)
break
case "ollama":
await this.updateGlobalState("ollamaModelId", newModelId)
break
case "lmstudio":
await this.updateGlobalState("lmStudioModelId", newModelId)
break
case "litellm":
await this.updateGlobalState("liteLlmModelId", newModelId)
break
case "requesty":
await this.updateGlobalState("requestyModelId", newModelId)
break
}
if (this.cline) {
const { apiConfiguration: updatedApiConfiguration } = await this.getState()
this.cline.api = buildApiHandler(updatedApiConfiguration)
}
}
await this.updateGlobalState("chatSettings", chatSettings)
await this.postStateToWebview()
// console.log("chatSettings", message.chatSettings)
if (this.cline) {
this.cline.updateChatSettings(chatSettings)
if (this.cline.isAwaitingPlanResponse && didSwitchToActMode) {
this.cline.didRespondToPlanAskBySwitchingMode = true
// this is necessary for the webview to update accordingly, but Cline instance will not send text back as feedback message
await this.postMessageToWebview({
type: "invoke",
invoke: "sendMessage",
text: chatContent?.message || "PLAN_MODE_TOGGLE_RESPONSE",
images: chatContent?.images,
})
} else {
this.cancelTask()
}
}
}
async subscribeEmail(email?: string) {
if (!email) {
return
}
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
if (!emailRegex.test(email)) {
vscode.window.showErrorMessage("Please enter a valid email address")
return
}
console.log("Subscribing email:", email)
this.postMessageToWebview({ type: "emailSubscribed" })
// Currently ignoring errors to this endpoint, but after accounts we'll remove this anyways
try {
const response = await axios.post(
"https://app.cline.bot/api/mailing-list",
{
email: email,
},
{
headers: {
"Content-Type": "application/json",
},
},
)
console.log("Email subscribed successfully. Response:", response.data)
} catch (error) {
console.error("Failed to subscribe email:", error)
}
}
async cancelTask() {
if (this.cline) {
const { historyItem } = await this.getTaskWithId(this.cline.taskId)
try {
await this.cline.abortTask()
} catch (error) {
console.error("Failed to abort task", error)
}
await pWaitFor(
() =>
this.cline === undefined ||
this.cline.isStreaming === false ||
this.cline.didFinishAbortingStream ||
this.cline.isWaitingForFirstChunk, // if only first chunk is processed, then there's no need to wait for graceful abort (closes edits, browser, etc)
{
timeout: 3_000,
},
).catch(() => {
console.error("Failed to abort task")
})
if (this.cline) {
// 'abandoned' will prevent this cline instance from affecting future cline instance gui. this may happen if its hanging on a streaming request
this.cline.abandoned = true
}
await this.initClineWithHistoryItem(historyItem) // clears task again, so we need to abortTask manually above
// await this.postStateToWebview() // new Cline instance will post state when it's ready. having this here sent an empty messages array to webview leading to virtuoso having to reload the entire list
}
}
async updateCustomInstructions(instructions?: string) {
// User may be clearing the field
await this.updateGlobalState("customInstructions", instructions || undefined)
if (this.cline) {
this.cline.customInstructions = instructions || undefined
}
await this.postStateToWebview()
}
// MCP
async getDocumentsPath(): Promise<string> {
if (process.platform === "win32") {
try {
const { stdout: docsPath } = await execa("powershell", [
"-NoProfile", // Ignore user's PowerShell profile(s)
"-Command",
"[System.Environment]::GetFolderPath([System.Environment+SpecialFolder]::MyDocuments)",
])
const trimmedPath = docsPath.trim()
if (trimmedPath) {
return trimmedPath
}
} catch (err) {
console.error("Failed to retrieve Windows Documents path. Falling back to homedir/Documents.")
}
} else if (process.platform === "linux") {
try {
// First check if xdg-user-dir exists
await execa("which", ["xdg-user-dir"])
// If it exists, try to get XDG documents path
const { stdout } = await execa("xdg-user-dir", ["DOCUMENTS"])
const trimmedPath = stdout.trim()
if (trimmedPath) {
return trimmedPath
}
} catch {
// Log error but continue to fallback
console.error("Failed to retrieve XDG Documents path. Falling back to homedir/Documents.")
}
}
// Default fallback for all platforms
return path.join(os.homedir(), "Documents")
}
async ensureMcpServersDirectoryExists(): Promise<string> {
const userDocumentsPath = await this.getDocumentsPath()
const mcpServersDir = path.join(userDocumentsPath, "Cline", "MCP")
try {
await fs.mkdir(mcpServersDir, { recursive: true })
} catch (error) {
return "~/Documents/Cline/MCP" // in case creating a directory in documents fails for whatever reason (e.g. permissions) - this is fine since this path is only ever used in the system prompt
}
return mcpServersDir
}
async ensureSettingsDirectoryExists(): Promise<string> {
const settingsDir = path.join(this.context.globalStorageUri.fsPath, "settings")
await fs.mkdir(settingsDir, { recursive: true })
return settingsDir
}
// VSCode LM API
private async getVsCodeLmModels() {
try {
const models = await vscode.lm.selectChatModels({})
return models || []
} catch (error) {
console.error("Error fetching VS Code LM models:", error)
return []
}
}
// Ollama
async getOllamaModels(baseUrl?: string) {
try {
if (!baseUrl) {
baseUrl = "http://localhost:11434"
}
if (!URL.canParse(baseUrl)) {
return []
}
const response = await axios.get(`${baseUrl}/api/tags`)
const modelsArray = response.data?.models?.map((model: any) => model.name) || []
const models = [...new Set<string>(modelsArray)]
return models
} catch (error) {
return []
}
}
// LM Studio
async getLmStudioModels(baseUrl?: string) {
try {
if (!baseUrl) {
baseUrl = "http://localhost:1234"
}
if (!URL.canParse(baseUrl)) {
return []
}
const response = await axios.get(`${baseUrl}/v1/models`)
const modelsArray = response.data?.data?.map((model: any) => model.id) || []
const models = [...new Set<string>(modelsArray)]
return models
} catch (error) {
return []
}
}
// Auth
public async validateAuthState(state: string | null): Promise<boolean> {
const storedNonce = await this.getSecret("authNonce")
if (!state || state !== storedNonce) {
return false
}
await this.storeSecret("authNonce", undefined) // Clear after use
return true
}
async handleAuthCallback(token: string) {
try {
// First sign in with Firebase to trigger auth state change
await this.authManager.signInWithCustomToken(token)
// Then store the token securely
await this.storeSecret("authToken", token)
await this.postStateToWebview()
vscode.window.showInformationMessage("Successfully logged in to Cline")
} catch (error) {
console.error("Failed to handle auth callback:", error)
vscode.window.showErrorMessage("Failed to log in to Cline")
}
}
// MCP Marketplace
private async fetchMcpMarketplaceFromApi(silent: boolean = false): Promise<McpMarketplaceCatalog | undefined> {
try {
const response = await axios.get("https://api.cline.bot/v1/mcp/marketplace", {
headers: {
"Content-Type": "application/json",
},
})
if (!response.data) {
throw new Error("Invalid response from MCP marketplace API")
}
const catalog: McpMarketplaceCatalog = {
items: (response.data || []).map((item: any) => ({
...item,
githubStars: item.githubStars ?? 0,
downloadCount: item.downloadCount ?? 0,
tags: item.tags ?? [],
})),
}
// Store in global state
await this.updateGlobalState("mcpMarketplaceCatalog", catalog)
return catalog
} catch (error) {
console.error("Failed to fetch MCP marketplace:", error)
if (!silent) {
const errorMessage = error instanceof Error ? error.message : "Failed to fetch MCP marketplace"
await this.postMessageToWebview({
type: "mcpMarketplaceCatalog",
error: errorMessage,
})
vscode.window.showErrorMessage(errorMessage)
}
return undefined
}
}
async silentlyRefreshMcpMarketplace() {
try {
const catalog = await this.fetchMcpMarketplaceFromApi(true)
if (catalog) {
await this.postMessageToWebview({
type: "mcpMarketplaceCatalog",
mcpMarketplaceCatalog: catalog,
})
}
} catch (error) {
console.error("Failed to silently refresh MCP marketplace:", error)
}
}
private async fetchMcpMarketplace(forceRefresh: boolean = false) {
try {
// Check if we have cached data
const cachedCatalog = (await this.getGlobalState("mcpMarketplaceCatalog")) as McpMarketplaceCatalog | undefined
if (!forceRefresh && cachedCatalog?.items) {
await this.postMessageToWebview({
type: "mcpMarketplaceCatalog",
mcpMarketplaceCatalog: cachedCatalog,
})
return
}
const catalog = await this.fetchMcpMarketplaceFromApi(false)
if (catalog) {
await this.postMessageToWebview({
type: "mcpMarketplaceCatalog",
mcpMarketplaceCatalog: catalog,
})
}
} catch (error) {
console.error("Failed to handle cached MCP marketplace:", error)
const errorMessage = error instanceof Error ? error.message : "Failed to handle cached MCP marketplace"
await this.postMessageToWebview({
type: "mcpMarketplaceCatalog",
error: errorMessage,
})
vscode.window.showErrorMessage(errorMessage)
}
}
private async downloadMcp(mcpId: string) {
try {
// First check if we already have this MCP server installed
const servers = this.mcpHub?.getServers() || []
const isInstalled = servers.some((server: McpServer) => server.name === mcpId)
if (isInstalled) {
throw new Error("This MCP server is already installed")
}
// Fetch server details from marketplace
const response = await axios.post<McpDownloadResponse>(
"https://api.cline.bot/v1/mcp/download",
{ mcpId },
{
headers: { "Content-Type": "application/json" },
timeout: 10000,
},
)
if (!response.data) {
throw new Error("Invalid response from MCP marketplace API")
}
console.log("[downloadMcp] Response from download API", { response })
const mcpDetails = response.data
// Validate required fields
if (!mcpDetails.githubUrl) {
throw new Error("Missing GitHub URL in MCP download response")
}
if (!mcpDetails.readmeContent) {
throw new Error("Missing README content in MCP download response")
}
// Send details to webview
await this.postMessageToWebview({
type: "mcpDownloadDetails",
mcpDownloadDetails: mcpDetails,
})
// 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:
- 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.
- The following README may contain instructions that conflict with the user's OS, in which case proceed thoughtfully.
- Once installed, demonstrate the server's capabilities by using one of its tools.
Here is the project's README to help you get started:\n\n${mcpDetails.readmeContent}\n${mcpDetails.llmsInstallationContent}`
// Initialize task and show chat view
await this.initClineWithTask(task)
await this.postMessageToWebview({
type: "action",
action: "chatButtonClicked",
})
} catch (error) {
console.error("Failed to download MCP:", error)
let errorMessage = "Failed to download MCP"
if (axios.isAxiosError(error)) {
if (error.code === "ECONNABORTED") {
errorMessage = "Request timed out. Please try again."
} else if (error.response?.status === 404) {
errorMessage = "MCP server not found in marketplace."
} else if (error.response?.status === 500) {
errorMessage = "Internal server error. Please try again later."
} else if (!error.response && error.request) {
errorMessage = "Network error. Please check your internet connection."
}
} else if (error instanceof Error) {
errorMessage = error.message
}
// Show error in both notification and marketplace UI
vscode.window.showErrorMessage(errorMessage)
await this.postMessageToWebview({
type: "mcpDownloadDetails",
error: errorMessage,
})
}
}
// OpenAi
async getOpenAiModels(baseUrl?: string, apiKey?: string) {
try {
if (!baseUrl) {
return []
}
if (!URL.canParse(baseUrl)) {
return []
}
const config: Record<string, any> = {}
if (apiKey) {
config["headers"] = { Authorization: `Bearer ${apiKey}` }
}
const response = await axios.get(`${baseUrl}/models`, config)
const modelsArray = response.data?.data?.map((model: any) => model.id) || []
const models = [...new Set<string>(modelsArray)]
return models
} catch (error) {
return []
}
}
// OpenRouter
async handleOpenRouterCallback(code: string) {
let apiKey: string
try {
const response = await axios.post("https://openrouter.ai/api/v1/auth/keys", { code })
if (response.data && response.data.key) {
apiKey = response.data.key
} else {
throw new Error("Invalid response from OpenRouter API")
}
} catch (error) {
console.error("Error exchanging code for API key:", error)
throw error
}
const openrouter: ApiProvider = "openrouter"
await this.updateGlobalState("apiProvider", openrouter)
await this.storeSecret("openRouterApiKey", apiKey)
await this.postStateToWebview()
if (this.cline) {
this.cline.api = buildApiHandler({
apiProvider: openrouter,
openRouterApiKey: apiKey,
})
}
// await this.postMessageToWebview({ type: "action", action: "settingsButtonClicked" }) // bad ux if user is on welcome
}
private async ensureCacheDirectoryExists(): Promise<string> {
const cacheDir = path.join(this.context.globalStorageUri.fsPath, "cache")
await fs.mkdir(cacheDir, { recursive: true })
return cacheDir
}
async readOpenRouterModels(): Promise<Record<string, ModelInfo> | undefined> {
const openRouterModelsFilePath = path.join(await this.ensureCacheDirectoryExists(), GlobalFileNames.openRouterModels)
const fileExists = await fileExistsAtPath(openRouterModelsFilePath)
if (fileExists) {
const fileContents = await fs.readFile(openRouterModelsFilePath, "utf8")
return JSON.parse(fileContents)
}
return undefined
}
async refreshOpenRouterModels() {
const openRouterModelsFilePath = path.join(await this.ensureCacheDirectoryExists(), GlobalFileNames.openRouterModels)
let models: Record<string, ModelInfo> = {}
try {
const response = await axios.get("https://openrouter.ai/api/v1/models")
/*
{
"id": "anthropic/claude-3.5-sonnet",
"name": "Anthropic: Claude 3.5 Sonnet",
"created": 1718841600,
"description": "Claude 3.5 Sonnet delivers better-than-Opus capabilities, faster-than-Sonnet speeds, at the same Sonnet prices. Sonnet is particularly good at:\n\n- Coding: Autonomously writes, edits, and runs code with reasoning and troubleshooting\n- Data science: Augments human data science expertise; navigates unstructured data while using multiple tools for insights\n- Visual processing: excelling at interpreting charts, graphs, and images, accurately transcribing text to derive insights beyond just the text alone\n- Agentic tasks: exceptional tool use, making it great at agentic tasks (i.e. complex, multi-step problem solving tasks that require engaging with other systems)\n\n#multimodal",
"context_length": 200000,
"architecture": {
"modality": "text+image-\u003Etext",
"tokenizer": "Claude",
"instruct_type": null
},
"pricing": {
"prompt": "0.000003",
"completion": "0.000015",
"image": "0.0048",
"request": "0"
},
"top_provider": {
"context_length": 200000,
"max_completion_tokens": 8192,
"is_moderated": true
},
"per_request_limits": null
},
*/
if (response.data?.data) {
const rawModels = response.data.data
const parsePrice = (price: any) => {
if (price) {
return parseFloat(price) * 1_000_000
}
return undefined
}
for (const rawModel of rawModels) {
const modelInfo: ModelInfo = {
maxTokens: rawModel.top_provider?.max_completion_tokens,
contextWindow: rawModel.context_length,
supportsImages: rawModel.architecture?.modality?.includes("image"),
supportsPromptCache: false,
inputPrice: parsePrice(rawModel.pricing?.prompt),
outputPrice: parsePrice(rawModel.pricing?.completion),
description: rawModel.description,
}
switch (rawModel.id) {
case "anthropic/claude-3-7-sonnet":
case "anthropic/claude-3-7-sonnet:beta":
case "anthropic/claude-3.7-sonnet":
case "anthropic/claude-3.7-sonnet:beta":
case "anthropic/claude-3.5-sonnet":
case "anthropic/claude-3.5-sonnet:beta":
// NOTE: this needs to be synced with api.ts/openrouter default model info
modelInfo.supportsComputerUse = true
modelInfo.supportsPromptCache = true
modelInfo.cacheWritesPrice = 3.75
modelInfo.cacheReadsPrice = 0.3
break
case "anthropic/claude-3.5-sonnet-20240620":
case "anthropic/claude-3.5-sonnet-20240620:beta":
modelInfo.supportsPromptCache = true
modelInfo.cacheWritesPrice = 3.75
modelInfo.cacheReadsPrice = 0.3
break
case "anthropic/claude-3-5-haiku":
case "anthropic/claude-3-5-haiku:beta":
case "anthropic/claude-3-5-haiku-20241022":
case "anthropic/claude-3-5-haiku-20241022:beta":
case "anthropic/claude-3.5-haiku":
case "anthropic/claude-3.5-haiku:beta":
case "anthropic/claude-3.5-haiku-20241022":
case "anthropic/claude-3.5-haiku-20241022:beta":
modelInfo.supportsPromptCache = true
modelInfo.cacheWritesPrice = 1.25
modelInfo.cacheReadsPrice = 0.1
break
case "anthropic/claude-3-opus":
case "anthropic/claude-3-opus:beta":
modelInfo.supportsPromptCache = true
modelInfo.cacheWritesPrice = 18.75
modelInfo.cacheReadsPrice = 1.5
break
case "anthropic/claude-3-haiku":
case "anthropic/claude-3-haiku:beta":
modelInfo.supportsPromptCache = true
modelInfo.cacheWritesPrice = 0.3
modelInfo.cacheReadsPrice = 0.03
break
case "deepseek/deepseek-chat":
modelInfo.supportsPromptCache = true
// see api.ts/deepSeekModels for more info
modelInfo.inputPrice = 0
modelInfo.cacheWritesPrice = 0.14
modelInfo.cacheReadsPrice = 0.014
break
}
models[rawModel.id] = modelInfo
}
} else {
console.error("Invalid response from OpenRouter API")
}
await fs.writeFile(openRouterModelsFilePath, JSON.stringify(models))
console.log("OpenRouter models fetched and saved", models)
} catch (error) {
console.error("Error fetching OpenRouter models:", error)
}
await this.postMessageToWebview({
type: "openRouterModels",
openRouterModels: models,
})
return models
}
// Task history
async getTaskWithId(id: string): Promise<{
historyItem: HistoryItem
taskDirPath: string
apiConversationHistoryFilePath: string
uiMessagesFilePath: string
apiConversationHistory: Anthropic.MessageParam[]
}> {
const history = ((await this.getGlobalState("taskHistory")) as HistoryItem[] | undefined) || []
const historyItem = history.find((item) => item.id === id)
if (historyItem) {
const taskDirPath = path.join(this.context.globalStorageUri.fsPath, "tasks", id)
const apiConversationHistoryFilePath = path.join(taskDirPath, GlobalFileNames.apiConversationHistory)
const uiMessagesFilePath = path.join(taskDirPath, GlobalFileNames.uiMessages)
const fileExists = await fileExistsAtPath(apiConversationHistoryFilePath)
if (fileExists) {
const apiConversationHistory = JSON.parse(await fs.readFile(apiConversationHistoryFilePath, "utf8"))
return {
historyItem,
taskDirPath,
apiConversationHistoryFilePath,
uiMessagesFilePath,
apiConversationHistory,
}
}
}
// if we tried to get a task that doesn't exist, remove it from state
// FIXME: this seems to happen sometimes when the json file doesnt save to disk for some reason
await this.deleteTaskFromState(id)
throw new Error("Task not found")
}
async showTaskWithId(id: string) {
if (id !== this.cline?.taskId) {
// non-current task
const { historyItem } = await this.getTaskWithId(id)
await this.initClineWithHistoryItem(historyItem) // clears existing task
}
await this.postMessageToWebview({
type: "action",
action: "chatButtonClicked",
})
}
async exportTaskWithId(id: string) {
const { historyItem, apiConversationHistory } = await this.getTaskWithId(id)
await downloadTask(historyItem.ts, apiConversationHistory)
}
async deleteTaskWithId(id: string) {
console.info("deleteTaskWithId: ", id)
if (id === this.cline?.taskId) {
await this.clearTask()
console.debug("cleared task")
}
const { taskDirPath, apiConversationHistoryFilePath, uiMessagesFilePath } = await this.getTaskWithId(id)
// Delete checkpoints
// deleteCheckpoints will determine if the task has legacy checkpoints or not and handle it accordingly
console.info("deleting checkpoints")
const taskHistory = ((await this.getGlobalState("taskHistory")) as HistoryItem[] | undefined) || []
const historyItem = taskHistory.find((item) => item.id === id)
//console.log("historyItem: ", historyItem)
if (historyItem) {
try {
await CheckpointTracker.deleteCheckpoints(id, historyItem, this.context.globalStorageUri.fsPath)
} catch (error) {
console.error(`Failed to delete checkpoints for task ${id}:`, error)
}
}
await this.deleteTaskFromState(id)
// Delete the task files
const apiConversationHistoryFileExists = await fileExistsAtPath(apiConversationHistoryFilePath)
if (apiConversationHistoryFileExists) {
await fs.unlink(apiConversationHistoryFilePath)
}
const uiMessagesFileExists = await fileExistsAtPath(uiMessagesFilePath)
if (uiMessagesFileExists) {
await fs.unlink(uiMessagesFilePath)
}
const legacyMessagesFilePath = path.join(taskDirPath, "claude_messages.json")
if (await fileExistsAtPath(legacyMessagesFilePath)) {
await fs.unlink(legacyMessagesFilePath)
}
await fs.rmdir(taskDirPath) // succeeds if the dir is empty
}
async deleteTaskFromState(id: string) {
console.log("deleteTaskFromState: ", id)
// Remove the task from history
const taskHistory = ((await this.getGlobalState("taskHistory")) as HistoryItem[] | undefined) || []
const updatedTaskHistory = taskHistory.filter((task) => task.id !== id)
await this.updateGlobalState("taskHistory", updatedTaskHistory)
// Notify the webview that the task has been deleted
await this.postStateToWebview()
}
async postStateToWebview() {
const state = await this.getStateToPostToWebview()
this.postMessageToWebview({ type: "state", state })
}
async getStateToPostToWebview(): Promise<ExtensionState> {
const {
apiConfiguration,
lastShownAnnouncementId,
customInstructions,
taskHistory,
autoApprovalSettings,
browserSettings,
chatSettings,
userInfo,
authToken,
mcpMarketplaceEnabled,
telemetrySetting,
} = await this.getState()
return {
version: this.context.extension?.packageJSON?.version ?? "",
apiConfiguration,
customInstructions,
uriScheme: vscode.env.uriScheme,
currentTaskItem: this.cline?.taskId ? (taskHistory || []).find((item) => item.id === this.cline?.taskId) : undefined,
checkpointTrackerErrorMessage: this.cline?.checkpointTrackerErrorMessage,
clineMessages: this.cline?.clineMessages || [],
taskHistory: (taskHistory || []).filter((item) => item.ts && item.task).sort((a, b) => b.ts - a.ts),
shouldShowAnnouncement: lastShownAnnouncementId !== this.latestAnnouncementId,
platform: process.platform as Platform,
autoApprovalSettings,
browserSettings,
chatSettings,
isLoggedIn: !!authToken,
userInfo,
mcpMarketplaceEnabled,
telemetrySetting,
vscMachineId: vscode.env.machineId,
}
}
async clearTask() {
this.cline?.abortTask()
this.cline = undefined // removes reference to it, so once promises end it will be garbage collected
}
// Caching mechanism to keep track of webview messages + API conversation history per provider instance
/*
Now that we use retainContextWhenHidden, we don't have to store a cache of cline messages in the user's state, but we could to reduce memory footprint in long conversations.
- We have to be careful of what state is shared between ClineProvider instances since there could be multiple instances of the extension running at once. For example when we cached cline messages using the same key, two instances of the extension could end up using the same key and overwriting each other's messages.
- Some state does need to be shared between the instances, i.e. the API key--however there doesn't seem to be a good way to notify the other instances that the API key has changed.
We need to use a unique identifier for each ClineProvider instance's message cache since we could be running several instances of the extension outside of just the sidebar i.e. in editor panels.
// conversation history to send in API requests
/*
It seems that some API messages do not comply with vscode state requirements. Either the Anthropic library is manipulating these values somehow in the backend in a way thats creating cyclic references, or the API returns a function or a Symbol as part of the message content.
VSCode docs about state: "The value must be JSON-stringifyable ... value  A value. MUST not contain cyclic references."
For now we'll store the conversation history in memory, and if we need to store in state directly we'd need to do a manual conversion to ensure proper json stringification.
*/
// getApiConversationHistory(): Anthropic.MessageParam[] {
// // const history = (await this.getGlobalState(
// // this.getApiConversationHistoryStateKey()
// // )) as Anthropic.MessageParam[]
// // return history || []
// return this.apiConversationHistory
// }
// setApiConversationHistory(history: Anthropic.MessageParam[] | undefined) {
// // await this.updateGlobalState(this.getApiConversationHistoryStateKey(), history)
// this.apiConversationHistory = history || []
// }
// addMessageToApiConversationHistory(message: Anthropic.MessageParam): Anthropic.MessageParam[] {
// // const history = await this.getApiConversationHistory()
// // history.push(message)
// // await this.setApiConversationHistory(history)
// // return history
// this.apiConversationHistory.push(message)
// return this.apiConversationHistory
// }
/*
Storage
https://dev.to/kompotkot/how-to-use-secretstorage-in-your-vscode-extensions-2hco
https://www.eliostruyf.com/devhack-code-extension-storage-options/
*/
async getState() {
const [
storedApiProvider,
apiModelId,
apiKey,
openRouterApiKey,
awsAccessKey,
awsSecretKey,
awsSessionToken,
awsRegion,
awsUseCrossRegionInference,
awsProfile,
awsUseProfile,
vertexProjectId,
vertexRegion,
openAiBaseUrl,
openAiApiKey,
openAiModelId,
openAiModelInfo,
ollamaModelId,
ollamaBaseUrl,
lmStudioModelId,
lmStudioBaseUrl,
anthropicBaseUrl,
geminiApiKey,
openAiNativeApiKey,
deepSeekApiKey,
requestyApiKey,
requestyModelId,
togetherApiKey,
togetherModelId,
qwenApiKey,
mistralApiKey,
azureApiVersion,
openRouterModelId,
openRouterModelInfo,
lastShownAnnouncementId,
customInstructions,
taskHistory,
autoApprovalSettings,
browserSettings,
chatSettings,
vsCodeLmModelSelector,
liteLlmBaseUrl,
liteLlmModelId,
userInfo,
authToken,
previousModeApiProvider,
previousModeModelId,
previousModeModelInfo,
qwenApiLine,
liteLlmApiKey,
telemetrySetting,
xaiApiKey,
] = await Promise.all([
this.getGlobalState("apiProvider") as Promise<ApiProvider | undefined>,
this.getGlobalState("apiModelId") as Promise<string | undefined>,
this.getSecret("apiKey") as Promise<string | undefined>,
this.getSecret("openRouterApiKey") as Promise<string | undefined>,
this.getSecret("awsAccessKey") as Promise<string | undefined>,
this.getSecret("awsSecretKey") as Promise<string | undefined>,
this.getSecret("awsSessionToken") as Promise<string | undefined>,
this.getGlobalState("awsRegion") as Promise<string | undefined>,
this.getGlobalState("awsUseCrossRegionInference") as Promise<boolean | undefined>,
this.getGlobalState("awsProfile") as Promise<string | undefined>,
this.getGlobalState("awsUseProfile") as Promise<boolean | undefined>,
this.getGlobalState("vertexProjectId") as Promise<string | undefined>,
this.getGlobalState("vertexRegion") as Promise<string | undefined>,
this.getGlobalState("openAiBaseUrl") as Promise<string | undefined>,
this.getSecret("openAiApiKey") as Promise<string | undefined>,
this.getGlobalState("openAiModelId") as Promise<string | undefined>,
this.getGlobalState("openAiModelInfo") as Promise<ModelInfo | undefined>,
this.getGlobalState("ollamaModelId") as Promise<string | undefined>,
this.getGlobalState("ollamaBaseUrl") as Promise<string | undefined>,
this.getGlobalState("lmStudioModelId") as Promise<string | undefined>,
this.getGlobalState("lmStudioBaseUrl") as Promise<string | undefined>,
this.getGlobalState("anthropicBaseUrl") as Promise<string | undefined>,
this.getSecret("geminiApiKey") as Promise<string | undefined>,
this.getSecret("openAiNativeApiKey") as Promise<string | undefined>,
this.getSecret("deepSeekApiKey") as Promise<string | undefined>,
this.getSecret("requestyApiKey") as Promise<string | undefined>,
this.getGlobalState("requestyModelId") as Promise<string | undefined>,
this.getSecret("togetherApiKey") as Promise<string | undefined>,
this.getGlobalState("togetherModelId") as Promise<string | undefined>,
this.getSecret("qwenApiKey") as Promise<string | undefined>,
this.getSecret("mistralApiKey") as Promise<string | undefined>,
this.getGlobalState("azureApiVersion") as Promise<string | undefined>,
this.getGlobalState("openRouterModelId") as Promise<string | undefined>,
this.getGlobalState("openRouterModelInfo") as Promise<ModelInfo | undefined>,
this.getGlobalState("lastShownAnnouncementId") as Promise<string | undefined>,
this.getGlobalState("customInstructions") as Promise<string | undefined>,
this.getGlobalState("taskHistory") as Promise<HistoryItem[] | undefined>,
this.getGlobalState("autoApprovalSettings") as Promise<AutoApprovalSettings | undefined>,
this.getGlobalState("browserSettings") as Promise<BrowserSettings | undefined>,
this.getGlobalState("chatSettings") as Promise<ChatSettings | undefined>,
this.getGlobalState("vsCodeLmModelSelector") as Promise<vscode.LanguageModelChatSelector | undefined>,
this.getGlobalState("liteLlmBaseUrl") as Promise<string | undefined>,
this.getGlobalState("liteLlmModelId") as Promise<string | undefined>,
this.getGlobalState("userInfo") as Promise<UserInfo | undefined>,
this.getSecret("authToken") as Promise<string | undefined>,
this.getGlobalState("previousModeApiProvider") as Promise<ApiProvider | undefined>,
this.getGlobalState("previousModeModelId") as Promise<string | undefined>,
this.getGlobalState("previousModeModelInfo") as Promise<ModelInfo | undefined>,
this.getGlobalState("qwenApiLine") as Promise<string | undefined>,
this.getSecret("liteLlmApiKey") as Promise<string | undefined>,
this.getGlobalState("telemetrySetting") as Promise<TelemetrySetting | undefined>,
this.getSecret("xaiApiKey") as Promise<string | undefined>,
])
let apiProvider: ApiProvider
if (storedApiProvider) {
apiProvider = storedApiProvider
} else {
// Either new user or legacy user that doesn't have the apiProvider stored in state
// (If they're using OpenRouter or Bedrock, then apiProvider state will exist)
if (apiKey) {
apiProvider = "anthropic"
} else {
// New users should default to openrouter
apiProvider = "openrouter"
}
}
const o3MiniReasoningEffort = vscode.workspace
.getConfiguration("cline.modelSettings.o3Mini")
.get("reasoningEffort", "medium")
const thinkingBudgetTokens = vscode.workspace
.getConfiguration("cline.modelSettings.anthropic")
.get("thinkingBudgetTokens", 0)
const mcpMarketplaceEnabled = vscode.workspace.getConfiguration("cline").get<boolean>("mcpMarketplace.enabled", true)
return {
apiConfiguration: {
apiProvider,
apiModelId,
apiKey,
openRouterApiKey,
awsAccessKey,
awsSecretKey,
awsSessionToken,
awsRegion,
awsUseCrossRegionInference,
awsProfile,
awsUseProfile,
vertexProjectId,
vertexRegion,
openAiBaseUrl,
openAiApiKey,
openAiModelId,
openAiModelInfo,
ollamaModelId,
ollamaBaseUrl,
lmStudioModelId,
lmStudioBaseUrl,
anthropicBaseUrl,
geminiApiKey,
openAiNativeApiKey,
deepSeekApiKey,
requestyApiKey,
requestyModelId,
togetherApiKey,
togetherModelId,
qwenApiKey,
qwenApiLine,
mistralApiKey,
azureApiVersion,
openRouterModelId,
openRouterModelInfo,
vsCodeLmModelSelector,
o3MiniReasoningEffort,
thinkingBudgetTokens,
liteLlmBaseUrl,
liteLlmModelId,
liteLlmApiKey,
xaiApiKey,
},
lastShownAnnouncementId,
customInstructions,
taskHistory,
autoApprovalSettings: autoApprovalSettings || DEFAULT_AUTO_APPROVAL_SETTINGS, // default value can be 0 or empty string
browserSettings: browserSettings || DEFAULT_BROWSER_SETTINGS,
chatSettings: chatSettings || DEFAULT_CHAT_SETTINGS,
userInfo,
authToken,
previousModeApiProvider,
previousModeModelId,
previousModeModelInfo,
mcpMarketplaceEnabled,
telemetrySetting: telemetrySetting || "unset",
}
}
async updateTaskHistory(item: HistoryItem): Promise<HistoryItem[]> {
const history = ((await this.getGlobalState("taskHistory")) as HistoryItem[]) || []
const existingItemIndex = history.findIndex((h) => h.id === item.id)
if (existingItemIndex !== -1) {
history[existingItemIndex] = item
} else {
history.push(item)
}
await this.updateGlobalState("taskHistory", history)
return history
}
// global
async updateGlobalState(key: GlobalStateKey, value: any) {
await this.context.globalState.update(key, value)
}
async getGlobalState(key: GlobalStateKey) {
return await this.context.globalState.get(key)
}
// workspace
private async updateWorkspaceState(key: string, value: any) {
await this.context.workspaceState.update(key, value)
}
private async getWorkspaceState(key: string) {
return await this.context.workspaceState.get(key)
}
// private async clearState() {
// this.context.workspaceState.keys().forEach((key) => {
// this.context.workspaceState.update(key, undefined)
// })
// this.context.globalState.keys().forEach((key) => {
// this.context.globalState.update(key, undefined)
// })
// this.context.secrets.delete("apiKey")
// }
// secrets
private async storeSecret(key: SecretKey, value?: string) {
if (value) {
await this.context.secrets.store(key, value)
} else {
await this.context.secrets.delete(key)
}
}
async getSecret(key: SecretKey) {
return await this.context.secrets.get(key)
}
// Open Graph Data
async fetchOpenGraphData(url: string) {
try {
// Use the fetchOpenGraphData function from link-preview.ts
const ogData = await fetchOpenGraphData(url)
// Send the data back to the webview
await this.postMessageToWebview({
type: "openGraphData",
openGraphData: ogData,
url: url,
})
} catch (error) {
console.error(`Error fetching Open Graph data for ${url}:`, error)
// Send an error response
await this.postMessageToWebview({
type: "openGraphData",
error: `Failed to fetch Open Graph data: ${error}`,
url: url,
})
}
}
// Check if a URL is an image
async checkIsImageUrl(url: string) {
try {
// Check if the URL is an image
const isImage = await isImageUrl(url)
// Send the result back to the webview
await this.postMessageToWebview({
type: "isImageUrlResult",
isImage,
url,
})
} catch (error) {
console.error(`Error checking if URL is an image: ${url}`, error)
// Send an error response
await this.postMessageToWebview({
type: "isImageUrlResult",
isImage: false,
url,
})
}
}
// dev
async resetState() {
vscode.window.showInformationMessage("Resetting state...")
for (const key of this.context.globalState.keys()) {
await this.context.globalState.update(key, undefined)
}
const secretKeys: SecretKey[] = [
"apiKey",
"openRouterApiKey",
"awsAccessKey",
"awsSecretKey",
"awsSessionToken",
"openAiApiKey",
"geminiApiKey",
"openAiNativeApiKey",
"deepSeekApiKey",
"requestyApiKey",
"togetherApiKey",
"qwenApiKey",
"mistralApiKey",
"liteLlmApiKey",
"authToken",
"xaiApiKey",
]
for (const key of secretKeys) {
await this.storeSecret(key, undefined)
}
if (this.cline) {
this.cline.abortTask()
this.cline = undefined
}
vscode.window.showInformationMessage("State reset")
await this.postStateToWebview()
await this.postMessageToWebview({
type: "action",
action: "chatButtonClicked",
})
}
}