Checkpoints

This commit is contained in:
Matt Rubens 2025-01-07 01:08:50 -05:00
parent b0396a6083
commit 882d80e14e
52 changed files with 2495 additions and 274 deletions

View file

@ -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)

View file

@ -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
<!-- Transparent pixel to create line break after floating image -->
<img width="2000" height="0" src="https://github.com/user-attachments/assets/ee14e6f7-20b8-4391-9091-8e8e25561929"><br>
<img align="right" width="350" src="https://github.com/user-attachments/assets/140c8606-d3bf-41b9-9a1f-4dbf0d4c90cb">
### 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.
<!-- Transparent pixel to create line break after floating image -->
<img width="2000" height="0" src="https://github.com/user-attachments/assets/ee14e6f7-20b8-4391-9091-8e8e25561929"><br>
## 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)!

58
package-lock.json generated
View file

@ -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",

View file

@ -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",

View file

@ -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],
}
}
}

View file

@ -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],
}
}
}

View file

@ -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],
}
}
}

View file

@ -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],
}
}
}

View file

@ -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],
}
}
}

View file

@ -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,
}
}
}

View file

@ -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],
}
}
}

View file

@ -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)

View file

@ -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 }

File diff suppressed because it is too large Load diff

View file

@ -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
})
: []

View file

@ -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)]
}

View file

@ -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<ExtensionState> {
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",
})
}
}

View file

@ -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",

View file

@ -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",
})
}),
)

View file

