mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-06 08:18:39 +00:00
2197 lines
76 KiB
TypeScript
2197 lines
76 KiB
TypeScript
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",
|
||
})
|
||
}
|
||
}
|