diff --git a/CHANGELOG.md b/CHANGELOG.md index 4baaf087ea..e78dd13dbe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Change Log +## [3.1.0] + +- Added checkpoints: Snapshots of workspace are automatically created whenever Cline uses a tool + - Compare changes: Hover over any tool use to see a diff between the snapshot and current workspace state + - Restore options: Choose to restore just the task state, just the workspace files, or both +- New 'See new changes' button appears after task completion, providing an overview of all workspace changes +- Task header now shows disk space usage with a delete button to help manage snapshot storage + ## [3.0.12] - Fix DeepSeek API cost reporting (input price is 0 since it's all either a cache read or write, different than how Anthropic reports cache usage) diff --git a/README.md b/README.md index 6819183bda..3617732b80 100644 --- a/README.md +++ b/README.md @@ -114,6 +114,22 @@ Thanks to the [Model Context Protocol](https://github.com/modelcontextprotocol), **`@folder`:** Adds folder's files all at once to speed up your workflow even more + + +
+ + + +### Checkpoints: Compare and Restore + +As Cline works through a task, the extension takes a snapshot of your workspace at each step. You can use the 'Compare' button to see a diff between the snapshot and your current workspace, and the 'Restore' button to roll back to that point. + +For example, when working with a local web server, you can use 'Restore Workspace Only' to quickly test different versions of your app, then use 'Restore Task and Workspace' when you find the version you want to continue building from. This lets you safely explore different approaches without losing progress. + + + +
+ ## Contributing To contribute to the project, start with our [Contributing Guide](CONTRIBUTING.md) to learn the basics. You can also join our [Discord](https://discord.gg/cline) to chat with other contributors in the `#contributors` channel. If you're looking for full-time work, check out our open positions on our [careers page](https://cline.bot/join-us)! diff --git a/package-lock.json b/package-lock.json index 5723f20b33..42e106c3bc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "claude-dev", - "version": "3.0.9", + "version": "3.0.12", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "claude-dev", - "version": "3.0.9", + "version": "3.0.12", "license": "Apache-2.0", "dependencies": { "@anthropic-ai/bedrock-sdk": "^0.10.2", @@ -15,6 +15,7 @@ "@google/generative-ai": "^0.18.0", "@modelcontextprotocol/sdk": "^1.0.1", "@types/clone-deep": "^4.0.4", + "@types/get-folder-size": "^3.0.4", "@types/pdf-parse": "^1.1.4", "@types/turndown": "^5.0.5", "@vscode/codicons": "^0.0.36", @@ -27,6 +28,7 @@ "diff": "^5.2.0", "execa": "^9.5.2", "fast-deep-equal": "^3.1.3", + "get-folder-size": "^5.0.0", "globby": "^14.0.2", "isbinaryfile": "^5.0.2", "mammoth": "^1.8.0", @@ -38,6 +40,7 @@ "puppeteer-chromium-resolver": "^23.0.0", "puppeteer-core": "^23.4.0", "serialize-error": "^11.0.3", + "simple-git": "^3.27.0", "strip-ansi": "^7.1.0", "tree-sitter-wasms": "^0.1.11", "turndown": "^7.2.0", @@ -2777,6 +2780,21 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@kwsites/file-exists": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@kwsites/file-exists/-/file-exists-1.1.1.tgz", + "integrity": "sha512-m9/5YGR18lIwxSFDwfE3oA7bWuq9kdau6ugN4H2rJeyhFQZcG9AgSHkQtSD15a8WvTgfz9aikZMrKPHvbpqFiw==", + "license": "MIT", + "dependencies": { + "debug": "^4.1.1" + } + }, + "node_modules/@kwsites/promise-deferred": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@kwsites/promise-deferred/-/promise-deferred-1.1.1.tgz", + "integrity": "sha512-GaHYm+c0O9MjZRu0ongGBRbinu8gVAMd2UZjji6jVmqKtZluZnptXGWhz1E8j8D2HJ3f/yMxKAUC0b+57wncIw==", + "license": "MIT" + }, "node_modules/@mixmark-io/domino": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/@mixmark-io/domino/-/domino-2.2.0.tgz", @@ -4546,6 +4564,15 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/get-folder-size": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/get-folder-size/-/get-folder-size-3.0.4.tgz", + "integrity": "sha512-tSf/k7Undx6jKRwpChR9tl+0ZPf0BVwkjBRtJ5qSnz6iWm2ZRYMAS2MktC2u7YaTAFHmxpL/LBxI85M7ioJCSg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/istanbul-lib-coverage": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", @@ -7198,6 +7225,18 @@ "node": "6.* || 8.* || >= 10.*" } }, + "node_modules/get-folder-size": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/get-folder-size/-/get-folder-size-5.0.0.tgz", + "integrity": "sha512-+fgtvbL83tSDypEK+T411GDBQVQtxv+qtQgbV+HVa/TYubqDhNd5ghH/D6cOHY9iC5/88GtOZB7WI8PXy2A3bg==", + "license": "MIT", + "bin": { + "get-folder-size": "bin/get-folder-size.js" + }, + "engines": { + "node": ">=18.11.0" + } + }, "node_modules/get-intrinsic": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.4.tgz", @@ -10464,6 +10503,21 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/simple-git": { + "version": "3.27.0", + "resolved": "https://registry.npmjs.org/simple-git/-/simple-git-3.27.0.tgz", + "integrity": "sha512-ivHoFS9Yi9GY49ogc6/YAi3Fl9ROnF4VyubNylgCkA+RVqLaKWnDSzXOVzya8csELIaWaYNutsEuAhZrtOjozA==", + "license": "MIT", + "dependencies": { + "@kwsites/file-exists": "^1.1.1", + "@kwsites/promise-deferred": "^1.1.1", + "debug": "^4.3.5" + }, + "funding": { + "type": "github", + "url": "https://github.com/steveukx/git-js?sponsor=1" + } + }, "node_modules/slash": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/slash/-/slash-5.1.0.tgz", diff --git a/package.json b/package.json index aef1c202bf..86cad9d838 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "claude-dev", "displayName": "Cline (prev. Claude Dev)", "description": "Autonomous coding agent right in your IDE, capable of creating/editing files, running commands, using the browser, and more with your permission every step of the way.", - "version": "3.0.12", + "version": "3.1.1", "icon": "assets/icons/icon.png", "galleryBanner": { "color": "#617A91", @@ -171,6 +171,7 @@ "@google/generative-ai": "^0.18.0", "@modelcontextprotocol/sdk": "^1.0.1", "@types/clone-deep": "^4.0.4", + "@types/get-folder-size": "^3.0.4", "@types/pdf-parse": "^1.1.4", "@types/turndown": "^5.0.5", "@vscode/codicons": "^0.0.36", @@ -183,6 +184,7 @@ "diff": "^5.2.0", "execa": "^9.5.2", "fast-deep-equal": "^3.1.3", + "get-folder-size": "^5.0.0", "globby": "^14.0.2", "isbinaryfile": "^5.0.2", "mammoth": "^1.8.0", @@ -194,6 +196,7 @@ "puppeteer-chromium-resolver": "^23.0.0", "puppeteer-core": "^23.4.0", "serialize-error": "^11.0.3", + "simple-git": "^3.27.0", "strip-ansi": "^7.1.0", "tree-sitter-wasms": "^0.1.11", "turndown": "^7.2.0", diff --git a/src/api/providers/anthropic.ts b/src/api/providers/anthropic.ts index 76e5487a93..6fbe1f2509 100644 --- a/src/api/providers/anthropic.ts +++ b/src/api/providers/anthropic.ts @@ -39,7 +39,13 @@ export class AnthropicHandler implements ApiHandler { model: modelId, max_tokens: this.getModel().info.maxTokens || 8192, temperature: 0, - system: [{ text: systemPrompt, type: "text", cache_control: { type: "ephemeral" } }], // setting cache breakpoint for system prompt so new tasks can reuse it + system: [ + { + text: systemPrompt, + type: "text", + cache_control: { type: "ephemeral" }, + }, + ], // setting cache breakpoint for system prompt so new tasks can reuse it messages: messages.map((message, index) => { if (index === lastUserMsgIndex || index === secondLastMsgUserIndex) { return { @@ -50,12 +56,19 @@ export class AnthropicHandler implements ApiHandler { { type: "text", text: message.content, - cache_control: { type: "ephemeral" }, + cache_control: { + type: "ephemeral", + }, }, ] : message.content.map((content, contentIndex) => contentIndex === message.content.length - 1 - ? { ...content, cache_control: { type: "ephemeral" } } + ? { + ...content, + cache_control: { + type: "ephemeral", + }, + } : content, ), } @@ -77,7 +90,9 @@ export class AnthropicHandler implements ApiHandler { case "claude-3-opus-20240229": case "claude-3-haiku-20240307": return { - headers: { "anthropic-beta": "prompt-caching-2024-07-31" }, + headers: { + "anthropic-beta": "prompt-caching-2024-07-31", + }, } default: return undefined @@ -165,6 +180,9 @@ export class AnthropicHandler implements ApiHandler { const id = modelId as AnthropicModelId return { id, info: anthropicModels[id] } } - return { id: anthropicDefaultModelId, info: anthropicModels[anthropicDefaultModelId] } + return { + id: anthropicDefaultModelId, + info: anthropicModels[anthropicDefaultModelId], + } } } diff --git a/src/api/providers/bedrock.ts b/src/api/providers/bedrock.ts index 58f75ad4ac..a82a71b8ef 100644 --- a/src/api/providers/bedrock.ts +++ b/src/api/providers/bedrock.ts @@ -107,6 +107,9 @@ export class AwsBedrockHandler implements ApiHandler { const id = modelId as BedrockModelId return { id, info: bedrockModels[id] } } - return { id: bedrockDefaultModelId, info: bedrockModels[bedrockDefaultModelId] } + return { + id: bedrockDefaultModelId, + info: bedrockModels[bedrockDefaultModelId], + } } } diff --git a/src/api/providers/deepseek.ts b/src/api/providers/deepseek.ts index 9539ce35aa..a903ce2dd9 100644 --- a/src/api/providers/deepseek.ts +++ b/src/api/providers/deepseek.ts @@ -56,6 +56,9 @@ export class DeepSeekHandler implements ApiHandler { const id = modelId as DeepSeekModelId return { id, info: deepSeekModels[id] } } - return { id: deepSeekDefaultModelId, info: deepSeekModels[deepSeekDefaultModelId] } + return { + id: deepSeekDefaultModelId, + info: deepSeekModels[deepSeekDefaultModelId], + } } } diff --git a/src/api/providers/gemini.ts b/src/api/providers/gemini.ts index d7ac5ec67d..39c55548d1 100644 --- a/src/api/providers/gemini.ts +++ b/src/api/providers/gemini.ts @@ -51,6 +51,9 @@ export class GeminiHandler implements ApiHandler { const id = modelId as GeminiModelId return { id, info: geminiModels[id] } } - return { id: geminiDefaultModelId, info: geminiModels[geminiDefaultModelId] } + return { + id: geminiDefaultModelId, + info: geminiModels[geminiDefaultModelId], + } } } diff --git a/src/api/providers/openai-native.ts b/src/api/providers/openai-native.ts index 70d55b7abe..d11481add4 100644 --- a/src/api/providers/openai-native.ts +++ b/src/api/providers/openai-native.ts @@ -80,6 +80,9 @@ export class OpenAiNativeHandler implements ApiHandler { const id = modelId as OpenAiNativeModelId return { id, info: openAiNativeModels[id] } } - return { id: openAiNativeDefaultModelId, info: openAiNativeModels[openAiNativeDefaultModelId] } + return { + id: openAiNativeDefaultModelId, + info: openAiNativeModels[openAiNativeDefaultModelId], + } } } diff --git a/src/api/providers/openrouter.ts b/src/api/providers/openrouter.ts index 32e50de5d7..3b9d7a354a 100644 --- a/src/api/providers/openrouter.ts +++ b/src/api/providers/openrouter.ts @@ -176,6 +176,9 @@ export class OpenRouterHandler implements ApiHandler { if (modelId && modelInfo) { return { id: modelId, info: modelInfo } } - return { id: openRouterDefaultModelId, info: openRouterDefaultModelInfo } + return { + id: openRouterDefaultModelId, + info: openRouterDefaultModelInfo, + } } } diff --git a/src/api/providers/vertex.ts b/src/api/providers/vertex.ts index 60e6967dd6..c8f1efd873 100644 --- a/src/api/providers/vertex.ts +++ b/src/api/providers/vertex.ts @@ -81,6 +81,9 @@ export class VertexHandler implements ApiHandler { const id = modelId as VertexModelId return { id, info: vertexModels[id] } } - return { id: vertexDefaultModelId, info: vertexModels[vertexDefaultModelId] } + return { + id: vertexDefaultModelId, + info: vertexModels[vertexDefaultModelId], + } } } diff --git a/src/api/transform/o1-format.ts b/src/api/transform/o1-format.ts index 1346fdbd54..b7577cf903 100644 --- a/src/api/transform/o1-format.ts +++ b/src/api/transform/o1-format.ts @@ -244,7 +244,10 @@ const toolNames = [ "attempt_completion", ] -function parseAIResponse(response: string): { normalText: string; toolCalls: ToolCall[] } { +function parseAIResponse(response: string): { + normalText: string + toolCalls: ToolCall[] +} { // Create a regex pattern to match any tool call opening tag const toolCallPattern = new RegExp(`<(${toolNames.join("|")})`, "i") const match = response.match(toolCallPattern) diff --git a/src/api/transform/openai-format.ts b/src/api/transform/openai-format.ts index aa995c7448..0b134ad4ac 100644 --- a/src/api/transform/openai-format.ts +++ b/src/api/transform/openai-format.ts @@ -8,7 +8,10 @@ export function convertToOpenAiMessages( for (const anthropicMessage of anthropicMessages) { if (typeof anthropicMessage.content === "string") { - openAiMessages.push({ role: anthropicMessage.role, content: anthropicMessage.content }) + openAiMessages.push({ + role: anthropicMessage.role, + content: anthropicMessage.content, + }) } else { // image_url.url is base64 encoded image data // ensure it contains the content-type of the image: data:image/png;base64, @@ -85,7 +88,9 @@ export function convertToOpenAiMessages( if (part.type === "image") { return { type: "image_url", - image_url: { url: `data:${part.source.media_type};base64,${part.source.data}` }, + image_url: { + url: `data:${part.source.media_type};base64,${part.source.data}`, + }, } } return { type: "text", text: part.text } diff --git a/src/core/Cline.ts b/src/core/Cline.ts index 7b2dd4d31b..e3f46e93a1 100644 --- a/src/core/Cline.ts +++ b/src/core/Cline.ts @@ -9,7 +9,7 @@ import { serializeError } from "serialize-error" import * as vscode from "vscode" import { ApiHandler, buildApiHandler } from "../api" import { ApiStream } from "../api/transform/stream" -import { DiffViewProvider } from "../integrations/editor/DiffViewProvider" +import { DIFF_VIEW_URI_SCHEME, DiffViewProvider } from "../integrations/editor/DiffViewProvider" import { findToolName, formatContentBlockToMarkdown } from "../integrations/misc/export-markdown" import { extractTextFromFile } from "../integrations/misc/extract-text" import { TerminalManager } from "../integrations/terminal/TerminalManager" @@ -19,7 +19,7 @@ import { listFiles } from "../services/glob/list-files" import { regexSearchFiles } from "../services/ripgrep" import { parseSourceCodeForDefinitionsTopLevel } from "../services/tree-sitter" import { ApiConfiguration } from "../shared/api" -import { findLastIndex } from "../shared/array" +import { findLast, findLastIndex } from "../shared/array" import { AutoApprovalSettings } from "../shared/AutoApprovalSettings" import { combineApiRequests } from "../shared/combineApiRequests" import { combineCommandSequences, COMMAND_REQ_APP_STRING } from "../shared/combineCommandSequences" @@ -35,10 +35,11 @@ import { ClineSay, ClineSayBrowserAction, ClineSayTool, + COMPLETION_RESULT_CHANGES_FLAG, } from "../shared/ExtensionMessage" import { getApiMetrics } from "../shared/getApiMetrics" import { HistoryItem } from "../shared/HistoryItem" -import { ClineAskResponse } from "../shared/WebviewMessage" +import { ClineAskResponse, ClineCheckpointRestore } from "../shared/WebviewMessage" import { calculateApiCost } from "../utils/cost" import { fileExistsAtPath } from "../utils/fs" import { arePathsEqual, getReadablePath } from "../utils/path" @@ -47,12 +48,14 @@ import { constructNewFileContent } from "./assistant-message/diff" import { parseMentions } from "./mentions" import { formatResponse } from "./prompts/responses" import { addUserInstructions, SYSTEM_PROMPT } from "./prompts/system" -import { truncateHalfConversation } from "./sliding-window" +import { getNextTruncationRange, getTruncatedMessages } from "./sliding-window" import { ClineProvider, GlobalFileNames } from "./webview/ClineProvider" import { showSystemNotification } from "../integrations/notifications" import { removeInvalidChars } from "../utils/string" import { fixModelHtmlEscaping } from "../utils/string" import { OpenAiHandler } from "../api/providers/openai" +import CheckpointTracker from "../integrations/checkpoints/CheckpointTracker" +import getFolderSize from "get-folder-size" const cwd = vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath).at(0) ?? path.join(os.homedir(), "Desktop") // may or may not exist but fs checking existence would immediately ask for permission which would be bad UX, need to come up with a better solution @@ -80,11 +83,16 @@ export class Cline { private consecutiveMistakeCount: number = 0 private providerRef: WeakRef private abort: boolean = false - didFinishAborting = false + didFinishAbortingStream = false abandoned = false private diffViewProvider: DiffViewProvider + private checkpointTracker?: CheckpointTracker + checkpointTrackerErrorMessage?: string + conversationHistoryDeletedRange?: [number, number] + isInitialized = false // streaming + isStreaming = false private currentStreamingContentIndex = 0 private assistantMessageContent: AssistantMessageContent[] = [] private presentAssistantMessageLocked = false @@ -114,6 +122,7 @@ export class Cline { this.autoApprovalSettings = autoApprovalSettings if (historyItem) { this.taskId = historyItem.id + this.conversationHistoryDeletedRange = historyItem.conversationHistoryDeletedRange this.resumeTaskFromHistory() } else if (task || images) { this.taskId = Date.now().toString() @@ -181,6 +190,10 @@ export class Cline { } private async addToClineMessages(message: ClineMessage) { + // these values allow us to reconstruct the conversation history at the time this cline message was created + // it's important that apiConversationHistory is initialized before we add cline messages + message.conversationHistoryIndex = this.apiConversationHistory.length - 1 // NOTE: this is the index of the last added message which is the user message, and once the clinemessages have been presented we update the apiconversationhistory with the completed assistant message. This means when reseting to a message, we need to +1 this index to get the correct assistant message that this tool use corresponds to + message.conversationHistoryDeletedRange = this.conversationHistoryDeletedRange this.clineMessages.push(message) await this.saveClineMessages() } @@ -192,7 +205,8 @@ export class Cline { private async saveClineMessages() { try { - const filePath = path.join(await this.ensureTaskDirectoryExists(), GlobalFileNames.uiMessages) + const taskDir = await this.ensureTaskDirectoryExists() + const filePath = path.join(taskDir, GlobalFileNames.uiMessages) await fs.writeFile(filePath, JSON.stringify(this.clineMessages)) // combined as they are in ChatView const apiMetrics = getApiMetrics(combineApiRequests(combineCommandSequences(this.clineMessages.slice(1)))) @@ -201,6 +215,14 @@ export class Cline { this.clineMessages[ findLastIndex(this.clineMessages, (m) => !(m.ask === "resume_task" || m.ask === "resume_completed_task")) ] + let taskDirSize = 0 + try { + // getFolderSize.loose silently ignores errors + // returns # of bytes, size/1000/1000 = MB + taskDirSize = await getFolderSize.loose(taskDir) + } catch (error) { + console.error("Failed to get task directory size:", taskDir, error) + } await this.providerRef.deref()?.updateTaskHistory({ id: this.taskId, ts: lastRelevantMessage.ts, @@ -210,12 +232,265 @@ export class Cline { cacheWrites: apiMetrics.totalCacheWrites, cacheReads: apiMetrics.totalCacheReads, totalCost: apiMetrics.totalCost, + size: taskDirSize, + shadowGitConfigWorkTree: await this.checkpointTracker?.getShadowGitConfigWorkTree(), + conversationHistoryDeletedRange: this.conversationHistoryDeletedRange, }) } catch (error) { console.error("Failed to save cline messages:", error) } } + async restoreCheckpoint(messageTs: number, restoreType: ClineCheckpointRestore) { + const messageIndex = this.clineMessages.findIndex((m) => m.ts === messageTs) + const message = this.clineMessages[messageIndex] + if (!message) { + console.error("Message not found", this.clineMessages) + return + } + + let didWorkspaceRestoreFail = false + + switch (restoreType) { + case "task": + break + case "taskAndWorkspace": + case "workspace": + if (!this.checkpointTracker) { + try { + this.checkpointTracker = await CheckpointTracker.create(this.taskId, this.providerRef.deref()) + this.checkpointTrackerErrorMessage = undefined + } catch (error) { + const errorMessage = error instanceof Error ? error.message : "Unknown error" + console.error("Failed to initialize checkpoint tracker:", errorMessage) + this.checkpointTrackerErrorMessage = errorMessage + await this.providerRef.deref()?.postStateToWebview() + vscode.window.showErrorMessage(errorMessage) + didWorkspaceRestoreFail = true + } + } + if (message.lastCheckpointHash && this.checkpointTracker) { + try { + await this.checkpointTracker.resetHead(message.lastCheckpointHash) + } catch (error) { + const errorMessage = error instanceof Error ? error.message : "Unknown error" + vscode.window.showErrorMessage("Failed to restore checkpoint: " + errorMessage) + didWorkspaceRestoreFail = true + } + } + break + } + + if (!didWorkspaceRestoreFail) { + switch (restoreType) { + case "task": + case "taskAndWorkspace": + this.conversationHistoryDeletedRange = message.conversationHistoryDeletedRange + const newConversationHistory = this.apiConversationHistory.slice( + 0, + (message.conversationHistoryIndex || 0) + 2, + ) // +1 since this index corresponds to the last user message, and another +1 since slice end index is exclusive + await this.overwriteApiConversationHistory(newConversationHistory) + + // aggregate deleted api reqs info so we don't lose costs/tokens + const deletedMessages = this.clineMessages.slice(messageIndex + 1) + const deletedApiReqsMetrics = getApiMetrics(combineApiRequests(combineCommandSequences(deletedMessages))) + + const newClineMessages = this.clineMessages.slice(0, messageIndex + 1) + await this.overwriteClineMessages(newClineMessages) // calls saveClineMessages which saves historyItem + + await this.say( + "deleted_api_reqs", + JSON.stringify({ + tokensIn: deletedApiReqsMetrics.totalTokensIn, + tokensOut: deletedApiReqsMetrics.totalTokensOut, + cacheWrites: deletedApiReqsMetrics.totalCacheWrites, + cacheReads: deletedApiReqsMetrics.totalCacheReads, + cost: deletedApiReqsMetrics.totalCost, + } satisfies ClineApiReqInfo), + ) + break + case "workspace": + break + } + + switch (restoreType) { + case "task": + vscode.window.showInformationMessage("Task messages have been restored to the checkpoint") + break + case "workspace": + vscode.window.showInformationMessage("Workspace files have been restored to the checkpoint") + break + case "taskAndWorkspace": + vscode.window.showInformationMessage("Task and workspace have been restored to the checkpoint") + break + } + + await this.providerRef.deref()?.postMessageToWebview({ type: "relinquishControl" }) + + this.providerRef.deref()?.cancelTask() // the task is already cancelled by the provider beforehand, but we need to re-init to get the updated messages + } else { + await this.providerRef.deref()?.postMessageToWebview({ type: "relinquishControl" }) + } + } + + async presentMultifileDiff(messageTs: number, seeNewChangesSinceLastTaskCompletion: boolean) { + const relinquishButton = () => { + this.providerRef.deref()?.postMessageToWebview({ type: "relinquishControl" }) + } + + console.log("presentMultifileDiff", messageTs) + const messageIndex = this.clineMessages.findIndex((m) => m.ts === messageTs) + const message = this.clineMessages[messageIndex] + if (!message) { + console.error("Message not found") + relinquishButton() + return + } + const hash = message.lastCheckpointHash + if (!hash) { + console.error("No checkpoint hash found") + relinquishButton() + return + } + + // TODO: handle if this is called from outside original workspace, in which case we need to show user error message we cant show diff outside of workspace? + if (!this.checkpointTracker) { + try { + this.checkpointTracker = await CheckpointTracker.create(this.taskId, this.providerRef.deref()) + this.checkpointTrackerErrorMessage = undefined + } catch (error) { + const errorMessage = error instanceof Error ? error.message : "Unknown error" + console.error("Failed to initialize checkpoint tracker:", errorMessage) + this.checkpointTrackerErrorMessage = errorMessage + await this.providerRef.deref()?.postStateToWebview() + vscode.window.showErrorMessage(errorMessage) + relinquishButton() + return + } + } + + let changedFiles: + | { + relativePath: string + absolutePath: string + before: string + after: string + }[] + | undefined + + try { + if (seeNewChangesSinceLastTaskCompletion) { + // Get last task completed + const lastTaskCompletedMessage = findLast( + this.clineMessages.slice(0, messageIndex), + (m) => m.say === "completion_result", + ) // ask is only used to relinquish control, its the last say we care about + // if undefined, then we get diff from beginning of git + // if (!lastTaskCompletedMessage) { + // console.error("No previous task completion message found") + // return + // } + + // Get changed files between current state and commit + changedFiles = await this.checkpointTracker?.getDiffSet( + lastTaskCompletedMessage?.lastCheckpointHash, // if undefined, then we get diff from beginning of git history, AKA when the task was started + hash, + ) + if (!changedFiles?.length) { + vscode.window.showInformationMessage("No changes found") + relinquishButton() + return + } + } else { + // Get changed files between current state and commit + changedFiles = await this.checkpointTracker?.getDiffSet(hash) + if (!changedFiles?.length) { + vscode.window.showInformationMessage("No changes found") + relinquishButton() + return + } + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : "Unknown error" + vscode.window.showErrorMessage("Failed to retrieve diff set: " + errorMessage) + relinquishButton() + return + } + + // Check if multi-diff editor is enabled in VS Code settings + // const config = vscode.workspace.getConfiguration() + // const isMultiDiffEnabled = config.get("multiDiffEditor.experimental.enabled") + + // if (!isMultiDiffEnabled) { + // vscode.window.showErrorMessage( + // "Please enable 'multiDiffEditor.experimental.enabled' in your VS Code settings to use this feature.", + // ) + // relinquishButton() + // return + // } + // Open multi-diff editor + await vscode.commands.executeCommand( + "vscode.changes", + seeNewChangesSinceLastTaskCompletion ? "New changes" : "Changes since snapshot", + changedFiles.map((file) => [ + vscode.Uri.file(file.absolutePath), + vscode.Uri.parse(`${DIFF_VIEW_URI_SCHEME}:${file.relativePath}`).with({ + query: Buffer.from(file.before ?? "").toString("base64"), + }), + vscode.Uri.parse(`${DIFF_VIEW_URI_SCHEME}:${file.relativePath}`).with({ + query: Buffer.from(file.after ?? "").toString("base64"), + }), + ]), + ) + relinquishButton() + } + + async doesLatestTaskCompletionHaveNewChanges() { + const messageIndex = findLastIndex(this.clineMessages, (m) => m.say === "completion_result") + const message = this.clineMessages[messageIndex] + if (!message) { + console.error("Completion message not found") + return false + } + const hash = message.lastCheckpointHash + if (!hash) { + console.error("No checkpoint hash found") + return false + } + + if (!this.checkpointTracker) { + try { + this.checkpointTracker = await CheckpointTracker.create(this.taskId, this.providerRef.deref()) + this.checkpointTrackerErrorMessage = undefined + } catch (error) { + const errorMessage = error instanceof Error ? error.message : "Unknown error" + console.error("Failed to initialize checkpoint tracker:", errorMessage) + return false + } + } + + // Get last task completed + const lastTaskCompletedMessage = findLast(this.clineMessages.slice(0, messageIndex), (m) => m.say === "completion_result") + + try { + // Get changed files between current state and commit + const changedFiles = await this.checkpointTracker?.getDiffSet( + lastTaskCompletedMessage?.lastCheckpointHash, // if undefined, then we get diff from beginning of git history, AKA when the task was started + hash, + ) + const changedFilesCount = changedFiles?.length || 0 + if (changedFilesCount > 0) { + return true + } + } catch (error) { + console.error("Failed to get diff set:", error) + return false + } + + return false + } + // Communicate with webview // partial has three valid states true (partial message), false (completion of partial message), undefined (individual complete message) @@ -223,7 +498,11 @@ export class Cline { type: ClineAsk, text?: string, partial?: boolean, - ): Promise<{ response: ClineAskResponse; text?: string; images?: string[] }> { + ): Promise<{ + response: ClineAskResponse + text?: string + images?: string[] + }> { // If this Cline instance was aborted by the provider, then the only thing keeping us alive is a promise still running in the background, in which case we don't want to send its result to the webview as it is attached to a new instance of Cline now. So we can safely ignore the result of any active promises, and this class will be deallocated. (Although we set Cline = undefined in provider, that simply removes the reference to this instance, but the instance is still alive until this promise resolves or rejects.) if (this.abort) { throw new Error("Cline instance aborted") @@ -241,7 +520,10 @@ export class Cline { // todo be more efficient about saving and posting only new data or one whole message at a time so ignore partial for saves, and only post parts of partial message instead of whole array in new listener // await this.saveClineMessages() // await this.providerRef.deref()?.postStateToWebview() - await this.providerRef.deref()?.postMessageToWebview({ type: "partialMessage", partialMessage: lastMessage }) + await this.providerRef.deref()?.postMessageToWebview({ + type: "partialMessage", + partialMessage: lastMessage, + }) throw new Error("Current ask promise was ignored 1") } else { // this is a new partial message, so add it with partial state @@ -250,7 +532,13 @@ export class Cline { // this.askResponseImages = undefined askTs = Date.now() this.lastMessageTs = askTs - await this.addToClineMessages({ ts: askTs, type: "ask", ask: type, text, partial }) + await this.addToClineMessages({ + ts: askTs, + type: "ask", + ask: type, + text, + partial, + }) await this.providerRef.deref()?.postStateToWebview() throw new Error("Current ask promise was ignored 2") } @@ -275,7 +563,10 @@ export class Cline { lastMessage.partial = false await this.saveClineMessages() // await this.providerRef.deref()?.postStateToWebview() - await this.providerRef.deref()?.postMessageToWebview({ type: "partialMessage", partialMessage: lastMessage }) + await this.providerRef.deref()?.postMessageToWebview({ + type: "partialMessage", + partialMessage: lastMessage, + }) } else { // this is a new partial=false message, so add it like normal this.askResponse = undefined @@ -283,7 +574,12 @@ export class Cline { this.askResponseImages = undefined askTs = Date.now() this.lastMessageTs = askTs - await this.addToClineMessages({ ts: askTs, type: "ask", ask: type, text }) + await this.addToClineMessages({ + ts: askTs, + type: "ask", + ask: type, + text, + }) await this.providerRef.deref()?.postStateToWebview() } } @@ -295,7 +591,12 @@ export class Cline { this.askResponseImages = undefined askTs = Date.now() this.lastMessageTs = askTs - await this.addToClineMessages({ ts: askTs, type: "ask", ask: type, text }) + await this.addToClineMessages({ + ts: askTs, + type: "ask", + ask: type, + text, + }) await this.providerRef.deref()?.postStateToWebview() } @@ -303,7 +604,11 @@ export class Cline { if (this.lastMessageTs !== askTs) { throw new Error("Current ask promise was ignored") // could happen if we send multiple asks in a row i.e. with command_output. It's important that when we know an ask could fail, it is handled gracefully } - const result = { response: this.askResponse!, text: this.askResponseText, images: this.askResponseImages } + const result = { + response: this.askResponse!, + text: this.askResponseText, + images: this.askResponseImages, + } this.askResponse = undefined this.askResponseText = undefined this.askResponseImages = undefined @@ -331,12 +636,22 @@ export class Cline { lastMessage.text = text lastMessage.images = images lastMessage.partial = partial - await this.providerRef.deref()?.postMessageToWebview({ type: "partialMessage", partialMessage: lastMessage }) + await this.providerRef.deref()?.postMessageToWebview({ + type: "partialMessage", + partialMessage: lastMessage, + }) } else { // this is a new partial message, so add it with partial state const sayTs = Date.now() this.lastMessageTs = sayTs - await this.addToClineMessages({ ts: sayTs, type: "say", say: type, text, images, partial }) + await this.addToClineMessages({ + ts: sayTs, + type: "say", + say: type, + text, + images, + partial, + }) await this.providerRef.deref()?.postStateToWebview() } } else { @@ -352,12 +667,21 @@ export class Cline { // instead of streaming partialMessage events, we do a save and post like normal to persist to disk await this.saveClineMessages() // await this.providerRef.deref()?.postStateToWebview() - await this.providerRef.deref()?.postMessageToWebview({ type: "partialMessage", partialMessage: lastMessage }) // more performant than an entire postStateToWebview + await this.providerRef.deref()?.postMessageToWebview({ + type: "partialMessage", + partialMessage: lastMessage, + }) // more performant than an entire postStateToWebview } else { // this is a new partial=false message, so add it like normal const sayTs = Date.now() this.lastMessageTs = sayTs - await this.addToClineMessages({ ts: sayTs, type: "say", say: type, text, images }) + await this.addToClineMessages({ + ts: sayTs, + type: "say", + say: type, + text, + images, + }) await this.providerRef.deref()?.postStateToWebview() } } @@ -365,7 +689,13 @@ export class Cline { // this is a new non-partial message, so add it like normal const sayTs = Date.now() this.lastMessageTs = sayTs - await this.addToClineMessages({ ts: sayTs, type: "say", say: type, text, images }) + await this.addToClineMessages({ + ts: sayTs, + type: "say", + say: type, + text, + images, + }) await this.providerRef.deref()?.postStateToWebview() } } @@ -400,17 +730,28 @@ export class Cline { await this.say("text", task, images) + this.isInitialized = true + let imageBlocks: Anthropic.ImageBlockParam[] = formatResponse.imageBlocks(images) - await this.initiateTaskLoop([ - { - type: "text", - text: `\n${task}\n`, - }, - ...imageBlocks, - ]) + await this.initiateTaskLoop( + [ + { + type: "text", + text: `\n${task}\n`, + }, + ...imageBlocks, + ], + true, + ) } private async resumeTaskFromHistory() { + // TODO: right now we let users init checkpoints for old tasks, assuming they're continuing them from the same workspace (which we never tied to tasks, so no way for us to know if it's opened in the right workspace) + // const doesShadowGitExist = await CheckpointTracker.doesShadowGitExist(this.taskId, this.providerRef.deref()) + // if (!doesShadowGitExist) { + // this.checkpointTrackerErrorMessage = "Checkpoints are only available for new tasks" + // } + const modifiedClineMessages = await this.getSavedClineMessages() // Remove any resume messages that may have been added before @@ -438,7 +779,9 @@ export class Cline { await this.overwriteClineMessages(modifiedClineMessages) this.clineMessages = await this.getSavedClineMessages() - // Now present the cline messages to the user and ask if they want to resume + // Now present the cline messages to the user and ask if they want to resume (NOTE: we ran into a bug before where the apiconversationhistory wouldnt be initialized when opening a old task, and it was because we were waiting for resume) + // This is important in case the user deletes messages without resuming the task first + this.apiConversationHistory = await this.getSavedApiConversationHistory() const lastClineMessage = this.clineMessages .slice() @@ -462,6 +805,8 @@ export class Cline { askType = "resume_task" } + this.isInitialized = true + const { response, text, images } = await this.ask(askType) // calls poststatetowebview let responseText: string | undefined let responseImages: string[] | undefined @@ -552,7 +897,12 @@ export class Cline { if (previousAssistantMessage && previousAssistantMessage.role === "assistant") { const assistantContent = Array.isArray(previousAssistantMessage.content) ? previousAssistantMessage.content - : [{ type: "text", text: previousAssistantMessage.content }] + : [ + { + type: "text", + text: previousAssistantMessage.content, + }, + ] const toolUseBlocks = assistantContent.filter( (block) => block.type === "tool_use", @@ -586,6 +936,9 @@ export class Cline { } } else { throw new Error("Unexpected: No existing API conversation history") + // console.error("Unexpected: No existing API conversation history") + // modifiedApiConversationHistory = [] + // modifiedOldUserContent = [] } let newUserContent: UserContent = [...modifiedOldUserContent] @@ -630,14 +983,14 @@ export class Cline { } await this.overwriteApiConversationHistory(modifiedApiConversationHistory) - await this.initiateTaskLoop(newUserContent) + await this.initiateTaskLoop(newUserContent, false) } - private async initiateTaskLoop(userContent: UserContent): Promise { + private async initiateTaskLoop(userContent: UserContent, isNewTask: boolean): Promise { let nextUserContent = userContent let includeFileDetails = true while (!this.abort) { - const didEndLoop = await this.recursivelyMakeClineRequests(nextUserContent, includeFileDetails) + const didEndLoop = await this.recursivelyMakeClineRequests(nextUserContent, includeFileDetails, isNewTask) includeFileDetails = false // we only need file details the first time // The way this agentic loop works is that cline will be given a task that he then calls tools to complete. unless there's an attempt_completion call, we keep responding back to him with his tool's responses until he either attempt_completion or does not use anymore tools. If he does not use anymore tools, we ask him to consider if he's completed the task and then call attempt_completion, otherwise proceed with completing the task. @@ -664,12 +1017,51 @@ export class Cline { } } - abortTask() { + async abortTask() { this.abort = true // will stop any autonomously running promises this.terminalManager.disposeAll() this.urlContentFetcher.closeBrowser() this.browserSession.closeBrowser() - this.diffViewProvider.revertChanges() + await this.diffViewProvider.revertChanges() // need to await for when we want to make sure directories/files are reverted before re-starting the task from a checkpoint + } + + // Checkpoints + + async saveCheckpoint() { + const commitHash = await this.checkpointTracker?.commit() // silently fails for now + if (commitHash) { + // Start from the end and work backwards until we find a tool use or another message with a hash + for (let i = this.clineMessages.length - 1; i >= 0; i--) { + const message = this.clineMessages[i] + if (message.lastCheckpointHash) { + // Found a message with a hash, so we can stop + break + } + // Update this message with a hash + message.lastCheckpointHash = commitHash + + // We only care about adding the hash to the last tool use (we don't want to add this hash to every prior message ie for tasks pre-checkpoint) + const isToolUse = + message.say === "tool" || + message.ask === "tool" || + message.say === "command" || + message.ask === "command" || + message.say === "completion_result" || + message.ask === "completion_result" || + message.ask === "followup" || + message.say === "use_mcp_server" || + message.ask === "use_mcp_server" || + message.say === "browser_action" || + message.say === "browser_action_launch" || + message.ask === "browser_action_launch" + + if (isToolUse) { + break + } + } + // Save the updated messages + await this.saveClineMessages() + } } // Tools @@ -831,14 +1223,26 @@ export class Cline { maxAllowedSize = Math.max(contextWindow - 40_000, contextWindow * 0.8) // for deepseek, 80% of 64k meant only ~10k buffer which was too small and resulted in users getting context window errors. } + // This is the most reliable way to know when we're close to hitting the context window. if (totalTokens >= maxAllowedSize) { - const truncatedMessages = truncateHalfConversation(this.apiConversationHistory) - await this.overwriteApiConversationHistory(truncatedMessages) + // NOTE: it's okay that we overwriteConversationHistory in resume task since we're only ever removing the last user message and not anything in the middle which would affect this range + this.conversationHistoryDeletedRange = getNextTruncationRange( + this.apiConversationHistory, + this.conversationHistoryDeletedRange, + ) + await this.saveClineMessages() // saves task history item which we use to keep track of conversation history deleted range + // await this.overwriteApiConversationHistory(truncatedMessages) } } } - const stream = this.api.createMessage(systemPrompt, this.apiConversationHistory) + // conversationHistoryDeletedRange is updated only when we're close to hitting the context window, so we don't continuously break the prompt cache + const truncatedConversationHistory = getTruncatedMessages( + this.apiConversationHistory, + this.conversationHistoryDeletedRange, + ) + + const stream = this.api.createMessage(systemPrompt, truncatedConversationHistory) const iterator = stream[Symbol.asyncIterator]() try { @@ -1063,6 +1467,10 @@ export class Cline { } const handleError = async (action: string, error: Error) => { + if (this.abandoned) { + console.log("Ignoring error since task was abandoned (i.e. from task cancellation after resetting)") + return + } const errorString = `Error ${action}: ${JSON.stringify(serializeError(error))}` await this.say( "error", @@ -1205,18 +1613,21 @@ export class Cline { this.consecutiveMistakeCount++ pushToolResult(await this.sayAndCreateMissingParamError(block.name, "path")) await this.diffViewProvider.reset() + await this.saveCheckpoint() break } if (block.name === "replace_in_file" && !diff) { this.consecutiveMistakeCount++ pushToolResult(await this.sayAndCreateMissingParamError("replace_in_file", "diff")) await this.diffViewProvider.reset() + await this.saveCheckpoint() break } if (block.name === "write_to_file" && !content) { this.consecutiveMistakeCount++ pushToolResult(await this.sayAndCreateMissingParamError("write_to_file", "content")) await this.diffViewProvider.reset() + await this.saveCheckpoint() break } this.consecutiveMistakeCount = 0 @@ -1288,6 +1699,7 @@ export class Cline { if (!didApprove) { await this.diffViewProvider.revertChanges() + await this.saveCheckpoint() break } } @@ -1331,12 +1743,14 @@ export class Cline { ) } await this.diffViewProvider.reset() + await this.saveCheckpoint() break } } catch (error) { await handleError("writing file", error) await this.diffViewProvider.revertChanges() await this.diffViewProvider.reset() + await this.saveCheckpoint() break } } @@ -1364,6 +1778,7 @@ export class Cline { if (!relPath) { this.consecutiveMistakeCount++ pushToolResult(await this.sayAndCreateMissingParamError("read_file", "path")) + await this.saveCheckpoint() break } this.consecutiveMistakeCount = 0 @@ -1383,16 +1798,19 @@ export class Cline { this.removeLastPartialMessageIfExistsWithType("say", "tool") const didApprove = await askApproval("tool", completeMessage) if (!didApprove) { + await this.saveCheckpoint() break } } // now execute the tool like normal const content = await extractTextFromFile(absolutePath) pushToolResult(content) + await this.saveCheckpoint() break } } catch (error) { await handleError("reading file", error) + await this.saveCheckpoint() break } } @@ -1422,6 +1840,7 @@ export class Cline { if (!relDirPath) { this.consecutiveMistakeCount++ pushToolResult(await this.sayAndCreateMissingParamError("list_files", "path")) + await this.saveCheckpoint() break } this.consecutiveMistakeCount = 0 @@ -1443,14 +1862,17 @@ export class Cline { this.removeLastPartialMessageIfExistsWithType("say", "tool") const didApprove = await askApproval("tool", completeMessage) if (!didApprove) { + await this.saveCheckpoint() break } } pushToolResult(result) + await this.saveCheckpoint() break } } catch (error) { await handleError("listing files", error) + await this.saveCheckpoint() break } } @@ -1478,6 +1900,7 @@ export class Cline { if (!relDirPath) { this.consecutiveMistakeCount++ pushToolResult(await this.sayAndCreateMissingParamError("list_code_definition_names", "path")) + await this.saveCheckpoint() break } this.consecutiveMistakeCount = 0 @@ -1498,14 +1921,17 @@ export class Cline { this.removeLastPartialMessageIfExistsWithType("say", "tool") const didApprove = await askApproval("tool", completeMessage) if (!didApprove) { + await this.saveCheckpoint() break } } pushToolResult(result) + await this.saveCheckpoint() break } } catch (error) { await handleError("parsing source code definitions", error) + await this.saveCheckpoint() break } } @@ -1537,11 +1963,13 @@ export class Cline { if (!relDirPath) { this.consecutiveMistakeCount++ pushToolResult(await this.sayAndCreateMissingParamError("search_files", "path")) + await this.saveCheckpoint() break } if (!regex) { this.consecutiveMistakeCount++ pushToolResult(await this.sayAndCreateMissingParamError("search_files", "regex")) + await this.saveCheckpoint() break } this.consecutiveMistakeCount = 0 @@ -1562,14 +1990,17 @@ export class Cline { this.removeLastPartialMessageIfExistsWithType("say", "tool") const didApprove = await askApproval("tool", completeMessage) if (!didApprove) { + await this.saveCheckpoint() break } } pushToolResult(results) + await this.saveCheckpoint() break } } catch (error) { await handleError("searching files", error) + await this.saveCheckpoint() break } } @@ -1628,6 +2059,7 @@ export class Cline { this.consecutiveMistakeCount++ pushToolResult(await this.sayAndCreateMissingParamError("browser_action", "url")) await this.browserSession.closeBrowser() + await this.saveCheckpoint() break } this.consecutiveMistakeCount = 0 @@ -1643,6 +2075,7 @@ export class Cline { this.removeLastPartialMessageIfExistsWithType("say", "browser_action_launch") const didApprove = await askApproval("browser_action_launch", url) if (!didApprove) { + await this.saveCheckpoint() break } } @@ -1661,6 +2094,7 @@ export class Cline { await this.sayAndCreateMissingParamError("browser_action", "coordinate"), ) await this.browserSession.closeBrowser() + await this.saveCheckpoint() break // can't be within an inner switch } } @@ -1669,6 +2103,7 @@ export class Cline { this.consecutiveMistakeCount++ pushToolResult(await this.sayAndCreateMissingParamError("browser_action", "text")) await this.browserSession.closeBrowser() + await this.saveCheckpoint() break } } @@ -1717,6 +2152,7 @@ export class Cline { browserActionResult.screenshot ? [browserActionResult.screenshot] : [], ), ) + await this.saveCheckpoint() break case "close": pushToolResult( @@ -1724,13 +2160,17 @@ export class Cline { `The browser has been closed. You may now proceed to using other tools.`, ), ) + await this.saveCheckpoint() break } + + await this.saveCheckpoint() break } } catch (error) { await this.browserSession.closeBrowser() // if any error occurs, the browser session is terminated await handleError("executing browser action", error) + await this.saveCheckpoint() break } } @@ -1758,6 +2198,7 @@ export class Cline { if (!command) { this.consecutiveMistakeCount++ pushToolResult(await this.sayAndCreateMissingParamError("execute_command", "command")) + await this.saveCheckpoint() break } if (!requiresApprovalRaw) { @@ -1765,6 +2206,7 @@ export class Cline { pushToolResult( await this.sayAndCreateMissingParamError("execute_command", "requires_approval"), ) + await this.saveCheckpoint() break } this.consecutiveMistakeCount = 0 @@ -1787,6 +2229,7 @@ export class Cline { `${this.shouldAutoApproveTool(block.name) && requiresApproval ? COMMAND_REQ_APP_STRING : ""}`, // ugly hack until we refactor combineCommandSequences ) if (!didApprove) { + await this.saveCheckpoint() break } } @@ -1811,10 +2254,12 @@ export class Cline { this.didRejectTool = true } pushToolResult(result) + await this.saveCheckpoint() break } } catch (error) { await handleError("executing command", error) + await this.saveCheckpoint() break } } @@ -1844,11 +2289,13 @@ export class Cline { if (!server_name) { this.consecutiveMistakeCount++ pushToolResult(await this.sayAndCreateMissingParamError("use_mcp_tool", "server_name")) + await this.saveCheckpoint() break } if (!tool_name) { this.consecutiveMistakeCount++ pushToolResult(await this.sayAndCreateMissingParamError("use_mcp_tool", "tool_name")) + await this.saveCheckpoint() break } // arguments are optional, but if they are provided they must be valid JSON @@ -1872,6 +2319,7 @@ export class Cline { formatResponse.invalidMcpToolArgumentError(server_name, tool_name), ), ) + await this.saveCheckpoint() break } } @@ -1894,6 +2342,7 @@ export class Cline { this.removeLastPartialMessageIfExistsWithType("say", "use_mcp_server") const didApprove = await askApproval("use_mcp_server", completeMessage) if (!didApprove) { + await this.saveCheckpoint() break } } @@ -1922,10 +2371,12 @@ export class Cline { .join("\n\n") || "(No response)" await this.say("mcp_server_response", toolResultPretty) pushToolResult(formatResponse.toolResult(toolResultPretty)) + await this.saveCheckpoint() break } } catch (error) { await handleError("executing MCP tool", error) + await this.saveCheckpoint() break } } @@ -1953,11 +2404,13 @@ export class Cline { if (!server_name) { this.consecutiveMistakeCount++ pushToolResult(await this.sayAndCreateMissingParamError("access_mcp_resource", "server_name")) + await this.saveCheckpoint() break } if (!uri) { this.consecutiveMistakeCount++ pushToolResult(await this.sayAndCreateMissingParamError("access_mcp_resource", "uri")) + await this.saveCheckpoint() break } this.consecutiveMistakeCount = 0 @@ -1978,6 +2431,7 @@ export class Cline { this.removeLastPartialMessageIfExistsWithType("say", "use_mcp_server") const didApprove = await askApproval("use_mcp_server", completeMessage) if (!didApprove) { + await this.saveCheckpoint() break } } @@ -1997,10 +2451,12 @@ export class Cline { .join("\n\n") || "(Empty response)" await this.say("mcp_server_response", resourceResultPretty) pushToolResult(formatResponse.toolResult(resourceResultPretty)) + await this.saveCheckpoint() break } } catch (error) { await handleError("accessing MCP resource", error) + await this.saveCheckpoint() break } } @@ -2014,6 +2470,7 @@ export class Cline { if (!question) { this.consecutiveMistakeCount++ pushToolResult(await this.sayAndCreateMissingParamError("ask_followup_question", "question")) + await this.saveCheckpoint() break } this.consecutiveMistakeCount = 0 @@ -2028,10 +2485,12 @@ export class Cline { const { text, images } = await this.ask("followup", question, false) await this.say("user_feedback", text ?? "", images) pushToolResult(formatResponse.toolResult(`\n${text}\n`, images)) + await this.saveCheckpoint() break } } catch (error) { await handleError("asking question", error) + await this.saveCheckpoint() break } } @@ -2058,6 +2517,22 @@ export class Cline { */ const result: string | undefined = block.params.result const command: string | undefined = block.params.command + + const addNewChangesFlagToLastCompletionResultMessage = async () => { + // Add newchanges flag if there are new changes to the workspace + + const hasNewChanges = await this.doesLatestTaskCompletionHaveNewChanges() + const lastCompletionResultMessage = findLast(this.clineMessages, (m) => m.say === "completion_result") + if ( + lastCompletionResultMessage && + hasNewChanges && + !lastCompletionResultMessage.text?.endsWith(COMPLETION_RESULT_CHANGES_FLAG) + ) { + lastCompletionResultMessage.text += COMPLETION_RESULT_CHANGES_FLAG + } + await this.saveClineMessages() + } + try { const lastMessage = this.clineMessages.at(-1) if (block.partial) { @@ -2076,6 +2551,8 @@ export class Cline { // last message is completion_result // we have command string, which means we have the result as well, so finish it (doesnt have to exist yet) await this.say("completion_result", removeClosingTag("result", result), undefined, false) + await this.saveCheckpoint() + await addNewChangesFlagToLastCompletionResultMessage() await this.ask("command", removeClosingTag("command", command), block.partial).catch( () => {}, ) @@ -2094,6 +2571,7 @@ export class Cline { if (!result) { this.consecutiveMistakeCount++ pushToolResult(await this.sayAndCreateMissingParamError("attempt_completion", "result")) + await this.saveCheckpoint() break } this.consecutiveMistakeCount = 0 @@ -2110,23 +2588,32 @@ export class Cline { if (lastMessage && lastMessage.ask !== "command") { // havent sent a command message yet so first send completion_result then command await this.say("completion_result", result, undefined, false) + await this.saveCheckpoint() + await addNewChangesFlagToLastCompletionResultMessage() + } else { + // we already sent a command message, meaning the complete completion message has also been sent + await this.saveCheckpoint() } // complete command message const didApprove = await askApproval("command", command) if (!didApprove) { + await this.saveCheckpoint() break } const [userRejected, execCommandResult] = await this.executeCommandTool(command!) if (userRejected) { this.didRejectTool = true pushToolResult(execCommandResult) + await this.saveCheckpoint() break } // user didn't reject, but the command may have output commandResult = execCommandResult } else { await this.say("completion_result", result, undefined, false) + await this.saveCheckpoint() + await addNewChangesFlagToLastCompletionResultMessage() } // we already sent completion_result says, an empty string asks relinquishes control over button and field @@ -2140,7 +2627,10 @@ export class Cline { const toolResults: (Anthropic.TextBlockParam | Anthropic.ImageBlockParam)[] = [] if (commandResult) { if (typeof commandResult === "string") { - toolResults.push({ type: "text", text: commandResult }) + toolResults.push({ + type: "text", + text: commandResult, + }) } else if (Array.isArray(commandResult)) { toolResults.push(...commandResult) } @@ -2156,10 +2646,12 @@ export class Cline { }) this.userMessageContent.push(...toolResults) + // await this.saveCheckpoint() break } } catch (error) { await handleError("attempting completion", error) + await this.saveCheckpoint() break } } @@ -2198,7 +2690,11 @@ export class Cline { } } - async recursivelyMakeClineRequests(userContent: UserContent, includeFileDetails: boolean = false): Promise { + async recursivelyMakeClineRequests( + userContent: UserContent, + includeFileDetails: boolean = false, + isNewTask: boolean = false, + ): Promise { if (this.abort) { throw new Error("Cline instance aborted") } @@ -2260,12 +2756,29 @@ export class Cline { }), ) + // use this opportunity to initialize the checkpoint tracker (can be expensive to initialize in the constructor) + // FIXME: right now we're letting users init checkpoints for old tasks, but this could be a problem if opening a task in the wrong workspace + // isNewTask && + if (!this.checkpointTracker) { + try { + this.checkpointTracker = await CheckpointTracker.create(this.taskId, this.providerRef.deref()) + this.checkpointTrackerErrorMessage = undefined + } catch (error) { + const errorMessage = error instanceof Error ? error.message : "Unknown error" + console.error("Failed to initialize checkpoint tracker:", errorMessage) + this.checkpointTrackerErrorMessage = errorMessage // will be displayed right away since we saveClineMessages next which posts state to webview + } + } + const [parsedUserContent, environmentDetails] = await this.loadContext(userContent, includeFileDetails) userContent = parsedUserContent // add environment details as its own text block, separate from tool results userContent.push({ type: "text", text: environmentDetails }) - await this.addToApiConversationHistory({ role: "user", content: userContent }) + await this.addToApiConversationHistory({ + role: "user", + content: userContent, + }) // since we sent off a placeholder api_req_started message to update the webview while waiting to actually start the API request (to load potential details for example), we need to update the text of that message const lastApiReqIndex = findLastIndex(this.clineMessages, (m) => m.say === "api_req_started") @@ -2337,7 +2850,7 @@ export class Cline { await this.saveClineMessages() // signals to provider that it can retrieve the saved messages from disk, as abortTask can not be awaited on in nature - this.didFinishAborting = true + this.didFinishAbortingStream = true } // reset streaming state @@ -2354,6 +2867,7 @@ export class Cline { const stream = this.attemptApiRequest(previousApiReqIndex) // yields only if the first chunk is successful, otherwise will allow the user to retry the request (most likely due to rate limit error, which gets thrown on the first chunk) let assistantMessage = "" + this.isStreaming = true try { for await (const chunk of stream) { switch (chunk.type) { @@ -2412,6 +2926,8 @@ export class Cline { // await this.providerRef.deref()?.postStateToWebview() } } + } finally { + this.isStreaming = false } // need to call here in case the stream was aborted @@ -2475,7 +2991,12 @@ export class Cline { ) await this.addToApiConversationHistory({ role: "assistant", - content: [{ type: "text", text: "Failure: I did not provide a response." }], + content: [ + { + type: "text", + text: "Failure: I did not provide a response.", + }, + ], }) } diff --git a/src/core/prompts/responses.ts b/src/core/prompts/responses.ts index 80b7d11e04..e932f4042c 100644 --- a/src/core/prompts/responses.ts +++ b/src/core/prompts/responses.ts @@ -67,7 +67,10 @@ Otherwise, if you have not completed the task and do not need additional informa return 1 } // Otherwise, sort alphabetically - return aParts[i].localeCompare(bParts[i], undefined, { numeric: true, sensitivity: "base" }) + return aParts[i].localeCompare(bParts[i], undefined, { + numeric: true, + sensitivity: "base", + }) } } // If all parts are the same up to the length of the shorter path, @@ -103,7 +106,11 @@ const formatImagesIntoBlocks = (images?: string[]): Anthropic.ImageBlockParam[] const mimeType = rest.split(":")[1].split(";")[0] return { type: "image", - source: { type: "base64", media_type: mimeType, data: base64 }, + source: { + type: "base64", + media_type: mimeType, + data: base64, + }, } as Anthropic.ImageBlockParam }) : [] diff --git a/src/core/sliding-window/index.ts b/src/core/sliding-window/index.ts index 9d54dbfc64..83b91eb381 100644 --- a/src/core/sliding-window/index.ts +++ b/src/core/sliding-window/index.ts @@ -8,17 +8,82 @@ a 200k context, we can assume that the first half is likely irrelevant to their Therefore, this function should only be called when absolutely necessary to fit within context limits, not as a continuous process. */ -export function truncateHalfConversation(messages: Anthropic.Messages.MessageParam[]): Anthropic.Messages.MessageParam[] { - // API expects messages to be in user-assistant order, and tool use messages must be followed by tool results. We need to maintain this structure while truncating. +// export function truncateHalfConversation( +// messages: Anthropic.Messages.MessageParam[], +// ): Anthropic.Messages.MessageParam[] { +// // API expects messages to be in user-assistant order, and tool use messages must be followed by tool results. We need to maintain this structure while truncating. - // Always keep the first Task message (this includes the project's file structure in environment_details) - const truncatedMessages = [messages[0]] +// // Always keep the first Task message (this includes the project's file structure in environment_details) +// const truncatedMessages = [messages[0]] + +// // Remove half of user-assistant pairs +// const messagesToRemove = Math.floor(messages.length / 4) * 2 // has to be even number + +// const remainingMessages = messages.slice(messagesToRemove + 1) // has to start with assistant message since tool result cannot follow assistant message with no tool use +// truncatedMessages.push(...remainingMessages) + +// return truncatedMessages +// } + +/* +getNextTruncationRange: Calculates the next range of messages to be "deleted" +- Takes the full messages array and optional current deleted range +- Always preserves the first message (task message) +- Removes 1/2 of remaining messages (rounded down to even number) after current deleted range +- Returns [startIndex, endIndex] representing inclusive range to delete + +getTruncatedMessages: Constructs the truncated array using the deleted range +- Takes full messages array and optional deleted range +- Returns new array with messages in deleted range removed +- Preserves order and structure of remaining messages + +The range is represented as [startIndex, endIndex] where both indices are inclusive +The functions maintain the original array integrity while allowing progressive truncation +through the deletedRange parameter + +Usage example: +const messages = [user1, assistant1, user2, assistant2, user3, assistant3]; +let deletedRange = getNextTruncationRange(messages); // [1,2] (assistant1,user2) +let truncated = getTruncatedMessages(messages, deletedRange); +// [user1, assistant2, user3, assistant3] + +deletedRange = getNextTruncationRange(messages, deletedRange); // [2,3] (assistant2,user3) +truncated = getTruncatedMessages(messages, deletedRange); +// [user1, assistant3] +*/ + +export function getNextTruncationRange( + messages: Anthropic.Messages.MessageParam[], + currentDeletedRange: [number, number] | undefined = undefined, +): [number, number] { + // Since we always keep the first message, currentDeletedRange[0] will always be 1 (for now until we have a smarter truncation algorithm) + const rangeStartIndex = 1 + const startOfRest = currentDeletedRange ? currentDeletedRange[1] + 1 : 1 // Remove half of user-assistant pairs - const messagesToRemove = Math.floor(messages.length / 4) * 2 // has to be even number + const messagesToRemove = Math.floor((messages.length - startOfRest) / 4) * 2 // Keep even number + let rangeEndIndex = startOfRest + messagesToRemove - 1 - const remainingMessages = messages.slice(messagesToRemove + 1) // has to start with assistant message since tool result cannot follow assistant message with no tool use - truncatedMessages.push(...remainingMessages) + // Make sure the last message being removed is a user message, so that the next message after the initial task message is an assistant message. This preservers the user-assistant-user-assistant structure. + // NOTE: anthropic format messages are always user-assitant-user-assistant, while openai format messages can have multiple user messages in a row (we use anthropic format throughout cline) + if (messages[rangeEndIndex].role !== "user") { + rangeEndIndex -= 1 + } - return truncatedMessages + // this is an inclusive range that will be removed from the conversation history + return [rangeStartIndex, rangeEndIndex] +} + +export function getTruncatedMessages( + messages: Anthropic.Messages.MessageParam[], + deletedRange: [number, number] | undefined, +): Anthropic.Messages.MessageParam[] { + if (!deletedRange) { + return messages + } + + const [start, end] = deletedRange + // the range is inclusive - both start and end indices and everything in between will be removed from the final result. + // NOTE: if you try to console log these, don't forget that logging a reference to an array may not provide the same result as logging a slice() snapshot of that array at that exact moment. The following DOES in fact include the latest assistant message. + return [...messages.slice(0, start), ...messages.slice(end + 1)] } diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 2b6641e1c3..5aec8adc9b 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -14,9 +14,9 @@ import WorkspaceTracker from "../../integrations/workspace/WorkspaceTracker" import { McpHub } from "../../services/mcp/McpHub" import { ApiProvider, ModelInfo } from "../../shared/api" import { findLast } from "../../shared/array" -import { ExtensionMessage } from "../../shared/ExtensionMessage" +import { ExtensionMessage, ExtensionState } from "../../shared/ExtensionMessage" import { HistoryItem } from "../../shared/HistoryItem" -import { WebviewMessage } from "../../shared/WebviewMessage" +import { ClineCheckpointRestore, WebviewMessage } from "../../shared/WebviewMessage" import { fileExistsAtPath } from "../../utils/fs" import { Cline } from "../Cline" import { openMention } from "../mentions" @@ -79,7 +79,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { private cline?: Cline private workspaceTracker?: WorkspaceTracker mcpHub?: McpHub - private latestAnnouncementId = "dec-17-2024" // update to some unique identifier when we add a new announcement + private latestAnnouncementId = "jan-6-2025" // update to some unique identifier when we add a new announcement constructor( readonly context: vscode.ExtensionContext, @@ -152,7 +152,10 @@ export class ClineProvider implements vscode.WebviewViewProvider { webviewView.onDidChangeViewState( () => { if (this.view?.visible) { - this.postMessageToWebview({ type: "action", action: "didBecomeVisible" }) + this.postMessageToWebview({ + type: "action", + action: "didBecomeVisible", + }) } }, null, @@ -163,7 +166,10 @@ export class ClineProvider implements vscode.WebviewViewProvider { webviewView.onDidChangeVisibility( () => { if (this.view?.visible) { - this.postMessageToWebview({ type: "action", action: "didBecomeVisible" }) + this.postMessageToWebview({ + type: "action", + action: "didBecomeVisible", + }) } }, null, @@ -186,7 +192,10 @@ export class ClineProvider implements vscode.WebviewViewProvider { async (e) => { if (e && e.affectsConfiguration("workbench.colorTheme")) { // Sends latest theme name to webview - await this.postMessageToWebview({ type: "theme", text: JSON.stringify(await getTheme()) }) + await this.postMessageToWebview({ + type: "theme", + text: JSON.stringify(await getTheme()), + }) } }, null, @@ -312,11 +321,19 @@ export class ClineProvider implements vscode.WebviewViewProvider { case "webviewDidLaunch": this.postStateToWebview() this.workspaceTracker?.initializeFilePaths() // don't await - getTheme().then((theme) => this.postMessageToWebview({ type: "theme", text: JSON.stringify(theme) })) + 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 }) + 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. @@ -433,7 +450,10 @@ export class ClineProvider implements vscode.WebviewViewProvider { break case "selectImages": const images = await selectImages() - await this.postMessageToWebview({ type: "selectedImages", images }) + await this.postMessageToWebview({ + type: "selectedImages", + images, + }) break case "exportCurrentTask": const currentTaskId = this.cline?.taskId @@ -455,11 +475,17 @@ export class ClineProvider implements vscode.WebviewViewProvider { break case "requestOllamaModels": const ollamaModels = await this.getOllamaModels(message.text) - this.postMessageToWebview({ type: "ollamaModels", ollamaModels }) + this.postMessageToWebview({ + type: "ollamaModels", + ollamaModels, + }) break case "requestLmStudioModels": const lmStudioModels = await this.getLmStudioModels(message.text) - this.postMessageToWebview({ type: "lmStudioModels", lmStudioModels }) + this.postMessageToWebview({ + type: "lmStudioModels", + lmStudioModels, + }) break case "refreshOpenRouterModels": await this.refreshOpenRouterModels() @@ -473,23 +499,35 @@ export class ClineProvider implements vscode.WebviewViewProvider { case "openMention": openMention(message.text) break - case "cancelTask": - if (this.cline) { - const { historyItem } = await this.getTaskWithId(this.cline.taskId) - this.cline.abortTask() - await pWaitFor(() => this.cline === undefined || this.cline.didFinishAborting, { + 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 abort task") + console.error("Failed to init new cline instance") }) - 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 + // 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 "openMcpSettings": { const mcpSettingsFilePath = await this.mcpHub?.getMcpSettingsFilePath() @@ -515,6 +553,31 @@ export class ClineProvider implements vscode.WebviewViewProvider { ) } + 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, + { + 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) @@ -601,7 +664,10 @@ export class ClineProvider implements vscode.WebviewViewProvider { await this.storeSecret("openRouterApiKey", apiKey) await this.postStateToWebview() if (this.cline) { - this.cline.api = buildApiHandler({ apiProvider: openrouter, openRouterApiKey: apiKey }) + this.cline.api = buildApiHandler({ + apiProvider: openrouter, + openRouterApiKey: apiKey, + }) } // await this.postMessageToWebview({ type: "action", action: "settingsButtonClicked" }) // bad ux if user is on welcome } @@ -732,7 +798,10 @@ export class ClineProvider implements vscode.WebviewViewProvider { console.error("Error fetching OpenRouter models:", error) } - await this.postMessageToWebview({ type: "openRouterModels", openRouterModels: models }) + await this.postMessageToWebview({ + type: "openRouterModels", + openRouterModels: models, + }) return models } @@ -775,7 +844,10 @@ export class ClineProvider implements vscode.WebviewViewProvider { const { historyItem } = await this.getTaskWithId(id) await this.initClineWithHistoryItem(historyItem) // clears existing task } - await this.postMessageToWebview({ type: "action", action: "chatButtonClicked" }) + await this.postMessageToWebview({ + type: "action", + action: "chatButtonClicked", + }) } async exportTaskWithId(id: string) { @@ -805,6 +877,18 @@ export class ClineProvider implements vscode.WebviewViewProvider { if (await fileExistsAtPath(legacyMessagesFilePath)) { await fs.unlink(legacyMessagesFilePath) } + + // Delete the checkpoints directory if it exists + const checkpointsDir = path.join(taskDirPath, "checkpoints") + if (await fileExistsAtPath(checkpointsDir)) { + try { + await fs.rm(checkpointsDir, { recursive: true, force: true }) + } catch (error) { + console.error(`Failed to delete checkpoints directory for task ${id}:`, error) + // Continue with deletion of task directory - don't throw since this is a cleanup operation + } + } + await fs.rmdir(taskDirPath) // succeeds if the dir is empty } @@ -823,7 +907,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { this.postMessageToWebview({ type: "state", state }) } - async getStateToPostToWebview() { + async getStateToPostToWebview(): Promise { const { apiConfiguration, lastShownAnnouncementId, customInstructions, taskHistory, autoApprovalSettings } = await this.getState() return { @@ -831,6 +915,8 @@ export class ClineProvider implements vscode.WebviewViewProvider { 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, @@ -1084,6 +1170,9 @@ export class ClineProvider implements vscode.WebviewViewProvider { } vscode.window.showInformationMessage("State reset") await this.postStateToWebview() - await this.postMessageToWebview({ type: "action", action: "chatButtonClicked" }) + await this.postMessageToWebview({ + type: "action", + action: "chatButtonClicked", + }) } } diff --git a/src/exports/index.ts b/src/exports/index.ts index 04d26d8c8b..9432f81fa9 100644 --- a/src/exports/index.ts +++ b/src/exports/index.ts @@ -17,7 +17,10 @@ export function createClineAPI(outputChannel: vscode.OutputChannel, sidebarProvi outputChannel.appendLine("Starting new task") await sidebarProvider.clearTask() await sidebarProvider.postStateToWebview() - await sidebarProvider.postMessageToWebview({ type: "action", action: "chatButtonClicked" }) + await sidebarProvider.postMessageToWebview({ + type: "action", + action: "chatButtonClicked", + }) await sidebarProvider.postMessageToWebview({ type: "invoke", invoke: "sendMessage", diff --git a/src/extension.ts b/src/extension.ts index 877993fe22..35dda8b588 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -39,13 +39,19 @@ export function activate(context: vscode.ExtensionContext) { outputChannel.appendLine("Plus button Clicked") await sidebarProvider.clearTask() await sidebarProvider.postStateToWebview() - await sidebarProvider.postMessageToWebview({ type: "action", action: "chatButtonClicked" }) + await sidebarProvider.postMessageToWebview({ + type: "action", + action: "chatButtonClicked", + }) }), ) context.subscriptions.push( vscode.commands.registerCommand("cline.mcpButtonClicked", () => { - sidebarProvider.postMessageToWebview({ type: "action", action: "mcpButtonClicked" }) + sidebarProvider.postMessageToWebview({ + type: "action", + action: "mcpButtonClicked", + }) }), ) @@ -88,13 +94,19 @@ export function activate(context: vscode.ExtensionContext) { context.subscriptions.push( vscode.commands.registerCommand("cline.settingsButtonClicked", () => { //vscode.window.showInformationMessage(message) - sidebarProvider.postMessageToWebview({ type: "action", action: "settingsButtonClicked" }) + sidebarProvider.postMessageToWebview({ + type: "action", + action: "settingsButtonClicked", + }) }), ) context.subscriptions.push( vscode.commands.registerCommand("cline.historyButtonClicked", () => { - sidebarProvider.postMessageToWebview({ type: "action", action: "historyButtonClicked" }) + sidebarProvider.postMessageToWebview({ + type: "action", + action: "historyButtonClicked", + }) }), ) diff --git a/src/integrations/checkpoints/CheckpointTracker.ts b/src/integrations/checkpoints/CheckpointTracker.ts new file mode 100644 index 0000000000..36a0c5a52e --- /dev/null +++ b/src/integrations/checkpoints/CheckpointTracker.ts @@ -0,0 +1,390 @@ +import fs from "fs/promises" +import os from "os" +import * as path from "path" +import simpleGit from "simple-git" +import * as vscode from "vscode" +import { ClineProvider } from "../../core/webview/ClineProvider" +import { fileExistsAtPath } from "../../utils/fs" +import { globby } from "globby" + +class CheckpointTracker { + private providerRef: WeakRef + private taskId: string + private disposables: vscode.Disposable[] = [] + private cwd: string + private lastRetrievedShadowGitConfigWorkTree?: string + lastCheckpointHash?: string + + private constructor(provider: ClineProvider, taskId: string, cwd: string) { + this.providerRef = new WeakRef(provider) + this.taskId = taskId + this.cwd = cwd + } + + public static async create(taskId: string, provider?: ClineProvider): Promise { + try { + if (!provider) { + throw new Error("Provider is required to create a checkpoint tracker") + } + + // Check if git is installed by attempting to get version + try { + await simpleGit().version() + } catch (error) { + throw new Error("Git must be installed to use checkpoints.") // FIXME: must match what we check for in TaskHeader to show link + } + + const cwd = await CheckpointTracker.getWorkingDirectory() + const newTracker = new CheckpointTracker(provider, taskId, cwd) + await newTracker.initShadowGit() + return newTracker + } catch (error) { + console.error("Failed to create CheckpointTracker:", error) + throw error + } + } + + private static async getWorkingDirectory(): Promise { + const cwd = vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath).at(0) + if (!cwd) { + throw new Error("No workspace detected. Please open Cline in a workspace to use checkpoints.") + } + const homedir = os.homedir() + const desktopPath = path.join(homedir, "Desktop") + const documentsPath = path.join(homedir, "Documents") + const downloadsPath = path.join(homedir, "Downloads") + + switch (cwd) { + case homedir: + throw new Error("Cannot use checkpoints in home directory") + case desktopPath: + throw new Error("Cannot use checkpoints in Desktop directory") + case documentsPath: + throw new Error("Cannot use checkpoints in Documents directory") + case downloadsPath: + throw new Error("Cannot use checkpoints in Downloads directory") + default: + return cwd + } + } + + private async getShadowGitPath(): Promise { + const globalStoragePath = this.providerRef.deref()?.context.globalStorageUri.fsPath + if (!globalStoragePath) { + throw new Error("Global storage uri is invalid") + } + const checkpointsDir = path.join(globalStoragePath, "tasks", this.taskId, "checkpoints") + await fs.mkdir(checkpointsDir, { recursive: true }) + const gitPath = path.join(checkpointsDir, ".git") + return gitPath + } + + public static async doesShadowGitExist(taskId: string, provider?: ClineProvider): Promise { + const globalStoragePath = provider?.context.globalStorageUri.fsPath + if (!globalStoragePath) { + return false + } + const gitPath = path.join(globalStoragePath, "tasks", taskId, "checkpoints", ".git") + return await fileExistsAtPath(gitPath) + } + + public async initShadowGit(): Promise { + const gitPath = await this.getShadowGitPath() + if (await fileExistsAtPath(gitPath)) { + // Make sure it's the same cwd as the configured worktree + const worktree = await this.getShadowGitConfigWorkTree() + if (worktree !== this.cwd) { + throw new Error("Checkpoints can only be used in the original workspace: " + worktree) + } + + return gitPath + } else { + const checkpointsDir = path.dirname(gitPath) + const git = simpleGit(checkpointsDir) + await git.init() + + await git.addConfig("core.worktree", this.cwd) // sets the working tree to the current workspace + + // Add basic excludes directly in git config, while respecting any .gitignore in the workspace + // .git/info/exclude is local to the shadow git repo, so it's not shared with the main repo - and won't conflict with user's .gitignore + // TODO: let user customize these + const excludesPath = path.join(gitPath, "info", "exclude") + await fs.mkdir(path.join(gitPath, "info"), { recursive: true }) + await fs.writeFile( + excludesPath, + [ + ".git/", // ignore the user's .git + `.git${GIT_DISABLED_SUFFIX}/`, // ignore the disabled nested git repos + ".DS_Store", + "*.log", + "node_modules/", + "__pycache__/", + "env/", + "venv/", + "target/dependency/", + "build/dependencies/", + "dist/", + "out/", + "bundle/", + "vendor/", + "tmp/", + "temp/", + "deps/", + "pkg/", + "Pods/", + // Media files + "*.jpg", + "*.jpeg", + "*.png", + "*.gif", + "*.bmp", + "*.ico", + // "*.svg", + "*.mp3", + "*.mp4", + "*.wav", + "*.avi", + "*.mov", + "*.wmv", + "*.webm", + "*.webp", + "*.m4a", + "*.flac", + // Build and dependency directories + "build/", + "bin/", + "obj/", + ".gradle/", + ".idea/", + ".vscode/", + ".vs/", + "coverage/", + ".next/", + ".nuxt/", + // Cache and temporary files + "*.cache", + "*.tmp", + "*.temp", + "*.swp", + "*.swo", + "*.pyc", + "*.pyo", + ".pytest_cache/", + ".eslintcache", + // Environment and config files + ".env*", + "*.local", + "*.development", + "*.production", + // Large data files + "*.zip", + "*.tar", + "*.gz", + "*.rar", + "*.7z", + "*.iso", + "*.bin", + "*.exe", + "*.dll", + "*.so", + "*.dylib", + // Database files + "*.sqlite", + "*.db", + "*.sql", + // Log files + "*.logs", + "*.error", + "npm-debug.log*", + "yarn-debug.log*", + "yarn-error.log*", + ].join("\n"), + ) + + // Set up git identity (git throws an error if user.name or user.email is not set) + await git.addConfig("user.name", "Cline Checkpoint") + await git.addConfig("user.email", "noreply@example.com") + + // Initial commit (--allow-empty ensures it works even with no files) + await this.renameNestedGitRepos(true) + await git.add(".") + await this.renameNestedGitRepos(false) + await git.commit("initial commit", { "--allow-empty": null }) + + return gitPath + } + } + + public async getShadowGitConfigWorkTree(): Promise { + if (this.lastRetrievedShadowGitConfigWorkTree) { + return this.lastRetrievedShadowGitConfigWorkTree + } + try { + const gitPath = await this.getShadowGitPath() + const git = simpleGit(path.dirname(gitPath)) + const worktree = await git.getConfig("core.worktree") + this.lastRetrievedShadowGitConfigWorkTree = worktree.value || undefined + return this.lastRetrievedShadowGitConfigWorkTree + } catch (error) { + console.error("Failed to get shadow git config worktree:", error) + return undefined + } + } + + public async commit(): Promise { + try { + const gitPath = await this.getShadowGitPath() + const git = simpleGit(path.dirname(gitPath)) + await this.renameNestedGitRepos(true) + await git.add(".") + await this.renameNestedGitRepos(false) + const result = await git.commit("checkpoint", { + "--allow-empty": null, + }) + const commitHash = result.commit || "" + this.lastCheckpointHash = commitHash + return commitHash + } catch (error) { + console.error("Failed to create checkpoint:", error) + return undefined + } + } + + public async resetHead(commitHash: string): Promise { + const gitPath = await this.getShadowGitPath() + const git = simpleGit(path.dirname(gitPath)) + + // Clean working directory and force reset + // This ensures that the operation will succeed regardless of: + // - Untracked files in the workspace + // - Staged changes + // - Unstaged changes + // - Partial commits + // - Merge conflicts + await git.clean("f", ["-d", "-f"]) // Remove untracked files and directories + await git.reset(["--hard", commitHash]) // Hard reset to target commit + } + + /** + * Return an array describing changed files between one commit and either: + * - another commit, or + * - the current working directory (including uncommitted changes). + * + * If `rhsHash` is omitted, compares `lhsHash` to the working directory. + * If you want truly untracked files to appear, `git add` them first. + * + * @param lhsHash - The commit to compare from (older commit) + * @param rhsHash - The commit to compare to (newer commit). + * If omitted, we compare to the working directory. + * @returns Array of file changes with before/after content + */ + public async getDiffSet( + lhsHash?: string, + rhsHash?: string, + ): Promise< + Array<{ + relativePath: string + absolutePath: string + before: string + after: string + }> + > { + const gitPath = await this.getShadowGitPath() + const git = simpleGit(path.dirname(gitPath)) + + // If lhsHash is missing, use the initial commit of the repo + let baseHash = lhsHash + if (!baseHash) { + const rootCommit = await git.raw(["rev-list", "--max-parents=0", "HEAD"]) + baseHash = rootCommit.trim() + } + + // Stage all changes so that untracked files appear in diff summary + await this.renameNestedGitRepos(true) + await git.add(".") + await this.renameNestedGitRepos(false) + + const diffSummary = rhsHash ? await git.diffSummary([`${baseHash}..${rhsHash}`]) : await git.diffSummary([baseHash]) + + // For each changed file, gather before/after content + const result = [] + const cwdPath = (await this.getShadowGitConfigWorkTree()) || this.cwd || "" + + for (const file of diffSummary.files) { + const filePath = file.file + const absolutePath = path.join(cwdPath, filePath) + + let beforeContent = "" + try { + beforeContent = await git.show([`${baseHash}:${filePath}`]) + } catch (_) { + // file didn't exist in older commit => remains empty + } + + let afterContent = "" + if (rhsHash) { + // if user provided a newer commit, use git.show at that commit + try { + afterContent = await git.show([`${rhsHash}:${filePath}`]) + } catch (_) { + // file didn't exist in newer commit => remains empty + } + } else { + // otherwise, read from disk (includes uncommitted changes) + try { + afterContent = await fs.readFile(absolutePath, "utf8") + } catch (_) { + // file might be deleted => remains empty + } + } + + result.push({ + relativePath: filePath, + absolutePath, + before: beforeContent, + after: afterContent, + }) + } + + return result + } + + // Since we use git to track checkpoints, we need to temporarily disable nested git repos to work around git's requirement of using submodules for nested repos. + async renameNestedGitRepos(disable: boolean) { + // Find all .git directories that are not at the root level + const gitPaths = await globby("**/.git" + (disable ? "" : GIT_DISABLED_SUFFIX), { + cwd: this.cwd, + onlyDirectories: true, + ignore: [".git"], // Ignore root level .git + dot: true, + markDirectories: false, + }) + + // For each nested .git directory, rename it based on operation + for (const gitPath of gitPaths) { + const fullPath = path.join(this.cwd, gitPath) + let newPath: string + if (disable) { + newPath = fullPath + GIT_DISABLED_SUFFIX + } else { + newPath = fullPath.endsWith(GIT_DISABLED_SUFFIX) ? fullPath.slice(0, -GIT_DISABLED_SUFFIX.length) : fullPath + } + + try { + await fs.rename(fullPath, newPath) + console.log(`CheckpointTracker ${disable ? "disabled" : "enabled"} nested git repo ${gitPath}`) + } catch (error) { + console.error(`CheckpointTracker failed to ${disable ? "disable" : "enable"} nested git repo ${gitPath}:`, error) + } + } + } + + public dispose() { + this.disposables.forEach((d) => d.dispose()) + this.disposables = [] + } +} + +const GIT_DISABLED_SUFFIX = "_disabled" + +export default CheckpointTracker diff --git a/src/integrations/editor/DiffViewProvider.ts b/src/integrations/editor/DiffViewProvider.ts index 46387dbc82..e416f99de6 100644 --- a/src/integrations/editor/DiffViewProvider.ts +++ b/src/integrations/editor/DiffViewProvider.ts @@ -162,7 +162,9 @@ export class DiffViewProvider { // get text after save in case there is any auto-formatting done by the editor const postSaveContent = updatedDocument.getText() - await vscode.window.showTextDocument(vscode.Uri.file(absolutePath), { preview: false }) + await vscode.window.showTextDocument(vscode.Uri.file(absolutePath), { + preview: false, + }) await this.closeAllDiffViews() /* @@ -220,7 +222,12 @@ export class DiffViewProvider { ) } - return { newProblemsMessage, userEdits, autoFormattingEdits, finalContent: normalizedPostSaveContent } + return { + newProblemsMessage, + userEdits, + autoFormattingEdits, + finalContent: normalizedPostSaveContent, + } } async revertChanges(): Promise { diff --git a/src/services/browser/BrowserSession.ts b/src/services/browser/BrowserSession.ts index b45265c77b..0b7b0961b5 100644 --- a/src/services/browser/BrowserSession.ts +++ b/src/services/browser/BrowserSession.ts @@ -166,7 +166,10 @@ export class BrowserSession { async navigateToUrl(url: string): Promise { return this.doAction(async (page) => { // networkidle2 isn't good enough since page may take some time to load. we can assume locally running dev sites will reach networkidle0 in a reasonable amount of time - await page.goto(url, { timeout: 7_000, waitUntil: ["domcontentloaded", "networkidle2"] }) + await page.goto(url, { + timeout: 7_000, + waitUntil: ["domcontentloaded", "networkidle2"], + }) // await page.goto(url, { timeout: 10_000, waitUntil: "load" }) await this.waitTillHTMLStable(page) // in case the page is loading more resources }) diff --git a/src/services/browser/UrlContentFetcher.ts b/src/services/browser/UrlContentFetcher.ts index caf19ee83b..614338503e 100644 --- a/src/services/browser/UrlContentFetcher.ts +++ b/src/services/browser/UrlContentFetcher.ts @@ -71,7 +71,10 @@ export class UrlContentFetcher { - domcontentloaded is when the basic DOM is loaded this should be sufficient for most doc sites */ - await this.page.goto(url, { timeout: 10_000, waitUntil: ["domcontentloaded", "networkidle2"] }) + await this.page.goto(url, { + timeout: 10_000, + waitUntil: ["domcontentloaded", "networkidle2"], + }) const content = await this.page.content() // use cheerio to parse and clean up the HTML diff --git a/src/services/tree-sitter/index.ts b/src/services/tree-sitter/index.ts index 83e02ac615..19d0234f01 100644 --- a/src/services/tree-sitter/index.ts +++ b/src/services/tree-sitter/index.ts @@ -50,7 +50,10 @@ export async function parseSourceCodeForDefinitionsTopLevel(dirPath: string): Pr return result ? result : "No source code definitions found." } -function separateFiles(allFiles: string[]): { filesToParse: string[]; remainingFiles: string[] } { +function separateFiles(allFiles: string[]): { + filesToParse: string[] + remainingFiles: string[] +} { const extensions = [ "js", "jsx", diff --git a/src/shared/ExtensionMessage.ts b/src/shared/ExtensionMessage.ts index 33ef75b3d1..e4760282a5 100644 --- a/src/shared/ExtensionMessage.ts +++ b/src/shared/ExtensionMessage.ts @@ -19,6 +19,7 @@ export interface ExtensionMessage { | "partialMessage" | "openRouterModels" | "mcpServers" + | "relinquishControl" text?: string action?: "chatButtonClicked" | "mcpButtonClicked" | "settingsButtonClicked" | "historyButtonClicked" | "didBecomeVisible" invoke?: "sendMessage" | "primaryButtonClick" | "secondaryButtonClick" @@ -37,6 +38,8 @@ export interface ExtensionState { apiConfiguration?: ApiConfiguration customInstructions?: string uriScheme?: string + currentTaskItem?: HistoryItem + checkpointTrackerErrorMessage?: string clineMessages: ClineMessage[] taskHistory: HistoryItem[] shouldShowAnnouncement: boolean @@ -51,6 +54,9 @@ export interface ClineMessage { text?: string images?: string[] partial?: boolean + lastCheckpointHash?: string + conversationHistoryIndex?: number + conversationHistoryDeletedRange?: [number, number] // for when conversation history is truncated for API requests } export type ClineAsk = @@ -88,6 +94,7 @@ export type ClineSay = | "mcp_server_response" | "use_mcp_server" | "diff_error" + | "deleted_api_reqs" export interface ClineSayTool { tool: @@ -142,3 +149,5 @@ export interface ClineApiReqInfo { } export type ClineApiReqCancelReason = "streaming_failed" | "user_cancelled" + +export const COMPLETION_RESULT_CHANGES_FLAG = "HAS_CHANGES" diff --git a/src/shared/HistoryItem.ts b/src/shared/HistoryItem.ts index d4539f6441..790c35cef6 100644 --- a/src/shared/HistoryItem.ts +++ b/src/shared/HistoryItem.ts @@ -7,4 +7,8 @@ export type HistoryItem = { cacheWrites?: number cacheReads?: number totalCost: number + + size?: number + shadowGitConfigWorkTree?: string + conversationHistoryDeletedRange?: [number, number] } diff --git a/src/shared/WebviewMessage.ts b/src/shared/WebviewMessage.ts index 82ad22d95e..419306ef6c 100644 --- a/src/shared/WebviewMessage.ts +++ b/src/shared/WebviewMessage.ts @@ -26,12 +26,18 @@ export interface WebviewMessage { | "openMcpSettings" | "restartMcpServer" | "autoApprovalSettings" + | "checkpointDiff" + | "checkpointRestore" + | "taskCompletionViewChanges" text?: string askResponse?: ClineAskResponse apiConfiguration?: ApiConfiguration images?: string[] bool?: boolean + number?: number autoApprovalSettings?: AutoApprovalSettings } export type ClineAskResponse = "yesButtonClicked" | "noButtonClicked" | "messageResponse" + +export type ClineCheckpointRestore = "task" | "workspace" | "taskAndWorkspace" diff --git a/src/shared/combineApiRequests.ts b/src/shared/combineApiRequests.ts index be8721b99d..36f318babf 100644 --- a/src/shared/combineApiRequests.ts +++ b/src/shared/combineApiRequests.ts @@ -29,7 +29,10 @@ export function combineApiRequests(messages: ClineMessage[]): ClineMessage[] { while (j < messages.length) { if (messages[j].type === "say" && messages[j].say === "api_req_finished") { let finishedRequest = JSON.parse(messages[j].text || "{}") - let combinedRequest = { ...startedRequest, ...finishedRequest } + let combinedRequest = { + ...startedRequest, + ...finishedRequest, + } combinedApiRequests.push({ ...messages[i], diff --git a/src/shared/getApiMetrics.ts b/src/shared/getApiMetrics.ts index bd7b1bbce0..23fa0516d9 100644 --- a/src/shared/getApiMetrics.ts +++ b/src/shared/getApiMetrics.ts @@ -12,7 +12,7 @@ interface ApiMetrics { * Calculates API metrics from an array of ClineMessages. * * This function processes 'api_req_started' messages that have been combined with their - * corresponding 'api_req_finished' messages by the combineApiRequests function. + * corresponding 'api_req_finished' messages by the combineApiRequests function. It also takes into account 'deleted_api_reqs' messages, which are aggregated from deleted messages. * It extracts and sums up the tokensIn, tokensOut, cacheWrites, cacheReads, and cost from these messages. * * @param messages - An array of ClineMessage objects to process. @@ -35,7 +35,7 @@ export function getApiMetrics(messages: ClineMessage[]): ApiMetrics { } messages.forEach((message) => { - if (message.type === "say" && message.say === "api_req_started" && message.text) { + if (message.type === "say" && (message.say === "api_req_started" || message.say === "deleted_api_reqs") && message.text) { try { const parsedData = JSON.parse(message.text) const { tokensIn, tokensOut, cacheWrites, cacheReads, cost } = parsedData diff --git a/webview-ui/package-lock.json b/webview-ui/package-lock.json index 4412b1f711..114e269a8f 100644 --- a/webview-ui/package-lock.json +++ b/webview-ui/package-lock.json @@ -19,6 +19,7 @@ "debounce": "^2.1.1", "fast-deep-equal": "^3.1.3", "fuse.js": "^7.0.0", + "pretty-bytes": "^6.1.1", "react": "^18.3.1", "react-dom": "^18.3.1", "react-remark": "^2.1.0", @@ -16061,12 +16062,12 @@ } }, "node_modules/pretty-bytes": { - "version": "5.6.0", - "resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-5.6.0.tgz", - "integrity": "sha512-FFw039TmrBqFK8ma/7OL3sDz/VytdtJr044/QUJtH0wK9lb9jLq9tJyIxUwtQJHwar2BqtiA4iCWSwo9JLkzFg==", + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-6.1.1.tgz", + "integrity": "sha512-mQUvGU6aUFQ+rNvTIAcZuWGRT9a6f6Yrg9bHs4ImKF+HZCEK+plBvnAZYSIQztknZF2qnzNtr6F8s0+IuptdlQ==", "license": "MIT", "engines": { - "node": ">=6" + "node": "^14.13.1 || >=16.0.0" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -20557,6 +20558,18 @@ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "license": "MIT" }, + "node_modules/workbox-build/node_modules/pretty-bytes": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-5.6.0.tgz", + "integrity": "sha512-FFw039TmrBqFK8ma/7OL3sDz/VytdtJr044/QUJtH0wK9lb9jLq9tJyIxUwtQJHwar2BqtiA4iCWSwo9JLkzFg==", + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/workbox-build/node_modules/source-map": { "version": "0.8.0-beta.0", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.8.0-beta.0.tgz", @@ -20730,6 +20743,18 @@ "webpack": "^4.4.0 || ^5.9.0" } }, + "node_modules/workbox-webpack-plugin/node_modules/pretty-bytes": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-5.6.0.tgz", + "integrity": "sha512-FFw039TmrBqFK8ma/7OL3sDz/VytdtJr044/QUJtH0wK9lb9jLq9tJyIxUwtQJHwar2BqtiA4iCWSwo9JLkzFg==", + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/workbox-webpack-plugin/node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", diff --git a/webview-ui/package.json b/webview-ui/package.json index cc5beb6397..5f9cfdb767 100644 --- a/webview-ui/package.json +++ b/webview-ui/package.json @@ -14,6 +14,7 @@ "debounce": "^2.1.1", "fast-deep-equal": "^3.1.3", "fuse.js": "^7.0.0", + "pretty-bytes": "^6.1.1", "react": "^18.3.1", "react-dom": "^18.3.1", "react-remark": "^2.1.0", diff --git a/webview-ui/src/components/chat/Announcement.tsx b/webview-ui/src/components/chat/Announcement.tsx index 0d47bd4be9..5567089226 100644 --- a/webview-ui/src/components/chat/Announcement.tsx +++ b/webview-ui/src/components/chat/Announcement.tsx @@ -31,39 +31,39 @@ const Announcement = ({ version, hideAnnouncement }: AnnouncementProps) => {
  • - Auto-approve menu: You can now specify which tools require approval, set a max # of auto-approved API - requests, and enable system notifications for when Cline completes a task. + Checkpoints are here! Cline now saves a snapshot of your workspace at each step of the task. Hover over + any message to see two new buttons: +
      +
    • + + Compare shows you a diff between the snapshot and your current workspace +
    • +
    • + + Restore lets you revert your project's files back to that point in the task +
    • +
  • - New diff editing for large files: Cline now uses an efficient search & replace approach when modifying - large files for faster, more reliable edits (no more "{"// rest of code here"}" deletions). -
  • -
  • - .clinerules: Add a root-level .clinerules file to specify custom instructions for the - project. -
  • -
-

v2.2 Updates:

-
    -
  • - Add and configure{" "} - - MCP servers - - by clicking the new icon in the - menu bar. -
  • -
  • - Cline can also create custom tools–just say "add a tool that...", and watch him create the MCP server and - install it in the extension, ready to use in future tasks. -
  • -
  • - Try it yourself by asking Cline to "add a tool that gets the latest npm docs", or - - see a demo of MCP in action here. - + 'See new changes' button when a task is completed, showing you an overview of all the changes Cline + made to your workspace throughout the task
+

+ + See a demo of Checkpoints here! + +

{/*
  • OpenRouter now supports prompt caching! They also have much higher rate limits than other providers, diff --git a/webview-ui/src/components/chat/AutoApproveMenu.tsx b/webview-ui/src/components/chat/AutoApproveMenu.tsx index f958b54668..0c2d9afc72 100644 --- a/webview-ui/src/components/chat/AutoApproveMenu.tsx +++ b/webview-ui/src/components/chat/AutoApproveMenu.tsx @@ -157,7 +157,9 @@ const AutoApproveMenu = ({ style }: AutoApproveMenuProps) => { } }}> { @@ -182,7 +184,13 @@ const AutoApproveMenu = ({ style }: AutoApproveMenuProps) => { setIsExpanded((prev) => !prev) } }}> - Auto-approve: + + Auto-approve: + { return launchMessage?.say === "browser_action_launch" }, [messages]) + const lastCheckpointMessageTs = useMemo(() => { + const lastCheckpointMessage = findLast(messages, (m) => m.lastCheckpointHash !== undefined) + return lastCheckpointMessage?.ts + }, [messages]) + // Find the latest available URL and screenshot const latestState = useMemo(() => { for (let i = pages.length - 1; i >= 0; i--) { @@ -150,7 +158,12 @@ const BrowserSessionRow = memo((props: BrowserSessionRowProps) => { } } } - return { url: undefined, mousePosition: undefined, consoleLogs: undefined, screenshot: undefined } + return { + url: undefined, + mousePosition: undefined, + consoleLogs: undefined, + screenshot: undefined, + } }, [pages]) const currentPage = pages[currentPageIndex] @@ -212,15 +225,29 @@ const BrowserSessionRow = memo((props: BrowserSessionRowProps) => { // Use latest click position while browsing, otherwise use display state const mousePosition = isBrowsing ? latestClickPosition || displayState.mousePosition : displayState.mousePosition + let shouldShowCheckpoints = true + if (isLast) { + shouldShowCheckpoints = lastModifiedMessage?.ask === "resume_completed_task" || lastModifiedMessage?.ask === "resume_task" + } + const [browserSessionRow, { height }] = useSize( -
    -
    + +
    {isBrowsing ? ( ) : ( + style={{ + color: "var(--vscode-foreground)", + marginBottom: "-1.5px", + }}> )} <>{isAutoApproved ? "Cline is using the browser:" : "Cline wants to use the browser:"} @@ -300,7 +327,10 @@ const BrowserSessionRow = memo((props: BrowserSessionRowProps) => { }}>
    )} @@ -370,7 +400,9 @@ const BrowserSessionRow = memo((props: BrowserSessionRowProps) => {
    )} - , + + {shouldShowCheckpoints && } + , ) // Height change effect @@ -536,4 +568,13 @@ const BrowserCursor: React.FC<{ style?: React.CSSProperties }> = ({ style }) => ) } +const BrowserSessionRowContainer = styled.div` + padding: 10px 6px 10px 15px; + position: relative; + + &:hover ${CheckpointControls} { + opacity: 1; + } +` + export default BrowserSessionRow diff --git a/webview-ui/src/components/chat/ChatRow.tsx b/webview-ui/src/components/chat/ChatRow.tsx index 66c25d2f3b..fc3c32e66b 100644 --- a/webview-ui/src/components/chat/ChatRow.tsx +++ b/webview-ui/src/components/chat/ChatRow.tsx @@ -1,20 +1,39 @@ import { VSCodeBadge, VSCodeProgressRing } from "@vscode/webview-ui-toolkit/react" import deepEqual from "fast-deep-equal" -import React, { memo, useEffect, useMemo, useRef } from "react" -import { useSize } from "react-use" -import { ClineApiReqInfo, ClineAskUseMcpServer, ClineMessage, ClineSayTool } from "../../../../src/shared/ExtensionMessage" +import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from "react" +import { useEvent, useSize } from "react-use" +import styled from "styled-components" +import { + ClineApiReqInfo, + ClineAskUseMcpServer, + ClineMessage, + ClineSayTool, + ExtensionMessage, + COMPLETION_RESULT_CHANGES_FLAG, +} from "../../../../src/shared/ExtensionMessage" import { COMMAND_OUTPUT_STRING, COMMAND_REQ_APP_STRING } from "../../../../src/shared/combineCommandSequences" import { useExtensionState } from "../../context/ExtensionStateContext" import { findMatchingResourceOrTemplate } from "../../utils/mcp" import { vscode } from "../../utils/vscode" +import { CheckpointControls, CheckpointOverlay } from "../common/CheckpointControls" import CodeAccordian, { removeLeadingNonAlphanumeric } from "../common/CodeAccordian" import CodeBlock, { CODE_BLOCK_BG_COLOR } from "../common/CodeBlock" import MarkdownBlock from "../common/MarkdownBlock" +import SuccessButton from "../common/SuccessButton" import Thumbnails from "../common/Thumbnails" import McpResourceRow from "../mcp/McpResourceRow" import McpToolRow from "../mcp/McpToolRow" import { highlightMentions } from "./TaskHeader" +const ChatRowContainer = styled.div` + padding: 10px 6px 10px 15px; + position: relative; + + &:hover ${CheckpointControls} { + opacity: 1; + } +` + interface ChatRowProps { message: ClineMessage isExpanded: boolean @@ -28,18 +47,33 @@ interface ChatRowContentProps extends Omit {} const ChatRow = memo( (props: ChatRowProps) => { - const { isLast, onHeightChange, message } = props + const { isLast, onHeightChange, message, lastModifiedMessage } = props // Store the previous height to compare with the current height // This allows us to detect changes without causing re-renders const prevHeightRef = useRef(0) + // NOTE: for tools that are interrupted and not responded to (approved or rejected), there won't be a checkpoint hash + let shouldShowCheckpoints = + message.lastCheckpointHash != null && + (message.say === "tool" || + message.ask === "tool" || + message.say === "command" || + message.ask === "command" || + message.say === "completion_result" || + message.ask === "completion_result" || + message.say === "use_mcp_server" || + message.ask === "use_mcp_server") + + if (shouldShowCheckpoints && isLast) { + shouldShowCheckpoints = + lastModifiedMessage?.ask === "resume_completed_task" || lastModifiedMessage?.ask === "resume_task" + } + const [chatrow, { height }] = useSize( -
    + -
    , + {shouldShowCheckpoints && } + , ) useEffect(() => { @@ -66,6 +100,9 @@ export default ChatRow export const ChatRowContent = ({ message, isExpanded, onToggleExpand, lastModifiedMessage, isLast }: ChatRowContentProps) => { const { mcpServers } = useExtensionState() + + const [seeNewChangesDisabled, setSeeNewChangesDisabled] = useState(false) + const [cost, apiReqCancelReason, apiReqStreamingFailedMessage] = useMemo(() => { if (message.text != null && message.say === "api_req_started") { const info: ClineApiReqInfo = JSON.parse(message.text) @@ -92,21 +129,48 @@ export const ChatRowContent = ({ message, isExpanded, onToggleExpand, lastModifi const successColor = "var(--vscode-charts-green)" const cancelledColor = "var(--vscode-descriptionForeground)" + const handleMessage = useCallback((event: MessageEvent) => { + const message: ExtensionMessage = event.data + switch (message.type) { + case "relinquishControl": { + setSeeNewChangesDisabled(false) + break + } + } + }, []) + + useEvent("message", handleMessage) + const [icon, title] = useMemo(() => { switch (type) { case "error": return [ - , + , Error, ] case "mistake_limit_reached": return [ - , + , Cline is having trouble..., ] case "auto_approval_max_req_reached": return [ - , + , Maximum Requests Reached, ] case "command": @@ -114,7 +178,12 @@ export const ChatRowContent = ({ message, isExpanded, onToggleExpand, lastModifi isCommandExecuting ? ( ) : ( - + ), {message.type === "ask" ? "Cline wants to execute this command:" : "Cline executed this command:"} @@ -126,7 +195,12 @@ export const ChatRowContent = ({ message, isExpanded, onToggleExpand, lastModifi isMcpServerResponding ? ( ) : ( - + ), {message.type === "ask" ? ( @@ -144,7 +218,12 @@ export const ChatRowContent = ({ message, isExpanded, onToggleExpand, lastModifi ] case "completion_result": return [ - , + , Task Completed, ] case "api_req_started": @@ -182,9 +261,21 @@ export const ChatRowContent = ({ message, isExpanded, onToggleExpand, lastModifi ), apiReqCancelReason != null ? ( apiReqCancelReason === "user_cancelled" ? ( - API Request Cancelled + + API Request Cancelled + ) : ( - API Streaming Failed + + API Streaming Failed + ) ) : cost != null ? ( API Request @@ -196,7 +287,12 @@ export const ChatRowContent = ({ message, isExpanded, onToggleExpand, lastModifi ] case "followup": return [ - , + , Cline has a question:, ] default: @@ -217,7 +313,7 @@ export const ChatRowContent = ({ message, isExpanded, onToggleExpand, lastModifi display: "flex", alignItems: "center", gap: "10px", - marginBottom: "10px", + marginBottom: "12px", } const pStyle: React.CSSProperties = { @@ -238,7 +334,10 @@ export const ChatRowContent = ({ message, isExpanded, onToggleExpand, lastModifi const toolIcon = (name: string) => ( + style={{ + color: "var(--vscode-foreground)", + marginBottom: "-1.5px", + }}> ) switch (tool.tool) { @@ -313,7 +412,10 @@ export const ChatRowContent = ({ message, isExpanded, onToggleExpand, lastModifi msUserSelect: "none", }} onClick={() => { - vscode.postMessage({ type: "openFile", text: tool.content }) + vscode.postMessage({ + type: "openFile", + text: tool.content, + }) }}> {tool.path?.startsWith(".") && .} + style={{ + fontSize: 13.5, + margin: "1px 0", + }}> @@ -532,7 +637,7 @@ export const ChatRowContent = ({ message, isExpanded, onToggleExpand, lastModifi gap: 10, padding: 8, fontSize: "12px", - color: "var(--vscode-errorForeground)", + color: "var(--vscode-editorWarning-foreground)", }}> The model has determined this command requires explicit approval. @@ -632,11 +737,19 @@ export const ChatRowContent = ({ message, isExpanded, onToggleExpand, lastModifi msUserSelect: "none", }} onClick={onToggleExpand}> -
    +
    {icon} {title} {/* Need to render this everytime since it affects height of row by 2px */} - 0 ? 1 : 0 }}> + 0 ? 1 : 0, + }}> ${Number(cost || 0)?.toFixed(4)}
    @@ -644,7 +757,11 @@ export const ChatRowContent = ({ message, isExpanded, onToggleExpand, lastModifi
    {((cost == null && apiRequestFailedMessage) || apiReqStreamingFailedMessage) && ( <> -

    +

    {apiRequestFailedMessage || apiReqStreamingFailedMessage} {apiRequestFailedMessage?.toLowerCase().includes("powershell") && ( <> @@ -653,7 +770,10 @@ export const ChatRowContent = ({ message, isExpanded, onToggleExpand, lastModifi It seems like you're having Windows PowerShell issues, please see this{" "} + style={{ + color: "inherit", + textDecoration: "underline", + }}> troubleshooting guide . @@ -758,7 +878,13 @@ export const ChatRowContent = ({ message, isExpanded, onToggleExpand, lastModifi {title} )} -

    {message.text}

    +

    + {message.text} +

    ) case "diff_error": @@ -773,7 +899,12 @@ export const ChatRowContent = ({ message, isExpanded, onToggleExpand, lastModifi borderRadius: 3, fontSize: 12, }}> -
    +
    - Diff Edit Failed + + Diff Edit Failed +
    This usually happens when the model uses search patterns that don't match anything in the @@ -791,15 +928,45 @@ export const ChatRowContent = ({ message, isExpanded, onToggleExpand, lastModifi ) case "completion_result": + const hasChanges = message.text?.endsWith(COMPLETION_RESULT_CHANGES_FLAG) ?? false + const text = hasChanges ? message.text?.slice(0, -COMPLETION_RESULT_CHANGES_FLAG.length) : message.text return ( <> -
    +
    {icon} {title}
    -
    - +
    +
    + {message.partial !== true && hasChanges && ( +
    + { + setSeeNewChangesDisabled(true) + vscode.postMessage({ + type: "taskCompletionViewChanges", + number: message.ts, + }) + }} + style={{ + width: "100%", + cursor: seeNewChangesDisabled ? "wait" : "pointer", + }}> + + See new changes + +
    + )} ) case "shell_integration_warning": @@ -814,7 +981,12 @@ export const ChatRowContent = ({ message, isExpanded, onToggleExpand, lastModifi borderRadius: 3, fontSize: 12, }}> -
    +
    - Shell Integration Unavailable + + Shell Integration Unavailable +
    Cline won't be able to view the command's output. Please update VSCode ( @@ -831,7 +1009,10 @@ export const ChatRowContent = ({ message, isExpanded, onToggleExpand, lastModifi Profile").{" "} + style={{ + color: "inherit", + textDecoration: "underline", + }}> Still having trouble?
    @@ -884,7 +1065,13 @@ export const ChatRowContent = ({ message, isExpanded, onToggleExpand, lastModifi {icon} {title}
    -

    {message.text}

    +

    + {message.text} +

    ) case "auto_approval_max_req_reached": @@ -894,19 +1081,59 @@ export const ChatRowContent = ({ message, isExpanded, onToggleExpand, lastModifi {icon} {title}
    -

    {message.text}

    +

    + {message.text} +

    ) case "completion_result": if (message.text) { + // FIXME: is this ever even used? + const hasChanges = message.text.endsWith(COMPLETION_RESULT_CHANGES_FLAG) ?? false + const text = hasChanges ? message.text.slice(0, -COMPLETION_RESULT_CHANGES_FLAG.length) : message.text return (
    -
    +
    {icon} {title}
    -
    - +
    + + {message.partial !== true && hasChanges && ( +
    + { + setSeeNewChangesDisabled(true) + vscode.postMessage({ + type: "taskCompletionViewChanges", + number: message.ts, + }) + }}> + + See new changes + +
    + )}
    ) @@ -950,7 +1177,13 @@ export const ProgressIndicator = () => ( const Markdown = memo(({ markdown }: { markdown?: string }) => { return ( -
    +
    ) diff --git a/webview-ui/src/components/chat/ChatTextArea.tsx b/webview-ui/src/components/chat/ChatTextArea.tsx index 38380825d6..5cac05b399 100644 --- a/webview-ui/src/components/chat/ChatTextArea.tsx +++ b/webview-ui/src/components/chat/ChatTextArea.tsx @@ -553,7 +553,12 @@ const ChatTextArea = forwardRef( bottom: 9.5, // should be 10 but doesnt look good on mac zIndex: 2, }}> -
    +
    { diff --git a/webview-ui/src/components/chat/ChatView.tsx b/webview-ui/src/components/chat/ChatView.tsx index c81a830a2d..db534e16e6 100644 --- a/webview-ui/src/components/chat/ChatView.tsx +++ b/webview-ui/src/components/chat/ChatView.tsx @@ -312,7 +312,10 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie case "resume_task": case "mistake_limit_reached": case "auto_approval_max_req_reached": - vscode.postMessage({ type: "askResponse", askResponse: "yesButtonClicked" }) + vscode.postMessage({ + type: "askResponse", + askResponse: "yesButtonClicked", + }) break case "completion_result": case "resume_completed_task": @@ -346,7 +349,10 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie case "browser_action_launch": case "use_mcp_server": // responds to the API with a "This operation failed" and lets it try again - vscode.postMessage({ type: "askResponse", askResponse: "noButtonClicked" }) + vscode.postMessage({ + type: "askResponse", + askResponse: "noButtonClicked", + }) break } setTextAreaDisabled(true) @@ -444,6 +450,7 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie switch (message.say) { case "api_req_finished": // combineApiRequests removes this from modifiedMessages anyways case "api_req_retried": // this message is used to update the latest api_req_started that the request was retried + case "deleted_api_reqs": // aggregated api_req metrics from deleted messages return false case "text": // Sometimes cline returns an empty text message, we don't want to render these. (We also use a say text for user messages, so in case they just sent images we still render that) @@ -786,7 +793,10 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie Footer: () =>
    , // Add empty padding at the bottom }} // increasing top by 3_000 to prevent jumping around when user collapses a row - increaseViewportBy={{ top: 3_000, bottom: Number.MAX_SAFE_INTEGER }} // hack to make sure the last message is always rendered to get truly perfect scroll to bottom animation when new messages are added (Number.MAX_SAFE_INTEGER is safe for arithmetic operations, which is all virtuoso uses this value for in src/sizeRangeSystem.ts) + increaseViewportBy={{ + top: 3_000, + bottom: Number.MAX_SAFE_INTEGER, + }} // hack to make sure the last message is always rendered to get truly perfect scroll to bottom animation when new messages are added (Number.MAX_SAFE_INTEGER is safe for arithmetic operations, which is all virtuoso uses this value for in src/sizeRangeSystem.ts) data={groupedMessages} // messages is the raw format returned by extension, modifiedMessages is the manipulated structure that combines certain messages of related type, and visibleMessages is the filtered structure that removes messages that should not be rendered itemContent={itemContent} atBottomStateChange={(isAtBottom) => { diff --git a/webview-ui/src/components/chat/ContextMenu.tsx b/webview-ui/src/components/chat/ContextMenu.tsx index 750b789ac4..2ba03776ca 100644 --- a/webview-ui/src/components/chat/ContextMenu.tsx +++ b/webview-ui/src/components/chat/ContextMenu.tsx @@ -153,7 +153,11 @@ const ContextMenu: React.FC = ({ }}> {renderOptionContent(option)}
    @@ -161,13 +165,24 @@ const ContextMenu: React.FC = ({ !option.value && ( )} {(option.type === ContextMenuOptionType.Problems || ((option.type === ContextMenuOptionType.File || option.type === ContextMenuOptionType.Folder) && option.value)) && ( - + )}
    ))} diff --git a/webview-ui/src/components/chat/TaskHeader.tsx b/webview-ui/src/components/chat/TaskHeader.tsx index 87775b7a52..899c25242e 100644 --- a/webview-ui/src/components/chat/TaskHeader.tsx +++ b/webview-ui/src/components/chat/TaskHeader.tsx @@ -1,12 +1,13 @@ import { VSCodeButton } from "@vscode/webview-ui-toolkit/react" import React, { memo, useEffect, useMemo, useRef, useState } from "react" import { useWindowSize } from "react-use" +import { mentionRegexGlobal } from "../../../../src/shared/context-mentions" import { ClineMessage } from "../../../../src/shared/ExtensionMessage" import { useExtensionState } from "../../context/ExtensionStateContext" +import { formatLargeNumber } from "../../utils/format" +import { formatSize } from "../../utils/size" import { vscode } from "../../utils/vscode" import Thumbnails from "../common/Thumbnails" -import { mentionRegexGlobal } from "../../../../src/shared/context-mentions" -import { formatLargeNumber } from "../../utils/format" interface TaskHeaderProps { task: ClineMessage @@ -29,7 +30,7 @@ const TaskHeader: React.FC = ({ totalCost, onClose, }) => { - const { apiConfiguration } = useExtensionState() + const { apiConfiguration, currentTaskItem, checkpointTrackerErrorMessage } = useExtensionState() const [isTaskExpanded, setIsTaskExpanded] = useState(true) const [isTextExpanded, setIsTextExpanded] = useState(false) const [showSeeMore, setShowSeeMore] = useState(false) @@ -137,7 +138,12 @@ const TaskHeader: React.FC = ({ minWidth: 0, // This allows the div to shrink below its content size }} onClick={() => setIsTaskExpanded(!isTaskExpanded)}> -
    +
    = ({
    )} {task.images && task.images.length > 0 && } -
    +
    -
    +
    Tokens: - + {formatLargeNumber(tokensIn || 0)} - + {formatLargeNumber(tokensOut || 0)}
    - {!isCostAvailable && } + {!isCostAvailable && ( + + )}
    {shouldShowPromptCacheInfo && (cacheReads !== undefined || cacheWrites !== undefined) && ( -
    +
    Cache: - + +{formatLargeNumber(cacheWrites || 0)} - + {formatLargeNumber(cacheReads || 0)} @@ -295,11 +356,44 @@ const TaskHeader: React.FC = ({ justifyContent: "space-between", alignItems: "center", }}> -
    +
    API Cost: ${totalCost?.toFixed(4)}
    - + +
    + )} + {checkpointTrackerErrorMessage && ( +
    + + + {checkpointTrackerErrorMessage} + {checkpointTrackerErrorMessage.includes("Git must be installed to use checkpoints.") && ( + <> + {" "} + + See here for instructions. + + + )} +
    )}
    @@ -360,18 +454,41 @@ export const highlightMentions = (text?: string, withShadow = true) => { }) } -const ExportButton = () => ( +const DeleteButton: React.FC<{ + taskSize: string + taskId?: string +}> = ({ taskSize, taskId }) => ( vscode.postMessage({ type: "exportCurrentTask" })} - style={ - { - // marginBottom: "-2px", - // marginRight: "-2.5px", - } - }> -
    EXPORT
    + onClick={() => vscode.postMessage({ type: "deleteTaskWithId", text: taskId })} + style={{ padding: "0px 0px" }}> +
    + + {taskSize} +
    ) +// const ExportButton = () => ( +// vscode.postMessage({ type: "exportCurrentTask" })} +// style={ +// { +// // marginBottom: "-2px", +// // marginRight: "-2.5px", +// } +// }> +//
    EXPORT
    +//
    +// ) + export default memo(TaskHeader) diff --git a/webview-ui/src/components/common/CheckpointControls.tsx b/webview-ui/src/components/common/CheckpointControls.tsx new file mode 100644 index 0000000000..fc514e594e --- /dev/null +++ b/webview-ui/src/components/common/CheckpointControls.tsx @@ -0,0 +1,268 @@ +import { VSCodeButton } from "@vscode/webview-ui-toolkit/react" +import { useCallback, useRef, useState } from "react" +import { useClickAway, useEvent } from "react-use" +import styled from "styled-components" +import { ExtensionMessage } from "../../../../src/shared/ExtensionMessage" +import { vscode } from "../../utils/vscode" +import { CODE_BLOCK_BG_COLOR } from "./CodeBlock" +import { ClineCheckpointRestore } from "../../../../src/shared/WebviewMessage" + +interface CheckpointOverlayProps { + messageTs?: number +} + +export const CheckpointOverlay = ({ messageTs }: CheckpointOverlayProps) => { + const [compareDisabled, setCompareDisabled] = useState(false) + const [restoreTaskDisabled, setRestoreTaskDisabled] = useState(false) + const [restoreWorkspaceDisabled, setRestoreWorkspaceDisabled] = useState(false) + const [restoreBothDisabled, setRestoreBothDisabled] = useState(false) + const [showRestoreConfirm, setShowRestoreConfirm] = useState(false) + const [hasMouseEntered, setHasMouseEntered] = useState(false) + const containerRef = useRef(null) + const tooltipRef = useRef(null) + + useClickAway(containerRef, () => { + if (showRestoreConfirm) { + setShowRestoreConfirm(false) + setHasMouseEntered(false) + } + }) + + const handleMessage = useCallback((event: MessageEvent) => { + const message: ExtensionMessage = event.data + switch (message.type) { + case "relinquishControl": { + setCompareDisabled(false) + setRestoreTaskDisabled(false) + setRestoreWorkspaceDisabled(false) + setRestoreBothDisabled(false) + setShowRestoreConfirm(false) + break + } + } + }, []) + + useEvent("message", handleMessage) + + const handleRestoreTask = () => { + setRestoreTaskDisabled(true) + vscode.postMessage({ + type: "checkpointRestore", + number: messageTs, + text: "task" satisfies ClineCheckpointRestore, + }) + } + + const handleRestoreWorkspace = () => { + setRestoreWorkspaceDisabled(true) + vscode.postMessage({ + type: "checkpointRestore", + number: messageTs, + text: "workspace" satisfies ClineCheckpointRestore, + }) + } + + const handleRestoreBoth = () => { + setRestoreBothDisabled(true) + vscode.postMessage({ + type: "checkpointRestore", + number: messageTs, + text: "taskAndWorkspace" satisfies ClineCheckpointRestore, + }) + } + + const handleMouseEnter = () => { + setHasMouseEntered(true) + } + + const handleMouseLeave = () => { + if (hasMouseEntered) { + setShowRestoreConfirm(false) + setHasMouseEntered(false) + } + } + + const handleControlsMouseLeave = (e: React.MouseEvent) => { + const tooltipElement = tooltipRef.current + + if (tooltipElement && showRestoreConfirm) { + const tooltipRect = tooltipElement.getBoundingClientRect() + + // If mouse is moving towards the tooltip, don't close it + if ( + e.clientY >= tooltipRect.top && + e.clientY <= tooltipRect.bottom && + e.clientX >= tooltipRect.left && + e.clientX <= tooltipRect.right + ) { + return + } + } + + setShowRestoreConfirm(false) + setHasMouseEntered(false) + } + + return ( + + { + setCompareDisabled(true) + vscode.postMessage({ + type: "checkpointDiff", + number: messageTs, + }) + }}> + + +
    + setShowRestoreConfirm(true)}> + + + {showRestoreConfirm && ( + + + + Restore Task and Workspace + +

    Restores the task and your project's files back to a snapshot taken at this point

    +
    + + + Restore Task Only + +

    Deletes messages after this point (does not affect workspace)

    +
    + + + Restore Workspace Only + +

    Restores your project's files to a snapshot taken at this point (task may become out of sync)

    +
    +
    + )} +
    +
    + ) +} + +export const CheckpointControls = styled.div` + position: absolute; + top: 3px; + right: 6px; + display: flex; + gap: 6px; + opacity: 0; + background-color: var(--vscode-sideBar-background); + padding: 3px 0 3px 3px; + + & > vscode-button, + & > div > vscode-button { + width: 24px; + height: 24px; + position: relative; + } + + & > vscode-button i, + & > div > vscode-button i { + position: absolute; + left: 50%; + top: 50%; + transform: translate(-50%, -50%); + } +` + +const RestoreOption = styled.div` + &:not(:last-child) { + margin-bottom: 10px; + padding-bottom: 4px; + border-bottom: 1px solid var(--vscode-editorGroup-border); + } + + p { + margin: 0 0 2px 0; + color: var(--vscode-descriptionForeground); + font-size: 11px; + line-height: 14px; + } + + &:last-child p { + margin: 0 0 -2px 0; + } + + vscode-button { + width: 100%; + margin-bottom: 10px; + } +` + +const RestoreConfirmTooltip = styled.div` + position: absolute; + top: calc(100% - 0.5px); + right: 0; + background: ${CODE_BLOCK_BG_COLOR}; + border: 1px solid var(--vscode-editorGroup-border); + padding: 12px; + border-radius: 3px; + margin-top: 8px; + width: calc(100vw - 57px); + min-width: 0px; + max-width: 100vw; + z-index: 1000; + + // Add invisible padding to create a safe hover zone + &::before { + content: ""; + position: absolute; + top: -8px; // Same as margin-top + left: 0; + right: 0; + height: 8px; + } + + // Adjust arrow to be above the padding + &::after { + content: ""; + position: absolute; + top: -6px; + right: 6px; + width: 10px; + height: 10px; + background: ${CODE_BLOCK_BG_COLOR}; + border-left: 1px solid var(--vscode-editorGroup-border); + border-top: 1px solid var(--vscode-editorGroup-border); + transform: rotate(45deg); + z-index: 1; // Ensure arrow stays above the padding + } + + p { + margin: 0 0 6px 0; + color: var(--vscode-descriptionForeground); + font-size: 12px; + white-space: normal; + word-wrap: break-word; + } +` diff --git a/webview-ui/src/components/common/SuccessButton.tsx b/webview-ui/src/components/common/SuccessButton.tsx new file mode 100644 index 0000000000..51993462a3 --- /dev/null +++ b/webview-ui/src/components/common/SuccessButton.tsx @@ -0,0 +1,30 @@ +import { VSCodeButton } from "@vscode/webview-ui-toolkit/react" +import styled from "styled-components" + +const StyledButton = styled(VSCodeButton)` + --success-button-bg: #176f2c; + --success-button-hover: #197f31; + --success-button-active: #156528; + + background-color: var(--success-button-bg) !important; + border-color: var(--success-button-bg) !important; + color: #ffffff !important; + + &:hover { + background-color: var(--success-button-hover) !important; + border-color: var(--success-button-hover) !important; + } + + &:active { + background-color: var(--success-button-active) !important; + border-color: var(--success-button-active) !important; + } +` + +interface SuccessButtonProps extends React.ComponentProps {} + +const SuccessButton: React.FC = (props) => { + return +} + +export default SuccessButton diff --git a/webview-ui/src/components/history/HistoryPreview.tsx b/webview-ui/src/components/history/HistoryPreview.tsx index 3d351d9e60..06a2e9bc62 100644 --- a/webview-ui/src/components/history/HistoryPreview.tsx +++ b/webview-ui/src/components/history/HistoryPreview.tsx @@ -59,7 +59,10 @@ const HistoryPreview = ({ showHistoryView }: HistoryPreviewProps) => { }}> + style={{ + marginRight: "4px", + transform: "scale(0.9)", + }}> { }}> {item.task}
    -
    +
    Tokens: ↑{formatLargeNumber(item.tokensIn || 0)} ↓{formatLargeNumber(item.tokensOut || 0)} @@ -126,7 +133,12 @@ const HistoryPreview = ({ showHistoryView }: HistoryPreviewProps) => {
    ))} -
    +
    showHistoryView()} diff --git a/webview-ui/src/components/history/HistoryView.tsx b/webview-ui/src/components/history/HistoryView.tsx index c8cf13c0d7..d50b4b39db 100644 --- a/webview-ui/src/components/history/HistoryView.tsx +++ b/webview-ui/src/components/history/HistoryView.tsx @@ -5,6 +5,7 @@ import { Virtuoso } from "react-virtuoso" import { memo, useMemo, useState, useEffect } from "react" import Fuse, { FuseResult } from "fuse.js" import { formatLargeNumber } from "../../utils/format" +import { formatSize } from "../../utils/size" type HistoryViewProps = { onDone: () => void @@ -136,11 +137,22 @@ const HistoryView = ({ onDone }: HistoryViewProps) => { alignItems: "center", padding: "10px 17px 10px 20px", }}> -

    History

    +

    + History +

    Done
    -
    +
    {
    + style={{ + fontSize: 13, + marginTop: 2.5, + opacity: 0.8, + }}>
    {searchQuery && (
    { e.stopPropagation() handleDeleteHistoryItem(item.id) }} - className="delete-button"> - + className="delete-button" + style={{ padding: "0px 0px" }}> +
    + + {formatSize(item.size)} +
    { wordBreak: "break-word", overflowWrap: "anywhere", }} - dangerouslySetInnerHTML={{ __html: item.task }} + dangerouslySetInnerHTML={{ + __html: item.task, + }} /> -
    +
    { alignItems: "center", marginTop: -2, }}> -
    +
    { }}> API Cost: - + ${item.totalCost?.toFixed(4)}
    diff --git a/webview-ui/src/components/mcp/McpToolRow.tsx b/webview-ui/src/components/mcp/McpToolRow.tsx index e2bc3d9b36..aad420f9d0 100644 --- a/webview-ui/src/components/mcp/McpToolRow.tsx +++ b/webview-ui/src/components/mcp/McpToolRow.tsx @@ -37,7 +37,13 @@ const McpToolRow = ({ tool }: McpToolRowProps) => { borderRadius: "3px", padding: "8px", }}> -
    +
    Parameters
    {Object.entries(tool.inputSchema.properties as Record).map(([paramName, schema]) => { @@ -61,7 +67,14 @@ const McpToolRow = ({ tool }: McpToolRowProps) => { marginRight: "8px", }}> {paramName} - {isRequired && *} + {isRequired && ( + + * + + )} { {/* Server List */} {servers.length > 0 && ( -
    +
    {servers.map((server) => ( ))} @@ -224,7 +229,10 @@ const ServerRow = ({ server }: { server: McpServer }) => { appearance="secondary" onClick={handleRestart} disabled={server.status === "connecting"} - style={{ width: "calc(100% - 20px)", margin: "0 10px 10px 10px" }}> + style={{ + width: "calc(100% - 20px)", + margin: "0 10px 10px 10px", + }}> {server.status === "connecting" ? "Retrying..." : "Retry Connection"}
    @@ -245,13 +253,23 @@ const ServerRow = ({ server }: { server: McpServer }) => { {server.tools && server.tools.length > 0 ? ( -
    +
    {server.tools.map((tool) => ( ))}
    ) : ( -
    +
    No tools found
    )} @@ -260,7 +278,13 @@ const ServerRow = ({ server }: { server: McpServer }) => { {(server.resources && server.resources.length > 0) || (server.resourceTemplates && server.resourceTemplates.length > 0) ? ( -
    +
    {[...(server.resourceTemplates || []), ...(server.resources || [])].map((item) => ( { ))}
    ) : ( -
    +
    No resources found
    )} @@ -280,7 +308,10 @@ const ServerRow = ({ server }: { server: McpServer }) => { appearance="secondary" onClick={handleRestart} disabled={server.status === "connecting"} - style={{ width: "calc(100% - 14px)", margin: "0 7px 3px 7px" }}> + style={{ + width: "calc(100% - 14px)", + margin: "0 7px 3px 7px", + }}> {server.status === "connecting" ? "Restarting..." : "Restart Server"}
    diff --git a/webview-ui/src/components/settings/ApiOptions.tsx b/webview-ui/src/components/settings/ApiOptions.tsx index 330b95b729..fa00598a8e 100644 --- a/webview-ui/src/components/settings/ApiOptions.tsx +++ b/webview-ui/src/components/settings/ApiOptions.tsx @@ -50,7 +50,10 @@ const ApiOptions = ({ showModelOptions, apiErrorMessage, modelIdErrorMessage }: const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false) const handleInputChange = (field: keyof ApiConfiguration) => (event: any) => { - setApiConfiguration({ ...apiConfiguration, [field]: event.target.value }) + setApiConfiguration({ + ...apiConfiguration, + [field]: event.target.value, + }) } const { selectedProvider, selectedModelId, selectedModelInfo } = useMemo(() => { @@ -60,9 +63,15 @@ const ApiOptions = ({ showModelOptions, apiErrorMessage, modelIdErrorMessage }: // Poll ollama/lmstudio models const requestLocalModels = useCallback(() => { if (selectedProvider === "ollama") { - vscode.postMessage({ type: "requestOllamaModels", text: apiConfiguration?.ollamaBaseUrl }) + vscode.postMessage({ + type: "requestOllamaModels", + text: apiConfiguration?.ollamaBaseUrl, + }) } else if (selectedProvider === "lmstudio") { - vscode.postMessage({ type: "requestLmStudioModels", text: apiConfiguration?.lmStudioBaseUrl }) + vscode.postMessage({ + type: "requestLmStudioModels", + text: apiConfiguration?.lmStudioBaseUrl, + }) } }, [selectedProvider, apiConfiguration?.ollamaBaseUrl, apiConfiguration?.lmStudioBaseUrl]) useEffect(() => { @@ -124,7 +133,11 @@ const ApiOptions = ({ showModelOptions, apiErrorMessage, modelIdErrorMessage }: id="api-provider" value={selectedProvider} onChange={handleInputChange("apiProvider")} - style={{ minWidth: 130, position: "relative", zIndex: OPENROUTER_MODEL_PICKER_Z_INDEX + 1 }}> + style={{ + minWidth: 130, + position: "relative", + zIndex: OPENROUTER_MODEL_PICKER_Z_INDEX + 1, + }}> OpenRouter Anthropic Google Gemini @@ -155,7 +168,10 @@ const ApiOptions = ({ showModelOptions, apiErrorMessage, modelIdErrorMessage }: const isChecked = e.target.checked === true setAnthropicBaseUrlSelected(isChecked) if (!isChecked) { - setApiConfiguration({ ...apiConfiguration, anthropicBaseUrl: "" }) + setApiConfiguration({ + ...apiConfiguration, + anthropicBaseUrl: "", + }) } }}> Use custom base URL @@ -181,7 +197,10 @@ const ApiOptions = ({ showModelOptions, apiErrorMessage, modelIdErrorMessage }: {!apiConfiguration?.apiKey && ( + style={{ + display: "inline", + fontSize: "inherit", + }}> You can get an Anthropic API key by signing up here. )} @@ -209,7 +228,10 @@ const ApiOptions = ({ showModelOptions, apiErrorMessage, modelIdErrorMessage }: {!apiConfiguration?.openAiNativeApiKey && ( + style={{ + display: "inline", + fontSize: "inherit", + }}> You can get an OpenAI API key by signing up here. )} @@ -235,7 +257,12 @@ const ApiOptions = ({ showModelOptions, apiErrorMessage, modelIdErrorMessage }: }}> This key is stored locally and only used to make API requests from this extension. {!apiConfiguration?.deepSeekApiKey && ( - + You can get a DeepSeek API key by signing up here. )} @@ -279,7 +306,12 @@ const ApiOptions = ({ showModelOptions, apiErrorMessage, modelIdErrorMessage }: )} {selectedProvider === "bedrock" && ( -
    +
    { const isChecked = e.target.checked === true - setApiConfiguration({ ...apiConfiguration, awsUseCrossRegionInference: isChecked }) + setApiConfiguration({ + ...apiConfiguration, + awsUseCrossRegionInference: isChecked, + }) }}> Use cross-region inference @@ -363,7 +398,12 @@ const ApiOptions = ({ showModelOptions, apiErrorMessage, modelIdErrorMessage }: )} {apiConfiguration?.apiProvider === "vertex" && ( -
    +
    This key is stored locally and only used to make API requests from this extension. {!apiConfiguration?.geminiApiKey && ( - + You can get a Gemini API key by signing up here. )} @@ -466,7 +511,10 @@ const ApiOptions = ({ showModelOptions, apiErrorMessage, modelIdErrorMessage }: const isChecked = e.target.checked === true setAzureApiVersionSelected(isChecked) if (!isChecked) { - setApiConfiguration({ ...apiConfiguration, azureApiVersion: "" }) + setApiConfiguration({ + ...apiConfiguration, + azureApiVersion: "", + }) } }}> Set Azure API version @@ -768,7 +816,12 @@ export const ModelInfoView = ({ ].filter(Boolean) return ( -

    +

    {infoItems.map((item, index) => ( {item} @@ -821,7 +874,11 @@ export function normalizeApiConfiguration(apiConfiguration?: ApiConfiguration) { selectedModelId = defaultId selectedModelInfo = models[defaultId] } - return { selectedProvider: provider, selectedModelId, selectedModelInfo } + return { + selectedProvider: provider, + selectedModelId, + selectedModelInfo, + } } switch (provider) { case "anthropic": diff --git a/webview-ui/src/components/settings/OpenRouterModelPicker.tsx b/webview-ui/src/components/settings/OpenRouterModelPicker.tsx index fc588281ab..cdace4472b 100644 --- a/webview-ui/src/components/settings/OpenRouterModelPicker.tsx +++ b/webview-ui/src/components/settings/OpenRouterModelPicker.tsx @@ -153,7 +153,11 @@ const OpenRouterModelPicker: React.FC = () => { }} onFocus={() => setIsDropdownVisible(true)} onKeyDown={handleKeyDown} - style={{ width: "100%", zIndex: OPENROUTER_MODEL_PICKER_Z_INDEX, position: "relative" }}> + style={{ + width: "100%", + zIndex: OPENROUTER_MODEL_PICKER_Z_INDEX, + position: "relative", + }}> {searchTerm && (

    { setModelIdErrorMessage(modelIdValidationResult) if (!apiValidationResult && !modelIdValidationResult) { vscode.postMessage({ type: "apiConfiguration", apiConfiguration }) - vscode.postMessage({ type: "customInstructions", text: customInstructions }) + vscode.postMessage({ + type: "customInstructions", + text: customInstructions, + }) onDone() } } @@ -73,7 +76,14 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {

    Settings

    Done
    -
    +
    { marginTop: "auto", padding: "10px 8px 15px 0px", }}> -

    +

    If you have any questions or feedback, feel free to open an issue at{" "} https://github.com/cline/cline

    -

    v{version}

    +

    + v{version} +

    diff --git a/webview-ui/src/components/welcome/WelcomeView.tsx b/webview-ui/src/components/welcome/WelcomeView.tsx index 5da4d7d7a7..7de9200270 100644 --- a/webview-ui/src/components/welcome/WelcomeView.tsx +++ b/webview-ui/src/components/welcome/WelcomeView.tsx @@ -21,7 +21,15 @@ const WelcomeView = () => { }, [apiConfiguration]) return ( -
    +

    Hi, I'm Cline

    I can do all kinds of tasks thanks to the latest breakthroughs in{" "} diff --git a/webview-ui/src/context/ExtensionStateContext.tsx b/webview-ui/src/context/ExtensionStateContext.tsx index 37c1aa4a37..c6bbd04a86 100644 --- a/webview-ui/src/context/ExtensionStateContext.tsx +++ b/webview-ui/src/context/ExtensionStateContext.tsx @@ -22,7 +22,9 @@ interface ExtensionStateContextType extends ExtensionState { const ExtensionStateContext = createContext(undefined) -export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { +export const ExtensionStateContextProvider: React.FC<{ + children: React.ReactNode +}> = ({ children }) => { const [state, setState] = useState({ version: "", clineMessages: [], @@ -116,9 +118,21 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode openRouterModels, mcpServers, filePaths, - setApiConfiguration: (value) => setState((prevState) => ({ ...prevState, apiConfiguration: value })), - setCustomInstructions: (value) => setState((prevState) => ({ ...prevState, customInstructions: value })), - setShowAnnouncement: (value) => setState((prevState) => ({ ...prevState, shouldShowAnnouncement: value })), + setApiConfiguration: (value) => + setState((prevState) => ({ + ...prevState, + apiConfiguration: value, + })), + setCustomInstructions: (value) => + setState((prevState) => ({ + ...prevState, + customInstructions: value, + })), + setShowAnnouncement: (value) => + setState((prevState) => ({ + ...prevState, + shouldShowAnnouncement: value, + })), } return {children} diff --git a/webview-ui/src/utils/context-mentions.ts b/webview-ui/src/utils/context-mentions.ts index 0f5049cf24..07b5ce5e75 100644 --- a/webview-ui/src/utils/context-mentions.ts +++ b/webview-ui/src/utils/context-mentions.ts @@ -64,14 +64,20 @@ export function getContextMenuOptions( if (selectedType === ContextMenuOptionType.File) { const files = queryItems .filter((item) => item.type === ContextMenuOptionType.File) - .map((item) => ({ type: ContextMenuOptionType.File, value: item.value })) + .map((item) => ({ + type: ContextMenuOptionType.File, + value: item.value, + })) return files.length > 0 ? files : [{ type: ContextMenuOptionType.NoResults }] } if (selectedType === ContextMenuOptionType.Folder) { const folders = queryItems .filter((item) => item.type === ContextMenuOptionType.Folder) - .map((item) => ({ type: ContextMenuOptionType.Folder, value: item.value })) + .map((item) => ({ + type: ContextMenuOptionType.Folder, + value: item.value, + })) return folders.length > 0 ? folders : [{ type: ContextMenuOptionType.NoResults }] } diff --git a/webview-ui/src/utils/size.ts b/webview-ui/src/utils/size.ts new file mode 100644 index 0000000000..e6b2bd3fe0 --- /dev/null +++ b/webview-ui/src/utils/size.ts @@ -0,0 +1,9 @@ +import prettyBytes from "pretty-bytes" + +export function formatSize(bytes?: number) { + if (bytes === undefined) { + return "--kb" + } + + return prettyBytes(bytes) +}