@ -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<ClineProvider>
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<CheckpointTracker> {
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<string> {
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<string> {
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<boolean> {
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<string> {
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<string | undefined> {
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<string | undefined> {
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<void> {
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

View file

@ -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<void> {

View file

@ -166,7 +166,10 @@ export class BrowserSession {
async navigateToUrl(url: string): Promise<BrowserActionResult> {
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
})

View file

@ -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

View file

@ -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",

View file

@ -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"

View file

@ -7,4 +7,8 @@ export type HistoryItem = {
cacheWrites?: number
cacheReads?: number
totalCost: number
size?: number
shadowGitConfigWorkTree?: string
conversationHistoryDeletedRange?: [number, number]
}

View file

@ -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"

View file

@ -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],

View file

@ -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

View file

@ -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",

View file

@ -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",

View file

@ -31,39 +31,39 @@ const Announcement = ({ version, hideAnnouncement }: AnnouncementProps) => {
</h3>
<ul style={{ margin: "0 0 8px", paddingLeft: "12px" }}>
<li>
<b>Auto-approve menu:</b> 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.
<b>Checkpoints are here!</b> Cline now saves a snapshot of your workspace at each step of the task. Hover over
any message to see two new buttons:
<ul style={{ margin: "4px 0", paddingLeft: 22 }}>
<li>
<span
className="codicon codicon-diff-multiple"
style={{
fontSize: "12px",
marginRight: "4px",
}}></span>
<b>Compare</b> shows you a diff between the snapshot and your current workspace
</li>
<li>
<span
className="codicon codicon-discard"
style={{
fontSize: "12px",
marginRight: "4px",
}}></span>
<b>Restore</b> lets you revert your project's files back to that point in the task
</li>
</ul>
</li>
<li>
<b>New diff editing for large files:</b> Cline now uses an efficient search & replace approach when modifying
large files for faster, more reliable edits (no more "<code>{"// rest of code here"}</code>" deletions).
</li>
<li>
<b>.clinerules:</b> Add a root-level <code>.clinerules</code> file to specify custom instructions for the
project.
</li>
</ul>
<p style={{ margin: "5px 0px", fontWeight: "bold" }}>v2.2 Updates:</p>
<ul style={{ margin: "0 0 8px", paddingLeft: "12px" }}>
<li>
Add and configure{" "}
<VSCodeLink href="https://github.com/modelcontextprotocol/servers" style={{ display: "inline" }}>
MCP servers
</VSCodeLink>
by clicking the new <span className="codicon codicon-server" style={{ fontSize: "10px" }}></span> icon in the
menu bar.
</li>
<li>
Cline can also create custom toolsjust say "add a tool that...", and watch him create the MCP server and
install it in the extension, ready to use in future tasks.
</li>
<li>
Try it yourself by asking Cline to "add a tool that gets the latest npm docs", or
<VSCodeLink href="https://x.com/sdrzn/status/1867271665086074969" style={{ display: "inline" }}>
see a demo of MCP in action here.
</VSCodeLink>
<b>'See new changes' button</b> when a task is completed, showing you an overview of all the changes Cline
made to your workspace throughout the task
</li>
</ul>
<p style={{ margin: "8px 0" }}>
<VSCodeLink href="https://x.com/sdrzn/status/1876378124126236949" style={{ display: "inline" }}>
See a demo of Checkpoints here!
</VSCodeLink>
</p>
{/*<ul style={{ margin: "0 0 8px", paddingLeft: "12px" }}>
<li>
OpenRouter now supports prompt caching! They also have much higher rate limits than other providers,

View file

@ -157,7 +157,9 @@ const AutoApproveMenu = ({ style }: AutoApproveMenuProps) => {
}
}}>
<VSCodeCheckbox
style={{ pointerEvents: hasEnabledActions ? "auto" : "none" }}
style={{
pointerEvents: hasEnabledActions ? "auto" : "none",
}}
checked={hasEnabledActions && autoApprovalSettings.enabled}
disabled={!hasEnabledActions}
// onChange={(e) => {
@ -182,7 +184,13 @@ const AutoApproveMenu = ({ style }: AutoApproveMenuProps) => {
setIsExpanded((prev) => !prev)
}
}}>
<span style={{ color: "var(--vscode-foreground)", whiteSpace: "nowrap" }}>Auto-approve:</span>
<span
style={{
color: "var(--vscode-foreground)",
whiteSpace: "nowrap",
}}>
Auto-approve:
</span>
<span
style={{
whiteSpace: "nowrap",

View file

@ -6,6 +6,9 @@ import { vscode } from "../../utils/vscode"
import CodeBlock, { CODE_BLOCK_BG_COLOR } from "../common/CodeBlock"
import { ChatRowContent, ProgressIndicator } from "./ChatRow"
import { VSCodeButton } from "@vscode/webview-ui-toolkit/react"
import styled from "styled-components"
import { CheckpointControls, CheckpointOverlay } from "../common/CheckpointControls"
import { findLast } from "../../../../src/shared/array"
interface BrowserSessionRowProps {
messages: ClineMessage[]
@ -137,6 +140,11 @@ const BrowserSessionRow = memo((props: BrowserSessionRowProps) => {
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(
<div style={{ padding: "10px 6px 10px 15px", marginBottom: -10 }}>
<div style={{ display: "flex", alignItems: "center", gap: "10px", marginBottom: "10px" }}>
<BrowserSessionRowContainer style={{ marginBottom: -10 }}>
<div
style={{
display: "flex",
alignItems: "center",
gap: "10px",
marginBottom: "10px",
}}>
{isBrowsing ? (
<ProgressIndicator />
) : (
<span
className={`codicon codicon-inspect`}
style={{ color: "var(--vscode-foreground)", marginBottom: "-1.5px" }}></span>
style={{
color: "var(--vscode-foreground)",
marginBottom: "-1.5px",
}}></span>
)}
<span style={{ fontWeight: "bold" }}>
<>{isAutoApproved ? "Cline is using the browser:" : "Cline wants to use the browser:"}</>
@ -300,7 +327,10 @@ const BrowserSessionRow = memo((props: BrowserSessionRowProps) => {
}}>
<span
className="codicon codicon-globe"
style={{ fontSize: "80px", color: "var(--vscode-descriptionForeground)" }}
style={{
fontSize: "80px",
color: "var(--vscode-descriptionForeground)",
}}
/>
</div>
)}
@ -370,7 +400,9 @@ const BrowserSessionRow = memo((props: BrowserSessionRowProps) => {
</div>
</div>
)}
</div>,
{shouldShowCheckpoints && <CheckpointOverlay messageTs={lastCheckpointMessageTs} />}
</BrowserSessionRowContainer>,
)
// 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

View file

@ -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<ChatRowProps, "onHeightChange"> {}
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(
<div
style={{
padding: "10px 6px 10px 15px",
}}>
<ChatRowContainer>
<ChatRowContent {...props} />
</div>,
{shouldShowCheckpoints && <CheckpointOverlay messageTs={message.ts} />}
</ChatRowContainer>,
)
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 [
<span className="codicon codicon-error" style={{ color: errorColor, marginBottom: "-1.5px" }}></span>,
<span
className="codicon codicon-error"
style={{
color: errorColor,
marginBottom: "-1.5px",
}}></span>,
<span style={{ color: errorColor, fontWeight: "bold" }}>Error</span>,
]
case "mistake_limit_reached":
return [
<span className="codicon codicon-error" style={{ color: errorColor, marginBottom: "-1.5px" }}></span>,
<span
className="codicon codicon-error"
style={{
color: errorColor,
marginBottom: "-1.5px",
}}></span>,
<span style={{ color: errorColor, fontWeight: "bold" }}>Cline is having trouble...</span>,
]
case "auto_approval_max_req_reached":
return [
<span className="codicon codicon-warning" style={{ color: errorColor, marginBottom: "-1.5px" }}></span>,
<span
className="codicon codicon-warning"
style={{
color: errorColor,
marginBottom: "-1.5px",
}}></span>,
<span style={{ color: errorColor, fontWeight: "bold" }}>Maximum Requests Reached</span>,
]
case "command":
@ -114,7 +178,12 @@ export const ChatRowContent = ({ message, isExpanded, onToggleExpand, lastModifi
isCommandExecuting ? (
<ProgressIndicator />
) : (
<span className="codicon codicon-terminal" style={{ color: normalColor, marginBottom: "-1.5px" }}></span>
<span
className="codicon codicon-terminal"
style={{
color: normalColor,
marginBottom: "-1.5px",
}}></span>
),
<span style={{ color: normalColor, fontWeight: "bold" }}>
{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 ? (
<ProgressIndicator />
) : (
<span className="codicon codicon-server" style={{ color: normalColor, marginBottom: "-1.5px" }}></span>
<span
className="codicon codicon-server"
style={{
color: normalColor,
marginBottom: "-1.5px",
}}></span>
),
<span style={{ color: normalColor, fontWeight: "bold" }}>
{message.type === "ask" ? (
@ -144,7 +218,12 @@ export const ChatRowContent = ({ message, isExpanded, onToggleExpand, lastModifi
]
case "completion_result":
return [
<span className="codicon codicon-check" style={{ color: successColor, marginBottom: "-1.5px" }}></span>,
<span
className="codicon codicon-check"
style={{
color: successColor,
marginBottom: "-1.5px",
}}></span>,
<span style={{ color: successColor, fontWeight: "bold" }}>Task Completed</span>,
]
case "api_req_started":
@ -182,9 +261,21 @@ export const ChatRowContent = ({ message, isExpanded, onToggleExpand, lastModifi
),
apiReqCancelReason != null ? (
apiReqCancelReason === "user_cancelled" ? (
<span style={{ color: normalColor, fontWeight: "bold" }}>API Request Cancelled</span>
<span
style={{
color: normalColor,
fontWeight: "bold",
}}>
API Request Cancelled
</span>
) : (
<span style={{ color: errorColor, fontWeight: "bold" }}>API Streaming Failed</span>
<span
style={{
color: errorColor,
fontWeight: "bold",
}}>
API Streaming Failed
</span>
)
) : cost != null ? (
<span style={{ color: normalColor, fontWeight: "bold" }}>API Request</span>
@ -196,7 +287,12 @@ export const ChatRowContent = ({ message, isExpanded, onToggleExpand, lastModifi
]
case "followup":
return [
<span className="codicon codicon-question" style={{ color: normalColor, marginBottom: "-1.5px" }}></span>,
<span
className="codicon codicon-question"
style={{
color: normalColor,
marginBottom: "-1.5px",
}}></span>,
<span style={{ color: normalColor, fontWeight: "bold" }}>Cline has a question:</span>,
]
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) => (
<span
className={`codicon codicon-${name}`}
style={{ color: "var(--vscode-foreground)", marginBottom: "-1.5px" }}></span>
style={{
color: "var(--vscode-foreground)",
marginBottom: "-1.5px",
}}></span>
)
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(".") && <span>.</span>}
<span
@ -330,7 +432,10 @@ export const ChatRowContent = ({ message, isExpanded, onToggleExpand, lastModifi
<div style={{ flexGrow: 1 }}></div>
<span
className={`codicon codicon-link-external`}
style={{ fontSize: 13.5, margin: "1px 0" }}></span>
style={{
fontSize: 13.5,
margin: "1px 0",
}}></span>
</div>
</div>
</>
@ -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)",
}}>
<i className="codicon codicon-warning"></i>
<span>The model has determined this command requires explicit approval.</span>
@ -632,11 +737,19 @@ export const ChatRowContent = ({ message, isExpanded, onToggleExpand, lastModifi
msUserSelect: "none",
}}
onClick={onToggleExpand}>
<div style={{ display: "flex", alignItems: "center", gap: "10px" }}>
<div
style={{
display: "flex",
alignItems: "center",
gap: "10px",
}}>
{icon}
{title}
{/* Need to render this everytime since it affects height of row by 2px */}
<VSCodeBadge style={{ opacity: cost != null && cost > 0 ? 1 : 0 }}>
<VSCodeBadge
style={{
opacity: cost != null && cost > 0 ? 1 : 0,
}}>
${Number(cost || 0)?.toFixed(4)}
</VSCodeBadge>
</div>
@ -644,7 +757,11 @@ export const ChatRowContent = ({ message, isExpanded, onToggleExpand, lastModifi
</div>
{((cost == null && apiRequestFailedMessage) || apiReqStreamingFailedMessage) && (
<>
<p style={{ ...pStyle, color: "var(--vscode-errorForeground)" }}>
<p
style={{
...pStyle,
color: "var(--vscode-errorForeground)",
}}>
{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{" "}
<a
href="https://github.com/cline/cline/wiki/TroubleShooting-%E2%80%90-%22PowerShell-is-not-recognized-as-an-internal-or-external-command%22"
style={{ color: "inherit", textDecoration: "underline" }}>
style={{
color: "inherit",
textDecoration: "underline",
}}>
troubleshooting guide
</a>
.
@ -758,7 +878,13 @@ export const ChatRowContent = ({ message, isExpanded, onToggleExpand, lastModifi
{title}
</div>
)}
<p style={{ ...pStyle, color: "var(--vscode-errorForeground)" }}>{message.text}</p>
<p
style={{
...pStyle,
color: "var(--vscode-errorForeground)",
}}>
{message.text}
</p>
</>
)
case "diff_error":
@ -773,7 +899,12 @@ export const ChatRowContent = ({ message, isExpanded, onToggleExpand, lastModifi
borderRadius: 3,
fontSize: 12,
}}>
<div style={{ display: "flex", alignItems: "center", marginBottom: 4 }}>
<div
style={{
display: "flex",
alignItems: "center",
marginBottom: 4,
}}>
<i
className="codicon codicon-error"
style={{
@ -781,7 +912,13 @@ export const ChatRowContent = ({ message, isExpanded, onToggleExpand, lastModifi
fontSize: 18,
color: "#FFA500",
}}></i>
<span style={{ fontWeight: 500, color: "#FFA500" }}>Diff Edit Failed</span>
<span
style={{
fontWeight: 500,
color: "#FFA500",
}}>
Diff Edit Failed
</span>
</div>
<div>
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 (
<>
<div style={headerStyle}>
<div
style={{
...headerStyle,
marginBottom: "10px",
}}>
{icon}
{title}
</div>
<div style={{ color: "var(--vscode-charts-green)", paddingTop: 10 }}>
<Markdown markdown={message.text} />
<div
style={{
color: "var(--vscode-charts-green)",
paddingTop: 10,
}}>
<Markdown markdown={text} />
</div>
{message.partial !== true && hasChanges && (
<div style={{ paddingTop: 17 }}>
<SuccessButton
disabled={seeNewChangesDisabled}
onClick={() => {
setSeeNewChangesDisabled(true)
vscode.postMessage({
type: "taskCompletionViewChanges",
number: message.ts,
})
}}
style={{
width: "100%",
cursor: seeNewChangesDisabled ? "wait" : "pointer",
}}>
<i className="codicon codicon-new-file" style={{ marginRight: 6 }} />
See new changes
</SuccessButton>
</div>
)}
</>
)
case "shell_integration_warning":
@ -814,7 +981,12 @@ export const ChatRowContent = ({ message, isExpanded, onToggleExpand, lastModifi
borderRadius: 3,
fontSize: 12,
}}>
<div style={{ display: "flex", alignItems: "center", marginBottom: 4 }}>
<div
style={{
display: "flex",
alignItems: "center",
marginBottom: 4,
}}>
<i
className="codicon codicon-warning"
style={{
@ -822,7 +994,13 @@ export const ChatRowContent = ({ message, isExpanded, onToggleExpand, lastModifi
fontSize: 18,
color: "#FFA500",
}}></i>
<span style={{ fontWeight: 500, color: "#FFA500" }}>Shell Integration Unavailable</span>
<span
style={{
fontWeight: 500,
color: "#FFA500",
}}>
Shell Integration Unavailable
</span>
</div>
<div>
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").{" "}
<a
href="https://github.com/cline/cline/wiki/Troubleshooting-%E2%80%90-Shell-Integration-Unavailable"
style={{ color: "inherit", textDecoration: "underline" }}>
style={{
color: "inherit",
textDecoration: "underline",
}}>
Still having trouble?
</a>
</div>
@ -884,7 +1065,13 @@ export const ChatRowContent = ({ message, isExpanded, onToggleExpand, lastModifi
{icon}
{title}
</div>
<p style={{ ...pStyle, color: "var(--vscode-errorForeground)" }}>{message.text}</p>
<p
style={{
...pStyle,
color: "var(--vscode-errorForeground)",
}}>
{message.text}
</p>
</>
)
case "auto_approval_max_req_reached":
@ -894,19 +1081,59 @@ export const ChatRowContent = ({ message, isExpanded, onToggleExpand, lastModifi
{icon}
{title}
</div>
<p style={{ ...pStyle, color: "var(--vscode-errorForeground)" }}>{message.text}</p>
<p
style={{
...pStyle,
color: "var(--vscode-errorForeground)",
}}>
{message.text}
</p>
</>
)
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 (
<div>
<div style={headerStyle}>
<div
style={{
...headerStyle,
marginBottom: "10px",
}}>
{icon}
{title}
</div>
<div style={{ color: "var(--vscode-charts-green)", paddingTop: 10 }}>
<Markdown markdown={message.text} />
<div
style={{
color: "var(--vscode-charts-green)",
paddingTop: 10,
}}>
<Markdown markdown={text} />
{message.partial !== true && hasChanges && (
<div style={{ marginTop: 15 }}>
<SuccessButton
appearance="secondary"
disabled={seeNewChangesDisabled}
onClick={() => {
setSeeNewChangesDisabled(true)
vscode.postMessage({
type: "taskCompletionViewChanges",
number: message.ts,
})
}}>
<i
className="codicon codicon-new-file"
style={{
marginRight: 6,
cursor: seeNewChangesDisabled ? "wait" : "pointer",
}}
/>
See new changes
</SuccessButton>
</div>
)}
</div>
</div>
)
@ -950,7 +1177,13 @@ export const ProgressIndicator = () => (
const Markdown = memo(({ markdown }: { markdown?: string }) => {
return (
<div style={{ wordBreak: "break-word", overflowWrap: "anywhere", marginBottom: -15, marginTop: -15 }}>
<div
style={{
wordBreak: "break-word",
overflowWrap: "anywhere",
marginBottom: -15,
marginTop: -15,
}}>
<MarkdownBlock markdown={markdown} />
</div>
)

View file

@ -553,7 +553,12 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
bottom: 9.5, // should be 10 but doesnt look good on mac
zIndex: 2,
}}>
<div style={{ display: "flex", flexDirection: "row", alignItems: "center" }}>
<div
style={{
display: "flex",
flexDirection: "row",
alignItems: "center",
}}>
<div
className={`input-icon-button ${shouldDisableImages ? "disabled" : ""} codicon codicon-device-camera`}
onClick={() => {

View file

@ -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: () => <div style={{ height: 5 }} />, // 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) => {

View file

@ -153,7 +153,11 @@ const ContextMenu: React.FC<ContextMenuProps> = ({
}}>
<i
className={`codicon codicon-${getIconForOption(option)}`}
style={{ marginRight: "8px", flexShrink: 0, fontSize: "14px" }}
style={{
marginRight: "8px",
flexShrink: 0,
fontSize: "14px",
}}
/>
{renderOptionContent(option)}
</div>
@ -161,13 +165,24 @@ const ContextMenu: React.FC<ContextMenuProps> = ({
!option.value && (
<i
className="codicon codicon-chevron-right"
style={{ fontSize: "14px", flexShrink: 0, marginLeft: 8 }}
style={{
fontSize: "14px",
flexShrink: 0,
marginLeft: 8,
}}
/>
)}
{(option.type === ContextMenuOptionType.Problems ||
((option.type === ContextMenuOptionType.File || option.type === ContextMenuOptionType.Folder) &&
option.value)) && (
<i className="codicon codicon-add" style={{ fontSize: "14px", flexShrink: 0, marginLeft: 8 }} />
<i
className="codicon codicon-add"
style={{
fontSize: "14px",
flexShrink: 0,
marginLeft: 8,
}}
/>
)}
</div>
))}

View file

@ -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<TaskHeaderProps> = ({
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<TaskHeaderProps> = ({
minWidth: 0, // This allows the div to shrink below its content size
}}
onClick={() => setIsTaskExpanded(!isTaskExpanded)}>
<div style={{ display: "flex", alignItems: "center", flexShrink: 0 }}>
<div
style={{
display: "flex",
alignItems: "center",
flexShrink: 0,
}}>
<span className={`codicon codicon-chevron-${isTaskExpanded ? "down" : "right"}`}></span>
</div>
<div
@ -242,47 +248,102 @@ const TaskHeader: React.FC<TaskHeaderProps> = ({
</div>
)}
{task.images && task.images.length > 0 && <Thumbnails images={task.images} />}
<div style={{ display: "flex", flexDirection: "column", gap: "4px" }}>
<div
style={{
display: "flex",
flexDirection: "column",
gap: "4px",
}}>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
}}>
<div style={{ display: "flex", alignItems: "center", gap: "4px", flexWrap: "wrap" }}>
<div
style={{
display: "flex",
alignItems: "center",
gap: "4px",
flexWrap: "wrap",
}}>
<span style={{ fontWeight: "bold" }}>Tokens:</span>
<span style={{ display: "flex", alignItems: "center", gap: "3px" }}>
<span
style={{
display: "flex",
alignItems: "center",
gap: "3px",
}}>
<i
className="codicon codicon-arrow-up"
style={{ fontSize: "12px", fontWeight: "bold", marginBottom: "-2px" }}
style={{
fontSize: "12px",
fontWeight: "bold",
marginBottom: "-2px",
}}
/>
{formatLargeNumber(tokensIn || 0)}
</span>
<span style={{ display: "flex", alignItems: "center", gap: "3px" }}>
<span
style={{
display: "flex",
alignItems: "center",
gap: "3px",
}}>
<i
className="codicon codicon-arrow-down"
style={{ fontSize: "12px", fontWeight: "bold", marginBottom: "-2px" }}
style={{
fontSize: "12px",
fontWeight: "bold",
marginBottom: "-2px",
}}
/>
{formatLargeNumber(tokensOut || 0)}
</span>
</div>
{!isCostAvailable && <ExportButton />}
{!isCostAvailable && (
<DeleteButton taskSize={formatSize(currentTaskItem?.size)} taskId={currentTaskItem?.id} />
)}
</div>
{shouldShowPromptCacheInfo && (cacheReads !== undefined || cacheWrites !== undefined) && (
<div style={{ display: "flex", alignItems: "center", gap: "4px", flexWrap: "wrap" }}>
<div
style={{
display: "flex",
alignItems: "center",
gap: "4px",
flexWrap: "wrap",
}}>
<span style={{ fontWeight: "bold" }}>Cache:</span>
<span style={{ display: "flex", alignItems: "center", gap: "3px" }}>
<span
style={{
display: "flex",
alignItems: "center",
gap: "3px",
}}>
<i
className="codicon codicon-database"
style={{ fontSize: "12px", fontWeight: "bold", marginBottom: "-1px" }}
style={{
fontSize: "12px",
fontWeight: "bold",
marginBottom: "-1px",
}}
/>
+{formatLargeNumber(cacheWrites || 0)}
</span>
<span style={{ display: "flex", alignItems: "center", gap: "3px" }}>
<span
style={{
display: "flex",
alignItems: "center",
gap: "3px",
}}>
<i
className="codicon codicon-arrow-right"
style={{ fontSize: "12px", fontWeight: "bold", marginBottom: 0 }}
style={{
fontSize: "12px",
fontWeight: "bold",
marginBottom: 0,
}}
/>
{formatLargeNumber(cacheReads || 0)}
</span>
@ -295,11 +356,44 @@ const TaskHeader: React.FC<TaskHeaderProps> = ({
justifyContent: "space-between",
alignItems: "center",
}}>
<div style={{ display: "flex", alignItems: "center", gap: "4px" }}>
<div
style={{
display: "flex",
alignItems: "center",
gap: "4px",
}}>
<span style={{ fontWeight: "bold" }}>API Cost:</span>
<span>${totalCost?.toFixed(4)}</span>
</div>
<ExportButton />
<DeleteButton taskSize={formatSize(currentTaskItem?.size)} taskId={currentTaskItem?.id} />
</div>
)}
{checkpointTrackerErrorMessage && (
<div
style={{
display: "flex",
alignItems: "center",
gap: "8px",
color: "var(--vscode-editorWarning-foreground)",
fontSize: "11px",
}}>
<i className="codicon codicon-warning" />
<span>
{checkpointTrackerErrorMessage}
{checkpointTrackerErrorMessage.includes("Git must be installed to use checkpoints.") && (
<>
{" "}
<a
href="https://github.com/cline/cline/wiki/Installing-Git-for-Checkpoints"
style={{
color: "inherit",
textDecoration: "underline",
}}>
See here for instructions.
</a>
</>
)}
</span>
</div>
)}
</div>
@ -360,18 +454,41 @@ export const highlightMentions = (text?: string, withShadow = true) => {
})
}
const ExportButton = () => (
const DeleteButton: React.FC<{
taskSize: string
taskId?: string
}> = ({ taskSize, taskId }) => (
<VSCodeButton
appearance="icon"
onClick={() => vscode.postMessage({ type: "exportCurrentTask" })}
style={
{
// marginBottom: "-2px",
// marginRight: "-2.5px",
}
}>
<div style={{ fontSize: "10.5px", fontWeight: "bold", opacity: 0.6 }}>EXPORT</div>
onClick={() => vscode.postMessage({ type: "deleteTaskWithId", text: taskId })}
style={{ padding: "0px 0px" }}>
<div
style={{
display: "flex",
alignItems: "center",
gap: "3px",
fontSize: "10px",
fontWeight: "bold",
opacity: 0.6,
}}>
<i className={`codicon codicon-trash`} />
{taskSize}
</div>
</VSCodeButton>
)
// const ExportButton = () => (
// <VSCodeButton
// appearance="icon"
// onClick={() => vscode.postMessage({ type: "exportCurrentTask" })}
// style={
// {
// // marginBottom: "-2px",
// // marginRight: "-2.5px",
// }
// }>
// <div style={{ fontSize: "10.5px", fontWeight: "bold", opacity: 0.6 }}>EXPORT</div>
// </VSCodeButton>
// )
export default memo(TaskHeader)

View file

@ -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<HTMLDivElement>(null)
const tooltipRef = useRef<HTMLDivElement>(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 (
<CheckpointControls onMouseLeave={handleControlsMouseLeave}>
<VSCodeButton
title="Compare"
appearance="secondary"
disabled={compareDisabled}
style={{ cursor: compareDisabled ? "wait" : "pointer" }}
onClick={() => {
setCompareDisabled(true)
vscode.postMessage({
type: "checkpointDiff",
number: messageTs,
})
}}>
<i className="codicon codicon-diff-multiple" style={{ position: "absolute" }} />
</VSCodeButton>
<div style={{ position: "relative" }} ref={containerRef}>
<VSCodeButton
title="Restore"
appearance="secondary"
style={{ cursor: "pointer" }}
onClick={() => setShowRestoreConfirm(true)}>
<i className="codicon codicon-discard" style={{ position: "absolute" }} />
</VSCodeButton>
{showRestoreConfirm && (
<RestoreConfirmTooltip ref={tooltipRef} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave}>
<RestoreOption>
<VSCodeButton
onClick={handleRestoreBoth}
disabled={restoreBothDisabled}
style={{
cursor: restoreBothDisabled ? "wait" : "pointer",
}}>
Restore Task and Workspace
</VSCodeButton>
<p>Restores the task and your project's files back to a snapshot taken at this point</p>
</RestoreOption>
<RestoreOption>
<VSCodeButton
onClick={handleRestoreTask}
disabled={restoreTaskDisabled}
style={{
cursor: restoreTaskDisabled ? "wait" : "pointer",
}}>
Restore Task Only
</VSCodeButton>
<p>Deletes messages after this point (does not affect workspace)</p>
</RestoreOption>
<RestoreOption>
<VSCodeButton
onClick={handleRestoreWorkspace}
disabled={restoreWorkspaceDisabled}
style={{
cursor: restoreWorkspaceDisabled ? "wait" : "pointer",
}}>
Restore Workspace Only
</VSCodeButton>
<p>Restores your project's files to a snapshot taken at this point (task may become out of sync)</p>
</RestoreOption>
</RestoreConfirmTooltip>
)}
</div>
</CheckpointControls>
)
}
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;
}
`

View file

@ -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<typeof VSCodeButton> {}
const SuccessButton: React.FC<SuccessButtonProps> = (props) => {
return <StyledButton {...props} />
}
export default SuccessButton

View file

@ -59,7 +59,10 @@ const HistoryPreview = ({ showHistoryView }: HistoryPreviewProps) => {
}}>
<span
className="codicon codicon-comment-discussion"
style={{ marginRight: "4px", transform: "scale(0.9)" }}></span>
style={{
marginRight: "4px",
transform: "scale(0.9)",
}}></span>
<span
style={{
fontWeight: 500,
@ -103,7 +106,11 @@ const HistoryPreview = ({ showHistoryView }: HistoryPreviewProps) => {
}}>
{item.task}
</div>
<div style={{ fontSize: "0.85em", color: "var(--vscode-descriptionForeground)" }}>
<div
style={{
fontSize: "0.85em",
color: "var(--vscode-descriptionForeground)",
}}>
<span>
Tokens: {formatLargeNumber(item.tokensIn || 0)} {formatLargeNumber(item.tokensOut || 0)}
</span>
@ -126,7 +133,12 @@ const HistoryPreview = ({ showHistoryView }: HistoryPreviewProps) => {
</div>
</div>
))}
<div style={{ display: "flex", alignItems: "center", justifyContent: "center" }}>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
}}>
<VSCodeButton
appearance="icon"
onClick={() => showHistoryView()}

View file

@ -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",
}}>
<h3 style={{ color: "var(--vscode-foreground)", margin: 0 }}>History</h3>
<h3
style={{
color: "var(--vscode-foreground)",
margin: 0,
}}>
History
</h3>
<VSCodeButton onClick={onDone}>Done</VSCodeButton>
</div>
<div style={{ padding: "5px 17px 6px 17px" }}>
<div style={{ display: "flex", flexDirection: "column", gap: "6px" }}>
<div
style={{
display: "flex",
flexDirection: "column",
gap: "6px",
}}>
<VSCodeTextField
style={{ width: "100%" }}
placeholder="Fuzzy search history..."
@ -156,7 +168,11 @@ const HistoryView = ({ onDone }: HistoryViewProps) => {
<div
slot="start"
className="codicon codicon-search"
style={{ fontSize: 13, marginTop: 2.5, opacity: 0.8 }}></div>
style={{
fontSize: 13,
marginTop: 2.5,
opacity: 0.8,
}}></div>
{searchQuery && (
<div
className="input-icon-button codicon codicon-close"
@ -248,8 +264,19 @@ const HistoryView = ({ onDone }: HistoryViewProps) => {
e.stopPropagation()
handleDeleteHistoryItem(item.id)
}}
className="delete-button">
<span className="codicon codicon-trash"></span>
className="delete-button"
style={{ padding: "0px 0px" }}>
<div
style={{
display: "flex",
alignItems: "center",
gap: "3px",
fontSize: "11px",
// fontWeight: "bold",
}}>
<span className="codicon codicon-trash"></span>
{formatSize(item.size)}
</div>
</VSCodeButton>
</div>
<div
@ -264,9 +291,16 @@ const HistoryView = ({ onDone }: HistoryViewProps) => {
wordBreak: "break-word",
overflowWrap: "anywhere",
}}
dangerouslySetInnerHTML={{ __html: item.task }}
dangerouslySetInnerHTML={{
__html: item.task,
}}
/>
<div style={{ display: "flex", flexDirection: "column", gap: "4px" }}>
<div
style={{
display: "flex",
flexDirection: "column",
gap: "4px",
}}>
<div
style={{
display: "flex",
@ -384,7 +418,12 @@ const HistoryView = ({ onDone }: HistoryViewProps) => {
alignItems: "center",
marginTop: -2,
}}>
<div style={{ display: "flex", alignItems: "center", gap: "4px" }}>
<div
style={{
display: "flex",
alignItems: "center",
gap: "4px",
}}>
<span
style={{
fontWeight: 500,
@ -392,7 +431,10 @@ const HistoryView = ({ onDone }: HistoryViewProps) => {
}}>
API Cost:
</span>
<span style={{ color: "var(--vscode-descriptionForeground)" }}>
<span
style={{
color: "var(--vscode-descriptionForeground)",
}}>
${item.totalCost?.toFixed(4)}
</span>
</div>

View file

@ -37,7 +37,13 @@ const McpToolRow = ({ tool }: McpToolRowProps) => {
borderRadius: "3px",
padding: "8px",
}}>
<div style={{ marginBottom: "4px", opacity: 0.8, fontSize: "11px", textTransform: "uppercase" }}>
<div
style={{
marginBottom: "4px",
opacity: 0.8,
fontSize: "11px",
textTransform: "uppercase",
}}>
Parameters
</div>
{Object.entries(tool.inputSchema.properties as Record<string, any>).map(([paramName, schema]) => {
@ -61,7 +67,14 @@ const McpToolRow = ({ tool }: McpToolRowProps) => {
marginRight: "8px",
}}>
{paramName}
{isRequired && <span style={{ color: "var(--vscode-errorForeground)" }}>*</span>}
{isRequired && (
<span
style={{
color: "var(--vscode-errorForeground)",
}}>
*
</span>
)}
</code>
<span
style={{

View file

@ -120,7 +120,12 @@ const McpView = ({ onDone }: McpViewProps) => {
{/* Server List */}
{servers.length > 0 && (
<div style={{ display: "flex", flexDirection: "column", gap: "10px" }}>
<div
style={{
display: "flex",
flexDirection: "column",
gap: "10px",
}}>
{servers.map((server) => (
<ServerRow key={server.name} server={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"}
</VSCodeButton>
</div>
@ -245,13 +253,23 @@ const ServerRow = ({ server }: { server: McpServer }) => {
<VSCodePanelView id="tools-view">
{server.tools && server.tools.length > 0 ? (
<div style={{ display: "flex", flexDirection: "column", gap: "8px", width: "100%" }}>
<div
style={{
display: "flex",
flexDirection: "column",
gap: "8px",
width: "100%",
}}>
{server.tools.map((tool) => (
<McpToolRow key={tool.name} tool={tool} />
))}
</div>
) : (
<div style={{ padding: "10px 0", color: "var(--vscode-descriptionForeground)" }}>
<div
style={{
padding: "10px 0",
color: "var(--vscode-descriptionForeground)",
}}>
No tools found
</div>
)}
@ -260,7 +278,13 @@ const ServerRow = ({ server }: { server: McpServer }) => {
<VSCodePanelView id="resources-view">
{(server.resources && server.resources.length > 0) ||
(server.resourceTemplates && server.resourceTemplates.length > 0) ? (
<div style={{ display: "flex", flexDirection: "column", gap: "8px", width: "100%" }}>
<div
style={{
display: "flex",
flexDirection: "column",
gap: "8px",
width: "100%",
}}>
{[...(server.resourceTemplates || []), ...(server.resources || [])].map((item) => (
<McpResourceRow
key={"uriTemplate" in item ? item.uriTemplate : item.uri}
@ -269,7 +293,11 @@ const ServerRow = ({ server }: { server: McpServer }) => {
))}
</div>
) : (
<div style={{ padding: "10px 0", color: "var(--vscode-descriptionForeground)" }}>
<div
style={{
padding: "10px 0",
color: "var(--vscode-descriptionForeground)",
}}>
No resources found
</div>
)}
@ -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"}
</VSCodeButton>
</div>

View file

@ -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,
}}>
<VSCodeOption value="openrouter">OpenRouter</VSCodeOption>
<VSCodeOption value="anthropic">Anthropic</VSCodeOption>
<VSCodeOption value="gemini">Google Gemini</VSCodeOption>
@ -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 && (
<VSCodeLink
href="https://console.anthropic.com/settings/keys"
style={{ display: "inline", fontSize: "inherit" }}>
style={{
display: "inline",
fontSize: "inherit",
}}>
You can get an Anthropic API key by signing up here.
</VSCodeLink>
)}
@ -209,7 +228,10 @@ const ApiOptions = ({ showModelOptions, apiErrorMessage, modelIdErrorMessage }:
{!apiConfiguration?.openAiNativeApiKey && (
<VSCodeLink
href="https://platform.openai.com/api-keys"
style={{ display: "inline", fontSize: "inherit" }}>
style={{
display: "inline",
fontSize: "inherit",
}}>
You can get an OpenAI API key by signing up here.
</VSCodeLink>
)}
@ -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 && (
<VSCodeLink href="https://www.deepseek.com/" style={{ display: "inline", fontSize: "inherit" }}>
<VSCodeLink
href="https://www.deepseek.com/"
style={{
display: "inline",
fontSize: "inherit",
}}>
You can get a DeepSeek API key by signing up here.
</VSCodeLink>
)}
@ -279,7 +306,12 @@ const ApiOptions = ({ showModelOptions, apiErrorMessage, modelIdErrorMessage }:
)}
{selectedProvider === "bedrock" && (
<div style={{ display: "flex", flexDirection: "column", gap: 5 }}>
<div
style={{
display: "flex",
flexDirection: "column",
gap: 5,
}}>
<VSCodeTextField
value={apiConfiguration?.awsAccessKey || ""}
style={{ width: "100%" }}
@ -345,7 +377,10 @@ const ApiOptions = ({ showModelOptions, apiErrorMessage, modelIdErrorMessage }:
checked={apiConfiguration?.awsUseCrossRegionInference || false}
onChange={(e: any) => {
const isChecked = e.target.checked === true
setApiConfiguration({ ...apiConfiguration, awsUseCrossRegionInference: isChecked })
setApiConfiguration({
...apiConfiguration,
awsUseCrossRegionInference: isChecked,
})
}}>
Use cross-region inference
</VSCodeCheckbox>
@ -363,7 +398,12 @@ const ApiOptions = ({ showModelOptions, apiErrorMessage, modelIdErrorMessage }:
)}
{apiConfiguration?.apiProvider === "vertex" && (
<div style={{ display: "flex", flexDirection: "column", gap: 5 }}>
<div
style={{
display: "flex",
flexDirection: "column",
gap: 5,
}}>
<VSCodeTextField
value={apiConfiguration?.vertexProjectId || ""}
style={{ width: "100%" }}
@ -427,7 +467,12 @@ const ApiOptions = ({ showModelOptions, apiErrorMessage, modelIdErrorMessage }:
}}>
This key is stored locally and only used to make API requests from this extension.
{!apiConfiguration?.geminiApiKey && (
<VSCodeLink href="https://ai.google.dev/" style={{ display: "inline", fontSize: "inherit" }}>
<VSCodeLink
href="https://ai.google.dev/"
style={{
display: "inline",
fontSize: "inherit",
}}>
You can get a Gemini API key by signing up here.
</VSCodeLink>
)}
@ -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 (
<p style={{ fontSize: "12px", marginTop: "2px", color: "var(--vscode-descriptionForeground)" }}>
<p
style={{
fontSize: "12px",
marginTop: "2px",
color: "var(--vscode-descriptionForeground)",
}}>
{infoItems.map((item, index) => (
<Fragment key={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":

View file

@ -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 && (
<div
className="input-icon-button codicon codicon-close"

View file

@ -23,7 +23,10 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
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) => {
<h3 style={{ color: "var(--vscode-foreground)", margin: 0 }}>Settings</h3>
<VSCodeButton onClick={handleSubmit}>Done</VSCodeButton>
</div>
<div style={{ flexGrow: 1, overflowY: "scroll", paddingRight: 8, display: "flex", flexDirection: "column" }}>
<div
style={{
flexGrow: 1,
overflowY: "scroll",
paddingRight: 8,
display: "flex",
flexDirection: "column",
}}>
<div style={{ marginBottom: 5 }}>
<ApiOptions
showModelOptions={true}
@ -127,13 +137,25 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
marginTop: "auto",
padding: "10px 8px 15px 0px",
}}>
<p style={{ wordWrap: "break-word", margin: 0, padding: 0 }}>
<p
style={{
wordWrap: "break-word",
margin: 0,
padding: 0,
}}>
If you have any questions or feedback, feel free to open an issue at{" "}
<VSCodeLink href="https://github.com/cline/cline" style={{ display: "inline" }}>
https://github.com/cline/cline
</VSCodeLink>
</p>
<p style={{ fontStyle: "italic", margin: "10px 0 0 0", padding: 0 }}>v{version}</p>
<p
style={{
fontStyle: "italic",
margin: "10px 0 0 0",
padding: 0,
}}>
v{version}
</p>
</div>
</div>
</div>

View file

@ -21,7 +21,15 @@ const WelcomeView = () => {
}, [apiConfiguration])
return (
<div style={{ position: "fixed", top: 0, left: 0, right: 0, bottom: 0, padding: "0 20px" }}>
<div
style={{
position: "fixed",
top: 0,
left: 0,
right: 0,
bottom: 0,
padding: "0 20px",
}}>
<h2>Hi, I'm Cline</h2>
<p>
I can do all kinds of tasks thanks to the latest breakthroughs in{" "}

View file

@ -22,7 +22,9 @@ interface ExtensionStateContextType extends ExtensionState {
const ExtensionStateContext = createContext<ExtensionStateContextType | undefined>(undefined)
export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
export const ExtensionStateContextProvider: React.FC<{
children: React.ReactNode
}> = ({ children }) => {
const [state, setState] = useState<ExtensionState>({
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 <ExtensionStateContext.Provider value={contextValue}>{children}</ExtensionStateContext.Provider>

View file

@ -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 }]
}

View file

@ -0,0 +1,9 @@
import prettyBytes from "pretty-bytes"
export function formatSize(bytes?: number) {
if (bytes === undefined) {
return "--kb"
}
return prettyBytes(bytes)
}