refactor: code cleanup, formatting updates, improved workspace handling, checkpoints feature

Add instructions

Fix completion

Refactor

Rename reset to restore

add haschanges flag

Remove log

Better error handling

Better error handling

Fix wording

Fix

Fix

Fix

Comment

Add hash for only latest tool

Prepare for release

Fix

Fix delete

Format fix
This commit is contained in:
Saoud Rizwan 2025-01-04 19:50:35 -08:00
parent a7e9d47375
commit 9de7253998
113 changed files with 7141 additions and 1684 deletions

6
.vscode/tasks.json vendored
View file

@ -5,7 +5,11 @@
"tasks": [
{
"label": "watch",
"dependsOn": ["npm: build:webview", "npm: watch:tsc", "npm: watch:esbuild"],
"dependsOn": [
"npm: build:webview",
"npm: watch:tsc",
"npm: watch:esbuild"
],
"presentation": {
"reveal": "never"
},

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,18 @@ 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/ac0efa14-5c1f-4c26-a42d-9d7c56f5fadd">
### Checkpoints: Compare and Restore
As Cline works through a task, the extension takes a snapshot of your workspace at each step. You can use the 'Compare' button to see a diff between the snapshot and your current workspace, and the 'Restore' button to roll back to that point.
For example, when working with a local web server, you can use 'Restore Workspace Only' to quickly test different versions of your app, then use 'Restore Task and Workspace' when you find the version you want to continue building from. This lets you safely explore different approaches without losing progress.
## Contributing
To contribute to the project, start with our [Contributing Guide](CONTRIBUTING.md) to learn the basics. You can also join our [Discord](https://discord.gg/cline) to chat with other contributors in the `#contributors` channel. If you're looking for full-time work, check out our open positions on our [careers page](https://cline.bot/join-us)!

View file

@ -18,7 +18,9 @@ const esbuildProblemMatcherPlugin = {
build.onEnd((result) => {
result.errors.forEach(({ text, location }) => {
console.error(`✘ [ERROR] ${text}`)
console.error(` ${location.file}:${location.line}:${location.column}:`)
console.error(
` ${location.file}:${location.line}:${location.column}:`,
)
})
console.log("[watch] build finished")
})
@ -30,14 +32,26 @@ const copyWasmFiles = {
setup(build) {
build.onEnd(() => {
// tree sitter
const sourceDir = path.join(__dirname, "node_modules", "web-tree-sitter")
const sourceDir = path.join(
__dirname,
"node_modules",
"web-tree-sitter",
)
const targetDir = path.join(__dirname, "dist")
// Copy tree-sitter.wasm
fs.copyFileSync(path.join(sourceDir, "tree-sitter.wasm"), path.join(targetDir, "tree-sitter.wasm"))
fs.copyFileSync(
path.join(sourceDir, "tree-sitter.wasm"),
path.join(targetDir, "tree-sitter.wasm"),
)
// Copy language-specific WASM files
const languageWasmDir = path.join(__dirname, "node_modules", "tree-sitter-wasms", "out")
const languageWasmDir = path.join(
__dirname,
"node_modules",
"tree-sitter-wasms",
"out",
)
const languages = [
"typescript",
"tsx",
@ -56,7 +70,10 @@ const copyWasmFiles = {
languages.forEach((lang) => {
const filename = `tree-sitter-${lang}.wasm`
fs.copyFileSync(path.join(languageWasmDir, filename), path.join(targetDir, filename))
fs.copyFileSync(
path.join(languageWasmDir, filename),
path.join(targetDir, filename),
)
})
})
},

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.0",
"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

@ -13,7 +13,10 @@ import { ApiStream } from "./transform/stream"
import { DeepSeekHandler } from "./providers/deepseek"
export interface ApiHandler {
createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream
createMessage(
systemPrompt: string,
messages: Anthropic.Messages.MessageParam[],
): ApiStream
getModel(): { id: string; info: ModelInfo }
}

View file

@ -22,7 +22,10 @@ export class AnthropicHandler implements ApiHandler {
})
}
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
async *createMessage(
systemPrompt: string,
messages: Anthropic.Messages.MessageParam[],
): ApiStream {
let stream: AnthropicStream<Anthropic.Beta.PromptCaching.Messages.RawPromptCachingBetaMessageStreamEvent>
const modelId = this.getModel().id
switch (modelId) {
@ -35,19 +38,31 @@ export class AnthropicHandler implements ApiHandler {
The latest message will be the new user message, one before will be the assistant message from a previous request, and the user message before that will be a previously cached user message. So we need to mark the latest user message as ephemeral to cache it for the next request, and mark the second to last user message as ephemeral to let the server know the last message to retrieve from the cache for the current request..
*/
const userMsgIndices = messages.reduce(
(acc, msg, index) => (msg.role === "user" ? [...acc, index] : acc),
(acc, msg, index) =>
msg.role === "user" ? [...acc, index] : acc,
[] as number[],
)
const lastUserMsgIndex = userMsgIndices[userMsgIndices.length - 1] ?? -1
const secondLastMsgUserIndex = userMsgIndices[userMsgIndices.length - 2] ?? -1
const lastUserMsgIndex =
userMsgIndices[userMsgIndices.length - 1] ?? -1
const secondLastMsgUserIndex =
userMsgIndices[userMsgIndices.length - 2] ?? -1
stream = await this.client.beta.promptCaching.messages.create(
{
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) {
if (
index === lastUserMsgIndex ||
index === secondLastMsgUserIndex
) {
return {
...message,
content:
@ -56,13 +71,24 @@ 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,
: message.content.map(
(content, contentIndex) =>
contentIndex ===
message.content.length -
1
? {
...content,
cache_control:
{
type: "ephemeral",
},
}
: content,
),
}
}
@ -83,7 +109,10 @@ 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
@ -116,8 +145,10 @@ export class AnthropicHandler implements ApiHandler {
type: "usage",
inputTokens: usage.input_tokens || 0,
outputTokens: usage.output_tokens || 0,
cacheWriteTokens: usage.cache_creation_input_tokens || undefined,
cacheReadTokens: usage.cache_read_input_tokens || undefined,
cacheWriteTokens:
usage.cache_creation_input_tokens || undefined,
cacheReadTokens:
usage.cache_read_input_tokens || undefined,
}
break
case "message_delta":
@ -171,6 +202,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

@ -1,7 +1,13 @@
import AnthropicBedrock from "@anthropic-ai/bedrock-sdk"
import { Anthropic } from "@anthropic-ai/sdk"
import { ApiHandler } from "../"
import { ApiHandlerOptions, bedrockDefaultModelId, BedrockModelId, bedrockModels, ModelInfo } from "../../shared/api"
import {
ApiHandlerOptions,
bedrockDefaultModelId,
BedrockModelId,
bedrockModels,
ModelInfo,
} from "../../shared/api"
import { ApiStream } from "../transform/stream"
// https://docs.anthropic.com/en/api/claude-on-amazon-bedrock
@ -14,9 +20,15 @@ export class AwsBedrockHandler implements ApiHandler {
this.client = new AnthropicBedrock({
// Authenticate by either providing the keys below or use the default AWS credential providers, such as
// using ~/.aws/credentials or the "AWS_SECRET_ACCESS_KEY" and "AWS_ACCESS_KEY_ID" environment variables.
...(this.options.awsAccessKey ? { awsAccessKey: this.options.awsAccessKey } : {}),
...(this.options.awsSecretKey ? { awsSecretKey: this.options.awsSecretKey } : {}),
...(this.options.awsSessionToken ? { awsSessionToken: this.options.awsSessionToken } : {}),
...(this.options.awsAccessKey
? { awsAccessKey: this.options.awsAccessKey }
: {}),
...(this.options.awsSecretKey
? { awsSecretKey: this.options.awsSecretKey }
: {}),
...(this.options.awsSessionToken
? { awsSessionToken: this.options.awsSessionToken }
: {}),
// awsRegion changes the aws region to which the request is made. By default, we read AWS_REGION,
// and if that's not present, we default to us-east-1. Note that we do not read ~/.aws/config for the region.
@ -24,7 +36,10 @@ export class AwsBedrockHandler implements ApiHandler {
})
}
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
async *createMessage(
systemPrompt: string,
messages: Anthropic.Messages.MessageParam[],
): ApiStream {
// cross region inference requires prefixing the model id with the region
let modelId: string
if (this.options.awsUseCrossRegionInference) {
@ -107,6 +122,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

@ -1,7 +1,13 @@
import { Anthropic } from "@anthropic-ai/sdk"
import OpenAI from "openai"
import { ApiHandler } from "../"
import { ApiHandlerOptions, DeepSeekModelId, ModelInfo, deepSeekDefaultModelId, deepSeekModels } from "../../shared/api"
import {
ApiHandlerOptions,
DeepSeekModelId,
ModelInfo,
deepSeekDefaultModelId,
deepSeekModels,
} from "../../shared/api"
import { convertToOpenAiMessages } from "../transform/openai-format"
import { ApiStream } from "../transform/stream"
@ -17,12 +23,18 @@ export class DeepSeekHandler implements ApiHandler {
})
}
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
async *createMessage(
systemPrompt: string,
messages: Anthropic.Messages.MessageParam[],
): ApiStream {
const stream = await this.client.chat.completions.create({
model: this.getModel().id,
max_completion_tokens: this.getModel().info.maxTokens,
temperature: 0,
messages: [{ role: "system", content: systemPrompt }, ...convertToOpenAiMessages(messages)],
messages: [
{ role: "system", content: systemPrompt },
...convertToOpenAiMessages(messages),
],
stream: true,
stream_options: { include_usage: true },
})
@ -56,6 +68,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

@ -1,7 +1,13 @@
import { Anthropic } from "@anthropic-ai/sdk"
import { GoogleGenerativeAI } from "@google/generative-ai"
import { ApiHandler } from "../"
import { ApiHandlerOptions, geminiDefaultModelId, GeminiModelId, geminiModels, ModelInfo } from "../../shared/api"
import {
ApiHandlerOptions,
geminiDefaultModelId,
GeminiModelId,
geminiModels,
ModelInfo,
} from "../../shared/api"
import { convertAnthropicMessageToGemini } from "../transform/gemini-format"
import { ApiStream } from "../transform/stream"
@ -17,7 +23,10 @@ export class GeminiHandler implements ApiHandler {
this.client = new GoogleGenerativeAI(options.geminiApiKey)
}
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
async *createMessage(
systemPrompt: string,
messages: Anthropic.Messages.MessageParam[],
): ApiStream {
const model = this.client.getGenerativeModel({
model: this.getModel().id,
systemInstruction: systemPrompt,
@ -51,6 +60,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

@ -1,7 +1,11 @@
import { Anthropic } from "@anthropic-ai/sdk"
import OpenAI from "openai"
import { ApiHandler } from "../"
import { ApiHandlerOptions, ModelInfo, openAiModelInfoSaneDefaults } from "../../shared/api"
import {
ApiHandlerOptions,
ModelInfo,
openAiModelInfoSaneDefaults,
} from "../../shared/api"
import { convertToOpenAiMessages } from "../transform/openai-format"
import { ApiStream } from "../transform/stream"
@ -12,12 +16,17 @@ export class LmStudioHandler implements ApiHandler {
constructor(options: ApiHandlerOptions) {
this.options = options
this.client = new OpenAI({
baseURL: (this.options.lmStudioBaseUrl || "http://localhost:1234") + "/v1",
baseURL:
(this.options.lmStudioBaseUrl || "http://localhost:1234") +
"/v1",
apiKey: "noop",
})
}
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
async *createMessage(
systemPrompt: string,
messages: Anthropic.Messages.MessageParam[],
): ApiStream {
const openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [
{ role: "system", content: systemPrompt },
...convertToOpenAiMessages(messages),

View file

@ -1,7 +1,11 @@
import { Anthropic } from "@anthropic-ai/sdk"
import OpenAI from "openai"
import { ApiHandler } from "../"
import { ApiHandlerOptions, ModelInfo, openAiModelInfoSaneDefaults } from "../../shared/api"
import {
ApiHandlerOptions,
ModelInfo,
openAiModelInfoSaneDefaults,
} from "../../shared/api"
import { convertToOpenAiMessages } from "../transform/openai-format"
import { ApiStream } from "../transform/stream"
@ -12,12 +16,17 @@ export class OllamaHandler implements ApiHandler {
constructor(options: ApiHandlerOptions) {
this.options = options
this.client = new OpenAI({
baseURL: (this.options.ollamaBaseUrl || "http://localhost:11434") + "/v1",
baseURL:
(this.options.ollamaBaseUrl || "http://localhost:11434") +
"/v1",
apiKey: "ollama",
})
}
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
async *createMessage(
systemPrompt: string,
messages: Anthropic.Messages.MessageParam[],
): ApiStream {
const openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [
{ role: "system", content: systemPrompt },
...convertToOpenAiMessages(messages),

View file

@ -22,14 +22,20 @@ export class OpenAiNativeHandler implements ApiHandler {
})
}
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
async *createMessage(
systemPrompt: string,
messages: Anthropic.Messages.MessageParam[],
): ApiStream {
switch (this.getModel().id) {
case "o1-preview":
case "o1-mini": {
// o1 doesnt support streaming, non-1 temp, or system prompt
const response = await this.client.chat.completions.create({
model: this.getModel().id,
messages: [{ role: "user", content: systemPrompt }, ...convertToOpenAiMessages(messages)],
messages: [
{ role: "user", content: systemPrompt },
...convertToOpenAiMessages(messages),
],
})
yield {
type: "text",
@ -47,7 +53,10 @@ export class OpenAiNativeHandler implements ApiHandler {
model: this.getModel().id,
// max_completion_tokens: this.getModel().info.maxTokens,
temperature: 0,
messages: [{ role: "system", content: systemPrompt }, ...convertToOpenAiMessages(messages)],
messages: [
{ role: "system", content: systemPrompt },
...convertToOpenAiMessages(messages),
],
stream: true,
stream_options: { include_usage: true },
})
@ -80,6 +89,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

@ -21,7 +21,9 @@ export class OpenAiHandler implements ApiHandler {
this.client = new AzureOpenAI({
baseURL: this.options.openAiBaseUrl,
apiKey: this.options.openAiApiKey,
apiVersion: this.options.azureApiVersion || azureOpenAiDefaultApiVersion,
apiVersion:
this.options.azureApiVersion ||
azureOpenAiDefaultApiVersion,
})
} else {
this.client = new OpenAI({
@ -31,7 +33,10 @@ export class OpenAiHandler implements ApiHandler {
}
}
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
async *createMessage(
systemPrompt: string,
messages: Anthropic.Messages.MessageParam[],
): ApiStream {
const openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [
{ role: "system", content: systemPrompt },
...convertToOpenAiMessages(messages),

View file

@ -2,7 +2,12 @@ import { Anthropic } from "@anthropic-ai/sdk"
import axios from "axios"
import OpenAI from "openai"
import { ApiHandler } from "../"
import { ApiHandlerOptions, ModelInfo, openRouterDefaultModelId, openRouterDefaultModelInfo } from "../../shared/api"
import {
ApiHandlerOptions,
ModelInfo,
openRouterDefaultModelId,
openRouterDefaultModelInfo,
} from "../../shared/api"
import { convertToOpenAiMessages } from "../transform/openai-format"
import { ApiStream } from "../transform/stream"
import delay from "delay"
@ -23,7 +28,10 @@ export class OpenRouterHandler implements ApiHandler {
})
}
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
async *createMessage(
systemPrompt: string,
messages: Anthropic.Messages.MessageParam[],
): ApiStream {
// Convert Anthropic messages to OpenAI format
const openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [
{ role: "system", content: systemPrompt },
@ -58,14 +66,18 @@ export class OpenRouterHandler implements ApiHandler {
}
// Add cache_control to the last two user messages
// (note: this works because we only ever add one user message at a time, but if we added multiple we'd need to mark the user message before the last assistant message)
const lastTwoUserMessages = openAiMessages.filter((msg) => msg.role === "user").slice(-2)
const lastTwoUserMessages = openAiMessages
.filter((msg) => msg.role === "user")
.slice(-2)
lastTwoUserMessages.forEach((msg) => {
if (typeof msg.content === "string") {
msg.content = [{ type: "text", text: msg.content }]
}
if (Array.isArray(msg.content)) {
// NOTE: this is fine since env details will always be added at the end. but if it weren't there, and the user added a image_url type message, it would pop a text part before it and then move it after to the end.
let lastTextPart = msg.content.filter((part) => part.type === "text").pop()
let lastTextPart = msg.content
.filter((part) => part.type === "text")
.pop()
if (!lastTextPart) {
lastTextPart = { type: "text", text: "..." }
@ -97,7 +109,8 @@ export class OpenRouterHandler implements ApiHandler {
}
// Removes messages in the middle when close to context window limit. Should not be applied to models that support prompt caching since it would continuously break the cache.
let shouldApplyMiddleOutTransform = !this.getModel().info.supportsPromptCache
let shouldApplyMiddleOutTransform =
!this.getModel().info.supportsPromptCache
// except for deepseek (which we set supportsPromptCache to true for), where because the context window is so small our truncation algo might miss and we should use openrouter's middle-out transform as a fallback to ensure we don't exceed the context window (FIXME: once we have a more robust token estimator we should not rely on this)
if (this.getModel().id === "deepseek/deepseek-chat") {
shouldApplyMiddleOutTransform = true
@ -110,7 +123,9 @@ export class OpenRouterHandler implements ApiHandler {
temperature: 0,
messages: openAiMessages,
stream: true,
transforms: shouldApplyMiddleOutTransform ? ["middle-out"] : undefined,
transforms: shouldApplyMiddleOutTransform
? ["middle-out"]
: undefined,
})
let genId: string | undefined
@ -119,8 +134,12 @@ export class OpenRouterHandler implements ApiHandler {
// openrouter returns an error object instead of the openai sdk throwing an error
if ("error" in chunk) {
const error = chunk.error as { message?: string; code?: number }
console.error(`OpenRouter API Error: ${error?.code} - ${error?.message}`)
throw new Error(`OpenRouter API Error ${error?.code}: ${error?.message}`)
console.error(
`OpenRouter API Error: ${error?.code} - ${error?.message}`,
)
throw new Error(
`OpenRouter API Error ${error?.code}: ${error?.message}`,
)
}
if (!genId && chunk.id) {
@ -146,12 +165,15 @@ export class OpenRouterHandler implements ApiHandler {
await delay(500) // FIXME: necessary delay to ensure generation endpoint is ready
try {
const response = await axios.get(`https://openrouter.ai/api/v1/generation?id=${genId}`, {
headers: {
Authorization: `Bearer ${this.options.openRouterApiKey}`,
const response = await axios.get(
`https://openrouter.ai/api/v1/generation?id=${genId}`,
{
headers: {
Authorization: `Bearer ${this.options.openRouterApiKey}`,
},
timeout: 5_000, // this request hangs sometimes
},
timeout: 5_000, // this request hangs sometimes
})
)
const generation = response.data?.data
console.log("OpenRouter generation details:", response.data)
@ -166,7 +188,10 @@ export class OpenRouterHandler implements ApiHandler {
}
} catch (error) {
// ignore if fails
console.error("Error fetching OpenRouter generation details:", error)
console.error(
"Error fetching OpenRouter generation details:",
error,
)
}
}
@ -176,6 +201,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

@ -1,7 +1,13 @@
import { Anthropic } from "@anthropic-ai/sdk"
import { AnthropicVertex } from "@anthropic-ai/vertex-sdk"
import { ApiHandler } from "../"
import { ApiHandlerOptions, ModelInfo, vertexDefaultModelId, VertexModelId, vertexModels } from "../../shared/api"
import {
ApiHandlerOptions,
ModelInfo,
vertexDefaultModelId,
VertexModelId,
vertexModels,
} from "../../shared/api"
import { ApiStream } from "../transform/stream"
// https://docs.anthropic.com/en/api/claude-on-vertex-ai
@ -18,7 +24,10 @@ export class VertexHandler implements ApiHandler {
})
}
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
async *createMessage(
systemPrompt: string,
messages: Anthropic.Messages.MessageParam[],
): ApiStream {
const stream = await this.client.messages.create({
model: this.getModel().id,
max_tokens: this.getModel().info.maxTokens || 8192,
@ -81,6 +90,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

@ -62,10 +62,20 @@ export function convertAnthropicContentToGemini(
} as FunctionResponsePart
} else {
// The only case when tool_result could be array is when the tool failed and we're providing ie user feedback potentially with images
const textParts = block.content.filter((part) => part.type === "text")
const imageParts = block.content.filter((part) => part.type === "image")
const text = textParts.length > 0 ? textParts.map((part) => part.text).join("\n\n") : ""
const imageText = imageParts.length > 0 ? "\n\n(See next part for image)" : ""
const textParts = block.content.filter(
(part) => part.type === "text",
)
const imageParts = block.content.filter(
(part) => part.type === "image",
)
const text =
textParts.length > 0
? textParts.map((part) => part.text).join("\n\n")
: ""
const imageText =
imageParts.length > 0
? "\n\n(See next part for image)"
: ""
return [
{
functionResponse: {
@ -88,32 +98,40 @@ export function convertAnthropicContentToGemini(
]
}
default:
throw new Error(`Unsupported content block type: ${(block as any).type}`)
throw new Error(
`Unsupported content block type: ${(block as any).type}`,
)
}
})
}
export function convertAnthropicMessageToGemini(message: Anthropic.Messages.MessageParam): Content {
export function convertAnthropicMessageToGemini(
message: Anthropic.Messages.MessageParam,
): Content {
return {
role: message.role === "assistant" ? "model" : "user",
parts: convertAnthropicContentToGemini(message.content),
}
}
export function convertAnthropicToolToGemini(tool: Anthropic.Messages.Tool): FunctionDeclaration {
export function convertAnthropicToolToGemini(
tool: Anthropic.Messages.Tool,
): FunctionDeclaration {
return {
name: tool.name,
description: tool.description || "",
parameters: {
type: SchemaType.OBJECT,
properties: Object.fromEntries(
Object.entries(tool.input_schema.properties || {}).map(([key, value]) => [
key,
{
type: (value as any).type.toUpperCase(),
description: (value as any).description || "",
},
]),
Object.entries(tool.input_schema.properties || {}).map(
([key, value]) => [
key,
{
type: (value as any).type.toUpperCase(),
description: (value as any).description || "",
},
],
),
),
required: (tool.input_schema.required as string[]) || [],
},
@ -147,7 +165,10 @@ export function convertGeminiResponseToAnthropic(
const functionCalls = response.functionCalls()
if (functionCalls) {
functionCalls.forEach((call, index) => {
if ("content" in call.args && typeof call.args.content === "string") {
if (
"content" in call.args &&
typeof call.args.content === "string"
) {
call.args.content = unescapeGeminiContent(call.args.content)
}
content.push({

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)
@ -269,7 +272,9 @@ function parseToolCalls(toolCallsText: string): ToolCall[] {
let remainingText = toolCallsText
while (remainingText.length > 0) {
const toolMatch = toolNames.find((tool) => new RegExp(`<${tool}`, "i").test(remainingText))
const toolMatch = toolNames.find((tool) =>
new RegExp(`<${tool}`, "i").test(remainingText),
)
if (!toolMatch) {
break // No more tool calls found
@ -284,7 +289,10 @@ function parseToolCalls(toolCallsText: string): ToolCall[] {
break // Malformed XML, no closing tag found
}
const toolCallContent = remainingText.slice(startIndex, endIndex + endTag.length)
const toolCallContent = remainingText.slice(
startIndex,
endIndex + endTag.length,
)
remainingText = remainingText.slice(endIndex + endTag.length).trim()
const toolCall = parseToolCall(toolMatch, toolCallContent)
@ -300,7 +308,9 @@ function parseToolCall(toolName: string, content: string): ToolCall | null {
const tool_input: Record<string, string> = {}
// Remove the outer tool tags
const innerContent = content.replace(new RegExp(`^<${toolName}>|</${toolName}>$`, "g"), "").trim()
const innerContent = content
.replace(new RegExp(`^<${toolName}>|</${toolName}>$`, "g"), "")
.trim()
// Parse nested XML elements
const paramRegex = /<(\w+)>([\s\S]*?)<\/\1>/gs
@ -321,7 +331,10 @@ function parseToolCall(toolName: string, content: string): ToolCall | null {
return { tool: toolName, tool_input }
}
function validateToolInput(toolName: string, tool_input: Record<string, string>): boolean {
function validateToolInput(
toolName: string,
tool_input: Record<string, string>,
): boolean {
switch (toolName) {
case "execute_command":
return "command" in tool_input
@ -363,7 +376,9 @@ export function convertO1ResponseToAnthropicMessage(
completion: OpenAI.Chat.Completions.ChatCompletion,
): Anthropic.Messages.Message {
const openAiMessage = completion.choices[0].message
const { normalText, toolCalls } = parseAIResponse(openAiMessage.content || "")
const { normalText, toolCalls } = parseAIResponse(
openAiMessage.content || "",
)
const anthropicMessage: Anthropic.Messages.Message = {
id: completion.id,
@ -398,14 +413,16 @@ export function convertO1ResponseToAnthropicMessage(
if (toolCalls.length > 0) {
anthropicMessage.content.push(
...toolCalls.map((toolCall: ToolCall, index: number): Anthropic.ToolUseBlock => {
return {
type: "tool_use",
id: `call_${index}_${Date.now()}`, // Generate a unique ID for each tool call
name: toolCall.tool,
input: toolCall.tool_input,
}
}),
...toolCalls.map(
(toolCall: ToolCall, index: number): Anthropic.ToolUseBlock => {
return {
type: "tool_use",
id: `call_${index}_${Date.now()}`, // Generate a unique ID for each tool call
name: toolCall.tool,
input: toolCall.tool_input,
}
},
),
)
}

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,
@ -19,20 +22,27 @@ export function convertToOpenAiMessages(
{ role: "tool", tool_call_id: "", content: ""}
*/
if (anthropicMessage.role === "user") {
const { nonToolMessages, toolMessages } = anthropicMessage.content.reduce<{
nonToolMessages: (Anthropic.TextBlockParam | Anthropic.ImageBlockParam)[]
toolMessages: Anthropic.ToolResultBlockParam[]
}>(
(acc, part) => {
if (part.type === "tool_result") {
acc.toolMessages.push(part)
} else if (part.type === "text" || part.type === "image") {
acc.nonToolMessages.push(part)
} // user cannot send tool_use messages
return acc
},
{ nonToolMessages: [], toolMessages: [] },
)
const { nonToolMessages, toolMessages } =
anthropicMessage.content.reduce<{
nonToolMessages: (
| Anthropic.TextBlockParam
| Anthropic.ImageBlockParam
)[]
toolMessages: Anthropic.ToolResultBlockParam[]
}>(
(acc, part) => {
if (part.type === "tool_result") {
acc.toolMessages.push(part)
} else if (
part.type === "text" ||
part.type === "image"
) {
acc.nonToolMessages.push(part)
} // user cannot send tool_use messages
return acc
},
{ nonToolMessages: [], toolMessages: [] },
)
// Process tool result messages FIRST since they must follow the tool use messages
let toolResultImages: Anthropic.Messages.ImageBlockParam[] = []
@ -85,7 +95,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 }
@ -93,20 +105,27 @@ export function convertToOpenAiMessages(
})
}
} else if (anthropicMessage.role === "assistant") {
const { nonToolMessages, toolMessages } = anthropicMessage.content.reduce<{
nonToolMessages: (Anthropic.TextBlockParam | Anthropic.ImageBlockParam)[]
toolMessages: Anthropic.ToolUseBlockParam[]
}>(
(acc, part) => {
if (part.type === "tool_use") {
acc.toolMessages.push(part)
} else if (part.type === "text" || part.type === "image") {
acc.nonToolMessages.push(part)
} // assistant cannot send tool_result messages
return acc
},
{ nonToolMessages: [], toolMessages: [] },
)
const { nonToolMessages, toolMessages } =
anthropicMessage.content.reduce<{
nonToolMessages: (
| Anthropic.TextBlockParam
| Anthropic.ImageBlockParam
)[]
toolMessages: Anthropic.ToolUseBlockParam[]
}>(
(acc, part) => {
if (part.type === "tool_use") {
acc.toolMessages.push(part)
} else if (
part.type === "text" ||
part.type === "image"
) {
acc.nonToolMessages.push(part)
} // assistant cannot send tool_result messages
return acc
},
{ nonToolMessages: [], toolMessages: [] },
)
// Process non-tool messages
let content: string | undefined
@ -122,15 +141,16 @@ export function convertToOpenAiMessages(
}
// Process tool use messages
let tool_calls: OpenAI.Chat.ChatCompletionMessageToolCall[] = toolMessages.map((toolMessage) => ({
id: toolMessage.id,
type: "function",
function: {
name: toolMessage.name,
// json string
arguments: JSON.stringify(toolMessage.input),
},
}))
let tool_calls: OpenAI.Chat.ChatCompletionMessageToolCall[] =
toolMessages.map((toolMessage) => ({
id: toolMessage.id,
type: "function",
function: {
name: toolMessage.name,
// json string
arguments: JSON.stringify(toolMessage.input),
},
}))
openAiMessages.push({
role: "assistant",
@ -183,20 +203,24 @@ export function convertToAnthropicMessage(
if (openAiMessage.tool_calls && openAiMessage.tool_calls.length > 0) {
anthropicMessage.content.push(
...openAiMessage.tool_calls.map((toolCall): Anthropic.ToolUseBlock => {
let parsedInput = {}
try {
parsedInput = JSON.parse(toolCall.function.arguments || "{}")
} catch (error) {
console.error("Failed to parse tool arguments:", error)
}
return {
type: "tool_use",
id: toolCall.id,
name: toolCall.function.name,
input: parsedInput,
}
}),
...openAiMessage.tool_calls.map(
(toolCall): Anthropic.ToolUseBlock => {
let parsedInput = {}
try {
parsedInput = JSON.parse(
toolCall.function.arguments || "{}",
)
} catch (error) {
console.error("Failed to parse tool arguments:", error)
}
return {
type: "tool_use",
id: toolCall.id,
name: toolCall.function.name,
input: parsedInput,
}
},
),
)
}
return anthropicMessage

File diff suppressed because it is too large Load diff

View file

@ -29,7 +29,11 @@ function lineTrimmedFallbackMatch(
}
// For each possible starting position in original content
for (let i = startLineNum; i <= originalLines.length - searchLines.length; i++) {
for (
let i = startLineNum;
i <= originalLines.length - searchLines.length;
i++
) {
let matches = true
// Try to match all search lines from this position
@ -122,7 +126,11 @@ function blockAnchorFallbackMatch(
}
// Look for matching start and end anchors
for (let i = startLineNum; i <= originalLines.length - searchBlockSize; i++) {
for (
let i = startLineNum;
i <= originalLines.length - searchBlockSize;
i++
) {
// Check if first line matches
if (originalLines[i].trim() !== firstLineSearch) {
continue
@ -231,7 +239,9 @@ export async function constructNewFileContent(
const lastLine = lines[lines.length - 1]
if (
lines.length > 0 &&
(lastLine.startsWith("<") || lastLine.startsWith("=") || lastLine.startsWith(">")) &&
(lastLine.startsWith("<") ||
lastLine.startsWith("=") ||
lastLine.startsWith(">")) &&
lastLine !== "<<<<<<< SEARCH" &&
lastLine !== "=======" &&
lastLine !== ">>>>>>> REPLACE"
@ -280,7 +290,10 @@ export async function constructNewFileContent(
// }
// Exact search match scenario
const exactIndex = originalContent.indexOf(currentSearchContent, lastProcessedIndex)
const exactIndex = originalContent.indexOf(
currentSearchContent,
lastProcessedIndex,
)
if (exactIndex !== -1) {
searchMatchIndex = exactIndex
searchEndIndex = exactIndex + currentSearchContent.length
@ -312,7 +325,10 @@ export async function constructNewFileContent(
}
// Output everything up to the match location
result += originalContent.slice(lastProcessedIndex, searchMatchIndex)
result += originalContent.slice(
lastProcessedIndex,
searchMatchIndex,
)
continue
}

View file

@ -60,7 +60,9 @@ export interface ToolUse {
export interface ExecuteCommandToolUse extends ToolUse {
name: "execute_command"
// Pick<Record<ToolParamName, string>, "command"> makes "command" required, but Partial<> makes it optional
params: Partial<Pick<Record<ToolParamName, string>, "command" | "requires_approval">>
params: Partial<
Pick<Record<ToolParamName, string>, "command" | "requires_approval">
>
}
export interface ReadFileToolUse extends ToolUse {
@ -80,7 +82,9 @@ export interface ReplaceInFileToolUse extends ToolUse {
export interface SearchFilesToolUse extends ToolUse {
name: "search_files"
params: Partial<Pick<Record<ToolParamName, string>, "path" | "regex" | "file_pattern">>
params: Partial<
Pick<Record<ToolParamName, string>, "path" | "regex" | "file_pattern">
>
}
export interface ListFilesToolUse extends ToolUse {
@ -95,12 +99,22 @@ export interface ListCodeDefinitionNamesToolUse extends ToolUse {
export interface BrowserActionToolUse extends ToolUse {
name: "browser_action"
params: Partial<Pick<Record<ToolParamName, string>, "action" | "url" | "coordinate" | "text">>
params: Partial<
Pick<
Record<ToolParamName, string>,
"action" | "url" | "coordinate" | "text"
>
>
}
export interface UseMcpToolToolUse extends ToolUse {
name: "use_mcp_tool"
params: Partial<Pick<Record<ToolParamName, string>, "server_name" | "tool_name" | "arguments">>
params: Partial<
Pick<
Record<ToolParamName, string>,
"server_name" | "tool_name" | "arguments"
>
>
}
export interface AccessMcpResourceToolUse extends ToolUse {

View file

@ -24,11 +24,15 @@ export function parseAssistantMessage(assistantMessage: string) {
// there should not be a param without a tool use
if (currentToolUse && currentParamName) {
const currentParamValue = accumulator.slice(currentParamValueStartIndex)
const currentParamValue = accumulator.slice(
currentParamValueStartIndex,
)
const paramClosingTag = `</${currentParamName}>`
if (currentParamValue.endsWith(paramClosingTag)) {
// end of param value
currentToolUse.params[currentParamName] = currentParamValue.slice(0, -paramClosingTag.length).trim()
currentToolUse.params[currentParamName] = currentParamValue
.slice(0, -paramClosingTag.length)
.trim()
currentParamName = undefined
continue
} else {
@ -49,11 +53,16 @@ export function parseAssistantMessage(assistantMessage: string) {
currentToolUse = undefined
continue
} else {
const possibleParamOpeningTags = toolParamNames.map((name) => `<${name}>`)
const possibleParamOpeningTags = toolParamNames.map(
(name) => `<${name}>`,
)
for (const paramOpeningTag of possibleParamOpeningTags) {
if (accumulator.endsWith(paramOpeningTag)) {
// start of a new parameter
currentParamName = paramOpeningTag.slice(1, -1) as ToolParamName
currentParamName = paramOpeningTag.slice(
1,
-1,
) as ToolParamName
currentParamValueStartIndex = accumulator.length
break
}
@ -63,13 +72,25 @@ export function parseAssistantMessage(assistantMessage: string) {
// special case for write_to_file where file contents could contain the closing tag, in which case the param would have closed and we end up with the rest of the file contents here. To work around this, we get the string between the starting content tag and the LAST content tag.
const contentParamName: ToolParamName = "content"
if (currentToolUse.name === "write_to_file" && accumulator.endsWith(`</${contentParamName}>`)) {
const toolContent = accumulator.slice(currentToolUseStartIndex)
if (
currentToolUse.name === "write_to_file" &&
accumulator.endsWith(`</${contentParamName}>`)
) {
const toolContent = accumulator.slice(
currentToolUseStartIndex,
)
const contentStartTag = `<${contentParamName}>`
const contentEndTag = `</${contentParamName}>`
const contentStartIndex = toolContent.indexOf(contentStartTag) + contentStartTag.length
const contentEndIndex = toolContent.lastIndexOf(contentEndTag)
if (contentStartIndex !== -1 && contentEndIndex !== -1 && contentEndIndex > contentStartIndex) {
const contentStartIndex =
toolContent.indexOf(contentStartTag) +
contentStartTag.length
const contentEndIndex =
toolContent.lastIndexOf(contentEndTag)
if (
contentStartIndex !== -1 &&
contentEndIndex !== -1 &&
contentEndIndex > contentStartIndex
) {
currentToolUse.params[contentParamName] = toolContent
.slice(contentStartIndex, contentEndIndex)
.trim()
@ -84,7 +105,9 @@ export function parseAssistantMessage(assistantMessage: string) {
// no currentToolUse
let didStartToolUse = false
const possibleToolUseOpeningTags = toolUseNames.map((name) => `<${name}>`)
const possibleToolUseOpeningTags = toolUseNames.map(
(name) => `<${name}>`,
)
for (const toolUseOpeningTag of possibleToolUseOpeningTags) {
if (accumulator.endsWith(toolUseOpeningTag)) {
// start of a new tool use
@ -128,7 +151,9 @@ export function parseAssistantMessage(assistantMessage: string) {
// stream did not complete tool call, add it as partial
if (currentParamName) {
// tool call has a parameter that was not completed
currentToolUse.params[currentParamName] = accumulator.slice(currentParamValueStartIndex).trim()
currentToolUse.params[currentParamName] = accumulator
.slice(currentParamValueStartIndex)
.trim()
}
contentBlocks.push(currentToolUse)
}

View file

@ -15,13 +15,18 @@ export function openMention(mention?: string): void {
if (mention.startsWith("/")) {
const relPath = mention.slice(1)
const cwd = vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath).at(0)
const cwd = vscode.workspace.workspaceFolders
?.map((folder) => folder.uri.fsPath)
.at(0)
if (!cwd) {
return
}
const absPath = path.resolve(cwd, relPath)
if (mention.endsWith("/")) {
vscode.commands.executeCommand("revealInExplorer", vscode.Uri.file(absPath))
vscode.commands.executeCommand(
"revealInExplorer",
vscode.Uri.file(absPath),
)
// vscode.commands.executeCommand("vscode.openFolder", , { forceNewWindow: false }) opens in new window
} else {
openFile(absPath)
@ -33,7 +38,11 @@ export function openMention(mention?: string): void {
}
}
export async function parseMentions(text: string, cwd: string, urlContentFetcher: UrlContentFetcher): Promise<string> {
export async function parseMentions(
text: string,
cwd: string,
urlContentFetcher: UrlContentFetcher,
): Promise<string> {
const mentions: Set<string> = new Set()
let parsedText = text.replace(mentionRegexGlobal, (match, mention) => {
mentions.add(mention)
@ -50,14 +59,18 @@ export async function parseMentions(text: string, cwd: string, urlContentFetcher
return match
})
const urlMention = Array.from(mentions).find((mention) => mention.startsWith("http"))
const urlMention = Array.from(mentions).find((mention) =>
mention.startsWith("http"),
)
let launchBrowserError: Error | undefined
if (urlMention) {
try {
await urlContentFetcher.launchBrowser()
} catch (error) {
launchBrowserError = error
vscode.window.showErrorMessage(`Error fetching content for ${urlMention}: ${error.message}`)
vscode.window.showErrorMessage(
`Error fetching content for ${urlMention}: ${error.message}`,
)
}
}
@ -68,10 +81,13 @@ export async function parseMentions(text: string, cwd: string, urlContentFetcher
result = `Error fetching content: ${launchBrowserError.message}`
} else {
try {
const markdown = await urlContentFetcher.urlToMarkdown(mention)
const markdown =
await urlContentFetcher.urlToMarkdown(mention)
result = markdown
} catch (error) {
vscode.window.showErrorMessage(`Error fetching content for ${mention}: ${error.message}`)
vscode.window.showErrorMessage(
`Error fetching content for ${mention}: ${error.message}`,
)
result = `Error fetching content: ${error.message}`
}
}
@ -113,7 +129,10 @@ export async function parseMentions(text: string, cwd: string, urlContentFetcher
return parsedText
}
async function getFileOrFolderContent(mentionPath: string, cwd: string): Promise<string> {
async function getFileOrFolderContent(
mentionPath: string,
cwd: string,
): Promise<string> {
const absPath = path.resolve(cwd, mentionPath)
try {
@ -141,11 +160,14 @@ async function getFileOrFolderContent(mentionPath: string, cwd: string): Promise
fileContentPromises.push(
(async () => {
try {
const isBinary = await isBinaryFile(absoluteFilePath).catch(() => false)
const isBinary = await isBinaryFile(
absoluteFilePath,
).catch(() => false)
if (isBinary) {
return undefined
}
const content = await extractTextFromFile(absoluteFilePath)
const content =
await extractTextFromFile(absoluteFilePath)
return `<file_content path="${filePath.toPosix()}">\n${content}\n</file_content>`
} catch (error) {
return undefined
@ -159,13 +181,17 @@ async function getFileOrFolderContent(mentionPath: string, cwd: string): Promise
folderContent += `${linePrefix}${entry.name}\n`
}
})
const fileContents = (await Promise.all(fileContentPromises)).filter((content) => content)
const fileContents = (
await Promise.all(fileContentPromises)
).filter((content) => content)
return `${folderContent}\n${fileContents.join("\n\n")}`.trim()
} else {
return `(Failed to read contents of ${mentionPath})`
}
} catch (error) {
throw new Error(`Failed to access path "${mentionPath}": ${error.message}`)
throw new Error(
`Failed to access path "${mentionPath}": ${error.message}`,
)
}
}

View file

@ -8,7 +8,8 @@ export const formatResponse = {
toolDeniedWithFeedback: (feedback?: string) =>
`The user denied this operation and provided the following feedback:\n<feedback>\n${feedback}\n</feedback>`,
toolError: (error?: string) => `The tool execution failed with the following error:\n<error>\n${error}\n</error>`,
toolError: (error?: string) =>
`The tool execution failed with the following error:\n<error>\n${error}\n</error>`,
noToolsUsed: () =>
`[ERROR] You did not use a tool in your previous response! Please retry with a tool use.
@ -37,7 +38,8 @@ Otherwise, if you have not completed the task and do not need additional informa
): string | Array<Anthropic.TextBlockParam | Anthropic.ImageBlockParam> => {
if (images && images.length > 0) {
const textBlock: Anthropic.TextBlockParam = { type: "text", text }
const imageBlocks: Anthropic.ImageBlockParam[] = formatImagesIntoBlocks(images)
const imageBlocks: Anthropic.ImageBlockParam[] =
formatImagesIntoBlocks(images)
// Placing images after text leads to better results
return [textBlock, ...imageBlocks]
} else {
@ -49,7 +51,11 @@ Otherwise, if you have not completed the task and do not need additional informa
return formatImagesIntoBlocks(images)
},
formatFilesList: (absolutePath: string, files: string[], didHitLimit: boolean): string => {
formatFilesList: (
absolutePath: string,
files: string[],
didHitLimit: boolean,
): string => {
const sorted = files
.map((file) => {
// convert absolute path to relative path
@ -60,7 +66,11 @@ Otherwise, if you have not completed the task and do not need additional informa
.sort((a, b) => {
const aParts = a.split("/") // only works if we use toPosix first
const bParts = b.split("/")
for (let i = 0; i < Math.min(aParts.length, bParts.length); i++) {
for (
let i = 0;
i < Math.min(aParts.length, bParts.length);
i++
) {
if (aParts[i] !== bParts[i]) {
// If one is a directory and the other isn't at this level, sort the directory first
if (i + 1 === aParts.length && i + 1 < bParts.length) {
@ -70,7 +80,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,
@ -81,16 +94,27 @@ Otherwise, if you have not completed the task and do not need additional informa
return `${sorted.join(
"\n",
)}\n\n(File list truncated. Use list_files on specific subdirectories if you need to explore further.)`
} else if (sorted.length === 0 || (sorted.length === 1 && sorted[0] === "")) {
} else if (
sorted.length === 0 ||
(sorted.length === 1 && sorted[0] === "")
) {
return "No files found."
} else {
return sorted.join("\n")
}
},
createPrettyPatch: (filename = "file", oldStr?: string, newStr?: string) => {
createPrettyPatch: (
filename = "file",
oldStr?: string,
newStr?: string,
) => {
// strings cannot be undefined or diff throws exception
const patch = diff.createPatch(filename.toPosix(), oldStr || "", newStr || "")
const patch = diff.createPatch(
filename.toPosix(),
oldStr || "",
newStr || "",
)
const lines = patch.split("\n")
const prettyPatchLines = lines.slice(4)
return prettyPatchLines.join("\n")
@ -98,7 +122,9 @@ Otherwise, if you have not completed the task and do not need additional informa
}
// to avoid circular dependency
const formatImagesIntoBlocks = (images?: string[]): Anthropic.ImageBlockParam[] => {
const formatImagesIntoBlocks = (
images?: string[],
): Anthropic.ImageBlockParam[] => {
return images
? images.map((dataUrl) => {
// data:image/png;base64,base64string
@ -106,7 +132,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

@ -362,11 +362,17 @@ ${
.join("\n\n")
const templates = server.resourceTemplates
?.map((template) => `- ${template.uriTemplate} (${template.name}): ${template.description}`)
?.map(
(template) =>
`- ${template.uriTemplate} (${template.name}): ${template.description}`,
)
.join("\n")
const resources = server.resources
?.map((resource) => `- ${resource.uri} (${resource.name}): ${resource.description}`)
?.map(
(resource) =>
`- ${resource.uri} (${resource.name}): ${resource.description}`,
)
.join("\n")
const config = JSON.parse(server.config)
@ -374,8 +380,12 @@ ${
return (
`## ${server.name} (\`${config.command}${config.args && Array.isArray(config.args) ? ` ${config.args.join(" ")}` : ""}\`)` +
(tools ? `\n\n### Available Tools\n${tools}` : "") +
(templates ? `\n\n### Resource Templates\n${templates}` : "") +
(resources ? `\n\n### Direct Resources\n${resources}` : "")
(templates
? `\n\n### Resource Templates\n${templates}`
: "") +
(resources
? `\n\n### Direct Resources\n${resources}`
: "")
)
})
.join("\n\n")}`
@ -889,7 +899,10 @@ You accomplish a given task iteratively, breaking it down into clear steps and w
4. Once you've completed the user's task, you must use the attempt_completion tool to present the result of the task to the user. You may also provide a CLI command to showcase the result of your task; this can be particularly useful for web development tasks, where you can run e.g. \`open index.html\` to show the website you've built.
5. The user may provide feedback, which you can use to make improvements and try again. But DO NOT continue in pointless back and forth conversations, i.e. don't end your responses with questions or offers for further assistance.`
export function addUserInstructions(settingsCustomInstructions?: string, clineRulesFileInstructions?: string) {
export function addUserInstructions(
settingsCustomInstructions?: string,
clineRulesFileInstructions?: string,
) {
let customInstructions = ""
if (settingsCustomInstructions) {
customInstructions += settingsCustomInstructions + "\n\n"

View file

@ -8,19 +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,15 +14,21 @@ 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"
import { getNonce } from "./getNonce"
import { getUri } from "./getUri"
import { AutoApprovalSettings, DEFAULT_AUTO_APPROVAL_SETTINGS } from "../../shared/AutoApprovalSettings"
import {
AutoApprovalSettings,
DEFAULT_AUTO_APPROVAL_SETTINGS,
} from "../../shared/AutoApprovalSettings"
/*
https://github.com/microsoft/vscode-webview-ui-toolkit-samples/blob/main/default/weather-webview/src/providers/WeatherViewProvider.ts
@ -79,7 +85,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-5-2025" // update to some unique identifier when we add a new announcement
constructor(
readonly context: vscode.ExtensionContext,
@ -119,7 +125,10 @@ export class ClineProvider implements vscode.WebviewViewProvider {
}
public static getVisibleInstance(): ClineProvider | undefined {
return findLast(Array.from(this.activeInstances), (instance) => instance.view?.visible === true)
return findLast(
Array.from(this.activeInstances),
(instance) => instance.view?.visible === true,
)
}
resolveWebviewView(
@ -152,7 +161,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 +175,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 +201,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,
@ -201,13 +219,22 @@ export class ClineProvider implements vscode.WebviewViewProvider {
async initClineWithTask(task?: string, images?: string[]) {
await this.clearTask() // ensures that an exising task doesn't exist before starting a new one, although this shouldn't be possible since user must clear task before starting a new one
const { apiConfiguration, customInstructions, autoApprovalSettings } = await this.getState()
this.cline = new Cline(this, apiConfiguration, autoApprovalSettings, customInstructions, task, images)
const { apiConfiguration, customInstructions, autoApprovalSettings } =
await this.getState()
this.cline = new Cline(
this,
apiConfiguration,
autoApprovalSettings,
customInstructions,
task,
images,
)
}
async initClineWithHistoryItem(historyItem: HistoryItem) {
await this.clearTask()
const { apiConfiguration, customInstructions, autoApprovalSettings } = await this.getState()
const { apiConfiguration, customInstructions, autoApprovalSettings } =
await this.getState()
this.cline = new Cline(
this,
apiConfiguration,
@ -248,7 +275,13 @@ export class ClineProvider implements vscode.WebviewViewProvider {
"main.css",
])
// The JS file from the React build output
const scriptUri = getUri(webview, this.context.extensionUri, ["webview-ui", "build", "static", "js", "main.js"])
const scriptUri = getUri(webview, this.context.extensionUri, [
"webview-ui",
"build",
"static",
"js",
"main.js",
])
// The codicon font from the React build output
// https://github.com/microsoft/vscode-extension-samples/blob/main/webview-codicons-sample/src/extension.ts
@ -319,30 +352,42 @@ export class ClineProvider implements vscode.WebviewViewProvider {
this.postStateToWebview()
this.workspaceTracker?.initializeFilePaths() // don't await
getTheme().then((theme) =>
this.postMessageToWebview({ type: "theme", text: JSON.stringify(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.
// we do this for all users since many users switch between api providers and if they were to switch back to openrouter it would be showing outdated model info if we hadn't retrieved the latest at this point
// (see normalizeApiConfiguration > openrouter)
this.refreshOpenRouterModels().then(async (openRouterModels) => {
if (openRouterModels) {
// update model info in state (this needs to be done here since we don't want to update state while settings is open, and we may refresh models there)
const { apiConfiguration } = await this.getState()
if (apiConfiguration.openRouterModelId) {
await this.updateGlobalState(
"openRouterModelInfo",
openRouterModels[apiConfiguration.openRouterModelId],
)
await this.postStateToWebview()
this.refreshOpenRouterModels().then(
async (openRouterModels) => {
if (openRouterModels) {
// update model info in state (this needs to be done here since we don't want to update state while settings is open, and we may refresh models there)
const { apiConfiguration } =
await this.getState()
if (apiConfiguration.openRouterModelId) {
await this.updateGlobalState(
"openRouterModelInfo",
openRouterModels[
apiConfiguration
.openRouterModelId
],
)
await this.postStateToWebview()
}
}
}
})
},
)
break
case "newTask":
// Code that should run in response to the hello message command
@ -353,7 +398,10 @@ export class ClineProvider implements vscode.WebviewViewProvider {
// Could also do this in extension .ts
//this.postMessageToWebview({ type: "text", text: `Extension: ${Date.now()}` })
// initializing new instance of Cline will make sure that any agentically running promises in old instance don't affect our new task. this essentially creates a fresh slate for the new task
await this.initClineWithTask(message.text, message.images)
await this.initClineWithTask(
message.text,
message.images,
)
break
case "apiConfiguration":
if (message.apiConfiguration) {
@ -384,33 +432,92 @@ export class ClineProvider implements vscode.WebviewViewProvider {
openRouterModelId,
openRouterModelInfo,
} = message.apiConfiguration
await this.updateGlobalState("apiProvider", apiProvider)
await this.updateGlobalState("apiModelId", apiModelId)
await this.updateGlobalState(
"apiProvider",
apiProvider,
)
await this.updateGlobalState(
"apiModelId",
apiModelId,
)
await this.storeSecret("apiKey", apiKey)
await this.storeSecret("openRouterApiKey", openRouterApiKey)
await this.storeSecret(
"openRouterApiKey",
openRouterApiKey,
)
await this.storeSecret("awsAccessKey", awsAccessKey)
await this.storeSecret("awsSecretKey", awsSecretKey)
await this.storeSecret("awsSessionToken", awsSessionToken)
await this.storeSecret(
"awsSessionToken",
awsSessionToken,
)
await this.updateGlobalState("awsRegion", awsRegion)
await this.updateGlobalState("awsUseCrossRegionInference", awsUseCrossRegionInference)
await this.updateGlobalState("vertexProjectId", vertexProjectId)
await this.updateGlobalState("vertexRegion", vertexRegion)
await this.updateGlobalState("openAiBaseUrl", openAiBaseUrl)
await this.updateGlobalState(
"awsUseCrossRegionInference",
awsUseCrossRegionInference,
)
await this.updateGlobalState(
"vertexProjectId",
vertexProjectId,
)
await this.updateGlobalState(
"vertexRegion",
vertexRegion,
)
await this.updateGlobalState(
"openAiBaseUrl",
openAiBaseUrl,
)
await this.storeSecret("openAiApiKey", openAiApiKey)
await this.updateGlobalState("openAiModelId", openAiModelId)
await this.updateGlobalState("ollamaModelId", ollamaModelId)
await this.updateGlobalState("ollamaBaseUrl", ollamaBaseUrl)
await this.updateGlobalState("lmStudioModelId", lmStudioModelId)
await this.updateGlobalState("lmStudioBaseUrl", lmStudioBaseUrl)
await this.updateGlobalState("anthropicBaseUrl", anthropicBaseUrl)
await this.updateGlobalState(
"openAiModelId",
openAiModelId,
)
await this.updateGlobalState(
"ollamaModelId",
ollamaModelId,
)
await this.updateGlobalState(
"ollamaBaseUrl",
ollamaBaseUrl,
)
await this.updateGlobalState(
"lmStudioModelId",
lmStudioModelId,
)
await this.updateGlobalState(
"lmStudioBaseUrl",
lmStudioBaseUrl,
)
await this.updateGlobalState(
"anthropicBaseUrl",
anthropicBaseUrl,
)
await this.storeSecret("geminiApiKey", geminiApiKey)
await this.storeSecret("openAiNativeApiKey", openAiNativeApiKey)
await this.storeSecret("deepSeekApiKey", deepSeekApiKey)
await this.updateGlobalState("azureApiVersion", azureApiVersion)
await this.updateGlobalState("openRouterModelId", openRouterModelId)
await this.updateGlobalState("openRouterModelInfo", openRouterModelInfo)
await this.storeSecret(
"openAiNativeApiKey",
openAiNativeApiKey,
)
await this.storeSecret(
"deepSeekApiKey",
deepSeekApiKey,
)
await this.updateGlobalState(
"azureApiVersion",
azureApiVersion,
)
await this.updateGlobalState(
"openRouterModelId",
openRouterModelId,
)
await this.updateGlobalState(
"openRouterModelInfo",
openRouterModelInfo,
)
if (this.cline) {
this.cline.api = buildApiHandler(message.apiConfiguration)
this.cline.api = buildApiHandler(
message.apiConfiguration,
)
}
}
await this.postStateToWebview()
@ -420,15 +527,23 @@ export class ClineProvider implements vscode.WebviewViewProvider {
break
case "autoApprovalSettings":
if (message.autoApprovalSettings) {
await this.updateGlobalState("autoApprovalSettings", message.autoApprovalSettings)
await this.updateGlobalState(
"autoApprovalSettings",
message.autoApprovalSettings,
)
if (this.cline) {
this.cline.autoApprovalSettings = message.autoApprovalSettings
this.cline.autoApprovalSettings =
message.autoApprovalSettings
}
await this.postStateToWebview()
}
break
case "askResponse":
this.cline?.handleWebviewAskResponse(message.askResponse!, message.text, message.images)
this.cline?.handleWebviewAskResponse(
message.askResponse!,
message.text,
message.images,
)
break
case "clearTask":
// newTask will start a new task with a given task text, while clear task resets the current session and allows for a new task to be started
@ -436,12 +551,18 @@ export class ClineProvider implements vscode.WebviewViewProvider {
await this.postStateToWebview()
break
case "didShowAnnouncement":
await this.updateGlobalState("lastShownAnnouncementId", this.latestAnnouncementId)
await this.updateGlobalState(
"lastShownAnnouncementId",
this.latestAnnouncementId,
)
await this.postStateToWebview()
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
@ -462,12 +583,22 @@ export class ClineProvider implements vscode.WebviewViewProvider {
await this.resetState()
break
case "requestOllamaModels":
const ollamaModels = await this.getOllamaModels(message.text)
this.postMessageToWebview({ type: "ollamaModels", ollamaModels })
const ollamaModels = await this.getOllamaModels(
message.text,
)
this.postMessageToWebview({
type: "ollamaModels",
ollamaModels,
})
break
case "requestLmStudioModels":
const lmStudioModels = await this.getLmStudioModels(message.text)
this.postMessageToWebview({ type: "lmStudioModels", lmStudioModels })
const lmStudioModels = await this.getLmStudioModels(
message.text,
)
this.postMessageToWebview({
type: "lmStudioModels",
lmStudioModels,
})
break
case "refreshOpenRouterModels":
await this.refreshOpenRouterModels()
@ -481,26 +612,53 @@ 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, {
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
case "checkpointDiff": {
if (message.number) {
await this.cline?.presentMultifileDiff(
message.number,
false,
)
}
break
}
case "checkpointRestore": {
await this.cancelTask() // we cannot alter message history say if the task is active, as it could be in the middle of editing a file or running a command, which expect the ask to be responded to rather than being superceded by a new message eg add deleted_api_reqs
// cancel task waits for any open editor to be reverted and starts a new cline instance
if (message.number) {
// wait for messages to be loaded
await pWaitFor(
() => this.cline?.isInitialized === true,
{
timeout: 3_000,
},
).catch(() => {
console.error(
"Failed to init new cline instance",
)
})
// NOTE: cancelTask awaits abortTask, which awaits diffViewProvider.revertChanges, which reverts any edited files, allowing us to reset to a checkpoint rather than running into a state where the revertChanges function is called alongside or after the checkpoint reset
await this.cline?.restoreCheckpoint(
message.number,
message.text! as ClineCheckpointRestore,
)
}
break
}
case "taskCompletionViewChanges": {
if (message.number) {
await this.cline?.presentMultifileDiff(
message.number,
true,
)
}
break
}
case "cancelTask":
this.cancelTask()
break
case "openMcpSettings": {
const mcpSettingsFilePath = await this.mcpHub?.getMcpSettingsFilePath()
const mcpSettingsFilePath =
await this.mcpHub?.getMcpSettingsFilePath()
if (mcpSettingsFilePath) {
openFile(mcpSettingsFilePath)
}
@ -510,7 +668,10 @@ export class ClineProvider implements vscode.WebviewViewProvider {
try {
await this.mcpHub?.restartConnection(message.text!)
} catch (error) {
console.error(`Failed to retry connection for ${message.text}:`, error)
console.error(
`Failed to retry connection for ${message.text}:`,
error,
)
}
break
}
@ -523,9 +684,40 @@ 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)
await this.updateGlobalState(
"customInstructions",
instructions || undefined,
)
if (this.cline) {
this.cline.customInstructions = instructions || undefined
}
@ -535,7 +727,12 @@ export class ClineProvider implements vscode.WebviewViewProvider {
// MCP
async ensureMcpServersDirectoryExists(): Promise<string> {
const mcpServersDir = path.join(os.homedir(), "Documents", "Cline", "MCP")
const mcpServersDir = path.join(
os.homedir(),
"Documents",
"Cline",
"MCP",
)
try {
await fs.mkdir(mcpServersDir, { recursive: true })
} catch (error) {
@ -545,7 +742,10 @@ export class ClineProvider implements vscode.WebviewViewProvider {
}
async ensureSettingsDirectoryExists(): Promise<string> {
const settingsDir = path.join(this.context.globalStorageUri.fsPath, "settings")
const settingsDir = path.join(
this.context.globalStorageUri.fsPath,
"settings",
)
await fs.mkdir(settingsDir, { recursive: true })
return settingsDir
}
@ -561,7 +761,8 @@ export class ClineProvider implements vscode.WebviewViewProvider {
return []
}
const response = await axios.get(`${baseUrl}/api/tags`)
const modelsArray = response.data?.models?.map((model: any) => model.name) || []
const modelsArray =
response.data?.models?.map((model: any) => model.name) || []
const models = [...new Set<string>(modelsArray)]
return models
} catch (error) {
@ -580,7 +781,8 @@ export class ClineProvider implements vscode.WebviewViewProvider {
return []
}
const response = await axios.get(`${baseUrl}/v1/models`)
const modelsArray = response.data?.data?.map((model: any) => model.id) || []
const modelsArray =
response.data?.data?.map((model: any) => model.id) || []
const models = [...new Set<string>(modelsArray)]
return models
} catch (error) {
@ -593,7 +795,10 @@ export class ClineProvider implements vscode.WebviewViewProvider {
async handleOpenRouterCallback(code: string) {
let apiKey: string
try {
const response = await axios.post("https://openrouter.ai/api/v1/auth/keys", { code })
const response = await axios.post(
"https://openrouter.ai/api/v1/auth/keys",
{ code },
)
if (response.data && response.data.key) {
apiKey = response.data.key
} else {
@ -609,25 +814,36 @@ 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
}
private async ensureCacheDirectoryExists(): Promise<string> {
const cacheDir = path.join(this.context.globalStorageUri.fsPath, "cache")
const cacheDir = path.join(
this.context.globalStorageUri.fsPath,
"cache",
)
await fs.mkdir(cacheDir, { recursive: true })
return cacheDir
}
async readOpenRouterModels(): Promise<Record<string, ModelInfo> | undefined> {
async readOpenRouterModels(): Promise<
Record<string, ModelInfo> | undefined
> {
const openRouterModelsFilePath = path.join(
await this.ensureCacheDirectoryExists(),
GlobalFileNames.openRouterModels,
)
const fileExists = await fileExistsAtPath(openRouterModelsFilePath)
if (fileExists) {
const fileContents = await fs.readFile(openRouterModelsFilePath, "utf8")
const fileContents = await fs.readFile(
openRouterModelsFilePath,
"utf8",
)
return JSON.parse(fileContents)
}
return undefined
@ -641,7 +857,9 @@ export class ClineProvider implements vscode.WebviewViewProvider {
let models: Record<string, ModelInfo> = {}
try {
const response = await axios.get("https://openrouter.ai/api/v1/models")
const response = await axios.get(
"https://openrouter.ai/api/v1/models",
)
/*
{
"id": "anthropic/claude-3.5-sonnet",
@ -680,7 +898,8 @@ export class ClineProvider implements vscode.WebviewViewProvider {
const modelInfo: ModelInfo = {
maxTokens: rawModel.top_provider?.max_completion_tokens,
contextWindow: rawModel.context_length,
supportsImages: rawModel.architecture?.modality?.includes("image"),
supportsImages:
rawModel.architecture?.modality?.includes("image"),
supportsPromptCache: false,
inputPrice: parsePrice(rawModel.pricing?.prompt),
outputPrice: parsePrice(rawModel.pricing?.completion),
@ -746,7 +965,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
}
@ -759,15 +981,32 @@ export class ClineProvider implements vscode.WebviewViewProvider {
uiMessagesFilePath: string
apiConversationHistory: Anthropic.MessageParam[]
}> {
const history = ((await this.getGlobalState("taskHistory")) as HistoryItem[] | undefined) || []
const history =
((await this.getGlobalState("taskHistory")) as
| HistoryItem[]
| undefined) || []
const historyItem = history.find((item) => item.id === id)
if (historyItem) {
const taskDirPath = path.join(this.context.globalStorageUri.fsPath, "tasks", id)
const apiConversationHistoryFilePath = path.join(taskDirPath, GlobalFileNames.apiConversationHistory)
const uiMessagesFilePath = path.join(taskDirPath, GlobalFileNames.uiMessages)
const fileExists = await fileExistsAtPath(apiConversationHistoryFilePath)
const taskDirPath = path.join(
this.context.globalStorageUri.fsPath,
"tasks",
id,
)
const apiConversationHistoryFilePath = path.join(
taskDirPath,
GlobalFileNames.apiConversationHistory,
)
const uiMessagesFilePath = path.join(
taskDirPath,
GlobalFileNames.uiMessages,
)
const fileExists = await fileExistsAtPath(
apiConversationHistoryFilePath,
)
if (fileExists) {
const apiConversationHistory = JSON.parse(await fs.readFile(apiConversationHistoryFilePath, "utf8"))
const apiConversationHistory = JSON.parse(
await fs.readFile(apiConversationHistoryFilePath, "utf8"),
)
return {
historyItem,
taskDirPath,
@ -789,11 +1028,15 @@ 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) {
const { historyItem, apiConversationHistory } = await this.getTaskWithId(id)
const { historyItem, apiConversationHistory } =
await this.getTaskWithId(id)
await downloadTask(historyItem.ts, apiConversationHistory)
}
@ -802,12 +1045,18 @@ export class ClineProvider implements vscode.WebviewViewProvider {
await this.clearTask()
}
const { taskDirPath, apiConversationHistoryFilePath, uiMessagesFilePath } = await this.getTaskWithId(id)
const {
taskDirPath,
apiConversationHistoryFilePath,
uiMessagesFilePath,
} = await this.getTaskWithId(id)
await this.deleteTaskFromState(id)
// Delete the task files
const apiConversationHistoryFileExists = await fileExistsAtPath(apiConversationHistoryFilePath)
const apiConversationHistoryFileExists = await fileExistsAtPath(
apiConversationHistoryFilePath,
)
if (apiConversationHistoryFileExists) {
await fs.unlink(apiConversationHistoryFilePath)
}
@ -815,16 +1064,37 @@ export class ClineProvider implements vscode.WebviewViewProvider {
if (uiMessagesFileExists) {
await fs.unlink(uiMessagesFilePath)
}
const legacyMessagesFilePath = path.join(taskDirPath, "claude_messages.json")
const legacyMessagesFilePath = path.join(
taskDirPath,
"claude_messages.json",
)
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
}
async deleteTaskFromState(id: string) {
// Remove the task from history
const taskHistory = ((await this.getGlobalState("taskHistory")) as HistoryItem[] | undefined) || []
const taskHistory =
((await this.getGlobalState("taskHistory")) as
| HistoryItem[]
| undefined) || []
const updatedTaskHistory = taskHistory.filter((task) => task.id !== id)
await this.updateGlobalState("taskHistory", updatedTaskHistory)
@ -837,17 +1107,32 @@ export class ClineProvider implements vscode.WebviewViewProvider {
this.postMessageToWebview({ type: "state", state })
}
async getStateToPostToWebview() {
const { apiConfiguration, lastShownAnnouncementId, customInstructions, taskHistory, autoApprovalSettings } =
await this.getState()
async getStateToPostToWebview(): Promise<ExtensionState> {
const {
apiConfiguration,
lastShownAnnouncementId,
customInstructions,
taskHistory,
autoApprovalSettings,
} = await this.getState()
return {
version: this.context.extension?.packageJSON?.version ?? "",
apiConfiguration,
customInstructions,
uriScheme: vscode.env.uriScheme,
currentTaskItem: this.cline?.taskId
? (taskHistory || []).find(
(item) => item.id === this.cline?.taskId,
)
: undefined,
checkpointTrackerErrorMessage:
this.cline?.checkpointTrackerErrorMessage,
clineMessages: this.cline?.clineMessages || [],
taskHistory: (taskHistory || []).filter((item) => item.ts && item.task).sort((a, b) => b.ts - a.ts),
shouldShowAnnouncement: lastShownAnnouncementId !== this.latestAnnouncementId,
taskHistory: (taskHistory || [])
.filter((item) => item.ts && item.task)
.sort((a, b) => b.ts - a.ts),
shouldShowAnnouncement:
lastShownAnnouncementId !== this.latestAnnouncementId,
autoApprovalSettings,
}
}
@ -935,7 +1220,9 @@ export class ClineProvider implements vscode.WebviewViewProvider {
taskHistory,
autoApprovalSettings,
] = await Promise.all([
this.getGlobalState("apiProvider") as Promise<ApiProvider | undefined>,
this.getGlobalState("apiProvider") as Promise<
ApiProvider | undefined
>,
this.getGlobalState("apiModelId") as Promise<string | undefined>,
this.getSecret("apiKey") as Promise<string | undefined>,
this.getSecret("openRouterApiKey") as Promise<string | undefined>,
@ -943,27 +1230,51 @@ export class ClineProvider implements vscode.WebviewViewProvider {
this.getSecret("awsSecretKey") as Promise<string | undefined>,
this.getSecret("awsSessionToken") as Promise<string | undefined>,
this.getGlobalState("awsRegion") as Promise<string | undefined>,
this.getGlobalState("awsUseCrossRegionInference") as Promise<boolean | undefined>,
this.getGlobalState("vertexProjectId") as Promise<string | undefined>,
this.getGlobalState("awsUseCrossRegionInference") as Promise<
boolean | undefined
>,
this.getGlobalState("vertexProjectId") as Promise<
string | undefined
>,
this.getGlobalState("vertexRegion") as Promise<string | undefined>,
this.getGlobalState("openAiBaseUrl") as Promise<string | undefined>,
this.getSecret("openAiApiKey") as Promise<string | undefined>,
this.getGlobalState("openAiModelId") as Promise<string | undefined>,
this.getGlobalState("ollamaModelId") as Promise<string | undefined>,
this.getGlobalState("ollamaBaseUrl") as Promise<string | undefined>,
this.getGlobalState("lmStudioModelId") as Promise<string | undefined>,
this.getGlobalState("lmStudioBaseUrl") as Promise<string | undefined>,
this.getGlobalState("anthropicBaseUrl") as Promise<string | undefined>,
this.getGlobalState("lmStudioModelId") as Promise<
string | undefined
>,
this.getGlobalState("lmStudioBaseUrl") as Promise<
string | undefined
>,
this.getGlobalState("anthropicBaseUrl") as Promise<
string | undefined
>,
this.getSecret("geminiApiKey") as Promise<string | undefined>,
this.getSecret("openAiNativeApiKey") as Promise<string | undefined>,
this.getSecret("deepSeekApiKey") as Promise<string | undefined>,
this.getGlobalState("azureApiVersion") as Promise<string | undefined>,
this.getGlobalState("openRouterModelId") as Promise<string | undefined>,
this.getGlobalState("openRouterModelInfo") as Promise<ModelInfo | undefined>,
this.getGlobalState("lastShownAnnouncementId") as Promise<string | undefined>,
this.getGlobalState("customInstructions") as Promise<string | undefined>,
this.getGlobalState("taskHistory") as Promise<HistoryItem[] | undefined>,
this.getGlobalState("autoApprovalSettings") as Promise<AutoApprovalSettings | undefined>,
this.getGlobalState("azureApiVersion") as Promise<
string | undefined
>,
this.getGlobalState("openRouterModelId") as Promise<
string | undefined
>,
this.getGlobalState("openRouterModelInfo") as Promise<
ModelInfo | undefined
>,
this.getGlobalState("lastShownAnnouncementId") as Promise<
string | undefined
>,
this.getGlobalState("customInstructions") as Promise<
string | undefined
>,
this.getGlobalState("taskHistory") as Promise<
HistoryItem[] | undefined
>,
this.getGlobalState("autoApprovalSettings") as Promise<
AutoApprovalSettings | undefined
>,
])
let apiProvider: ApiProvider
@ -1011,12 +1322,14 @@ export class ClineProvider implements vscode.WebviewViewProvider {
lastShownAnnouncementId,
customInstructions,
taskHistory,
autoApprovalSettings: autoApprovalSettings || DEFAULT_AUTO_APPROVAL_SETTINGS, // default value can be 0 or empty string
autoApprovalSettings:
autoApprovalSettings || DEFAULT_AUTO_APPROVAL_SETTINGS, // default value can be 0 or empty string
}
}
async updateTaskHistory(item: HistoryItem): Promise<HistoryItem[]> {
const history = ((await this.getGlobalState("taskHistory")) as HistoryItem[]) || []
const history =
((await this.getGlobalState("taskHistory")) as HistoryItem[]) || []
const existingItemIndex = history.findIndex((h) => h.id === item.id)
if (existingItemIndex !== -1) {
history[existingItemIndex] = item
@ -1098,6 +1411,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

@ -8,7 +8,8 @@
*/
export function getNonce() {
let text = ""
const possible = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
const possible =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
for (let i = 0; i < 32; i++) {
text += possible.charAt(Math.floor(Math.random() * possible.length))
}

View file

@ -10,6 +10,10 @@ import { Uri, Webview } from "vscode"
* @param pathList An array of strings representing the path to a file/resource
* @returns A URI pointing to the file/resource
*/
export function getUri(webview: Webview, extensionUri: Uri, pathList: string[]) {
export function getUri(
webview: Webview,
extensionUri: Uri,
pathList: string[],
) {
return webview.asWebviewUri(Uri.joinPath(extensionUri, ...pathList))
}

View file

@ -7,7 +7,9 @@ The Cline extension exposes an API that can be used by other extensions. To use
3. Get access to the API with the following code:
```ts
const clineExtension = vscode.extensions.getExtension<ClineAPI>("saoudrizwan.claude-dev")
const clineExtension = vscode.extensions.getExtension<ClineAPI>(
"saoudrizwan.claude-dev",
)
if (!clineExtension?.isActive) {
throw new Error("Cline extension is not activated")
@ -29,7 +31,9 @@ The Cline extension exposes an API that can be used by other extensions. To use
await cline.startNewTask("Hello, Cline! Let's make a new project...")
// Start a new task with an initial message and images
await cline.startNewTask("Use this design language", ["data:image/webp;base64,..."])
await cline.startNewTask("Use this design language", [
"data:image/webp;base64,...",
])
// Send a message to the current task
await cline.sendMessage("Can you fix the @problems?")

View file

@ -2,7 +2,10 @@ import * as vscode from "vscode"
import { ClineProvider } from "../core/webview/ClineProvider"
import { ClineAPI } from "./cline"
export function createClineAPI(outputChannel: vscode.OutputChannel, sidebarProvider: ClineProvider): ClineAPI {
export function createClineAPI(
outputChannel: vscode.OutputChannel,
sidebarProvider: ClineProvider,
): ClineAPI {
const api: ClineAPI = {
setCustomInstructions: async (value: string) => {
await sidebarProvider.updateCustomInstructions(value)
@ -10,14 +13,19 @@ export function createClineAPI(outputChannel: vscode.OutputChannel, sidebarProvi
},
getCustomInstructions: async () => {
return (await sidebarProvider.getGlobalState("customInstructions")) as string | undefined
return (await sidebarProvider.getGlobalState(
"customInstructions",
)) as string | undefined
},
startNewTask: async (task?: string, images?: string[]) => {
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

@ -29,9 +29,13 @@ export function activate(context: vscode.ExtensionContext) {
const sidebarProvider = new ClineProvider(context, outputChannel)
context.subscriptions.push(
vscode.window.registerWebviewViewProvider(ClineProvider.sideBarId, sidebarProvider, {
webviewOptions: { retainContextWhenHidden: true },
}),
vscode.window.registerWebviewViewProvider(
ClineProvider.sideBarId,
sidebarProvider,
{
webviewOptions: { retainContextWhenHidden: true },
},
),
)
context.subscriptions.push(
@ -39,13 +43,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",
})
}),
)
@ -55,25 +65,48 @@ export function activate(context: vscode.ExtensionContext) {
// https://github.com/microsoft/vscode-extension-samples/blob/main/webview-sample/src/extension.ts
const tabProvider = new ClineProvider(context, outputChannel)
//const column = vscode.window.activeTextEditor ? vscode.window.activeTextEditor.viewColumn : undefined
const lastCol = Math.max(...vscode.window.visibleTextEditors.map((editor) => editor.viewColumn || 0))
const lastCol = Math.max(
...vscode.window.visibleTextEditors.map(
(editor) => editor.viewColumn || 0,
),
)
// Check if there are any visible text editors, otherwise open a new group to the right
const hasVisibleEditors = vscode.window.visibleTextEditors.length > 0
if (!hasVisibleEditors) {
await vscode.commands.executeCommand("workbench.action.newGroupRight")
await vscode.commands.executeCommand(
"workbench.action.newGroupRight",
)
}
const targetCol = hasVisibleEditors ? Math.max(lastCol + 1, 1) : vscode.ViewColumn.Two
const targetCol = hasVisibleEditors
? Math.max(lastCol + 1, 1)
: vscode.ViewColumn.Two
const panel = vscode.window.createWebviewPanel(ClineProvider.tabPanelId, "Cline", targetCol, {
enableScripts: true,
retainContextWhenHidden: true,
localResourceRoots: [context.extensionUri],
})
const panel = vscode.window.createWebviewPanel(
ClineProvider.tabPanelId,
"Cline",
targetCol,
{
enableScripts: true,
retainContextWhenHidden: true,
localResourceRoots: [context.extensionUri],
},
)
// TODO: use better svg icon with light and dark variants (see https://stackoverflow.com/questions/58365687/vscode-extension-iconpath)
panel.iconPath = {
light: vscode.Uri.joinPath(context.extensionUri, "assets", "icons", "robot_panel_light.png"),
dark: vscode.Uri.joinPath(context.extensionUri, "assets", "icons", "robot_panel_dark.png"),
light: vscode.Uri.joinPath(
context.extensionUri,
"assets",
"icons",
"robot_panel_light.png",
),
dark: vscode.Uri.joinPath(
context.extensionUri,
"assets",
"icons",
"robot_panel_dark.png",
),
}
tabProvider.resolveWebviewView(panel)
@ -82,19 +115,35 @@ export function activate(context: vscode.ExtensionContext) {
await vscode.commands.executeCommand("workbench.action.lockEditorGroup")
}
context.subscriptions.push(vscode.commands.registerCommand("cline.popoutButtonClicked", openClineInNewTab))
context.subscriptions.push(vscode.commands.registerCommand("cline.openInNewTab", openClineInNewTab))
context.subscriptions.push(
vscode.commands.registerCommand(
"cline.popoutButtonClicked",
openClineInNewTab,
),
)
context.subscriptions.push(
vscode.commands.registerCommand(
"cline.openInNewTab",
openClineInNewTab,
),
)
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",
})
}),
)
@ -105,13 +154,18 @@ export function activate(context: vscode.ExtensionContext) {
- Note how the provider doesn't create uris for virtual documents - its role is to provide contents given such an uri. In return, content providers are wired into the open document logic so that providers are always considered.
https://code.visualstudio.com/api/extension-guides/virtual-documents
*/
const diffContentProvider = new (class implements vscode.TextDocumentContentProvider {
const diffContentProvider = new (class
implements vscode.TextDocumentContentProvider
{
provideTextDocumentContent(uri: vscode.Uri): string {
return Buffer.from(uri.query, "base64").toString("utf-8")
}
})()
context.subscriptions.push(
vscode.workspace.registerTextDocumentContentProvider(DIFF_VIEW_URI_SCHEME, diffContentProvider),
vscode.workspace.registerTextDocumentContentProvider(
DIFF_VIEW_URI_SCHEME,
diffContentProvider,
),
)
// URI Handler

View file

@ -0,0 +1,435 @@
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

@ -11,7 +11,10 @@ export function getNewDiagnostics(
for (const [uri, newDiags] of newDiagnostics) {
const oldDiags = oldMap.get(uri) || []
const newProblemsForUri = newDiags.filter((newDiag) => !oldDiags.some((oldDiag) => deepEqual(oldDiag, newDiag)))
const newProblemsForUri = newDiags.filter(
(newDiag) =>
!oldDiags.some((oldDiag) => deepEqual(oldDiag, newDiag)),
)
if (newProblemsForUri.length > 0) {
newProblems.push([uri, newProblemsForUri])
@ -77,7 +80,9 @@ export function diagnosticsToProblemsString(
): string {
let result = ""
for (const [uri, fileDiagnostics] of diagnostics) {
const problems = fileDiagnostics.filter((d) => severities.includes(d.severity))
const problems = fileDiagnostics.filter((d) =>
severities.includes(d.severity),
)
if (problems.length > 0) {
result += `\n\n${path.relative(cwd, uri.fsPath).toPosix()}`
for (const diagnostic of problems) {

View file

@ -1,10 +1,12 @@
import * as vscode from "vscode"
const fadedOverlayDecorationType = vscode.window.createTextEditorDecorationType({
backgroundColor: "rgba(255, 255, 0, 0.1)",
opacity: "0.4",
isWholeLine: true,
})
const fadedOverlayDecorationType = vscode.window.createTextEditorDecorationType(
{
backgroundColor: "rgba(255, 255, 0, 0.1)",
opacity: "0.4",
isWholeLine: true,
},
)
const activeLineDecorationType = vscode.window.createTextEditorDecorationType({
backgroundColor: "rgba(255, 255, 0, 0.3)",
@ -42,10 +44,20 @@ export class DecorationController {
const lastRange = this.ranges[this.ranges.length - 1]
if (lastRange && lastRange.end.line === startIndex - 1) {
this.ranges[this.ranges.length - 1] = lastRange.with(undefined, lastRange.end.translate(numLines))
this.ranges[this.ranges.length - 1] = lastRange.with(
undefined,
lastRange.end.translate(numLines),
)
} else {
const endLine = startIndex + numLines - 1
this.ranges.push(new vscode.Range(startIndex, 0, endLine, Number.MAX_SAFE_INTEGER))
this.ranges.push(
new vscode.Range(
startIndex,
0,
endLine,
Number.MAX_SAFE_INTEGER,
),
)
}
this.editor.setDecorations(this.getDecoration(), this.ranges)
@ -65,7 +77,10 @@ export class DecorationController {
this.ranges.push(
new vscode.Range(
new vscode.Position(line + 1, 0),
new vscode.Position(totalLines - 1, Number.MAX_SAFE_INTEGER),
new vscode.Position(
totalLines - 1,
Number.MAX_SAFE_INTEGER,
),
),
)
}

View file

@ -33,8 +33,8 @@ export class DiffViewProvider {
this.isEditing = true
// if the file is already open, ensure it's not dirty before getting its contents
if (fileExists) {
const existingDocument = vscode.workspace.textDocuments.find((doc) =>
arePathsEqual(doc.uri.fsPath, absolutePath),
const existingDocument = vscode.workspace.textDocuments.find(
(doc) => arePathsEqual(doc.uri.fsPath, absolutePath),
)
if (existingDocument && existingDocument.isDirty) {
await existingDocument.save()
@ -62,7 +62,9 @@ export class DiffViewProvider {
.map((tg) => tg.tabs)
.flat()
.filter(
(tab) => tab.input instanceof vscode.TabInputText && arePathsEqual(tab.input.uri.fsPath, absolutePath),
(tab) =>
tab.input instanceof vscode.TabInputText &&
arePathsEqual(tab.input.uri.fsPath, absolutePath),
)
for (const tab of tabs) {
if (!tab.isDirty) {
@ -71,16 +73,29 @@ export class DiffViewProvider {
this.documentWasOpen = true
}
this.activeDiffEditor = await this.openDiffEditor()
this.fadedOverlayController = new DecorationController("fadedOverlay", this.activeDiffEditor)
this.activeLineController = new DecorationController("activeLine", this.activeDiffEditor)
this.fadedOverlayController = new DecorationController(
"fadedOverlay",
this.activeDiffEditor,
)
this.activeLineController = new DecorationController(
"activeLine",
this.activeDiffEditor,
)
// Apply faded overlay to all lines initially
this.fadedOverlayController.addLines(0, this.activeDiffEditor.document.lineCount)
this.fadedOverlayController.addLines(
0,
this.activeDiffEditor.document.lineCount,
)
this.scrollEditorToLine(0) // will this crash for new files?
this.streamedLines = []
}
async update(accumulatedContent: string, isFinal: boolean) {
if (!this.relPath || !this.activeLineController || !this.fadedOverlayController) {
if (
!this.relPath ||
!this.activeLineController ||
!this.fadedOverlayController
) {
throw new Error("Required values not set")
}
this.newContent = accumulatedContent
@ -98,7 +113,10 @@ export class DiffViewProvider {
// Place cursor at the beginning of the diff editor to keep it out of the way of the stream animation
const beginningOfDocument = new vscode.Position(0, 0)
diffEditor.selection = new vscode.Selection(beginningOfDocument, beginningOfDocument)
diffEditor.selection = new vscode.Selection(
beginningOfDocument,
beginningOfDocument,
)
for (let i = 0; i < diffLines.length; i++) {
const currentLine = this.streamedLines.length + i
@ -106,12 +124,16 @@ export class DiffViewProvider {
// This is necessary (as compared to inserting one line at a time) to handle cases where html tags on previous lines are auto closed for example
const edit = new vscode.WorkspaceEdit()
const rangeToReplace = new vscode.Range(0, 0, currentLine + 1, 0)
const contentToReplace = accumulatedLines.slice(0, currentLine + 1).join("\n") + "\n"
const contentToReplace =
accumulatedLines.slice(0, currentLine + 1).join("\n") + "\n"
edit.replace(document.uri, rangeToReplace, contentToReplace)
await vscode.workspace.applyEdit(edit)
// Update decorations
this.activeLineController.setActiveLine(currentLine)
this.fadedOverlayController.updateOverlayAfterLine(currentLine, document.lineCount)
this.fadedOverlayController.updateOverlayAfterLine(
currentLine,
document.lineCount,
)
// Scroll to the current line
this.scrollEditorToLine(currentLine)
}
@ -121,7 +143,15 @@ export class DiffViewProvider {
// Handle any remaining lines if the new content is shorter than the original
if (this.streamedLines.length < document.lineCount) {
const edit = new vscode.WorkspaceEdit()
edit.delete(document.uri, new vscode.Range(this.streamedLines.length, 0, document.lineCount, 0))
edit.delete(
document.uri,
new vscode.Range(
this.streamedLines.length,
0,
document.lineCount,
0,
),
)
await vscode.workspace.applyEdit(edit)
}
// Add empty last line if original content had one
@ -166,7 +196,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()
/*
@ -195,14 +227,22 @@ export class DiffViewProvider {
this.cwd,
) // will be empty string if no errors
const newProblemsMessage =
newProblems.length > 0 ? `\n\nNew problems detected after saving the file:\n${newProblems}` : ""
newProblems.length > 0
? `\n\nNew problems detected after saving the file:\n${newProblems}`
: ""
// If the edited content has different EOL characters, we don't want to show a diff with all the EOL differences.
const newContentEOL = this.newContent.includes("\r\n") ? "\r\n" : "\n"
const normalizedPreSaveContent = preSaveContent.replace(/\r\n|\n/g, newContentEOL).trimEnd() + newContentEOL // trimEnd to fix issue where editor adds in extra new line automatically
const normalizedPostSaveContent = postSaveContent.replace(/\r\n|\n/g, newContentEOL).trimEnd() + newContentEOL // this is the final content we return to the model to use as the new baseline for future edits
const normalizedPreSaveContent =
preSaveContent.replace(/\r\n|\n/g, newContentEOL).trimEnd() +
newContentEOL // trimEnd to fix issue where editor adds in extra new line automatically
const normalizedPostSaveContent =
postSaveContent.replace(/\r\n|\n/g, newContentEOL).trimEnd() +
newContentEOL // this is the final content we return to the model to use as the new baseline for future edits
// just in case the new content has a mix of varying EOL characters
const normalizedNewContent = this.newContent.replace(/\r\n|\n/g, newContentEOL).trimEnd() + newContentEOL
const normalizedNewContent =
this.newContent.replace(/\r\n|\n/g, newContentEOL).trimEnd() +
newContentEOL
let userEdits: string | undefined
if (normalizedPreSaveContent !== normalizedNewContent) {
@ -228,7 +268,12 @@ export class DiffViewProvider {
)
}
return { newProblemsMessage, userEdits, autoFormattingEdits, finalContent: normalizedPostSaveContent }
return {
newProblemsMessage,
userEdits,
autoFormattingEdits,
finalContent: normalizedPostSaveContent,
}
}
async revertChanges(): Promise<void> {
@ -247,7 +292,9 @@ export class DiffViewProvider {
// Remove only the directories we created, in reverse order
for (let i = this.createdDirs.length - 1; i >= 0; i--) {
await fs.rmdir(this.createdDirs[i])
console.log(`Directory ${this.createdDirs[i]} has been deleted.`)
console.log(
`Directory ${this.createdDirs[i]} has been deleted.`,
)
}
console.log(`File ${absolutePath} has been deleted.`)
} else {
@ -257,15 +304,24 @@ export class DiffViewProvider {
updatedDocument.positionAt(0),
updatedDocument.positionAt(updatedDocument.getText().length),
)
edit.replace(updatedDocument.uri, fullRange, this.originalContent ?? "")
edit.replace(
updatedDocument.uri,
fullRange,
this.originalContent ?? "",
)
// Apply the edit and save, since contents shouldnt have changed this wont show in local history unless of course the user made changes and saved during the edit
await vscode.workspace.applyEdit(edit)
await updatedDocument.save()
console.log(`File ${absolutePath} has been reverted to its original content.`)
console.log(
`File ${absolutePath} has been reverted to its original content.`,
)
if (this.documentWasOpen) {
await vscode.window.showTextDocument(vscode.Uri.file(absolutePath), {
preview: false,
})
await vscode.window.showTextDocument(
vscode.Uri.file(absolutePath),
{
preview: false,
},
)
}
await this.closeAllDiffViews()
}
@ -305,23 +361,32 @@ export class DiffViewProvider {
arePathsEqual(tab.input.modified.fsPath, uri.fsPath),
)
if (diffTab && diffTab.input instanceof vscode.TabInputTextDiff) {
const editor = await vscode.window.showTextDocument(diffTab.input.modified)
const editor = await vscode.window.showTextDocument(
diffTab.input.modified,
)
return editor
}
// Open new diff editor
return new Promise<vscode.TextEditor>((resolve, reject) => {
const fileName = path.basename(uri.fsPath)
const fileExists = this.editType === "modify"
const disposable = vscode.window.onDidChangeActiveTextEditor((editor) => {
if (editor && arePathsEqual(editor.document.uri.fsPath, uri.fsPath)) {
disposable.dispose()
resolve(editor)
}
})
const disposable = vscode.window.onDidChangeActiveTextEditor(
(editor) => {
if (
editor &&
arePathsEqual(editor.document.uri.fsPath, uri.fsPath)
) {
disposable.dispose()
resolve(editor)
}
},
)
vscode.commands.executeCommand(
"vscode.diff",
vscode.Uri.parse(`${DIFF_VIEW_URI_SCHEME}:${fileName}`).with({
query: Buffer.from(this.originalContent ?? "").toString("base64"),
query: Buffer.from(this.originalContent ?? "").toString(
"base64",
),
}),
uri,
`${fileName}: ${fileExists ? "Original ↔ Cline's Changes" : "New File"} (Editable)`,
@ -329,7 +394,11 @@ export class DiffViewProvider {
// This may happen on very slow machines ie project idx
setTimeout(() => {
disposable.dispose()
reject(new Error("Failed to open diff editor, please try again..."))
reject(
new Error(
"Failed to open diff editor, please try again...",
),
)
}, 10_000)
})
}

View file

@ -6,10 +6,21 @@ import * as vscode from "vscode"
* @param newFileContent The new content of the file to check.
* @returns True if a potential omission is detected, false otherwise.
*/
function detectCodeOmission(originalFileContent: string, newFileContent: string): boolean {
function detectCodeOmission(
originalFileContent: string,
newFileContent: string,
): boolean {
const originalLines = originalFileContent.split("\n")
const newLines = newFileContent.split("\n")
const omissionKeywords = ["remain", "remains", "unchanged", "rest", "previous", "existing", "..."]
const omissionKeywords = [
"remain",
"remains",
"unchanged",
"rest",
"previous",
"existing",
"...",
]
const commentPatterns = [
/^\s*\/\//, // Single-line comment for most languages
@ -38,7 +49,10 @@ function detectCodeOmission(originalFileContent: string, newFileContent: string)
* @param originalFileContent The original content of the file.
* @param newFileContent The new content of the file to check.
*/
export function showOmissionWarning(originalFileContent: string, newFileContent: string): void {
export function showOmissionWarning(
originalFileContent: string,
newFileContent: string,
): void {
if (detectCodeOmission(originalFileContent, newFileContent)) {
vscode.window
.showWarningMessage(

View file

@ -3,7 +3,10 @@ import os from "os"
import * as path from "path"
import * as vscode from "vscode"
export async function downloadTask(dateTs: number, conversationHistory: Anthropic.MessageParam[]) {
export async function downloadTask(
dateTs: number,
conversationHistory: Anthropic.MessageParam[],
) {
// File name
const date = new Date(dateTs)
const month = date.toLocaleString("en-US", { month: "short" }).toLowerCase()
@ -20,9 +23,12 @@ export async function downloadTask(dateTs: number, conversationHistory: Anthropi
// Generate markdown
const markdownContent = conversationHistory
.map((message) => {
const role = message.role === "user" ? "**User:**" : "**Assistant:**"
const role =
message.role === "user" ? "**User:**" : "**Assistant:**"
const content = Array.isArray(message.content)
? message.content.map((block) => formatContentBlockToMarkdown(block)).join("\n")
? message.content
.map((block) => formatContentBlockToMarkdown(block))
.join("\n")
: message.content
return `${role}\n\n${content}\n\n`
})
@ -31,12 +37,17 @@ export async function downloadTask(dateTs: number, conversationHistory: Anthropi
// Prompt user for save location
const saveUri = await vscode.window.showSaveDialog({
filters: { Markdown: ["md"] },
defaultUri: vscode.Uri.file(path.join(os.homedir(), "Downloads", fileName)),
defaultUri: vscode.Uri.file(
path.join(os.homedir(), "Downloads", fileName),
),
})
if (saveUri) {
// Write content to the selected location
await vscode.workspace.fs.writeFile(saveUri, Buffer.from(markdownContent))
await vscode.workspace.fs.writeFile(
saveUri,
Buffer.from(markdownContent),
)
vscode.window.showTextDocument(saveUri, { preview: true })
}
}
@ -58,7 +69,10 @@ export function formatContentBlockToMarkdown(
let input: string
if (typeof block.input === "object" && block.input !== null) {
input = Object.entries(block.input)
.map(([key, value]) => `${key.charAt(0).toUpperCase() + key.slice(1)}: ${value}`)
.map(
([key, value]) =>
`${key.charAt(0).toUpperCase() + key.slice(1)}: ${value}`,
)
.join("\n")
} else {
input = String(block.input)
@ -72,7 +86,9 @@ export function formatContentBlockToMarkdown(
return `[${toolName}${block.is_error ? " (Error)" : ""}]\n${block.content}`
} else if (Array.isArray(block.content)) {
return `[${toolName}${block.is_error ? " (Error)" : ""}]\n${block.content
.map((contentBlock) => formatContentBlockToMarkdown(contentBlock))
.map((contentBlock) =>
formatContentBlockToMarkdown(contentBlock),
)
.join("\n")}`
} else {
return `[${toolName}${block.is_error ? " (Error)" : ""}]`
@ -82,7 +98,10 @@ export function formatContentBlockToMarkdown(
}
}
export function findToolName(toolCallId: string, messages: Anthropic.MessageParam[]): string {
export function findToolName(
toolCallId: string,
messages: Anthropic.MessageParam[],
): string {
for (const message of messages) {
if (Array.isArray(message.content)) {
for (const block of message.content) {

View file

@ -24,7 +24,9 @@ export async function extractTextFromFile(filePath: string): Promise<string> {
if (!isBinary) {
return await fs.readFile(filePath, "utf8")
} else {
throw new Error(`Cannot read text for file type: ${fileExtension}`)
throw new Error(
`Cannot read text for file type: ${fileExtension}`,
)
}
}
}
@ -46,7 +48,10 @@ async function extractTextFromIPYNB(filePath: string): Promise<string> {
let extractedText = ""
for (const cell of notebook.cells) {
if ((cell.cell_type === "markdown" || cell.cell_type === "code") && cell.source) {
if (
(cell.cell_type === "markdown" || cell.cell_type === "code") &&
cell.source
) {
extractedText += cell.source.join("\n") + "\n"
}
}

View file

@ -11,10 +11,19 @@ export async function openImage(dataUri: string) {
}
const [, format, base64Data] = matches
const imageBuffer = Buffer.from(base64Data, "base64")
const tempFilePath = path.join(os.tmpdir(), `temp_image_${Date.now()}.${format}`)
const tempFilePath = path.join(
os.tmpdir(),
`temp_image_${Date.now()}.${format}`,
)
try {
await vscode.workspace.fs.writeFile(vscode.Uri.file(tempFilePath), imageBuffer)
await vscode.commands.executeCommand("vscode.open", vscode.Uri.file(tempFilePath))
await vscode.workspace.fs.writeFile(
vscode.Uri.file(tempFilePath),
imageBuffer,
)
await vscode.commands.executeCommand(
"vscode.open",
vscode.Uri.file(tempFilePath),
)
} catch (error) {
vscode.window.showErrorMessage(`Error opening image: ${error}`)
}
@ -29,14 +38,20 @@ export async function openFile(absolutePath: string) {
for (const group of vscode.window.tabGroups.all) {
const existingTab = group.tabs.find(
(tab) =>
tab.input instanceof vscode.TabInputText && arePathsEqual(tab.input.uri.fsPath, uri.fsPath),
tab.input instanceof vscode.TabInputText &&
arePathsEqual(tab.input.uri.fsPath, uri.fsPath),
)
if (existingTab) {
const activeColumn = vscode.window.activeTextEditor?.viewColumn
const tabColumn = vscode.window.tabGroups.all.find((group) =>
group.tabs.includes(existingTab),
const activeColumn =
vscode.window.activeTextEditor?.viewColumn
const tabColumn = vscode.window.tabGroups.all.find(
(group) => group.tabs.includes(existingTab),
)?.viewColumn
if (activeColumn && activeColumn !== tabColumn && !existingTab.isDirty) {
if (
activeColumn &&
activeColumn !== tabColumn &&
!existingTab.isDirty
) {
await vscode.window.tabGroups.close(existingTab)
}
break

View file

@ -7,7 +7,9 @@ interface NotificationOptions {
message: string
}
async function showMacOSNotification(options: NotificationOptions): Promise<void> {
async function showMacOSNotification(
options: NotificationOptions,
): Promise<void> {
const { title, subtitle = "", message } = options
const script = `display notification "${message}" with title "${title}" subtitle "${subtitle}" sound name "Tink"`
@ -19,7 +21,9 @@ async function showMacOSNotification(options: NotificationOptions): Promise<void
}
}
async function showWindowsNotification(options: NotificationOptions): Promise<void> {
async function showWindowsNotification(
options: NotificationOptions,
): Promise<void> {
const { subtitle, message } = options
const script = `
@ -50,7 +54,9 @@ async function showWindowsNotification(options: NotificationOptions): Promise<vo
}
}
async function showLinuxNotification(options: NotificationOptions): Promise<void> {
async function showLinuxNotification(
options: NotificationOptions,
): Promise<void> {
const { title = "", subtitle = "", message } = options
// Combine subtitle and message if subtitle exists
@ -63,7 +69,9 @@ async function showLinuxNotification(options: NotificationOptions): Promise<void
}
}
export async function showSystemNotification(options: NotificationOptions): Promise<void> {
export async function showSystemNotification(
options: NotificationOptions,
): Promise<void> {
try {
const { title = "Cline", message } = options

View file

@ -1,7 +1,11 @@
import pWaitFor from "p-wait-for"
import * as vscode from "vscode"
import { arePathsEqual } from "../../utils/path"
import { mergePromise, TerminalProcess, TerminalProcessResultPromise } from "./TerminalProcess"
import {
mergePromise,
TerminalProcess,
TerminalProcessResultPromise,
} from "./TerminalProcess"
import { TerminalInfo, TerminalRegistry } from "./TerminalRegistry"
/*
@ -97,7 +101,9 @@ export class TerminalManager {
constructor() {
let disposable: vscode.Disposable | undefined
try {
disposable = (vscode.window as vscode.Window).onDidStartTerminalShellExecution?.(async (e) => {
disposable = (
vscode.window as vscode.Window
).onDidStartTerminalShellExecution?.(async (e) => {
// Creating a read stream here results in a more consistent output. This is most obvious when running the `date` command.
e?.execution?.read()
})
@ -109,7 +115,10 @@ export class TerminalManager {
}
}
runCommand(terminalInfo: TerminalInfo, command: string): TerminalProcessResultPromise {
runCommand(
terminalInfo: TerminalInfo,
command: string,
): TerminalProcessResultPromise {
terminalInfo.busy = true
terminalInfo.lastCommand = command
const process = new TerminalProcess()
@ -121,7 +130,9 @@ export class TerminalManager {
// if shell integration is not available, remove terminal so it does not get reused as it may be running a long-running process
process.once("no_shell_integration", () => {
console.log(`no_shell_integration received for terminal ${terminalInfo.id}`)
console.log(
`no_shell_integration received for terminal ${terminalInfo.id}`,
)
// Remove the terminal so we can't reuse it (in case it's running a long-running process)
TerminalRegistry.removeTerminal(terminalInfo.id)
this.terminalIds.delete(terminalInfo.id)
@ -144,9 +155,15 @@ export class TerminalManager {
process.run(terminalInfo.terminal, command)
} else {
// docs recommend waiting 3s for shell integration to activate
pWaitFor(() => terminalInfo.terminal.shellIntegration !== undefined, { timeout: 4000 }).finally(() => {
pWaitFor(
() => terminalInfo.terminal.shellIntegration !== undefined,
{ timeout: 4000 },
).finally(() => {
const existingProcess = this.processes.get(terminalInfo.id)
if (existingProcess && existingProcess.waitForShellIntegration) {
if (
existingProcess &&
existingProcess.waitForShellIntegration
) {
existingProcess.waitForShellIntegration = false
existingProcess.run(terminalInfo.terminal, command)
}
@ -158,16 +175,21 @@ export class TerminalManager {
async getOrCreateTerminal(cwd: string): Promise<TerminalInfo> {
// Find available terminal from our pool first (created for this task)
const availableTerminal = TerminalRegistry.getAllTerminals().find((t) => {
if (t.busy) {
return false
}
const terminalCwd = t.terminal.shellIntegration?.cwd // one of cline's commands could have changed the cwd of the terminal
if (!terminalCwd) {
return false
}
return arePathsEqual(vscode.Uri.file(cwd).fsPath, terminalCwd.fsPath)
})
const availableTerminal = TerminalRegistry.getAllTerminals().find(
(t) => {
if (t.busy) {
return false
}
const terminalCwd = t.terminal.shellIntegration?.cwd // one of cline's commands could have changed the cwd of the terminal
if (!terminalCwd) {
return false
}
return arePathsEqual(
vscode.Uri.file(cwd).fsPath,
terminalCwd.fsPath,
)
},
)
if (availableTerminal) {
this.terminalIds.add(availableTerminal.id)
return availableTerminal
@ -181,7 +203,9 @@ export class TerminalManager {
getTerminals(busy: boolean): { id: number; lastCommand: string }[] {
return Array.from(this.terminalIds)
.map((id) => TerminalRegistry.getTerminal(id))
.filter((t): t is TerminalInfo => t !== undefined && t.busy === busy)
.filter(
(t): t is TerminalInfo => t !== undefined && t.busy === busy,
)
.map((t) => ({ id: t.id, lastCommand: t.lastCommand }))
}

View file

@ -27,7 +27,10 @@ export class TerminalProcess extends EventEmitter<TerminalProcessEvents> {
// super()
async run(terminal: vscode.Terminal, command: string) {
if (terminal.shellIntegration && terminal.shellIntegration.executeCommand) {
if (
terminal.shellIntegration &&
terminal.shellIntegration.executeCommand
) {
const execution = terminal.shellIntegration.executeCommand(command)
const stream = execution.read()
// todo: need to handle errors
@ -60,7 +63,9 @@ export class TerminalProcess extends EventEmitter<TerminalProcessEvents> {
// Once we've retrieved any potential output between sequences, we can remove everything up to end of the last sequence
// https://code.visualstudio.com/docs/terminal/shell-integration#_vs-code-custom-sequences-osc-633-st
const vscodeSequenceRegex = /\x1b\]633;.[^\x07]*\x07/g
const lastMatch = [...data.matchAll(vscodeSequenceRegex)].pop()
const lastMatch = [
...data.matchAll(vscodeSequenceRegex),
].pop()
if (lastMatch && lastMatch.index !== undefined) {
data = data.slice(lastMatch.index + lastMatch[0].length)
}
@ -77,7 +82,11 @@ export class TerminalProcess extends EventEmitter<TerminalProcessEvents> {
lines[0] = lines[0].replace(/[^\x20-\x7E]/g, "")
}
// Check if first two characters are the same, if so remove the first character
if (lines.length > 0 && lines[0].length >= 2 && lines[0][0] === lines[0][1]) {
if (
lines.length > 0 &&
lines[0].length >= 2 &&
lines[0][0] === lines[0][1]
) {
lines[0] = lines[0].slice(1)
}
// Remove everything up to the first alphanumeric character for first two lines
@ -120,7 +129,14 @@ export class TerminalProcess extends EventEmitter<TerminalProcessEvents> {
clearTimeout(this.hotTimer)
}
// these markers indicate the command is some kind of local dev server recompiling the app, which we want to wait for output of before sending request to cline
const compilingMarkers = ["compiling", "building", "bundling", "transpiling", "generating", "starting"]
const compilingMarkers = [
"compiling",
"building",
"bundling",
"transpiling",
"generating",
"starting",
]
const markerNullifiers = [
"compiled",
"success",
@ -136,13 +152,19 @@ export class TerminalProcess extends EventEmitter<TerminalProcessEvents> {
"fail",
]
const isCompiling =
compilingMarkers.some((marker) => data.toLowerCase().includes(marker.toLowerCase())) &&
!markerNullifiers.some((nullifier) => data.toLowerCase().includes(nullifier.toLowerCase()))
compilingMarkers.some((marker) =>
data.toLowerCase().includes(marker.toLowerCase()),
) &&
!markerNullifiers.some((nullifier) =>
data.toLowerCase().includes(nullifier.toLowerCase()),
)
this.hotTimer = setTimeout(
() => {
this.isHot = false
},
isCompiling ? PROCESS_HOT_TIMEOUT_COMPILING : PROCESS_HOT_TIMEOUT_NORMAL,
isCompiling
? PROCESS_HOT_TIMEOUT_COMPILING
: PROCESS_HOT_TIMEOUT_NORMAL,
)
// For non-immediately returning commands we want to show loading spinner right away but this wouldnt happen until it emits a line break, so as soon as we get any output we emit "" to let webview know to show spinner
@ -154,7 +176,8 @@ export class TerminalProcess extends EventEmitter<TerminalProcessEvents> {
this.fullOutput += data
if (this.isListening) {
this.emitIfEol(data)
this.lastRetrievedIndex = this.fullOutput.length - this.buffer.length
this.lastRetrievedIndex =
this.fullOutput.length - this.buffer.length
}
}
@ -237,10 +260,20 @@ export class TerminalProcess extends EventEmitter<TerminalProcessEvents> {
export type TerminalProcessResultPromise = TerminalProcess & Promise<void>
// Similar to execa's ResultPromise, this lets us create a mixin of both a TerminalProcess and a Promise: https://github.com/sindresorhus/execa/blob/main/lib/methods/promise.js
export function mergePromise(process: TerminalProcess, promise: Promise<void>): TerminalProcessResultPromise {
export function mergePromise(
process: TerminalProcess,
promise: Promise<void>,
): TerminalProcessResultPromise {
const nativePromisePrototype = (async () => {})().constructor.prototype
const descriptors = ["then", "catch", "finally"].map(
(property) => [property, Reflect.getOwnPropertyDescriptor(nativePromisePrototype, property)] as const,
(property) =>
[
property,
Reflect.getOwnPropertyDescriptor(
nativePromisePrototype,
property,
),
] as const,
)
for (const [property, descriptor] of descriptors) {
if (descriptor) {

View file

@ -50,7 +50,9 @@ export class TerminalRegistry {
}
static getAllTerminals(): TerminalInfo[] {
this.terminals = this.terminals.filter((t) => !this.isTerminalClosed(t.terminal))
this.terminals = this.terminals.filter(
(t) => !this.isTerminalClosed(t.terminal),
)
return this.terminals
}

View file

@ -154,7 +154,10 @@
}
},
{
"scope": ["keyword.operator.or.regexp", "keyword.control.anchor.regexp"],
"scope": [
"keyword.operator.or.regexp",
"keyword.control.anchor.regexp"
],
"settings": {
"foreground": "#DCDCAA"
}

View file

@ -345,7 +345,10 @@
}
},
{
"scope": ["punctuation.section.embedded.begin.php", "punctuation.section.embedded.end.php"],
"scope": [
"punctuation.section.embedded.begin.php",
"punctuation.section.embedded.end.php"
],
"settings": {
"foreground": "#569cd6"
}

View file

@ -410,7 +410,11 @@
},
{
"name": "Variable and parameter name",
"scope": ["variable", "meta.definition.variable.name", "support.variable"],
"scope": [
"variable",
"meta.definition.variable.name",
"support.variable"
],
"settings": {
"foreground": "#9CDCFE"
}

View file

@ -3,7 +3,11 @@
"name": "Light High Contrast",
"tokenColors": [
{
"scope": ["meta.embedded", "source.groovy.embedded", "variable.legacy.builtin.python"],
"scope": [
"meta.embedded",
"source.groovy.embedded",
"variable.legacy.builtin.python"
],
"settings": {
"foreground": "#292929"
}
@ -146,7 +150,10 @@
}
},
{
"scope": ["punctuation.definition.quote.begin.markdown", "punctuation.definition.list.begin.markdown"],
"scope": [
"punctuation.definition.quote.begin.markdown",
"punctuation.definition.list.begin.markdown"
],
"settings": {
"foreground": "#0451A5"
}
@ -328,7 +335,10 @@
}
},
{
"scope": ["punctuation.section.embedded.begin.php", "punctuation.section.embedded.end.php"],
"scope": [
"punctuation.section.embedded.begin.php",
"punctuation.section.embedded.end.php"
],
"settings": {
"foreground": "#0F4A85"
}
@ -509,7 +519,10 @@
}
},
{
"scope": ["keyword.operator.or.regexp", "keyword.control.anchor.regexp"],
"scope": [
"keyword.operator.or.regexp",
"keyword.control.anchor.regexp"
],
"settings": {
"foreground": "#EE0000"
}

View file

@ -160,7 +160,10 @@
}
},
{
"scope": ["keyword.operator.or.regexp", "keyword.control.anchor.regexp"],
"scope": [
"keyword.operator.or.regexp",
"keyword.control.anchor.regexp"
],
"settings": {
"foreground": "#EE0000"
}

View file

@ -185,7 +185,10 @@
}
},
{
"scope": ["punctuation.definition.quote.begin.markdown", "punctuation.definition.list.begin.markdown"],
"scope": [
"punctuation.definition.quote.begin.markdown",
"punctuation.definition.list.begin.markdown"
],
"settings": {
"foreground": "#0451a5"
}
@ -370,7 +373,10 @@
}
},
{
"scope": ["punctuation.section.embedded.begin.php", "punctuation.section.embedded.end.php"],
"scope": [
"punctuation.section.embedded.begin.php",
"punctuation.section.embedded.end.php"
],
"settings": {
"foreground": "#800000"
}

View file

@ -32,7 +32,10 @@ function parseThemeString(themeString: string | undefined): any {
export async function getTheme() {
let currentTheme = undefined
const colorTheme = vscode.workspace.getConfiguration("workbench").get<string>("colorTheme") || "Default Dark Modern"
const colorTheme =
vscode.workspace
.getConfiguration("workbench")
.get<string>("colorTheme") || "Default Dark Modern"
try {
for (let i = vscode.extensions.all.length - 1; i >= 0; i--) {
@ -43,7 +46,10 @@ export async function getTheme() {
if (extension.packageJSON?.contributes?.themes?.length > 0) {
for (const theme of extension.packageJSON.contributes.themes) {
if (theme.label === colorTheme) {
const themePath = path.join(extension.extensionPath, theme.path)
const themePath = path.join(
extension.extensionPath,
theme.path,
)
currentTheme = await fs.readFile(themePath, "utf-8")
break
}
@ -54,7 +60,14 @@ export async function getTheme() {
if (currentTheme === undefined && defaultThemes[colorTheme]) {
const filename = `${defaultThemes[colorTheme]}.json`
currentTheme = await fs.readFile(
path.join(getExtensionUri().fsPath, "src", "integrations", "theme", "default-themes", filename),
path.join(
getExtensionUri().fsPath,
"src",
"integrations",
"theme",
"default-themes",
filename,
),
"utf-8",
)
}
@ -64,7 +77,14 @@ export async function getTheme() {
if (parsed.include) {
const includeThemeString = await fs.readFile(
path.join(getExtensionUri().fsPath, "src", "integrations", "theme", "default-themes", parsed.include),
path.join(
getExtensionUri().fsPath,
"src",
"integrations",
"theme",
"default-themes",
parsed.include,
),
"utf-8",
)
const includeTheme = parseThemeString(includeThemeString)
@ -114,7 +134,11 @@ export function mergeJson(
// Merge keys are used to determine whether an item form the second object should override one from the first
const keptFromFirst: any[] = []
firstValue.forEach((item: any) => {
if (!secondValue.some((item2: any) => mergeKeys[key](item, item2))) {
if (
!secondValue.some((item2: any) =>
mergeKeys[key](item, item2),
)
) {
keptFromFirst.push(item)
}
})
@ -122,9 +146,16 @@ export function mergeJson(
} else {
copyOfFirst[key] = [...firstValue, ...secondValue]
}
} else if (typeof secondValue === "object" && typeof firstValue === "object") {
} else if (
typeof secondValue === "object" &&
typeof firstValue === "object"
) {
// Object
copyOfFirst[key] = mergeJson(firstValue, secondValue, mergeBehavior)
copyOfFirst[key] = mergeJson(
firstValue,
secondValue,
mergeBehavior,
)
} else {
// Other (boolean, number, string)
copyOfFirst[key] = secondValue
@ -141,5 +172,6 @@ export function mergeJson(
}
function getExtensionUri(): vscode.Uri {
return vscode.extensions.getExtension("saoudrizwan.claude-dev")!.extensionUri
return vscode.extensions.getExtension("saoudrizwan.claude-dev")!
.extensionUri
}

View file

@ -3,7 +3,9 @@ import * as path from "path"
import { listFiles } from "../../services/glob/list-files"
import { ClineProvider } from "../../core/webview/ClineProvider"
const cwd = vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath).at(0)
const cwd = vscode.workspace.workspaceFolders
?.map((folder) => folder.uri.fsPath)
.at(0)
// Note: this is not a drop-in replacement for listFiles at the start of tasks, since that will be done for Desktops when there is no workspace selected
class WorkspaceTracker {
@ -22,20 +24,28 @@ class WorkspaceTracker {
return
}
const [files, _] = await listFiles(cwd, true, 1_000)
files.forEach((file) => this.filePaths.add(this.normalizeFilePath(file)))
files.forEach((file) =>
this.filePaths.add(this.normalizeFilePath(file)),
)
this.workspaceDidUpdate()
}
private registerListeners() {
// Listen for file creation
// .bind(this) ensures the callback refers to class instance when using this, not necessary when using arrow function
this.disposables.push(vscode.workspace.onDidCreateFiles(this.onFilesCreated.bind(this)))
this.disposables.push(
vscode.workspace.onDidCreateFiles(this.onFilesCreated.bind(this)),
)
// Listen for file deletion
this.disposables.push(vscode.workspace.onDidDeleteFiles(this.onFilesDeleted.bind(this)))
this.disposables.push(
vscode.workspace.onDidDeleteFiles(this.onFilesDeleted.bind(this)),
)
// Listen for file renaming
this.disposables.push(vscode.workspace.onDidRenameFiles(this.onFilesRenamed.bind(this)))
this.disposables.push(
vscode.workspace.onDidRenameFiles(this.onFilesRenamed.bind(this)),
)
/*
An event that is emitted when a workspace folder is added or removed.
@ -95,16 +105,23 @@ class WorkspaceTracker {
}
private normalizeFilePath(filePath: string): string {
const resolvedPath = cwd ? path.resolve(cwd, filePath) : path.resolve(filePath)
const resolvedPath = cwd
? path.resolve(cwd, filePath)
: path.resolve(filePath)
return filePath.endsWith("/") ? resolvedPath + "/" : resolvedPath
}
private async addFilePath(filePath: string): Promise<string> {
const normalizedPath = this.normalizeFilePath(filePath)
try {
const stat = await vscode.workspace.fs.stat(vscode.Uri.file(normalizedPath))
const stat = await vscode.workspace.fs.stat(
vscode.Uri.file(normalizedPath),
)
const isDirectory = (stat.type & vscode.FileType.Directory) !== 0
const pathWithSlash = isDirectory && !normalizedPath.endsWith("/") ? normalizedPath + "/" : normalizedPath
const pathWithSlash =
isDirectory && !normalizedPath.endsWith("/")
? normalizedPath + "/"
: normalizedPath
this.filePaths.add(pathWithSlash)
return pathWithSlash
} catch {
@ -116,7 +133,10 @@ class WorkspaceTracker {
private async removeFilePath(filePath: string): Promise<boolean> {
const normalizedPath = this.normalizeFilePath(filePath)
return this.filePaths.delete(normalizedPath) || this.filePaths.delete(normalizedPath + "/")
return (
this.filePaths.delete(normalizedPath) ||
this.filePaths.delete(normalizedPath + "/")
)
}
public dispose() {

View file

@ -33,7 +33,9 @@ export async function getPythonEnvPath(): Promise<string | undefined> {
return undefined
}
// Get the active python environment path for the current workspace
const pythonEnv = await pythonApi?.environments?.getActiveEnvironmentPath(workspaceFolder.uri)
const pythonEnv = await pythonApi?.environments?.getActiveEnvironmentPath(
workspaceFolder.uri,
)
if (pythonEnv && pythonEnv.path) {
return pythonEnv.path
} else {

View file

@ -1,7 +1,13 @@
import * as vscode from "vscode"
import * as fs from "fs/promises"
import * as path from "path"
import { Browser, Page, ScreenshotOptions, TimeoutError, launch } from "puppeteer-core"
import {
Browser,
Page,
ScreenshotOptions,
TimeoutError,
launch,
} from "puppeteer-core"
// @ts-ignore
import PCR from "puppeteer-chromium-resolver"
import pWaitFor from "p-wait-for"
@ -79,7 +85,9 @@ export class BrowserSession {
return {}
}
async doAction(action: (page: Page) => Promise<void>): Promise<BrowserActionResult> {
async doAction(
action: (page: Page) => Promise<void>,
): Promise<BrowserActionResult> {
if (!this.page) {
throw new Error(
"Browser is not launched. This may occur if the browser was automatically closed by a non-`browser_action` tool.",
@ -166,7 +174,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

@ -3,10 +3,15 @@ import os from "os"
import * as path from "path"
import { arePathsEqual } from "../../utils/path"
export async function listFiles(dirPath: string, recursive: boolean, limit: number): Promise<[string[], boolean]> {
export async function listFiles(
dirPath: string,
recursive: boolean,
limit: number,
): Promise<[string[], boolean]> {
const absolutePath = path.resolve(dirPath)
// Do not allow listing files in root or home directory, which cline tends to want to do when the user's prompt is vague.
const root = process.platform === "win32" ? path.parse(absolutePath).root : "/"
const root =
process.platform === "win32" ? path.parse(absolutePath).root : "/"
const isRoot = arePathsEqual(absolutePath, root)
if (isRoot) {
return [[root], false]
@ -46,7 +51,9 @@ export async function listFiles(dirPath: string, recursive: boolean, limit: numb
onlyFiles: false, // true by default, false means it will list directories on their own too
}
// * globs all files in one dir, ** globs files in nested directories
const files = recursive ? await globbyLevelByLevel(limit, options) : (await globby("*", options)).slice(0, limit)
const files = recursive
? await globbyLevelByLevel(limit, options)
: (await globby("*", options)).slice(0, limit)
return [files, files.length >= limit]
}

View file

@ -1,5 +1,8 @@
import { Client } from "@modelcontextprotocol/sdk/client/index.js"
import { StdioClientTransport, StdioServerParameters } from "@modelcontextprotocol/sdk/client/stdio.js"
import {
StdioClientTransport,
StdioServerParameters,
} from "@modelcontextprotocol/sdk/client/stdio.js"
import {
CallToolResultSchema,
ListResourcesResultSchema,
@ -14,7 +17,10 @@ import * as fs from "fs/promises"
import * as path from "path"
import * as vscode from "vscode"
import { z } from "zod"
import { ClineProvider, GlobalFileNames } from "../../core/webview/ClineProvider"
import {
ClineProvider,
GlobalFileNames,
} from "../../core/webview/ClineProvider"
import {
McpResource,
McpResourceResponse,
@ -114,11 +120,20 @@ export class McpHub {
return
}
try {
vscode.window.showInformationMessage("Updating MCP servers...")
await this.updateServerConnections(result.data.mcpServers || {})
vscode.window.showInformationMessage("MCP servers updated")
vscode.window.showInformationMessage(
"Updating MCP servers...",
)
await this.updateServerConnections(
result.data.mcpServers || {},
)
vscode.window.showInformationMessage(
"MCP servers updated",
)
} catch (error) {
console.error("Failed to process MCP settings change:", error)
console.error(
"Failed to process MCP settings change:",
error,
)
}
}
}),
@ -136,16 +151,23 @@ export class McpHub {
}
}
private async connectToServer(name: string, config: StdioServerParameters): Promise<void> {
private async connectToServer(
name: string,
config: StdioServerParameters,
): Promise<void> {
// Remove existing connection if it exists (should never happen, the connection should be deleted beforehand)
this.connections = this.connections.filter((conn) => conn.server.name !== name)
this.connections = this.connections.filter(
(conn) => conn.server.name !== name,
)
try {
// Each MCP server requires its own transport connection and has unique capabilities, configurations, and error handling. Having separate clients also allows proper scoping of resources/tools and independent server management like reconnection.
const client = new Client(
{
name: "Cline",
version: this.providerRef.deref()?.context.extension?.packageJSON?.version ?? "1.0.0",
version:
this.providerRef.deref()?.context.extension?.packageJSON
?.version ?? "1.0.0",
},
{
capabilities: {},
@ -165,7 +187,9 @@ export class McpHub {
transport.onerror = async (error) => {
console.error(`Transport error for "${name}":`, error)
const connection = this.connections.find((conn) => conn.server.name === name)
const connection = this.connections.find(
(conn) => conn.server.name === name,
)
if (connection) {
connection.server.status = "disconnected"
this.appendErrorMessage(connection, error.message)
@ -174,7 +198,9 @@ export class McpHub {
}
transport.onclose = async () => {
const connection = this.connections.find((conn) => conn.server.name === name)
const connection = this.connections.find(
(conn) => conn.server.name === name,
)
if (connection) {
connection.server.status = "disconnected"
}
@ -183,7 +209,9 @@ export class McpHub {
// If the config is invalid, show an error
if (!StdioConfigSchema.safeParse(config).success) {
console.error(`Invalid config for "${name}": missing or invalid parameters`)
console.error(
`Invalid config for "${name}": missing or invalid parameters`,
)
const connection: McpConnection = {
server: {
name,
@ -218,7 +246,9 @@ export class McpHub {
stderrStream.on("data", async (data: Buffer) => {
const errorOutput = data.toString()
console.error(`Server "${name}" stderr:`, errorOutput)
const connection = this.connections.find((conn) => conn.server.name === name)
const connection = this.connections.find(
(conn) => conn.server.name === name,
)
if (connection) {
// NOTE: we do not set server status to "disconnected" because stderr logs do not necessarily mean the server crashed or disconnected, it could just be informational. In fact when the server first starts up, it immediately logs "<name> server running on stdio" to stderr.
this.appendErrorMessage(connection, errorOutput)
@ -263,20 +293,28 @@ export class McpHub {
// Initial fetch of tools and resources
connection.server.tools = await this.fetchToolsList(name)
connection.server.resources = await this.fetchResourcesList(name)
connection.server.resourceTemplates = await this.fetchResourceTemplatesList(name)
connection.server.resourceTemplates =
await this.fetchResourceTemplatesList(name)
} catch (error) {
// Update status with error
const connection = this.connections.find((conn) => conn.server.name === name)
const connection = this.connections.find(
(conn) => conn.server.name === name,
)
if (connection) {
connection.server.status = "disconnected"
this.appendErrorMessage(connection, error instanceof Error ? error.message : String(error))
this.appendErrorMessage(
connection,
error instanceof Error ? error.message : String(error),
)
}
throw error
}
}
private appendErrorMessage(connection: McpConnection, error: string) {
const newError = connection.server.error ? `${connection.server.error}\n${error}` : error
const newError = connection.server.error
? `${connection.server.error}\n${error}`
: error
connection.server.error = newError //.slice(0, 800)
}
@ -284,7 +322,10 @@ export class McpHub {
try {
const response = await this.connections
.find((conn) => conn.server.name === serverName)
?.client.request({ method: "tools/list" }, ListToolsResultSchema)
?.client.request(
{ method: "tools/list" },
ListToolsResultSchema,
)
return response?.tools || []
} catch (error) {
// console.error(`Failed to fetch tools for ${serverName}:`, error)
@ -292,11 +333,16 @@ export class McpHub {
}
}
private async fetchResourcesList(serverName: string): Promise<McpResource[]> {
private async fetchResourcesList(
serverName: string,
): Promise<McpResource[]> {
try {
const response = await this.connections
.find((conn) => conn.server.name === serverName)
?.client.request({ method: "resources/list" }, ListResourcesResultSchema)
?.client.request(
{ method: "resources/list" },
ListResourcesResultSchema,
)
return response?.resources || []
} catch (error) {
// console.error(`Failed to fetch resources for ${serverName}:`, error)
@ -304,11 +350,16 @@ export class McpHub {
}
}
private async fetchResourceTemplatesList(serverName: string): Promise<McpResourceTemplate[]> {
private async fetchResourceTemplatesList(
serverName: string,
): Promise<McpResourceTemplate[]> {
try {
const response = await this.connections
.find((conn) => conn.server.name === serverName)
?.client.request({ method: "resources/templates/list" }, ListResourceTemplatesResultSchema)
?.client.request(
{ method: "resources/templates/list" },
ListResourceTemplatesResultSchema,
)
return response?.resourceTemplates || []
} catch (error) {
// console.error(`Failed to fetch resource templates for ${serverName}:`, error)
@ -317,7 +368,9 @@ export class McpHub {
}
async deleteConnection(name: string): Promise<void> {
const connection = this.connections.find((conn) => conn.server.name === name)
const connection = this.connections.find(
(conn) => conn.server.name === name,
)
if (connection) {
try {
// connection.client.removeNotificationHandler("notifications/tools/list_changed")
@ -329,14 +382,20 @@ export class McpHub {
} catch (error) {
console.error(`Failed to close transport for ${name}:`, error)
}
this.connections = this.connections.filter((conn) => conn.server.name !== name)
this.connections = this.connections.filter(
(conn) => conn.server.name !== name,
)
}
}
async updateServerConnections(newServers: Record<string, any>): Promise<void> {
async updateServerConnections(
newServers: Record<string, any>,
): Promise<void> {
this.isConnecting = true
this.removeAllFileWatchers()
const currentNames = new Set(this.connections.map((conn) => conn.server.name))
const currentNames = new Set(
this.connections.map((conn) => conn.server.name),
)
const newNames = new Set(Object.keys(newServers))
// Delete removed servers
@ -349,7 +408,9 @@ export class McpHub {
// Update or add servers
for (const [name, config] of Object.entries(newServers)) {
const currentConnection = this.connections.find((conn) => conn.server.name === name)
const currentConnection = this.connections.find(
(conn) => conn.server.name === name,
)
if (!currentConnection) {
// New server
@ -357,17 +418,27 @@ export class McpHub {
this.setupFileWatcher(name, config)
await this.connectToServer(name, config)
} catch (error) {
console.error(`Failed to connect to new MCP server ${name}:`, error)
console.error(
`Failed to connect to new MCP server ${name}:`,
error,
)
}
} else if (!deepEqual(JSON.parse(currentConnection.server.config), config)) {
} else if (
!deepEqual(JSON.parse(currentConnection.server.config), config)
) {
// Existing server with changed config
try {
this.setupFileWatcher(name, config)
await this.deleteConnection(name)
await this.connectToServer(name, config)
console.log(`Reconnected MCP server with updated config: ${name}`)
console.log(
`Reconnected MCP server with updated config: ${name}`,
)
} catch (error) {
console.error(`Failed to reconnect MCP server ${name}:`, error)
console.error(
`Failed to reconnect MCP server ${name}:`,
error,
)
}
}
// If server exists with same config, do nothing
@ -377,7 +448,9 @@ export class McpHub {
}
private setupFileWatcher(name: string, config: any) {
const filePath = config.args?.find((arg: string) => arg.includes("build/index.js"))
const filePath = config.args?.find((arg: string) =>
arg.includes("build/index.js"),
)
if (filePath) {
// we use chokidar instead of onDidSaveTextDocument because it doesn't require the file to be open in the editor. The settings config is better suited for onDidSave since that will be manually updated by the user or Cline (and we want to detect save events, not every file change)
const watcher = chokidar.watch(filePath, {
@ -387,7 +460,9 @@ export class McpHub {
})
watcher.on("change", () => {
console.log(`Detected change in ${filePath}. Restarting server ${name}...`)
console.log(
`Detected change in ${filePath}. Restarting server ${name}...`,
)
this.restartConnection(name)
})
@ -408,10 +483,14 @@ export class McpHub {
}
// Get existing connection and update its status
const connection = this.connections.find((conn) => conn.server.name === serverName)
const connection = this.connections.find(
(conn) => conn.server.name === serverName,
)
const config = connection?.server.config
if (config) {
vscode.window.showInformationMessage(`Restarting ${serverName} MCP server...`)
vscode.window.showInformationMessage(
`Restarting ${serverName} MCP server...`,
)
connection.server.status = "connecting"
connection.server.error = ""
await this.notifyWebviewOfServerChanges()
@ -420,10 +499,17 @@ export class McpHub {
await this.deleteConnection(serverName)
// Try to connect again using existing config
await this.connectToServer(serverName, JSON.parse(config))
vscode.window.showInformationMessage(`${serverName} MCP server connected`)
vscode.window.showInformationMessage(
`${serverName} MCP server connected`,
)
} catch (error) {
console.error(`Failed to restart connection for ${serverName}:`, error)
vscode.window.showErrorMessage(`Failed to connect to ${serverName} MCP server`)
console.error(
`Failed to restart connection for ${serverName}:`,
error,
)
vscode.window.showErrorMessage(
`Failed to connect to ${serverName} MCP server`,
)
}
}
@ -451,8 +537,13 @@ export class McpHub {
// Using server
async readResource(serverName: string, uri: string): Promise<McpResourceResponse> {
const connection = this.connections.find((conn) => conn.server.name === serverName)
async readResource(
serverName: string,
uri: string,
): Promise<McpResourceResponse> {
const connection = this.connections.find(
(conn) => conn.server.name === serverName,
)
if (!connection) {
throw new Error(`No connection found for server: ${serverName}`)
}
@ -472,7 +563,9 @@ export class McpHub {
toolName: string,
toolArguments?: Record<string, unknown>,
): Promise<McpToolCallResponse> {
const connection = this.connections.find((conn) => conn.server.name === serverName)
const connection = this.connections.find(
(conn) => conn.server.name === serverName,
)
if (!connection) {
throw new Error(
`No connection found for server: ${serverName}. Please make sure to use MCP servers available under 'Connected MCP Servers'.`,
@ -496,7 +589,10 @@ export class McpHub {
try {
await this.deleteConnection(connection.server.name)
} catch (error) {
console.error(`Failed to close connection for ${connection.server.name}:`, error)
console.error(
`Failed to close connection for ${connection.server.name}:`,
error,
)
}
}
this.connections = []

View file

@ -135,7 +135,16 @@ export async function regexSearchFiles(
throw new Error("Could not find ripgrep binary")
}
const args = ["--json", "-e", regex, "--glob", filePattern || "*", "--context", "1", directoryPath]
const args = [
"--json",
"-e",
regex,
"--glob",
filePattern || "*",
"--context",
"1",
directoryPath,
]
let output: string
try {
@ -164,7 +173,9 @@ export async function regexSearchFiles(
}
} else if (parsed.type === "context" && currentResult) {
if (parsed.data.line_number < currentResult.line!) {
currentResult.beforeContext!.push(parsed.data.lines.text)
currentResult.beforeContext!.push(
parsed.data.lines.text,
)
} else {
currentResult.afterContext!.push(parsed.data.lines.text)
}
@ -205,7 +216,11 @@ function formatResults(results: SearchResult[], cwd: string): string {
output += `${filePath.toPosix()}\n│----\n`
fileResults.forEach((result, index) => {
const allLines = [...result.beforeContext, result.match, ...result.afterContext]
const allLines = [
...result.beforeContext,
result.match,
...result.afterContext,
]
allLines.forEach((line) => {
output += `${line?.trimEnd() ?? ""}\n`
})

View file

@ -5,7 +5,9 @@ import { LanguageParser, loadRequiredLanguageParsers } from "./languageParser"
import { fileExistsAtPath } from "../../utils/fs"
// TODO: implement caching behavior to avoid having to keep analyzing project for new tasks.
export async function parseSourceCodeForDefinitionsTopLevel(dirPath: string): Promise<string> {
export async function parseSourceCodeForDefinitionsTopLevel(
dirPath: string,
): Promise<string> {
// check if the path exists
const dirExists = await fileExistsAtPath(path.resolve(dirPath))
if (!dirExists) {
@ -50,7 +52,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",
@ -74,8 +79,12 @@ function separateFiles(allFiles: string[]): { filesToParse: string[]; remainingF
"php",
"swift",
].map((e) => `.${e}`)
const filesToParse = allFiles.filter((file) => extensions.includes(path.extname(file))).slice(0, 50) // 50 files max
const remainingFiles = allFiles.filter((file) => !filesToParse.includes(file))
const filesToParse = allFiles
.filter((file) => extensions.includes(path.extname(file)))
.slice(0, 50) // 50 files max
const remainingFiles = allFiles.filter(
(file) => !filesToParse.includes(file),
)
return { filesToParse, remainingFiles }
}
@ -95,7 +104,10 @@ This approach allows us to focus on the most relevant parts of the code (defined
- https://github.com/tree-sitter/tree-sitter/blob/master/lib/binding_web/test/helper.js
- https://tree-sitter.github.io/tree-sitter/code-navigation-systems
*/
async function parseFile(filePath: string, languageParsers: LanguageParser): Promise<string | undefined> {
async function parseFile(
filePath: string,
languageParsers: LanguageParser,
): Promise<string | undefined> {
const fileContent = await fs.readFile(filePath, "utf8")
const ext = path.extname(filePath).toLowerCase().slice(1)
@ -115,7 +127,9 @@ async function parseFile(filePath: string, languageParsers: LanguageParser): Pro
const captures = query.captures(tree.rootNode)
// Sort captures by their start position
captures.sort((a, b) => a.node.startPosition.row - b.node.startPosition.row)
captures.sort(
(a, b) => a.node.startPosition.row - b.node.startPosition.row,
)
// Split the file content into individual lines
const lines = fileContent.split("\n")

View file

@ -23,7 +23,9 @@ export interface LanguageParser {
}
async function loadLanguage(langName: string) {
return await Parser.Language.load(path.join(__dirname, `tree-sitter-${langName}.wasm`))
return await Parser.Language.load(
path.join(__dirname, `tree-sitter-${langName}.wasm`),
)
}
let isParserInitialized = false
@ -57,9 +59,13 @@ Sources:
- https://github.com/tree-sitter/tree-sitter/blob/master/lib/binding_web/README.md
- https://github.com/tree-sitter/tree-sitter/blob/master/lib/binding_web/test/query-test.js
*/
export async function loadRequiredLanguageParsers(filesToParse: string[]): Promise<LanguageParser> {
export async function loadRequiredLanguageParsers(
filesToParse: string[],
): Promise<LanguageParser> {
await initializeParser()
const extensionsToLoad = new Set(filesToParse.map((file) => path.extname(file).toLowerCase().slice(1)))
const extensionsToLoad = new Set(
filesToParse.map((file) => path.extname(file).toLowerCase().slice(1)),
)
const parsers: LanguageParser = {}
for (const ext of extensionsToLoad) {
let language: Parser.Language

View file

@ -19,6 +19,7 @@ export interface ExtensionMessage {
| "partialMessage"
| "openRouterModels"
| "mcpServers"
| "relinquishControl"
text?: string
action?:
| "chatButtonClicked"
@ -42,6 +43,8 @@ export interface ExtensionState {
apiConfiguration?: ApiConfiguration
customInstructions?: string
uriScheme?: string
currentTaskItem?: HistoryItem
checkpointTrackerErrorMessage?: string
clineMessages: ClineMessage[]
taskHistory: HistoryItem[]
shouldShowAnnouncement: boolean
@ -56,6 +59,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 =
@ -93,6 +99,7 @@ export type ClineSay =
| "mcp_server_response"
| "use_mcp_server"
| "diff_error"
| "deleted_api_reqs"
export interface ClineSayTool {
tool:
@ -111,7 +118,14 @@ export interface ClineSayTool {
}
// must keep in sync with system prompt
export const browserActions = ["launch", "click", "type", "scroll_down", "scroll_up", "close"] as const
export const browserActions = [
"launch",
"click",
"type",
"scroll_down",
"scroll_up",
"close",
] as const
export type BrowserAction = (typeof browserActions)[number]
export interface ClineSayBrowserAction {
@ -147,3 +161,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,21 @@ 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 ClineAskResponse =
| "yesButtonClicked"
| "noButtonClicked"
| "messageResponse"
export type ClineCheckpointRestore = "task" | "workspace" | "taskAndWorkspace"

View file

@ -59,7 +59,8 @@ export interface ModelInfo {
// Anthropic
// https://docs.anthropic.com/en/docs/about-claude/models
export type AnthropicModelId = keyof typeof anthropicModels
export const anthropicDefaultModelId: AnthropicModelId = "claude-3-5-sonnet-20241022"
export const anthropicDefaultModelId: AnthropicModelId =
"claude-3-5-sonnet-20241022"
export const anthropicModels = {
"claude-3-5-sonnet-20241022": {
maxTokens: 8192,
@ -107,7 +108,8 @@ export const anthropicModels = {
// AWS Bedrock
// https://docs.aws.amazon.com/bedrock/latest/userguide/conversation-inference.html
export type BedrockModelId = keyof typeof bedrockModels
export const bedrockDefaultModelId: BedrockModelId = "anthropic.claude-3-5-sonnet-20241022-v2:0"
export const bedrockDefaultModelId: BedrockModelId =
"anthropic.claude-3-5-sonnet-20241022-v2:0"
export const bedrockModels = {
"anthropic.claude-3-5-sonnet-20241022-v2:0": {
maxTokens: 8192,
@ -180,7 +182,8 @@ export const openRouterDefaultModelInfo: ModelInfo = {
// Vertex AI
// https://cloud.google.com/vertex-ai/generative-ai/docs/partner-models/use-claude
export type VertexModelId = keyof typeof vertexModels
export const vertexDefaultModelId: VertexModelId = "claude-3-5-sonnet-v2@20241022"
export const vertexDefaultModelId: VertexModelId =
"claude-3-5-sonnet-v2@20241022"
export const vertexModels = {
"claude-3-5-sonnet-v2@20241022": {
maxTokens: 8192,
@ -237,7 +240,8 @@ export const openAiModelInfoSaneDefaults: ModelInfo = {
// Gemini
// https://ai.google.dev/gemini-api/docs/models/gemini
export type GeminiModelId = keyof typeof geminiModels
export const geminiDefaultModelId: GeminiModelId = "gemini-2.0-flash-thinking-exp-1219"
export const geminiDefaultModelId: GeminiModelId =
"gemini-2.0-flash-thinking-exp-1219"
export const geminiModels = {
"gemini-2.0-flash-thinking-exp-1219": {
maxTokens: 8192,

View file

@ -6,7 +6,10 @@
* order, until it finds one where predicate returns true. If such an element is found,
* findLastIndex immediately returns that element index. Otherwise, findLastIndex returns -1.
*/
export function findLastIndex<T>(array: Array<T>, predicate: (value: T, index: number, obj: T[]) => boolean): number {
export function findLastIndex<T>(
array: Array<T>,
predicate: (value: T, index: number, obj: T[]) => boolean,
): number {
let l = array.length
while (l--) {
if (predicate(array[l], l, array)) {
@ -16,7 +19,10 @@ export function findLastIndex<T>(array: Array<T>, predicate: (value: T, index: n
return -1
}
export function findLast<T>(array: Array<T>, predicate: (value: T, index: number, obj: T[]) => boolean): T | undefined {
export function findLast<T>(
array: Array<T>,
predicate: (value: T, index: number, obj: T[]) => boolean,
): T | undefined {
const index = findLastIndex(array, predicate)
return index === -1 ? undefined : array[index]
}

View file

@ -22,14 +22,23 @@ export function combineApiRequests(messages: ClineMessage[]): ClineMessage[] {
const combinedApiRequests: ClineMessage[] = []
for (let i = 0; i < messages.length; i++) {
if (messages[i].type === "say" && messages[i].say === "api_req_started") {
if (
messages[i].type === "say" &&
messages[i].say === "api_req_started"
) {
let startedRequest = JSON.parse(messages[i].text || "{}")
let j = i + 1
while (j < messages.length) {
if (messages[j].type === "say" && messages[j].say === "api_req_finished") {
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],
@ -51,10 +60,14 @@ export function combineApiRequests(messages: ClineMessage[]): ClineMessage[] {
// Replace original api_req_started and remove api_req_finished
return messages
.filter((msg) => !(msg.type === "say" && msg.say === "api_req_finished"))
.filter(
(msg) => !(msg.type === "say" && msg.say === "api_req_finished"),
)
.map((msg) => {
if (msg.type === "say" && msg.say === "api_req_started") {
const combinedRequest = combinedApiRequests.find((req) => req.ts === msg.ts)
const combinedRequest = combinedApiRequests.find(
(req) => req.ts === msg.ts,
)
return combinedRequest || msg
}
return msg

View file

@ -20,22 +20,34 @@ import { ClineMessage } from "./ExtensionMessage"
* const result = simpleCombineCommandSequences(messages);
* // Result: [{ type: 'ask', ask: 'command', text: 'ls\nfile1.txt\nfile2.txt', ts: 1625097600000 }]
*/
export function combineCommandSequences(messages: ClineMessage[]): ClineMessage[] {
export function combineCommandSequences(
messages: ClineMessage[],
): ClineMessage[] {
const combinedCommands: ClineMessage[] = []
// First pass: combine commands with their outputs
for (let i = 0; i < messages.length; i++) {
if (messages[i].type === "ask" && (messages[i].ask === "command" || messages[i].say === "command")) {
if (
messages[i].type === "ask" &&
(messages[i].ask === "command" || messages[i].say === "command")
) {
let combinedText = messages[i].text || ""
let didAddOutput = false
let j = i + 1
while (j < messages.length) {
if (messages[j].type === "ask" && (messages[j].ask === "command" || messages[j].say === "command")) {
if (
messages[j].type === "ask" &&
(messages[j].ask === "command" ||
messages[j].say === "command")
) {
// Stop if we encounter the next command
break
}
if (messages[j].ask === "command_output" || messages[j].say === "command_output") {
if (
messages[j].ask === "command_output" ||
messages[j].say === "command_output"
) {
if (!didAddOutput) {
// Add a newline before the first output
combinedText += `\n${COMMAND_OUTPUT_STRING}`
@ -61,10 +73,18 @@ export function combineCommandSequences(messages: ClineMessage[]): ClineMessage[
// Second pass: remove command_outputs and replace original commands with combined ones
return messages
.filter((msg) => !(msg.ask === "command_output" || msg.say === "command_output"))
.filter(
(msg) =>
!(msg.ask === "command_output" || msg.say === "command_output"),
)
.map((msg) => {
if (msg.type === "ask" && (msg.ask === "command" || msg.say === "command")) {
const combinedCommand = combinedCommands.find((cmd) => cmd.ts === msg.ts)
if (
msg.type === "ask" &&
(msg.ask === "command" || msg.say === "command")
) {
const combinedCommand = combinedCommands.find(
(cmd) => cmd.ts === msg.ts,
)
return combinedCommand || msg
}
return msg

View file

@ -44,5 +44,6 @@ Mention regex:
- `mentionRegexGlobal`: Creates a global version of the `mentionRegex` to find all matches within a given string.
*/
export const mentionRegex = /@((?:\/|\w+:\/\/)[^\s]+?|problems\b)(?=[.,;:!?]?(?=[\s\r\n]|$))/
export const mentionRegex =
/@((?:\/|\w+:\/\/)[^\s]+?|problems\b)(?=[.,;:!?]?(?=[\s\r\n]|$))/
export const mentionRegexGlobal = new RegExp(mentionRegex.source, "g")

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,10 +35,16 @@ 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
const { tokensIn, tokensOut, cacheWrites, cacheReads, cost } =
parsedData
if (typeof tokensIn === "number") {
result.totalTokensIn += tokensIn
@ -47,10 +53,12 @@ export function getApiMetrics(messages: ClineMessage[]): ApiMetrics {
result.totalTokensOut += tokensOut
}
if (typeof cacheWrites === "number") {
result.totalCacheWrites = (result.totalCacheWrites ?? 0) + cacheWrites
result.totalCacheWrites =
(result.totalCacheWrites ?? 0) + cacheWrites
}
if (typeof cacheReads === "number") {
result.totalCacheReads = (result.totalCacheReads ?? 0) + cacheReads
result.totalCacheReads =
(result.totalCacheReads ?? 0) + cacheReads
}
if (typeof cost === "number") {
result.totalCost += cost

View file

@ -10,15 +10,19 @@ export function calculateApiCost(
const modelCacheWritesPrice = modelInfo.cacheWritesPrice
let cacheWritesCost = 0
if (cacheCreationInputTokens && modelCacheWritesPrice) {
cacheWritesCost = (modelCacheWritesPrice / 1_000_000) * cacheCreationInputTokens
cacheWritesCost =
(modelCacheWritesPrice / 1_000_000) * cacheCreationInputTokens
}
const modelCacheReadsPrice = modelInfo.cacheReadsPrice
let cacheReadsCost = 0
if (cacheReadInputTokens && modelCacheReadsPrice) {
cacheReadsCost = (modelCacheReadsPrice / 1_000_000) * cacheReadInputTokens
cacheReadsCost =
(modelCacheReadsPrice / 1_000_000) * cacheReadInputTokens
}
const baseInputCost = ((modelInfo.inputPrice || 0) / 1_000_000) * inputTokens
const baseInputCost =
((modelInfo.inputPrice || 0) / 1_000_000) * inputTokens
const outputCost = ((modelInfo.outputPrice || 0) / 1_000_000) * outputTokens
const totalCost = cacheWritesCost + cacheReadsCost + baseInputCost + outputCost
const totalCost =
cacheWritesCost + cacheReadsCost + baseInputCost + outputCost
return totalCost
}

View file

@ -6,7 +6,10 @@ import "should"
import { createDirectoriesForFile, fileExistsAtPath } from "./fs"
describe("Filesystem Utilities", () => {
const tmpDir = path.join(os.tmpdir(), "cline-test-" + Math.random().toString(36).slice(2))
const tmpDir = path.join(
os.tmpdir(),
"cline-test-" + Math.random().toString(36).slice(2),
)
// Clean up after tests
after(async () => {
@ -36,7 +39,13 @@ describe("Filesystem Utilities", () => {
describe("createDirectoriesForFile", () => {
it("should create all necessary directories", async () => {
const deepPath = path.join(tmpDir, "deep", "nested", "dir", "file.txt")
const deepPath = path.join(
tmpDir,
"deep",
"nested",
"dir",
"file.txt",
)
const createdDirs = await createDirectoriesForFile(deepPath)
// Verify directories were created
@ -59,7 +68,14 @@ describe("Filesystem Utilities", () => {
})
it("should normalize paths", async () => {
const unnormalizedPath = path.join(tmpDir, "a", "..", "b", ".", "file.txt")
const unnormalizedPath = path.join(
tmpDir,
"a",
"..",
"b",
".",
"file.txt",
)
const createdDirs = await createDirectoriesForFile(unnormalizedPath)
// Should create only the necessary directory

View file

@ -8,7 +8,9 @@ import * as path from "path"
* @param filePath - The full path to a file.
* @returns A promise that resolves to an array of newly created directories.
*/
export async function createDirectoriesForFile(filePath: string): Promise<string[]> {
export async function createDirectoriesForFile(
filePath: string,
): Promise<string[]> {
const newDirectories: string[] = []
const normalizedFilePath = path.normalize(filePath) // Normalize path for cross-platform compatibility
const directoryPath = path.dirname(normalizedFilePath)

View file

@ -30,7 +30,9 @@ describe("Path Utilities", () => {
it("should handle desktop path", () => {
const desktop = path.join(os.homedir(), "Desktop")
const testPath = path.join(desktop, "test.txt")
getReadablePath(desktop, "test.txt").should.equal(testPath.replace(/\\/g, "/"))
getReadablePath(desktop, "test.txt").should.equal(
testPath.replace(/\\/g, "/"),
)
})
it("should show relative paths within cwd", () => {

View file

@ -72,7 +72,10 @@ function normalizePath(p: string): string {
let normalized = path.normalize(p)
// however it doesn't remove trailing slashes
// remove trailing slash, except for root paths
if (normalized.length > 1 && (normalized.endsWith("/") || normalized.endsWith("\\"))) {
if (
normalized.length > 1 &&
(normalized.endsWith("/") || normalized.endsWith("\\"))
) {
normalized = normalized.slice(0, -1)
}
return normalized

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

@ -5,7 +5,9 @@
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta name="description" content="Web site created using create-react-app" />
<meta
name="description"
content="Web site created using create-react-app" />
<!--
manifest.json provides metadata used when your web app is installed on a
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/

View file

@ -5,12 +5,16 @@ import ChatView from "./components/chat/ChatView"
import HistoryView from "./components/history/HistoryView"
import SettingsView from "./components/settings/SettingsView"
import WelcomeView from "./components/welcome/WelcomeView"
import { ExtensionStateContextProvider, useExtensionState } from "./context/ExtensionStateContext"
import {
ExtensionStateContextProvider,
useExtensionState,
} from "./context/ExtensionStateContext"
import { vscode } from "./utils/vscode"
import McpView from "./components/mcp/McpView"
const AppContent = () => {
const { didHydrateState, showWelcome, shouldShowAnnouncement } = useExtensionState()
const { didHydrateState, showWelcome, shouldShowAnnouncement } =
useExtensionState()
const [showSettings, setShowSettings] = useState(false)
const [showHistory, setShowHistory] = useState(false)
const [showMcp, setShowMcp] = useState(false)
@ -65,8 +69,12 @@ const AppContent = () => {
<WelcomeView />
) : (
<>
{showSettings && <SettingsView onDone={() => setShowSettings(false)} />}
{showHistory && <HistoryView onDone={() => setShowHistory(false)} />}
{showSettings && (
<SettingsView onDone={() => setShowSettings(false)} />
)}
{showHistory && (
<HistoryView onDone={() => setShowHistory(false)} />
)}
{showMcp && <McpView onDone={() => setShowMcp(false)} />}
{/* Do not conditionally load ChatView, it's expensive and there's state we don't want to lose (user input, disableInput, askResponse promise, etc.) */}
<ChatView

View file

@ -16,7 +16,8 @@ const Announcement = ({ version, hideAnnouncement }: AnnouncementProps) => {
return (
<div
style={{
backgroundColor: "var(--vscode-editor-inactiveSelectionBackground)",
backgroundColor:
"var(--vscode-editor-inactiveSelectionBackground)",
borderRadius: "3px",
padding: "12px 16px",
margin: "5px 15px 5px 15px",
@ -34,40 +35,45 @@ 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/1867271665086074969"
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,
@ -125,7 +131,9 @@ const Announcement = ({ version, hideAnnouncement }: AnnouncementProps) => {
/>
<p style={{ margin: "0" }}>
Join
<VSCodeLink style={{ display: "inline" }} href="https://discord.gg/cline">
<VSCodeLink
style={{ display: "inline" }}
href="https://discord.gg/cline">
discord.gg/cline
</VSCodeLink>
for more updates!

View file

@ -1,4 +1,7 @@
import { VSCodeCheckbox, VSCodeTextField } from "@vscode/webview-ui-toolkit/react"
import {
VSCodeCheckbox,
VSCodeTextField,
} from "@vscode/webview-ui-toolkit/react"
import { useCallback, useState } from "react"
import styled from "styled-components"
import { useExtensionState } from "../../context/ExtensionStateContext"
@ -38,25 +41,32 @@ const ACTION_METADATA: {
id: "useBrowser",
label: "Use the browser",
shortName: "Browser",
description: "Allows ability to launch and interact with any website in a headless browser.",
description:
"Allows ability to launch and interact with any website in a headless browser.",
},
{
id: "useMcp",
label: "Use MCP servers",
shortName: "MCP",
description: "Allows use of configured MCP servers which may modify filesystem or interact with APIs.",
description:
"Allows use of configured MCP servers which may modify filesystem or interact with APIs.",
},
]
const AutoApproveMenu = ({ style }: AutoApproveMenuProps) => {
const { autoApprovalSettings } = useExtensionState()
const [isExpanded, setIsExpanded] = useState(false)
const [isHoveringCollapsibleSection, setIsHoveringCollapsibleSection] = useState(false)
const [isHoveringCollapsibleSection, setIsHoveringCollapsibleSection] =
useState(false)
// Careful not to use partials to mutate since spread operator only does shallow copy
const enabledActions = ACTION_METADATA.filter((action) => autoApprovalSettings.actions[action.id])
const enabledActionsList = enabledActions.map((action) => action.shortName).join(", ")
const enabledActions = ACTION_METADATA.filter(
(action) => autoApprovalSettings.actions[action.id],
)
const enabledActionsList = enabledActions
.map((action) => action.shortName)
.join(", ")
const hasEnabledActions = enabledActions.length > 0
const updateEnabled = useCallback(
@ -81,7 +91,8 @@ const AutoApproveMenu = ({ style }: AutoApproveMenuProps) => {
}
// Check if this will result in any enabled actions
const willHaveEnabledActions = Object.values(newActions).some(Boolean)
const willHaveEnabledActions =
Object.values(newActions).some(Boolean)
vscode.postMessage({
type: "autoApprovalSettings",
@ -89,7 +100,9 @@ const AutoApproveMenu = ({ style }: AutoApproveMenuProps) => {
...autoApprovalSettings,
actions: newActions,
// If no actions will be enabled, ensure the main toggle is off
enabled: willHaveEnabledActions ? autoApprovalSettings.enabled : false,
enabled: willHaveEnabledActions
? autoApprovalSettings.enabled
: false,
},
})
},
@ -157,7 +170,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,14 +197,22 @@ 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",
overflow: "hidden",
textOverflow: "ellipsis",
}}>
{enabledActions.length === 0 ? "None" : enabledActionsList}
{enabledActions.length === 0
? "None"
: enabledActionsList}
</span>
<span
className={`codicon codicon-chevron-${isExpanded ? "down" : "right"}`}
@ -208,15 +231,20 @@ const AutoApproveMenu = ({ style }: AutoApproveMenuProps) => {
color: "var(--vscode-descriptionForeground)",
fontSize: "12px",
}}>
Auto-approve allows Cline to perform the following actions without asking for permission. Please
use with caution and only enable if you understand the risks.
Auto-approve allows Cline to perform the following
actions without asking for permission. Please use with
caution and only enable if you understand the risks.
</div>
{ACTION_METADATA.map((action) => (
<div key={action.id} style={{ margin: "6px 0" }}>
<VSCodeCheckbox
checked={autoApprovalSettings.actions[action.id]}
checked={
autoApprovalSettings.actions[action.id]
}
onChange={(e) => {
const checked = (e.target as HTMLInputElement).checked
const checked = (
e.target as HTMLInputElement
).checked
updateAction(action.id, checked)
}}>
{action.label}
@ -234,7 +262,8 @@ const AutoApproveMenu = ({ style }: AutoApproveMenuProps) => {
<div
style={{
height: "0.5px",
background: "var(--vscode-titleBar-inactiveForeground)",
background:
"var(--vscode-titleBar-inactiveForeground)",
margin: "15px 0",
opacity: 0.2,
}}
@ -248,7 +277,9 @@ const AutoApproveMenu = ({ style }: AutoApproveMenuProps) => {
marginBottom: "8px",
color: "var(--vscode-foreground)",
}}>
<span style={{ flexShrink: 1, minWidth: 0 }}>Max Requests:</span>
<span style={{ flexShrink: 1, minWidth: 0 }}>
Max Requests:
</span>
<VSCodeTextField
// placeholder={DEFAULT_AUTO_APPROVAL_SETTINGS.maxRequests.toString()}
value={autoApprovalSettings.maxRequests.toString()}
@ -265,7 +296,12 @@ const AutoApproveMenu = ({ style }: AutoApproveMenuProps) => {
// Prevent non-numeric keys (except for backspace, delete, arrows)
if (
!/^\d$/.test(e.key) &&
!["Backspace", "Delete", "ArrowLeft", "ArrowRight"].includes(e.key)
![
"Backspace",
"Delete",
"ArrowLeft",
"ArrowRight",
].includes(e.key)
) {
e.preventDefault()
}
@ -279,14 +315,15 @@ const AutoApproveMenu = ({ style }: AutoApproveMenuProps) => {
fontSize: "12px",
marginBottom: "10px",
}}>
Cline will automatically make this many API requests before asking for approval to proceed with
the task.
Cline will automatically make this many API requests
before asking for approval to proceed with the task.
</div>
<div style={{ margin: "6px 0" }}>
<VSCodeCheckbox
checked={autoApprovalSettings.enableNotifications}
onChange={(e) => {
const checked = (e.target as HTMLInputElement).checked
const checked = (e.target as HTMLInputElement)
.checked
updateNotifications(checked)
}}>
Enable Notifications
@ -297,8 +334,8 @@ const AutoApproveMenu = ({ style }: AutoApproveMenuProps) => {
color: "var(--vscode-descriptionForeground)",
fontSize: "12px",
}}>
Receive system notifications when Cline requires approval to proceed or when a task is
completed.
Receive system notifications when Cline requires
approval to proceed or when a task is completed.
</div>
</div>
</div>
@ -311,7 +348,10 @@ const CollapsibleSection = styled.div<{ isHovered?: boolean }>`
display: flex;
align-items: center;
gap: 4px;
color: ${(props) => (props.isHovered ? "var(--vscode-foreground)" : "var(--vscode-descriptionForeground)")};
color: ${(props) =>
props.isHovered
? "var(--vscode-foreground)"
: "var(--vscode-descriptionForeground)"};
flex: 1;
min-width: 0;

View file

@ -11,6 +11,12 @@ 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[]
@ -29,14 +35,17 @@ const BrowserSessionRow = memo((props: BrowserSessionRowProps) => {
const isLastApiReqInterrupted = useMemo(() => {
// Check if last api_req_started is cancelled
const lastApiReqStarted = [...messages].reverse().find((m) => m.say === "api_req_started")
const lastApiReqStarted = [...messages]
.reverse()
.find((m) => m.say === "api_req_started")
if (lastApiReqStarted?.text != null) {
const info = JSON.parse(lastApiReqStarted.text)
if (info.cancelReason != null) {
return true
}
}
const lastApiReqFailed = isLast && lastModifiedMessage?.ask === "api_req_failed"
const lastApiReqFailed =
isLast && lastModifiedMessage?.ask === "api_req_failed"
if (lastApiReqFailed) {
return true
}
@ -44,7 +53,11 @@ const BrowserSessionRow = memo((props: BrowserSessionRowProps) => {
}, [messages, lastModifiedMessage, isLast])
const isBrowsing = useMemo(() => {
return isLast && messages.some((m) => m.say === "browser_action_result") && !isLastApiReqInterrupted // after user approves, browser_action_result with "" is sent to indicate that the session has started
return (
isLast &&
messages.some((m) => m.say === "browser_action_result") &&
!isLastApiReqInterrupted
) // after user approves, browser_action_result with "" is sent to indicate that the session has started
}, [isLast, messages, isLastApiReqInterrupted])
// Organize messages into pages with current state and next action
@ -66,7 +79,10 @@ const BrowserSessionRow = memo((props: BrowserSessionRowProps) => {
let nextActionMessages: ClineMessage[] = []
messages.forEach((message) => {
if (message.ask === "browser_action_launch" || message.say === "browser_action_launch") {
if (
message.ask === "browser_action_launch" ||
message.say === "browser_action_launch"
) {
// Start first page
currentStateMessages = [message]
} else if (message.say === "browser_action_result") {
@ -76,7 +92,9 @@ const BrowserSessionRow = memo((props: BrowserSessionRowProps) => {
}
// Complete current state
currentStateMessages.push(message)
const resultData = JSON.parse(message.text || "{}") as BrowserActionResult
const resultData = JSON.parse(
message.text || "{}",
) as BrowserActionResult
// Add page with current state and previous next actions
result.push({
@ -138,18 +156,30 @@ const BrowserSessionRow = memo((props: BrowserSessionRowProps) => {
// Get initial URL from launch message
const initialUrl = useMemo(() => {
const launchMessage = messages.find(
(m) => m.ask === "browser_action_launch" || m.say === "browser_action_launch",
(m) =>
m.ask === "browser_action_launch" ||
m.say === "browser_action_launch",
)
return launchMessage?.text || ""
}, [messages])
const isAutoApproved = useMemo(() => {
const launchMessage = messages.find(
(m) => m.ask === "browser_action_launch" || m.say === "browser_action_launch",
(m) =>
m.ask === "browser_action_launch" ||
m.say === "browser_action_launch",
)
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--) {
@ -163,7 +193,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]
@ -172,14 +207,23 @@ const BrowserSessionRow = memo((props: BrowserSessionRowProps) => {
// Use latest state if we're on the last page and don't have a state yet
const displayState = isLastPage
? {
url: currentPage?.currentState.url || latestState.url || initialUrl,
mousePosition: currentPage?.currentState.mousePosition || latestState.mousePosition || "700,400",
url:
currentPage?.currentState.url ||
latestState.url ||
initialUrl,
mousePosition:
currentPage?.currentState.mousePosition ||
latestState.mousePosition ||
"700,400",
consoleLogs: currentPage?.currentState.consoleLogs,
screenshot: currentPage?.currentState.screenshot || latestState.screenshot,
screenshot:
currentPage?.currentState.screenshot ||
latestState.screenshot,
}
: {
url: currentPage?.currentState.url || initialUrl,
mousePosition: currentPage?.currentState.mousePosition || "700,400",
mousePosition:
currentPage?.currentState.mousePosition || "700,400",
consoleLogs: currentPage?.currentState.consoleLogs,
screenshot: currentPage?.currentState.screenshot,
}
@ -194,9 +238,11 @@ const BrowserSessionRow = memo((props: BrowserSessionRowProps) => {
setMaxActionHeight={setMaxActionHeight}
/>
))}
{!isBrowsing && messages.some((m) => m.say === "browser_action_result") && currentPageIndex === 0 && (
<BrowserActionBox action={"launch"} text={initialUrl} />
)}
{!isBrowsing &&
messages.some((m) => m.say === "browser_action_result") &&
currentPageIndex === 0 && (
<BrowserActionBox action={"launch"} text={initialUrl} />
)}
</div>,
)
@ -218,8 +264,13 @@ const BrowserSessionRow = memo((props: BrowserSessionRowProps) => {
for (let i = actions.length - 1; i >= 0; i--) {
const message = actions[i]
if (message.say === "browser_action") {
const browserAction = JSON.parse(message.text || "{}") as ClineSayBrowserAction
if (browserAction.action === "click" && browserAction.coordinate) {
const browserAction = JSON.parse(
message.text || "{}",
) as ClineSayBrowserAction
if (
browserAction.action === "click" &&
browserAction.coordinate
) {
return browserAction.coordinate
}
}
@ -228,20 +279,42 @@ const BrowserSessionRow = memo((props: BrowserSessionRowProps) => {
}, [isBrowsing, currentPage?.nextAction?.messages])
// Use latest click position while browsing, otherwise use display state
const mousePosition = isBrowsing ? latestClickPosition || displayState.mousePosition : displayState.mousePosition
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:"}</>
<>
{isAutoApproved
? "Cline is using the browser:"
: "Cline wants to use the browser:"}
</>
</span>
</div>
<div
@ -320,7 +393,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>
)}
@ -330,7 +406,8 @@ const BrowserSessionRow = memo((props: BrowserSessionRowProps) => {
position: "absolute",
top: `${(parseInt(mousePosition.split(",")[1]) / 600) * 100}%`,
left: `${(parseInt(mousePosition.split(",")[0]) / 900) * 100}%`,
transition: "top 0.3s ease-out, left 0.3s ease-out",
transition:
"top 0.3s ease-out, left 0.3s ease-out",
}}
/>
)}
@ -350,11 +427,14 @@ const BrowserSessionRow = memo((props: BrowserSessionRowProps) => {
cursor: "pointer",
padding: `9px 8px ${consoleLogsExpanded ? 0 : 8}px 8px`,
}}>
<span className={`codicon codicon-chevron-${consoleLogsExpanded ? "down" : "right"}`}></span>
<span
className={`codicon codicon-chevron-${consoleLogsExpanded ? "down" : "right"}`}></span>
<span style={{ fontSize: "0.8em" }}>Console Logs</span>
</div>
{consoleLogsExpanded && (
<CodeBlock source={`${"```"}shell\n${displayState.consoleLogs || "(No new logs)"}\n${"```"}`} />
<CodeBlock
source={`${"```"}shell\n${displayState.consoleLogs || "(No new logs)"}\n${"```"}`}
/>
)}
</div>
</div>
@ -383,20 +463,32 @@ const BrowserSessionRow = memo((props: BrowserSessionRowProps) => {
Previous
</VSCodeButton>
<VSCodeButton
disabled={currentPageIndex === pages.length - 1 || isBrowsing}
disabled={
currentPageIndex === pages.length - 1 ||
isBrowsing
}
onClick={() => setCurrentPageIndex((i) => i + 1)}>
Next
</VSCodeButton>
</div>
</div>
)}
</div>,
{shouldShowCheckpoints && (
<CheckpointOverlay messageTs={lastCheckpointMessageTs} />
)}
</BrowserSessionRowContainer>,
)
// Height change effect
useEffect(() => {
const isInitialRender = prevHeightRef.current === 0
if (isLast && height !== 0 && height !== Infinity && height !== prevHeightRef.current) {
if (
isLast &&
height !== 0 &&
height !== Infinity &&
height !== prevHeightRef.current
) {
if (!isInitialRender) {
onHeightChange(height > prevHeightRef.current)
}
@ -407,7 +499,8 @@ const BrowserSessionRow = memo((props: BrowserSessionRowProps) => {
return browserSessionRow
}, deepEqual)
interface BrowserSessionRowContentProps extends Omit<BrowserSessionRowProps, "messages"> {
interface BrowserSessionRowContentProps
extends Omit<BrowserSessionRowProps, "messages"> {
message: ClineMessage
setMaxActionHeight: (height: number) => void
}
@ -427,11 +520,16 @@ const BrowserSessionRowContent = ({
marginBottom: "10px",
}
if (message.ask === "browser_action_launch" || message.say === "browser_action_launch") {
if (
message.ask === "browser_action_launch" ||
message.say === "browser_action_launch"
) {
return (
<>
<div style={headerStyle}>
<span style={{ fontWeight: "bold" }}>Browser Session Started</span>
<span style={{ fontWeight: "bold" }}>
Browser Session Started
</span>
</div>
<div
style={{
@ -440,7 +538,10 @@ const BrowserSessionRowContent = ({
overflow: "hidden",
backgroundColor: CODE_BLOCK_BG_COLOR,
}}>
<CodeBlock source={`${"```"}shell\n${message.text}\n${"```"}`} forceWrap={true} />
<CodeBlock
source={`${"```"}shell\n${message.text}\n${"```"}`}
forceWrap={true}
/>
</div>
</>
)
@ -469,7 +570,9 @@ const BrowserSessionRowContent = ({
)
case "browser_action":
const browserAction = JSON.parse(message.text || "{}") as ClineSayBrowserAction
const browserAction = JSON.parse(
message.text || "{}",
) as ClineSayBrowserAction
return (
<BrowserActionBox
action={browserAction.action}
@ -499,7 +602,11 @@ const BrowserActionBox = ({
coordinate?: string
text?: string
}) => {
const getBrowserActionText = (action: BrowserAction, coordinate?: string, text?: string) => {
const getBrowserActionText = (
action: BrowserAction,
coordinate?: string,
text?: string,
) => {
switch (action) {
case "launch":
return `Launch browser at ${text}`
@ -546,7 +653,9 @@ const BrowserActionBox = ({
)
}
const BrowserCursor: React.FC<{ style?: React.CSSProperties }> = ({ style }) => {
const BrowserCursor: React.FC<{ style?: React.CSSProperties }> = ({
style,
}) => {
// (can't use svgs in vsc extensions)
const cursorBase64 =
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABUAAAAYCAYAAAAVibZIAAAAAXNSR0IArs4c6QAAAERlWElmTU0AKgAAAAgAAYdpAAQAAAABAAAAGgAAAAAAA6ABAAMAAAABAAEAAKACAAQAAAABAAAAFaADAAQAAAABAAAAGAAAAADwi9a/AAADGElEQVQ4EZ2VbUiTURTH772be/PxZdsz3cZwC4RVaB8SAjMpxQwSWZbQG/TFkN7oW1Df+h6IRV9C+hCpKUSIZUXOfGM5tAKViijFFEyfZ7Ol29S1Pbdzl8Uw9+aBu91zzv3/nt17zt2DEZjBYOAkKrtFMXIghAWM8U2vMN/FctsxGRMpM7NbEEYNMM2CYUSInlJx3OpawO9i+XSNQYkmk2uFb9njzkcfVSr1p/GJiQKMULVaw2WuBv296UKRxWJR6wxGCmM1EAhSNppv33GBH9qI32cPTAtss9lUm6EM3N7R+RbigT+5/CeosFCZKpjEW+iorS1pb30wDUXzQfHqtD/9L3ieZ2ee1OJCmbL8QHnRs+4uj0wmW4QzrpCwvJ8zGg3JqAmhTLynuLiwv8/5KyND8Q3cEkUEDWu15oJE4KRQJt5hs1rcriGNRqP+DK4dyyWXXm/aFQ+cEpSJ8/LyDGPuEZNOmzsOroUSOqzXG/dtBU4ZysTZYKNut91sNo2Cq6cE9enz86s2g9OCMrFSqVC5hgb32u072W3jKMU90Hb1seC0oUwsB+t92bO/rKx0EFGkgFCnjjc1/gVvC8rE0L+4o63t4InjxwbAJQjTe3qD8QrLkXA4DC24fWtuajp06cLFYSBIFKGmXKPRRmAnME9sPt+yLwIWb9WN69fKoTneQz4Dh2mpPNkvfeV0jjecb9wNAkwIEVQq5VJOds4Kb+DXoAsiVquVwI1Dougpij6UyGYx+5cKroeDEFibm5lWRRMbH1+npmYrq6qhwlQHIbajZEf1fElcqGGFpGg9HMuKzpfBjhytCTMgkJ56RX09zy/ysENTBElmjIgJnmNChJqohDVQqpEfwkILE8v/o0GAnV9F1eEvofVQCbiTBEXOIPQh5PGgefDZeAcjrpGZjULBr/m3tZOnz7oEQWRAQZLjWlEU/XEJWySiILgRc5Cz1DkcAyuBFcnpfF0JiXWKpcolQXizhS5hKAqFpr0MVbgbuxJ6+5xX+P4wNpbqPPrugZfbmIbLmgQR3Aw8QSi66hUXulOFbF73GxqjE5BNXWNeAAAAAElFTkSuQmCC"
@ -564,4 +673,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,25 +1,57 @@
import { VSCodeBadge, VSCodeProgressRing } from "@vscode/webview-ui-toolkit/react"
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 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 {
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 CodeAccordian, { removeLeadingNonAlphanumeric } from "../common/CodeAccordian"
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
@ -33,18 +65,36 @@ 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(() => {
@ -52,7 +102,12 @@ const ChatRow = memo(
// NOTE: it's important we don't distinguish between partial or complete here since our scroll effects in chatview need to handle height change during partial -> complete
const isInitialRender = prevHeightRef.current === 0 // prevents scrolling when new element is added since we already scroll for that
// height starts off at Infinity
if (isLast && height !== 0 && height !== Infinity && height !== prevHeightRef.current) {
if (
isLast &&
height !== 0 &&
height !== Infinity &&
height !== prevHeightRef.current
) {
if (!isInitialRender) {
onHeightChange(height > prevHeightRef.current)
}
@ -77,13 +132,21 @@ export const ChatRowContent = ({
isLast,
}: ChatRowContentProps) => {
const { mcpServers } = useExtensionState()
const [cost, apiReqCancelReason, apiReqStreamingFailedMessage] = useMemo(() => {
if (message.text != null && message.say === "api_req_started") {
const info: ClineApiReqInfo = JSON.parse(message.text)
return [info.cost, info.cancelReason, info.streamingFailedMessage]
}
return [undefined, undefined, undefined]
}, [message.text, message.say])
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)
return [
info.cost,
info.cancelReason,
info.streamingFailedMessage,
]
}
return [undefined, undefined, undefined]
}, [message.text, message.say])
// when resuming task, last wont be api_req_failed but a resume_task message, so api_req_started will show loading spinner. that's why we just remove the last api_req_started that failed without streaming anything
const apiRequestFailedMessage =
isLast && lastModifiedMessage?.ask === "api_req_failed" // if request is retried then the latest message is a api_req_retried
@ -91,10 +154,12 @@ export const ChatRowContent = ({
: undefined
const isCommandExecuting =
isLast &&
(lastModifiedMessage?.ask === "command" || lastModifiedMessage?.say === "command") &&
(lastModifiedMessage?.ask === "command" ||
lastModifiedMessage?.say === "command") &&
lastModifiedMessage?.text?.includes(COMMAND_OUTPUT_STRING)
const isMcpServerResponding = isLast && lastModifiedMessage?.say === "mcp_server_request_started"
const isMcpServerResponding =
isLast && lastModifiedMessage?.say === "mcp_server_request_started"
const type = message.type === "ask" ? message.ask : message.say
@ -103,28 +168,55 @@ export const ChatRowContent = ({
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 style={{ color: errorColor, fontWeight: "bold" }}>Error</span>,
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 style={{ color: errorColor, fontWeight: "bold" }}>Cline is having trouble...</span>,
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 style={{ color: errorColor, fontWeight: "bold" }}>Maximum Requests Reached</span>,
style={{
color: errorColor,
marginBottom: "-1.5px",
}}></span>,
<span style={{ color: errorColor, fontWeight: "bold" }}>
Maximum Requests Reached
</span>,
]
case "command":
return [
@ -133,7 +225,10 @@ export const ChatRowContent = ({
) : (
<span
className="codicon codicon-terminal"
style={{ color: normalColor, marginBottom: "-1.5px" }}></span>
style={{
color: normalColor,
marginBottom: "-1.5px",
}}></span>
),
<span style={{ color: normalColor, fontWeight: "bold" }}>
{message.type === "ask"
@ -142,26 +237,38 @@ export const ChatRowContent = ({
</span>,
]
case "use_mcp_server":
const mcpServerUse = JSON.parse(message.text || "{}") as ClineAskUseMcpServer
const mcpServerUse = JSON.parse(
message.text || "{}",
) as ClineAskUseMcpServer
return [
isMcpServerResponding ? (
<ProgressIndicator />
) : (
<span
className="codicon codicon-server"
style={{ color: normalColor, marginBottom: "-1.5px" }}></span>
style={{
color: normalColor,
marginBottom: "-1.5px",
}}></span>
),
<span style={{ color: normalColor, fontWeight: "bold" }}>
{message.type === "ask" ? (
<>
Cline wants to{" "}
{mcpServerUse.type === "use_mcp_tool" ? "use a tool" : "access a resource"} on the{" "}
<code>{mcpServerUse.serverName}</code> MCP server:
{mcpServerUse.type === "use_mcp_tool"
? "use a tool"
: "access a resource"}{" "}
on the <code>{mcpServerUse.serverName}</code>{" "}
MCP server:
</>
) : (
<>
Cline {mcpServerUse.type === "use_mcp_tool" ? "used a tool" : "accessed a resource"} on
the <code>{mcpServerUse.serverName}</code> MCP server:
Cline{" "}
{mcpServerUse.type === "use_mcp_tool"
? "used a tool"
: "accessed a resource"}{" "}
on the <code>{mcpServerUse.serverName}</code>{" "}
MCP server:
</>
)}
</span>,
@ -170,8 +277,13 @@ export const ChatRowContent = ({
return [
<span
className="codicon codicon-check"
style={{ color: successColor, marginBottom: "-1.5px" }}></span>,
<span style={{ color: successColor, fontWeight: "bold" }}>Task Completed</span>,
style={{
color: successColor,
marginBottom: "-1.5px",
}}></span>,
<span style={{ color: successColor, fontWeight: "bold" }}>
Task Completed
</span>,
]
case "api_req_started":
const getIconSpan = (iconName: string, color: string) => (
@ -208,24 +320,49 @@ export const ChatRowContent = ({
),
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>
<span
style={{ color: normalColor, fontWeight: "bold" }}>
API Request
</span>
) : apiRequestFailedMessage ? (
<span style={{ color: errorColor, fontWeight: "bold" }}>API Request Failed</span>
<span style={{ color: errorColor, fontWeight: "bold" }}>
API Request Failed
</span>
) : (
<span style={{ color: normalColor, fontWeight: "bold" }}>API Request...</span>
<span
style={{ color: normalColor, fontWeight: "bold" }}>
API Request...
</span>
),
]
case "followup":
return [
<span
className="codicon codicon-question"
style={{ color: normalColor, marginBottom: "-1.5px" }}></span>,
<span style={{ color: normalColor, fontWeight: "bold" }}>Cline has a question:</span>,
style={{
color: normalColor,
marginBottom: "-1.5px",
}}></span>,
<span style={{ color: normalColor, fontWeight: "bold" }}>
Cline has a question:
</span>,
]
default:
return [null, null]
@ -245,7 +382,7 @@ export const ChatRowContent = ({
display: "flex",
alignItems: "center",
gap: "10px",
marginBottom: "10px",
marginBottom: "12px",
}
const pStyle: React.CSSProperties = {
@ -266,7 +403,10 @@ export const ChatRowContent = ({
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) {
@ -316,7 +456,9 @@ export const ChatRowContent = ({
<div style={headerStyle}>
{toolIcon("file-code")}
<span style={{ fontWeight: "bold" }}>
{message.type === "ask" ? "Cline wants to read this file:" : "Cline read this file:"}
{message.type === "ask"
? "Cline wants to read this file:"
: "Cline read this file:"}
</span>
</div>
{/* <CodeAccordian
@ -345,7 +487,10 @@ export const ChatRowContent = ({
msUserSelect: "none",
}}
onClick={() => {
vscode.postMessage({ type: "openFile", text: tool.content })
vscode.postMessage({
type: "openFile",
text: tool.content,
})
}}>
{tool.path?.startsWith(".") && <span>.</span>}
<span
@ -357,12 +502,17 @@ export const ChatRowContent = ({
direction: "rtl",
textAlign: "left",
}}>
{removeLeadingNonAlphanumeric(tool.path ?? "") + "\u200E"}
{removeLeadingNonAlphanumeric(
tool.path ?? "",
) + "\u200E"}
</span>
<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>
</>
@ -434,18 +584,25 @@ export const ChatRowContent = ({
<span style={{ fontWeight: "bold" }}>
{message.type === "ask" ? (
<>
Cline wants to search this directory for <code>{tool.regex}</code>:
Cline wants to search this directory for{" "}
<code>{tool.regex}</code>:
</>
) : (
<>
Cline searched this directory for <code>{tool.regex}</code>:
Cline searched this directory for{" "}
<code>{tool.regex}</code>:
</>
)}
</span>
</div>
<CodeAccordian
code={tool.content!}
path={tool.path! + (tool.filePattern ? `/(${tool.filePattern})` : "")}
path={
tool.path! +
(tool.filePattern
? `/(${tool.filePattern})`
: "")
}
language="plaintext"
isExpanded={isExpanded}
onToggleExpand={onToggleExpand}
@ -516,7 +673,9 @@ export const ChatRowContent = ({
const { command: rawCommand, output } = splitMessage(message.text || "")
const requestsApproval = rawCommand.endsWith(COMMAND_REQ_APP_STRING)
const command = requestsApproval ? rawCommand.slice(0, -COMMAND_REQ_APP_STRING.length) : rawCommand
const command = requestsApproval
? rawCommand.slice(0, -COMMAND_REQ_APP_STRING.length)
: rawCommand
return (
<>
@ -535,7 +694,10 @@ export const ChatRowContent = ({
overflow: "hidden",
backgroundColor: CODE_BLOCK_BG_COLOR,
}}>
<CodeBlock source={`${"```"}shell\n${command}\n${"```"}`} forceWrap={true} />
<CodeBlock
source={`${"```"}shell\n${command}\n${"```"}`}
forceWrap={true}
/>
{output.length > 0 && (
<div style={{ width: "100%" }}>
<div
@ -549,10 +711,17 @@ export const ChatRowContent = ({
cursor: "pointer",
padding: `2px 8px ${isExpanded ? 0 : 8}px 8px`,
}}>
<span className={`codicon codicon-chevron-${isExpanded ? "down" : "right"}`}></span>
<span style={{ fontSize: "0.8em" }}>Command Output</span>
<span
className={`codicon codicon-chevron-${isExpanded ? "down" : "right"}`}></span>
<span style={{ fontSize: "0.8em" }}>
Command Output
</span>
</div>
{isExpanded && <CodeBlock source={`${"```"}shell\n${output}\n${"```"}`} />}
{isExpanded && (
<CodeBlock
source={`${"```"}shell\n${output}\n${"```"}`}
/>
)}
</div>
)}
</div>
@ -567,7 +736,10 @@ export const ChatRowContent = ({
color: "var(--vscode-errorForeground)",
}}>
<i className="codicon codicon-warning"></i>
<span>The model has determined this command requires explicit approval.</span>
<span>
The model has determined this command requires
explicit approval.
</span>
</div>
)}
</>
@ -575,8 +747,12 @@ export const ChatRowContent = ({
}
if (message.ask === "use_mcp_server" || message.say === "use_mcp_server") {
const useMcpServer = JSON.parse(message.text || "{}") as ClineAskUseMcpServer
const server = mcpServers.find((server) => server.name === useMcpServer.serverName)
const useMcpServer = JSON.parse(
message.text || "{}",
) as ClineAskUseMcpServer
const server = mcpServers.find(
(server) => server.name === useMcpServer.serverName,
)
return (
<>
<div style={headerStyle}>
@ -616,29 +792,33 @@ export const ChatRowContent = ({
tool={{
name: useMcpServer.toolName || "",
description:
server?.tools?.find((tool) => tool.name === useMcpServer.toolName)
?.description || "",
server?.tools?.find(
(tool) =>
tool.name ===
useMcpServer.toolName,
)?.description || "",
}}
/>
{useMcpServer.arguments && useMcpServer.arguments !== "{}" && (
<div style={{ marginTop: "8px" }}>
<div
style={{
marginBottom: "4px",
opacity: 0.8,
fontSize: "12px",
textTransform: "uppercase",
}}>
Arguments
{useMcpServer.arguments &&
useMcpServer.arguments !== "{}" && (
<div style={{ marginTop: "8px" }}>
<div
style={{
marginBottom: "4px",
opacity: 0.8,
fontSize: "12px",
textTransform: "uppercase",
}}>
Arguments
</div>
<CodeAccordian
code={useMcpServer.arguments}
language="json"
isExpanded={true}
onToggleExpand={onToggleExpand}
/>
</div>
<CodeAccordian
code={useMcpServer.arguments}
language="json"
isExpanded={true}
onToggleExpand={onToggleExpand}
/>
</div>
)}
)}
</>
)}
</div>
@ -656,7 +836,9 @@ export const ChatRowContent = ({
style={{
...headerStyle,
marginBottom:
(cost == null && apiRequestFailedMessage) || apiReqStreamingFailedMessage
(cost == null &&
apiRequestFailedMessage) ||
apiReqStreamingFailedMessage
? 10
: 0,
justifyContent: "space-between",
@ -667,28 +849,54 @@ export const ChatRowContent = ({
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>
<span className={`codicon codicon-chevron-${isExpanded ? "up" : "down"}`}></span>
<span
className={`codicon codicon-chevron-${isExpanded ? "up" : "down"}`}></span>
</div>
{((cost == null && apiRequestFailedMessage) || apiReqStreamingFailedMessage) && (
{((cost == null && apiRequestFailedMessage) ||
apiReqStreamingFailedMessage) && (
<>
<p style={{ ...pStyle, color: "var(--vscode-errorForeground)" }}>
{apiRequestFailedMessage || apiReqStreamingFailedMessage}
{apiRequestFailedMessage?.toLowerCase().includes("powershell") && (
<p
style={{
...pStyle,
color: "var(--vscode-errorForeground)",
}}>
{apiRequestFailedMessage ||
apiReqStreamingFailedMessage}
{apiRequestFailedMessage
?.toLowerCase()
.includes("powershell") && (
<>
<br />
<br />
It seems like you're having Windows PowerShell issues, please see this{" "}
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>
.
@ -734,7 +942,10 @@ export const ChatRowContent = ({
{isExpanded && (
<div style={{ marginTop: "10px" }}>
<CodeAccordian
code={JSON.parse(message.text || "{}").request}
code={
JSON.parse(message.text || "{}")
.request
}
language="markdown"
isExpanded={true}
onToggleExpand={onToggleExpand}
@ -755,21 +966,29 @@ export const ChatRowContent = ({
return (
<div
style={{
backgroundColor: "var(--vscode-badge-background)",
backgroundColor:
"var(--vscode-badge-background)",
color: "var(--vscode-badge-foreground)",
borderRadius: "3px",
padding: "9px",
whiteSpace: "pre-line",
wordWrap: "break-word",
}}>
<span style={{ display: "block" }}>{highlightMentions(message.text)}</span>
<span style={{ display: "block" }}>
{highlightMentions(message.text)}
</span>
{message.images && message.images.length > 0 && (
<Thumbnails images={message.images} style={{ marginTop: "8px" }} />
<Thumbnails
images={message.images}
style={{ marginTop: "8px" }}
/>
)}
</div>
)
case "user_feedback_diff":
const tool = JSON.parse(message.text || "{}") as ClineSayTool
const tool = JSON.parse(
message.text || "{}",
) as ClineSayTool
return (
<div
style={{
@ -793,7 +1012,13 @@ export const ChatRowContent = ({
{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":
@ -808,7 +1033,12 @@ export const ChatRowContent = ({
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={{
@ -816,25 +1046,75 @@ export const ChatRowContent = ({
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 file. Retrying...
This usually happens when the model uses
search patterns that don't match anything in
the file. Retrying...
</div>
</div>
</>
)
case "completion_result":
const hasChanges =
message.text?.endsWith(
COMPLETION_RESULT_CHANGES_FLAG,
) ?? false
const text = hasChanges
? message.text?.slice(
0,
-COMPLETION_RESULT_CHANGES_FLAG.length,
)
: message.text
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":
@ -849,7 +1129,12 @@ export const ChatRowContent = ({
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={{
@ -857,18 +1142,29 @@ export const ChatRowContent = ({
fontSize: 18,
color: "#FFA500",
}}></i>
<span style={{ fontWeight: 500, color: "#FFA500" }}>
<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 (
<code>CMD/CTRL + Shift + P</code> "Update") and make sure you're using a supported
shell: zsh, bash, fish, or PowerShell (<code>CMD/CTRL + Shift + P</code>
Cline won't be able to view the command's
output. Please update VSCode (
<code>CMD/CTRL + Shift + P</code>
"Update") and make sure you're using a
supported shell: zsh, bash, fish, or
PowerShell (
<code>CMD/CTRL + Shift + P</code>
"Terminal: Select Default 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>
@ -921,7 +1217,13 @@ export const ChatRowContent = ({
{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":
@ -931,19 +1233,71 @@ export const ChatRowContent = ({
{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>
)
@ -987,7 +1341,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

@ -1,6 +1,17 @@
import React, { forwardRef, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"
import React, {
forwardRef,
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react"
import DynamicTextArea from "react-textarea-autosize"
import { mentionRegex, mentionRegexGlobal } from "../../../../src/shared/context-mentions"
import {
mentionRegex,
mentionRegexGlobal,
} from "../../../../src/shared/context-mentions"
import { useExtensionState } from "../../context/ExtensionStateContext"
import {
ContextMenuOptionType,
@ -45,7 +56,9 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
const { filePaths } = useExtensionState()
const [isTextAreaFocused, setIsTextAreaFocused] = useState(false)
const [thumbnailsHeight, setThumbnailsHeight] = useState(0)
const [textAreaBaseHeight, setTextAreaBaseHeight] = useState<number | undefined>(undefined)
const [textAreaBaseHeight, setTextAreaBaseHeight] = useState<
number | undefined
>(undefined)
const [showContextMenu, setShowContextMenu] = useState(false)
const [cursorPosition, setCursorPosition] = useState(0)
const [searchQuery, setSearchQuery] = useState("")
@ -53,9 +66,13 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
const [isMouseDownOnMenu, setIsMouseDownOnMenu] = useState(false)
const highlightLayerRef = useRef<HTMLDivElement>(null)
const [selectedMenuIndex, setSelectedMenuIndex] = useState(-1)
const [selectedType, setSelectedType] = useState<ContextMenuOptionType | null>(null)
const [justDeletedSpaceAfterMention, setJustDeletedSpaceAfterMention] = useState(false)
const [intendedCursorPosition, setIntendedCursorPosition] = useState<number | null>(null)
const [selectedType, setSelectedType] =
useState<ContextMenuOptionType | null>(null)
const [justDeletedSpaceAfterMention, setJustDeletedSpaceAfterMention] =
useState(false)
const [intendedCursorPosition, setIntendedCursorPosition] = useState<
number | null
>(null)
const contextMenuContainerRef = useRef<HTMLDivElement>(null)
const queryItems = useMemo(() => {
@ -64,7 +81,9 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
...filePaths
.map((file) => "/" + file)
.map((path) => ({
type: path.endsWith("/") ? ContextMenuOptionType.Folder : ContextMenuOptionType.File,
type: path.endsWith("/")
? ContextMenuOptionType.Folder
: ContextMenuOptionType.File,
value: path,
})),
]
@ -74,7 +93,9 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
const handleClickOutside = (event: MouseEvent) => {
if (
contextMenuContainerRef.current &&
!contextMenuContainerRef.current.contains(event.target as Node)
!contextMenuContainerRef.current.contains(
event.target as Node,
)
) {
setShowContextMenu(false)
}
@ -95,7 +116,10 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
return
}
if (type === ContextMenuOptionType.File || type === ContextMenuOptionType.Folder) {
if (
type === ContextMenuOptionType.File ||
type === ContextMenuOptionType.Folder
) {
if (!value) {
setSelectedType(type)
setSearchQuery("")
@ -110,7 +134,10 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
let insertValue = value || ""
if (type === ContextMenuOptionType.URL) {
insertValue = value || ""
} else if (type === ContextMenuOptionType.File || type === ContextMenuOptionType.Folder) {
} else if (
type === ContextMenuOptionType.File ||
type === ContextMenuOptionType.Folder
) {
insertValue = value || ""
} else if (type === ContextMenuOptionType.Problems) {
insertValue = "problems"
@ -123,7 +150,11 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
)
setInputValue(newValue)
const newCursorPosition = newValue.indexOf(" ", mentionIndex + insertValue.length) + 1
const newCursorPosition =
newValue.indexOf(
" ",
mentionIndex + insertValue.length,
) + 1
setCursorPosition(newCursorPosition)
setIntendedCursorPosition(newCursorPosition)
// textAreaRef.current.focus()
@ -154,7 +185,11 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
event.preventDefault()
setSelectedMenuIndex((prevIndex) => {
const direction = event.key === "ArrowUp" ? -1 : 1
const options = getContextMenuOptions(searchQuery, selectedType, queryItems)
const options = getContextMenuOptions(
searchQuery,
selectedType,
queryItems,
)
const optionsLength = options.length
if (optionsLength === 0) return prevIndex
@ -163,36 +198,53 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
const selectableOptions = options.filter(
(option) =>
option.type !== ContextMenuOptionType.URL &&
option.type !== ContextMenuOptionType.NoResults,
option.type !==
ContextMenuOptionType.NoResults,
)
if (selectableOptions.length === 0) return -1 // No selectable options
// Find the index of the next selectable option
const currentSelectableIndex = selectableOptions.findIndex(
(option) => option === options[prevIndex],
)
const currentSelectableIndex =
selectableOptions.findIndex(
(option) => option === options[prevIndex],
)
const newSelectableIndex =
(currentSelectableIndex + direction + selectableOptions.length) %
(currentSelectableIndex +
direction +
selectableOptions.length) %
selectableOptions.length
// Find the index of the selected option in the original options array
return options.findIndex((option) => option === selectableOptions[newSelectableIndex])
return options.findIndex(
(option) =>
option ===
selectableOptions[newSelectableIndex],
)
})
return
}
if ((event.key === "Enter" || event.key === "Tab") && selectedMenuIndex !== -1) {
if (
(event.key === "Enter" || event.key === "Tab") &&
selectedMenuIndex !== -1
) {
event.preventDefault()
const selectedOption = getContextMenuOptions(searchQuery, selectedType, queryItems)[
selectedMenuIndex
]
const selectedOption = getContextMenuOptions(
searchQuery,
selectedType,
queryItems,
)[selectedMenuIndex]
if (
selectedOption &&
selectedOption.type !== ContextMenuOptionType.URL &&
selectedOption.type !== ContextMenuOptionType.NoResults
selectedOption.type !==
ContextMenuOptionType.NoResults
) {
handleMentionSelect(selectedOption.type, selectedOption.value)
handleMentionSelect(
selectedOption.type,
selectedOption.value,
)
}
return
}
@ -209,25 +261,37 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
const charAfterCursor = inputValue[cursorPosition + 1]
const charBeforeIsWhitespace =
charBeforeCursor === " " || charBeforeCursor === "\n" || charBeforeCursor === "\r\n"
charBeforeCursor === " " ||
charBeforeCursor === "\n" ||
charBeforeCursor === "\r\n"
const charAfterIsWhitespace =
charAfterCursor === " " || charAfterCursor === "\n" || charAfterCursor === "\r\n"
charAfterCursor === " " ||
charAfterCursor === "\n" ||
charAfterCursor === "\r\n"
// checks if char before cusor is whitespace after a mention
if (
charBeforeIsWhitespace &&
inputValue.slice(0, cursorPosition - 1).match(new RegExp(mentionRegex.source + "$")) // "$" is added to ensure the match occurs at the end of the string
inputValue
.slice(0, cursorPosition - 1)
.match(new RegExp(mentionRegex.source + "$")) // "$" is added to ensure the match occurs at the end of the string
) {
const newCursorPosition = cursorPosition - 1
// if mention is followed by another word, then instead of deleting the space separating them we just move the cursor to the end of the mention
if (!charAfterIsWhitespace) {
event.preventDefault()
textAreaRef.current?.setSelectionRange(newCursorPosition, newCursorPosition)
textAreaRef.current?.setSelectionRange(
newCursorPosition,
newCursorPosition,
)
setCursorPosition(newCursorPosition)
}
setCursorPosition(newCursorPosition)
setJustDeletedSpaceAfterMention(true)
} else if (justDeletedSpaceAfterMention) {
const { newText, newPosition } = removeMention(inputValue, cursorPosition)
const { newText, newPosition } = removeMention(
inputValue,
cursorPosition,
)
if (newText !== inputValue) {
event.preventDefault()
setInputValue(newText)
@ -257,7 +321,10 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
useLayoutEffect(() => {
if (intendedCursorPosition !== null && textAreaRef.current) {
textAreaRef.current.setSelectionRange(intendedCursorPosition, intendedCursorPosition)
textAreaRef.current.setSelectionRange(
intendedCursorPosition,
intendedCursorPosition,
)
setIntendedCursorPosition(null) // Reset the state
}
}, [inputValue, intendedCursorPosition])
@ -268,12 +335,21 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
const newCursorPosition = e.target.selectionStart
setInputValue(newValue)
setCursorPosition(newCursorPosition)
const showMenu = shouldShowContextMenu(newValue, newCursorPosition)
const showMenu = shouldShowContextMenu(
newValue,
newCursorPosition,
)
setShowContextMenu(showMenu)
if (showMenu) {
const lastAtIndex = newValue.lastIndexOf("@", newCursorPosition - 1)
const query = newValue.slice(lastAtIndex + 1, newCursorPosition)
const lastAtIndex = newValue.lastIndexOf(
"@",
newCursorPosition - 1,
)
const query = newValue.slice(
lastAtIndex + 1,
newCursorPosition,
)
setSearchQuery(query)
if (query.length > 0) {
setSelectedMenuIndex(0)
@ -313,9 +389,13 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
e.preventDefault()
const trimmedUrl = pastedText.trim()
const newValue =
inputValue.slice(0, cursorPosition) + trimmedUrl + " " + inputValue.slice(cursorPosition)
inputValue.slice(0, cursorPosition) +
trimmedUrl +
" " +
inputValue.slice(cursorPosition)
setInputValue(newValue)
const newCursorPosition = cursorPosition + trimmedUrl.length + 1
const newCursorPosition =
cursorPosition + trimmedUrl.length + 1
setCursorPosition(newCursorPosition)
setIntendedCursorPosition(newCursorPosition)
setShowContextMenu(false)
@ -350,27 +430,47 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
const reader = new FileReader()
reader.onloadend = () => {
if (reader.error) {
console.error("Error reading file:", reader.error)
console.error(
"Error reading file:",
reader.error,
)
resolve(null)
} else {
const result = reader.result
resolve(typeof result === "string" ? result : null)
resolve(
typeof result === "string"
? result
: null,
)
}
}
reader.readAsDataURL(blob)
})
})
const imageDataArray = await Promise.all(imagePromises)
const dataUrls = imageDataArray.filter((dataUrl): dataUrl is string => dataUrl !== null)
const dataUrls = imageDataArray.filter(
(dataUrl): dataUrl is string => dataUrl !== null,
)
//.map((dataUrl) => dataUrl.split(",")[1]) // strip the mime type prefix, sharp doesn't need it
if (dataUrls.length > 0) {
setSelectedImages((prevImages) => [...prevImages, ...dataUrls].slice(0, MAX_IMAGES_PER_MESSAGE))
setSelectedImages((prevImages) =>
[...prevImages, ...dataUrls].slice(
0,
MAX_IMAGES_PER_MESSAGE,
),
)
} else {
console.warn("No valid images were processed")
}
}
},
[shouldDisableImages, setSelectedImages, cursorPosition, setInputValue, inputValue],
[
shouldDisableImages,
setSelectedImages,
cursorPosition,
setInputValue,
inputValue,
],
)
const handleThumbnailsHeightChange = useCallback((height: number) => {
@ -394,11 +494,18 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
highlightLayerRef.current.innerHTML = text
.replace(/\n$/, "\n\n")
.replace(/[<>&]/g, (c) => ({ "<": "&lt;", ">": "&gt;", "&": "&amp;" })[c] || c)
.replace(mentionRegexGlobal, '<mark class="mention-context-textarea-highlight">$&</mark>')
.replace(
/[<>&]/g,
(c) => ({ "<": "&lt;", ">": "&gt;", "&": "&amp;" })[c] || c,
)
.replace(
mentionRegexGlobal,
'<mark class="mention-context-textarea-highlight">$&</mark>',
)
highlightLayerRef.current.scrollTop = textAreaRef.current.scrollTop
highlightLayerRef.current.scrollLeft = textAreaRef.current.scrollLeft
highlightLayerRef.current.scrollLeft =
textAreaRef.current.scrollLeft
}, [])
useLayoutEffect(() => {
@ -413,7 +520,16 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
const handleKeyUp = useCallback(
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown", "Home", "End"].includes(e.key)) {
if (
[
"ArrowLeft",
"ArrowRight",
"ArrowUp",
"ArrowDown",
"Home",
"End",
].includes(e.key)
) {
updateCursorPosition()
}
},
@ -502,7 +618,10 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
onSelect={updateCursorPosition}
onMouseUp={updateCursorPosition}
onHeightChange={(height) => {
if (textAreaBaseHeight === undefined || height < textAreaBaseHeight) {
if (
textAreaBaseHeight === undefined ||
height < textAreaBaseHeight
) {
setTextAreaBaseHeight(height)
}
onHeightChange?.(height)
@ -567,7 +686,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" : ""

View file

@ -35,14 +35,30 @@ interface ChatViewProps {
export const MAX_IMAGES_PER_MESSAGE = 20 // Anthropic limits to 20 images
const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryView }: ChatViewProps) => {
const { version, clineMessages: messages, taskHistory, apiConfiguration } = useExtensionState()
const ChatView = ({
isHidden,
showAnnouncement,
hideAnnouncement,
showHistoryView,
}: ChatViewProps) => {
const {
version,
clineMessages: messages,
taskHistory,
apiConfiguration,
} = useExtensionState()
//const task = messages.length > 0 ? (messages[0].say === "task" ? messages[0] : undefined) : undefined) : undefined
const task = useMemo(() => messages.at(0), [messages]) // leaving this less safe version here since if the first message is not a task, then the extension is in a bad state and needs to be debugged (see Cline.abort)
const modifiedMessages = useMemo(() => combineApiRequests(combineCommandSequences(messages.slice(1))), [messages])
const modifiedMessages = useMemo(
() => combineApiRequests(combineCommandSequences(messages.slice(1))),
[messages],
)
// has to be after api_req_finished are all reduced into api_req_started messages
const apiMetrics = useMemo(() => getApiMetrics(modifiedMessages), [modifiedMessages])
const apiMetrics = useMemo(
() => getApiMetrics(modifiedMessages),
[modifiedMessages],
)
const [inputValue, setInputValue] = useState("")
const textAreaRef = useRef<HTMLTextAreaElement>(null)
@ -52,11 +68,17 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
// we need to hold on to the ask because useEffect > lastMessage will always let us know when an ask comes in and handle it, but by the time handleMessage is called, the last message might not be the ask anymore (it could be a say that followed)
const [clineAsk, setClineAsk] = useState<ClineAsk | undefined>(undefined)
const [enableButtons, setEnableButtons] = useState<boolean>(false)
const [primaryButtonText, setPrimaryButtonText] = useState<string | undefined>("Approve")
const [secondaryButtonText, setSecondaryButtonText] = useState<string | undefined>("Reject")
const [primaryButtonText, setPrimaryButtonText] = useState<
string | undefined
>("Approve")
const [secondaryButtonText, setSecondaryButtonText] = useState<
string | undefined
>("Reject")
const [didClickCancel, setDidClickCancel] = useState(false)
const virtuosoRef = useRef<VirtuosoHandle>(null)
const [expandedRows, setExpandedRows] = useState<Record<number, boolean>>({})
const [expandedRows, setExpandedRows] = useState<Record<number, boolean>>(
{},
)
const scrollContainerRef = useRef<HTMLDivElement>(null)
const disableAutoScrollRef = useRef(false)
const [showScrollToBottom, setShowScrollToBottom] = useState(false)
@ -107,7 +129,9 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
setTextAreaDisabled(isPartial)
setClineAsk("tool")
setEnableButtons(!isPartial)
const tool = JSON.parse(lastMessage.text || "{}") as ClineSayTool
const tool = JSON.parse(
lastMessage.text || "{}",
) as ClineSayTool
switch (tool.tool) {
case "editedExistingFile":
case "newFileCreated":
@ -232,7 +256,10 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
const isStreaming = useMemo(() => {
const isLastAsk = !!modifiedMessages.at(-1)?.ask // checking clineAsk isn't enough since messages effect may be called again for a tool for example, set clineAsk to its value, and if the next message is not an ask then it doesn't reset. This is likely due to how much more often we're updating messages as compared to before, and should be resolved with optimizations as it's likely a rendering bug. but as a final guard for now, the cancel button will show if the last message is not an ask
const isToolCurrentlyAsking =
isLastAsk && clineAsk !== undefined && enableButtons && primaryButtonText !== undefined
isLastAsk &&
clineAsk !== undefined &&
enableButtons &&
primaryButtonText !== undefined
if (isToolCurrentlyAsking) {
return false
}
@ -241,8 +268,15 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
if (isLastMessagePartial) {
return true
} else {
const lastApiReqStarted = findLast(modifiedMessages, (message) => message.say === "api_req_started")
if (lastApiReqStarted && lastApiReqStarted.text != null && lastApiReqStarted.say === "api_req_started") {
const lastApiReqStarted = findLast(
modifiedMessages,
(message) => message.say === "api_req_started",
)
if (
lastApiReqStarted &&
lastApiReqStarted.text != null &&
lastApiReqStarted.say === "api_req_started"
) {
const cost = JSON.parse(lastApiReqStarted.text).cost
if (cost === undefined) {
// api request has not finished yet
@ -313,7 +347,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":
@ -347,7 +384,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)
@ -371,7 +411,9 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
}, [])
const shouldDisableImages =
!selectedModelInfo.supportsImages || textAreaDisabled || selectedImages.length >= MAX_IMAGES_PER_MESSAGE
!selectedModelInfo.supportsImages ||
textAreaDisabled ||
selectedImages.length >= MAX_IMAGES_PER_MESSAGE
const handleMessage = useCallback(
(e: MessageEvent) => {
@ -380,7 +422,11 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
case "action":
switch (message.action!) {
case "didBecomeVisible":
if (!isHidden && !textAreaDisabled && !enableButtons) {
if (
!isHidden &&
!textAreaDisabled &&
!enableButtons
) {
textAreaRef.current?.focus()
}
break
@ -390,14 +436,20 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
const newImages = message.images ?? []
if (newImages.length > 0) {
setSelectedImages((prevImages) =>
[...prevImages, ...newImages].slice(0, MAX_IMAGES_PER_MESSAGE),
[...prevImages, ...newImages].slice(
0,
MAX_IMAGES_PER_MESSAGE,
),
)
}
break
case "invoke":
switch (message.invoke!) {
case "sendMessage":
handleSendMessage(message.text ?? "", message.images ?? [])
handleSendMessage(
message.text ?? "",
message.images ?? [],
)
break
case "primaryButtonClick":
handlePrimaryButtonClick()
@ -454,10 +506,14 @@ 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)
if ((message.text ?? "") === "" && (message.images?.length ?? 0) === 0) {
if (
(message.text ?? "") === "" &&
(message.images?.length ?? 0) === 0
) {
return false
}
break
@ -499,7 +555,10 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
}
visibleMessages.forEach((message) => {
if (message.ask === "browser_action_launch" || message.say === "browser_action_launch") {
if (
message.ask === "browser_action_launch" ||
message.say === "browser_action_launch"
) {
// complete existing browser session if any
endBrowserSession()
// start new
@ -510,7 +569,9 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
if (message.say === "api_req_started") {
// get last api_req_started in currentGroup to check if it's cancelled. If it is then this api req is not part of the current browser session
const lastApiReqStarted = [...currentGroup].reverse().find((m) => m.say === "api_req_started")
const lastApiReqStarted = [...currentGroup]
.reverse()
.find((m) => m.say === "api_req_started")
if (lastApiReqStarted?.text != null) {
const info = JSON.parse(lastApiReqStarted.text)
const isCancelled = info.cancelReason != null
@ -527,7 +588,9 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
// Check if this is a close action
if (message.say === "browser_action") {
const browserAction = JSON.parse(message.text || "{}") as ClineSayBrowserAction
const browserAction = JSON.parse(
message.text || "{}",
) as ClineSayBrowserAction
if (browserAction.action === "close") {
endBrowserSession()
}
@ -579,7 +642,9 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
(ts: number) => {
const isCollapsing = expandedRows[ts] ?? false
const lastGroup = groupedMessages.at(-1)
const isLast = Array.isArray(lastGroup) ? lastGroup[0].ts === ts : lastGroup?.ts === ts
const isLast = Array.isArray(lastGroup)
? lastGroup[0].ts === ts
: lastGroup?.ts === ts
const secondToLastGroup = groupedMessages.at(-2)
const isSecondToLast = Array.isArray(secondToLastGroup)
? secondToLastGroup[0].ts === ts
@ -656,7 +721,9 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
const handleWheel = useCallback((event: Event) => {
const wheelEvent = event as WheelEvent
if (wheelEvent.deltaY && wheelEvent.deltaY < 0) {
if (scrollContainerRef.current?.contains(wheelEvent.target as Node)) {
if (
scrollContainerRef.current?.contains(wheelEvent.target as Node)
) {
// user scrolled up
disableAutoScrollRef.current = true
}
@ -665,7 +732,9 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
useEvent("wheel", handleWheel, window, { passive: true }) // passive improves scrolling performance
const placeholderText = useMemo(() => {
const text = task ? "Type a message (@ to add context)..." : "Type your task here (@ to add context)..."
const text = task
? "Type a message (@ to add context)..."
: "Type your task here (@ to add context)..."
return text
}, [task])
@ -680,7 +749,9 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
lastModifiedMessage={modifiedMessages.at(-1)}
onHeightChange={handleRowHeightChange}
// Pass handlers for each message in the group
isExpanded={(messageTs: number) => expandedRows[messageTs] ?? false}
isExpanded={(messageTs: number) =>
expandedRows[messageTs] ?? false
}
onToggleExpand={(messageTs: number) => {
setExpandedRows((prev) => ({
...prev,
@ -704,7 +775,13 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
/>
)
},
[expandedRows, modifiedMessages, groupedMessages.length, toggleRowExpansion, handleRowHeightChange],
[
expandedRows,
modifiedMessages,
groupedMessages.length,
toggleRowExpansion,
handleRowHeightChange,
],
)
return (
@ -724,7 +801,9 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
task={task}
tokensIn={apiMetrics.totalTokensIn}
tokensOut={apiMetrics.totalTokensOut}
doesModelSupportPromptCache={selectedModelInfo.supportsPromptCache}
doesModelSupportPromptCache={
selectedModelInfo.supportsPromptCache
}
cacheWrites={apiMetrics.totalCacheWrites}
cacheReads={apiMetrics.totalCacheReads}
totalCost={apiMetrics.totalCost}
@ -740,7 +819,12 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
flexDirection: "column",
paddingBottom: "10px",
}}>
{showAnnouncement && <Announcement version={version} hideAnnouncement={hideAnnouncement} />}
{showAnnouncement && (
<Announcement
version={version}
hideAnnouncement={hideAnnouncement}
/>
)}
<div style={{ padding: "0 20px", flexShrink: 0 }}>
<h2>What can I do for you?</h2>
<p>
@ -750,13 +834,18 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
style={{ display: "inline" }}>
Claude 3.5 Sonnet's agentic coding capabilities,
</VSCodeLink>{" "}
I can handle complex software development tasks step-by-step. With tools that let me create
& edit files, explore complex projects, use the browser, and execute terminal commands
(after you grant permission), I can assist you in ways that go beyond code completion or
tech support. I can even use MCP to create new tools and extend my own capabilities.
I can handle complex software development tasks
step-by-step. With tools that let me create & edit
files, explore complex projects, use the browser,
and execute terminal commands (after you grant
permission), I can assist you in ways that go beyond
code completion or tech support. I can even use MCP
to create new tools and extend my own capabilities.
</p>
</div>
{taskHistory.length > 0 && <HistoryPreview showHistoryView={showHistoryView} />}
{taskHistory.length > 0 && (
<HistoryPreview showHistoryView={showHistoryView} />
)}
</div>
)}
@ -787,7 +876,9 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
{task && (
<>
<div style={{ flexGrow: 1, display: "flex" }} ref={scrollContainerRef}>
<div
style={{ flexGrow: 1, display: "flex" }}
ref={scrollContainerRef}>
<Virtuoso
ref={virtuosoRef}
key={task.ts} // trick to make sure virtuoso re-renders when task changes, and we use initialTopMostItemIndex to start at the bottom
@ -800,7 +891,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) => {
@ -808,7 +902,9 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
if (isAtBottom) {
disableAutoScrollRef.current = false
}
setShowScrollToBottom(disableAutoScrollRef.current && !isAtBottom)
setShowScrollToBottom(
disableAutoScrollRef.current && !isAtBottom,
)
}}
atBottomThreshold={10} // anything lower causes issues with followOutput
initialTopMostItemIndex={groupedMessages.length - 1}
@ -826,15 +922,20 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
scrollToBottomSmooth()
disableAutoScrollRef.current = false
}}>
<span className="codicon codicon-chevron-down" style={{ fontSize: "18px" }}></span>
<span
className="codicon codicon-chevron-down"
style={{ fontSize: "18px" }}></span>
</ScrollToBottomButton>
</div>
) : (
<div
style={{
opacity:
primaryButtonText || secondaryButtonText || isStreaming
? enableButtons || (isStreaming && !didClickCancel)
primaryButtonText ||
secondaryButtonText ||
isStreaming
? enableButtons ||
(isStreaming && !didClickCancel)
? 1
: 0.5
: 0,
@ -847,7 +948,9 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
disabled={!enableButtons}
style={{
flex: secondaryButtonText ? 1 : 2,
marginRight: secondaryButtonText ? "6px" : "0",
marginRight: secondaryButtonText
? "6px"
: "0",
}}
onClick={handlePrimaryButtonClick}>
{primaryButtonText}
@ -856,13 +959,18 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
{(secondaryButtonText || isStreaming) && (
<VSCodeButton
appearance="secondary"
disabled={!enableButtons && !(isStreaming && !didClickCancel)}
disabled={
!enableButtons &&
!(isStreaming && !didClickCancel)
}
style={{
flex: isStreaming ? 2 : 1,
marginLeft: isStreaming ? 0 : "6px",
}}
onClick={handleSecondaryButtonClick}>
{isStreaming ? "Cancel" : secondaryButtonText}
{isStreaming
? "Cancel"
: secondaryButtonText}
</VSCodeButton>
)}
</div>
@ -891,7 +999,11 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
}
const ScrollToBottomButton = styled.div`
background-color: color-mix(in srgb, var(--vscode-toolbar-hoverBackground) 55%, transparent);
background-color: color-mix(
in srgb,
var(--vscode-toolbar-hoverBackground) 55%,
transparent
);
border-radius: 3px;
overflow: hidden;
cursor: pointer;
@ -902,11 +1014,19 @@ const ScrollToBottomButton = styled.div`
height: 24px;
&:hover {
background-color: color-mix(in srgb, var(--vscode-toolbar-hoverBackground) 90%, transparent);
background-color: color-mix(
in srgb,
var(--vscode-toolbar-hoverBackground) 90%,
transparent
);
}
&:active {
background-color: color-mix(in srgb, var(--vscode-toolbar-hoverBackground) 70%, transparent);
background-color: color-mix(
in srgb,
var(--vscode-toolbar-hoverBackground) 70%,
transparent
);
}
`

View file

@ -1,5 +1,9 @@
import React, { useEffect, useMemo, useRef } from "react"
import { ContextMenuOptionType, ContextMenuQueryItem, getContextMenuOptions } from "../../utils/context-mentions"
import {
ContextMenuOptionType,
ContextMenuQueryItem,
getContextMenuOptions,
} from "../../utils/context-mentions"
import { removeLeadingNonAlphanumeric } from "../common/CodeAccordian"
interface ContextMenuProps {
@ -30,13 +34,16 @@ const ContextMenu: React.FC<ContextMenuProps> = ({
useEffect(() => {
if (menuRef.current) {
const selectedElement = menuRef.current.children[selectedIndex] as HTMLElement
const selectedElement = menuRef.current.children[
selectedIndex
] as HTMLElement
if (selectedElement) {
const menuRect = menuRef.current.getBoundingClientRect()
const selectedRect = selectedElement.getBoundingClientRect()
if (selectedRect.bottom > menuRect.bottom) {
menuRef.current.scrollTop += selectedRect.bottom - menuRect.bottom
menuRef.current.scrollTop +=
selectedRect.bottom - menuRect.bottom
} else if (selectedRect.top < menuRect.top) {
menuRef.current.scrollTop -= menuRect.top - selectedRect.top
}
@ -67,12 +74,21 @@ const ContextMenu: React.FC<ContextMenuProps> = ({
direction: "rtl",
textAlign: "left",
}}>
{removeLeadingNonAlphanumeric(option.value || "") + "\u200E"}
{removeLeadingNonAlphanumeric(
option.value || "",
) + "\u200E"}
</span>
</>
)
} else {
return <span>Add {option.type === ContextMenuOptionType.File ? "File" : "Folder"}</span>
return (
<span>
Add{" "}
{option.type === ContextMenuOptionType.File
? "File"
: "Folder"}
</span>
)
}
}
}
@ -95,7 +111,10 @@ const ContextMenu: React.FC<ContextMenuProps> = ({
}
const isOptionSelectable = (option: ContextMenuQueryItem): boolean => {
return option.type !== ContextMenuOptionType.NoResults && option.type !== ContextMenuOptionType.URL
return (
option.type !== ContextMenuOptionType.NoResults &&
option.type !== ContextMenuOptionType.URL
)
}
return (
@ -125,24 +144,35 @@ const ContextMenu: React.FC<ContextMenuProps> = ({
{filteredOptions.map((option, index) => (
<div
key={`${option.type}-${option.value || index}`}
onClick={() => isOptionSelectable(option) && onSelect(option.type, option.value)}
onClick={() =>
isOptionSelectable(option) &&
onSelect(option.type, option.value)
}
style={{
padding: "8px 12px",
cursor: isOptionSelectable(option) ? "pointer" : "default",
cursor: isOptionSelectable(option)
? "pointer"
: "default",
color:
index === selectedIndex && isOptionSelectable(option)
index === selectedIndex &&
isOptionSelectable(option)
? "var(--vscode-quickInputList-focusForeground)"
: "",
borderBottom: "1px solid var(--vscode-editorGroup-border)",
borderBottom:
"1px solid var(--vscode-editorGroup-border)",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
backgroundColor:
index === selectedIndex && isOptionSelectable(option)
index === selectedIndex &&
isOptionSelectable(option)
? "var(--vscode-quickInputList-focusBackground)"
: "",
}}
onMouseEnter={() => isOptionSelectable(option) && setSelectedIndex(index)}>
onMouseEnter={() =>
isOptionSelectable(option) &&
setSelectedIndex(index)
}>
<div
style={{
display: "flex",
@ -153,15 +183,24 @@ 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>
{(option.type === ContextMenuOptionType.File || option.type === ContextMenuOptionType.Folder) &&
{(option.type === ContextMenuOptionType.File ||
option.type === ContextMenuOptionType.Folder) &&
!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 ||
@ -170,7 +209,11 @@ const ContextMenu: React.FC<ContextMenuProps> = ({
option.value)) && (
<i
className="codicon codicon-add"
style={{ fontSize: "14px", flexShrink: 0, marginLeft: 8 }}
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,8 @@ 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)
@ -81,9 +83,11 @@ const TaskHeader: React.FC<TaskHeaderProps> = ({
if (textRef.current && textContainerRef.current) {
let textContainerHeight = textContainerRef.current.clientHeight
if (!textContainerHeight) {
textContainerHeight = textContainerRef.current.getBoundingClientRect().height
textContainerHeight =
textContainerRef.current.getBoundingClientRect().height
}
const isOverflowing = textRef.current.scrollHeight > textContainerHeight
const isOverflowing =
textRef.current.scrollHeight > textContainerHeight
// necessary to show see more button again if user resizes window to expand and then back to collapse
if (!isOverflowing) {
setIsTextExpanded(false)
@ -101,7 +105,9 @@ const TaskHeader: React.FC<TaskHeaderProps> = ({
)
}, [apiConfiguration?.apiProvider])
const shouldShowPromptCacheInfo = doesModelSupportPromptCache && apiConfiguration?.apiProvider !== "openrouter"
const shouldShowPromptCacheInfo =
doesModelSupportPromptCache &&
apiConfiguration?.apiProvider !== "openrouter"
return (
<div style={{ padding: "10px 13px 10px 13px" }}>
@ -137,8 +143,14 @@ 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 }}>
<span className={`codicon codicon-chevron-${isTaskExpanded ? "down" : "right"}`}></span>
<div
style={{
display: "flex",
alignItems: "center",
flexShrink: 0,
}}>
<span
className={`codicon codicon-chevron-${isTaskExpanded ? "down" : "right"}`}></span>
</div>
<div
style={{
@ -149,9 +161,13 @@ const TaskHeader: React.FC<TaskHeaderProps> = ({
flexGrow: 1,
minWidth: 0, // This allows the div to shrink below its content size
}}>
<span style={{ fontWeight: "bold" }}>Task{!isTaskExpanded && ":"}</span>
<span style={{ fontWeight: "bold" }}>
Task{!isTaskExpanded && ":"}
</span>
{!isTaskExpanded && (
<span style={{ marginLeft: 4 }}>{highlightMentions(task.text, false)}</span>
<span style={{ marginLeft: 4 }}>
{highlightMentions(task.text, false)}
</span>
)}
</div>
</div>
@ -159,7 +175,8 @@ const TaskHeader: React.FC<TaskHeaderProps> = ({
<div
style={{
marginLeft: 10,
backgroundColor: "color-mix(in srgb, var(--vscode-badge-foreground) 70%, transparent)",
backgroundColor:
"color-mix(in srgb, var(--vscode-badge-foreground) 70%, transparent)",
color: "var(--vscode-badge-background)",
padding: "2px 4px",
borderRadius: "500px",
@ -171,7 +188,10 @@ const TaskHeader: React.FC<TaskHeaderProps> = ({
${totalCost?.toFixed(4)}
</div>
)}
<VSCodeButton appearance="icon" onClick={onClose} style={{ marginLeft: 6, flexShrink: 0 }}>
<VSCodeButton
appearance="icon"
onClick={onClose}
style={{ marginLeft: 6, flexShrink: 0 }}>
<span className="codicon codicon-close"></span>
</VSCodeButton>
</div>
@ -191,7 +211,9 @@ const TaskHeader: React.FC<TaskHeaderProps> = ({
ref={textRef}
style={{
display: "-webkit-box",
WebkitLineClamp: isTextExpanded ? "unset" : 3,
WebkitLineClamp: isTextExpanded
? "unset"
: 3,
WebkitBoxOrient: "vertical",
overflow: "hidden",
whiteSpace: "pre-wrap",
@ -223,9 +245,12 @@ const TaskHeader: React.FC<TaskHeaderProps> = ({
color: "var(--vscode-textLink-foreground)",
paddingRight: 0,
paddingLeft: 3,
backgroundColor: "var(--vscode-badge-background)",
backgroundColor:
"var(--vscode-badge-background)",
}}
onClick={() => setIsTextExpanded(!isTextExpanded)}>
onClick={() =>
setIsTextExpanded(!isTextExpanded)
}>
See more
</div>
</div>
@ -240,57 +265,130 @@ const TaskHeader: React.FC<TaskHeaderProps> = ({
textAlign: "right",
paddingRight: 2,
}}
onClick={() => setIsTextExpanded(!isTextExpanded)}>
onClick={() =>
setIsTextExpanded(!isTextExpanded)
}>
See less
</div>
)}
{task.images && task.images.length > 0 && <Thumbnails images={task.images} />}
<div style={{ display: "flex", flexDirection: "column", gap: "4px" }}>
{task.images && task.images.length > 0 && (
<Thumbnails images={task.images} />
)}
<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" }}>
<span style={{ fontWeight: "bold" }}>Tokens:</span>
<span style={{ display: "flex", alignItems: "center", gap: "3px" }}>
<div
style={{
display: "flex",
alignItems: "center",
gap: "4px",
flexWrap: "wrap",
}}>
<span style={{ fontWeight: "bold" }}>
Tokens:
</span>
<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" }}>
<span style={{ fontWeight: "bold" }}>Cache:</span>
<span style={{ display: "flex", alignItems: "center", gap: "3px" }}>
<i
className="codicon codicon-database"
style={{ fontSize: "12px", fontWeight: "bold", marginBottom: "-1px" }}
/>
+{formatLargeNumber(cacheWrites || 0)}
</span>
<span style={{ display: "flex", alignItems: "center", gap: "3px" }}>
<i
className="codicon codicon-arrow-right"
style={{ fontSize: "12px", fontWeight: "bold", marginBottom: 0 }}
/>
{formatLargeNumber(cacheReads || 0)}
</span>
</div>
)}
{shouldShowPromptCacheInfo &&
(cacheReads !== undefined ||
cacheWrites !== undefined) && (
<div
style={{
display: "flex",
alignItems: "center",
gap: "4px",
flexWrap: "wrap",
}}>
<span style={{ fontWeight: "bold" }}>
Cache:
</span>
<span
style={{
display: "flex",
alignItems: "center",
gap: "3px",
}}>
<i
className="codicon codicon-database"
style={{
fontSize: "12px",
fontWeight: "bold",
marginBottom: "-1px",
}}
/>
+
{formatLargeNumber(
cacheWrites || 0,
)}
</span>
<span
style={{
display: "flex",
alignItems: "center",
gap: "3px",
}}>
<i
className="codicon codicon-arrow-right"
style={{
fontSize: "12px",
fontWeight: "bold",
marginBottom: 0,
}}
/>
{formatLargeNumber(cacheReads || 0)}
</span>
</div>
)}
{isCostAvailable && (
<div
style={{
@ -298,11 +396,54 @@ const TaskHeader: React.FC<TaskHeaderProps> = ({
justifyContent: "space-between",
alignItems: "center",
}}>
<div style={{ display: "flex", alignItems: "center", gap: "4px" }}>
<span style={{ fontWeight: "bold" }}>API Cost:</span>
<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>
@ -353,9 +494,15 @@ export const highlightMentions = (text?: string, withShadow = true) => {
return (
<span
key={index}
className={withShadow ? "mention-context-highlight-with-shadow" : "mention-context-highlight"}
className={
withShadow
? "mention-context-highlight-with-shadow"
: "mention-context-highlight"
}
style={{ cursor: "pointer" }}
onClick={() => vscode.postMessage({ type: "openMention", text: part })}>
onClick={() =>
vscode.postMessage({ type: "openMention", text: part })
}>
@{part}
</span>
)
@ -363,18 +510,43 @@ 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,294 @@
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

@ -20,7 +20,8 @@ We need to remove leading non-alphanumeric characters from the path in order for
[^a-zA-Z0-9]+: Matches one or more characters that are not alphanumeric.
The replace method removes these matched characters, effectively trimming the string up to the first alphanumeric character.
*/
export const removeLeadingNonAlphanumeric = (path: string): string => path.replace(/^[^a-zA-Z0-9]+/, "")
export const removeLeadingNonAlphanumeric = (path: string): string =>
path.replace(/^[^a-zA-Z0-9]+/, "")
const CodeAccordian = ({
code,
@ -34,7 +35,9 @@ const CodeAccordian = ({
isLoading,
}: CodeAccordianProps) => {
const inferredLanguage = useMemo(
() => code && (language ?? (path ? getLanguageFromPath(path) : undefined)),
() =>
code &&
(language ?? (path ? getLanguageFromPath(path) : undefined)),
[path, language, code],
)
@ -90,12 +93,14 @@ const CodeAccordian = ({
direction: "rtl",
textAlign: "left",
}}>
{removeLeadingNonAlphanumeric(path ?? "") + "\u200E"}
{removeLeadingNonAlphanumeric(path ?? "") +
"\u200E"}
</span>
</>
)}
<div style={{ flexGrow: 1 }}></div>
<span className={`codicon codicon-chevron-${isExpanded ? "up" : "down"}`}></span>
<span
className={`codicon codicon-chevron-${isExpanded ? "up" : "down"}`}></span>
</div>
)}
{(!(path || isFeedback || isConsoleLogs) || isExpanded) && (

View file

@ -5,7 +5,8 @@ import styled from "styled-components"
import { visit } from "unist-util-visit"
import { useExtensionState } from "../../context/ExtensionStateContext"
export const CODE_BLOCK_BG_COLOR = "var(--vscode-editor-background, --vscode-sideBar-background, rgb(30 30 30))"
export const CODE_BLOCK_BG_COLOR =
"var(--vscode-editor-background, --vscode-sideBar-background, rgb(30 30 30))"
/*
overflowX: auto + inner div with padding results in an issue where the top/left/bottom padding renders but the right padding inside does not count as overflow as the width of the element is not exceeded. Once the inner div is outside the boundaries of the parent it counts as overflow.
@ -59,7 +60,10 @@ const StyledMarkdown = styled.div<{ forceWrap: boolean }>`
word-wrap: break-word;
border-radius: 5px;
background-color: ${CODE_BLOCK_BG_COLOR};
font-size: var(--vscode-editor-font-size, var(--vscode-font-size, 12px));
font-size: var(
--vscode-editor-font-size,
var(--vscode-font-size, 12px)
);
font-family: var(--vscode-editor-font-family);
}
@ -135,7 +139,9 @@ const CodeBlock = memo(({ source, forceWrap = false }: CodeBlockProps) => {
],
rehypeReactOptions: {
components: {
pre: ({ node, ...preProps }: any) => <StyledPre {...preProps} theme={theme} />,
pre: ({ node, ...preProps }: any) => (
<StyledPre {...preProps} theme={theme} />
),
},
},
})
@ -151,7 +157,9 @@ const CodeBlock = memo(({ source, forceWrap = false }: CodeBlockProps) => {
maxHeight: forceWrap ? "none" : "100%",
backgroundColor: CODE_BLOCK_BG_COLOR,
}}>
<StyledMarkdown forceWrap={forceWrap}>{reactContent}</StyledMarkdown>
<StyledMarkdown forceWrap={forceWrap}>
{reactContent}
</StyledMarkdown>
</div>
)
})

View file

@ -57,43 +57,67 @@ function Demo() {
<div className="grid gap-3 p-2 place-items-start">
<VSCodeDataGrid>
<VSCodeDataGridRow row-type="header">
<VSCodeDataGridCell cell-type="columnheader" grid-column="1">
<VSCodeDataGridCell
cell-type="columnheader"
grid-column="1">
A Custom Header Title
</VSCodeDataGridCell>
<VSCodeDataGridCell cell-type="columnheader" grid-column="2">
<VSCodeDataGridCell
cell-type="columnheader"
grid-column="2">
Another Custom Title
</VSCodeDataGridCell>
<VSCodeDataGridCell cell-type="columnheader" grid-column="3">
<VSCodeDataGridCell
cell-type="columnheader"
grid-column="3">
Title Is Custom
</VSCodeDataGridCell>
<VSCodeDataGridCell cell-type="columnheader" grid-column="4">
<VSCodeDataGridCell
cell-type="columnheader"
grid-column="4">
Custom Title
</VSCodeDataGridCell>
</VSCodeDataGridRow>
{rowData.map((row, index) => (
<VSCodeDataGridRow key={index}>
<VSCodeDataGridCell grid-column="1">{row.cell1}</VSCodeDataGridCell>
<VSCodeDataGridCell grid-column="2">{row.cell2}</VSCodeDataGridCell>
<VSCodeDataGridCell grid-column="3">{row.cell3}</VSCodeDataGridCell>
<VSCodeDataGridCell grid-column="4">{row.cell4}</VSCodeDataGridCell>
<VSCodeDataGridCell grid-column="1">
{row.cell1}
</VSCodeDataGridCell>
<VSCodeDataGridCell grid-column="2">
{row.cell2}
</VSCodeDataGridCell>
<VSCodeDataGridCell grid-column="3">
{row.cell3}
</VSCodeDataGridCell>
<VSCodeDataGridCell grid-column="4">
{row.cell4}
</VSCodeDataGridCell>
</VSCodeDataGridRow>
))}
</VSCodeDataGrid>
<VSCodeTextField>
<section slot="end" style={{ display: "flex", alignItems: "center" }}>
<section
slot="end"
style={{ display: "flex", alignItems: "center" }}>
<VSCodeButton appearance="icon" aria-label="Match Case">
<span className="codicon codicon-case-sensitive"></span>
</VSCodeButton>
<VSCodeButton appearance="icon" aria-label="Match Whole Word">
<VSCodeButton
appearance="icon"
aria-label="Match Whole Word">
<span className="codicon codicon-whole-word"></span>
</VSCodeButton>
<VSCodeButton appearance="icon" aria-label="Use Regular Expression">
<VSCodeButton
appearance="icon"
aria-label="Use Regular Expression">
<span className="codicon codicon-regex"></span>
</VSCodeButton>
</section>
</VSCodeTextField>
<span slot="end" className="codicon codicon-chevron-right"></span>
<span
slot="end"
className="codicon codicon-chevron-right"></span>
<span className="flex gap-3">
<VSCodeProgressRing />

View file

@ -81,7 +81,10 @@ const StyledMarkdown = styled.div`
word-wrap: break-word;
border-radius: 3px;
background-color: ${CODE_BLOCK_BG_COLOR};
font-size: var(--vscode-editor-font-size, var(--vscode-font-size, 12px));
font-size: var(
--vscode-editor-font-size,
var(--vscode-font-size, 12px)
);
font-family: var(--vscode-editor-font-family);
}
@ -181,7 +184,9 @@ const MarkdownBlock = memo(({ markdown }: MarkdownBlockProps) => {
],
rehypeReactOptions: {
components: {
pre: ({ node, ...preProps }: any) => <StyledPre {...preProps} theme={theme} />,
pre: ({ node, ...preProps }: any) => (
<StyledPre {...preProps} theme={theme} />
),
},
},
})

View file

@ -0,0 +1,31 @@
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

@ -9,7 +9,12 @@ interface ThumbnailsProps {
onHeightChange?: (height: number) => void
}
const Thumbnails = ({ images, style, setImages, onHeightChange }: ThumbnailsProps) => {
const Thumbnails = ({
images,
style,
setImages,
onHeightChange,
}: ThumbnailsProps) => {
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null)
const containerRef = useRef<HTMLDivElement>(null)
const { width } = useWindowSize()
@ -74,7 +79,8 @@ const Thumbnails = ({ images, style, setImages, onHeightChange }: ThumbnailsProp
width: 13,
height: 13,
borderRadius: "50%",
backgroundColor: "var(--vscode-badge-background)",
backgroundColor:
"var(--vscode-badge-background)",
display: "flex",
justifyContent: "center",
alignItems: "center",

View file

@ -7,7 +7,11 @@ interface VSCodeButtonLinkProps {
[key: string]: any
}
const VSCodeButtonLink: React.FC<VSCodeButtonLinkProps> = ({ href, children, ...props }) => {
const VSCodeButtonLink: React.FC<VSCodeButtonLinkProps> = ({
href,
children,
...props
}) => {
return (
<a
href={href}

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,
@ -106,31 +109,51 @@ 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)}
Tokens:
{formatLargeNumber(item.tokensIn || 0)}{" "}
{formatLargeNumber(item.tokensOut || 0)}
</span>
{!!item.cacheWrites && (
<>
{" • "}
<span>
Cache: +{formatLargeNumber(item.cacheWrites || 0)} {" "}
{formatLargeNumber(item.cacheReads || 0)}
Cache: +
{formatLargeNumber(
item.cacheWrites || 0,
)}{" "}
{" "}
{formatLargeNumber(
item.cacheReads || 0,
)}
</span>
</>
)}
{!!item.totalCost && (
<>
{" • "}
<span>API Cost: ${item.totalCost?.toFixed(4)}</span>
<span>
API Cost: $
{item.totalCost?.toFixed(4)}
</span>
</>
)}
</div>
</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

@ -1,28 +1,48 @@
import { VSCodeButton, VSCodeTextField, VSCodeRadioGroup, VSCodeRadio } from "@vscode/webview-ui-toolkit/react"
import {
VSCodeButton,
VSCodeTextField,
VSCodeRadioGroup,
VSCodeRadio,
} from "@vscode/webview-ui-toolkit/react"
import { useExtensionState } from "../../context/ExtensionStateContext"
import { vscode } from "../../utils/vscode"
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
}
type SortOption = "newest" | "oldest" | "mostExpensive" | "mostTokens" | "mostRelevant"
type SortOption =
| "newest"
| "oldest"
| "mostExpensive"
| "mostTokens"
| "mostRelevant"
const HistoryView = ({ onDone }: HistoryViewProps) => {
const { taskHistory } = useExtensionState()
const [searchQuery, setSearchQuery] = useState("")
const [sortOption, setSortOption] = useState<SortOption>("newest")
const [lastNonRelevantSort, setLastNonRelevantSort] = useState<SortOption | null>("newest")
const [lastNonRelevantSort, setLastNonRelevantSort] =
useState<SortOption | null>("newest")
useEffect(() => {
if (searchQuery && sortOption !== "mostRelevant" && !lastNonRelevantSort) {
if (
searchQuery &&
sortOption !== "mostRelevant" &&
!lastNonRelevantSort
) {
setLastNonRelevantSort(sortOption)
setSortOption("mostRelevant")
} else if (!searchQuery && sortOption === "mostRelevant" && lastNonRelevantSort) {
} else if (
!searchQuery &&
sortOption === "mostRelevant" &&
lastNonRelevantSort
) {
setSortOption(lastNonRelevantSort)
setLastNonRelevantSort(null)
}
@ -68,7 +88,9 @@ const HistoryView = ({ onDone }: HistoryViewProps) => {
}, [presentableTasks])
const taskHistorySearchResults = useMemo(() => {
let results = searchQuery ? highlight(fuse.search(searchQuery)) : presentableTasks
let results = searchQuery
? highlight(fuse.search(searchQuery))
: presentableTasks
results.sort((a, b) => {
switch (sortOption) {
@ -82,7 +104,10 @@ const HistoryView = ({ onDone }: HistoryViewProps) => {
(b.tokensOut || 0) +
(b.cacheWrites || 0) +
(b.cacheReads || 0) -
((a.tokensIn || 0) + (a.tokensOut || 0) + (a.cacheWrites || 0) + (a.cacheReads || 0))
((a.tokensIn || 0) +
(a.tokensOut || 0) +
(a.cacheWrites || 0) +
(a.cacheReads || 0))
)
case "mostRelevant":
// NOTE: you must never sort directly on object since it will cause members to be reordered
@ -136,19 +161,35 @@ 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..."
value={searchQuery}
onInput={(e) => {
const newValue = (e.target as HTMLInputElement)?.value
const newValue = (e.target as HTMLInputElement)
?.value
setSearchQuery(newValue)
if (newValue && !searchQuery && sortOption !== "mostRelevant") {
if (
newValue &&
!searchQuery &&
sortOption !== "mostRelevant"
) {
setLastNonRelevantSort(sortOption)
setSortOption("mostRelevant")
}
@ -156,7 +197,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"
@ -175,11 +220,20 @@ const HistoryView = ({ onDone }: HistoryViewProps) => {
<VSCodeRadioGroup
style={{ display: "flex", flexWrap: "wrap" }}
value={sortOption}
onChange={(e) => setSortOption((e.target as HTMLInputElement).value as SortOption)}>
onChange={(e) =>
setSortOption(
(e.target as HTMLInputElement)
.value as SortOption,
)
}>
<VSCodeRadio value="newest">Newest</VSCodeRadio>
<VSCodeRadio value="oldest">Oldest</VSCodeRadio>
<VSCodeRadio value="mostExpensive">Most Expensive</VSCodeRadio>
<VSCodeRadio value="mostTokens">Most Tokens</VSCodeRadio>
<VSCodeRadio value="mostExpensive">
Most Expensive
</VSCodeRadio>
<VSCodeRadio value="mostTokens">
Most Tokens
</VSCodeRadio>
<VSCodeRadio
value="mostRelevant"
disabled={!searchQuery}
@ -253,8 +307,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
@ -269,9 +334,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",
@ -304,10 +376,13 @@ const HistoryView = ({ onDone }: HistoryViewProps) => {
style={{
fontSize: "12px",
fontWeight: "bold",
marginBottom: "-2px",
marginBottom:
"-2px",
}}
/>
{formatLargeNumber(item.tokensIn || 0)}
{formatLargeNumber(
item.tokensIn || 0,
)}
</span>
<span
style={{
@ -321,13 +396,20 @@ const HistoryView = ({ onDone }: HistoryViewProps) => {
style={{
fontSize: "12px",
fontWeight: "bold",
marginBottom: "-2px",
marginBottom:
"-2px",
}}
/>
{formatLargeNumber(item.tokensOut || 0)}
{formatLargeNumber(
item.tokensOut || 0,
)}
</span>
</div>
{!item.totalCost && <ExportButton itemId={item.id} />}
{!item.totalCost && (
<ExportButton
itemId={item.id}
/>
)}
</div>
{!!item.cacheWrites && (
@ -357,10 +439,14 @@ const HistoryView = ({ onDone }: HistoryViewProps) => {
style={{
fontSize: "12px",
fontWeight: "bold",
marginBottom: "-1px",
marginBottom:
"-1px",
}}
/>
+{formatLargeNumber(item.cacheWrites || 0)}
+
{formatLargeNumber(
item.cacheWrites || 0,
)}
</span>
<span
style={{
@ -377,7 +463,9 @@ const HistoryView = ({ onDone }: HistoryViewProps) => {
marginBottom: 0,
}}
/>
{formatLargeNumber(item.cacheReads || 0)}
{formatLargeNumber(
item.cacheReads || 0,
)}
</span>
</div>
)}
@ -385,11 +473,17 @@ const HistoryView = ({ onDone }: HistoryViewProps) => {
<div
style={{
display: "flex",
justifyContent: "space-between",
justifyContent:
"space-between",
alignItems: "center",
marginTop: -2,
}}>
<div style={{ display: "flex", alignItems: "center", gap: "4px" }}>
<div
style={{
display: "flex",
alignItems: "center",
gap: "4px",
}}>
<span
style={{
fontWeight: 500,
@ -397,11 +491,19 @@ const HistoryView = ({ onDone }: HistoryViewProps) => {
}}>
API Cost:
</span>
<span style={{ color: "var(--vscode-descriptionForeground)" }}>
${item.totalCost?.toFixed(4)}
<span
style={{
color: "var(--vscode-descriptionForeground)",
}}>
$
{item.totalCost?.toFixed(
4,
)}
</span>
</div>
<ExportButton itemId={item.id} />
<ExportButton
itemId={item.id}
/>
</div>
)}
</div>
@ -423,7 +525,9 @@ const ExportButton = ({ itemId }: { itemId: string }) => (
e.stopPropagation()
vscode.postMessage({ type: "exportTaskWithId", text: itemId })
}}>
<div style={{ fontSize: "11px", fontWeight: 500, opacity: 1 }}>EXPORT</div>
<div style={{ fontSize: "11px", fontWeight: 500, opacity: 1 }}>
EXPORT
</div>
</VSCodeButton>
)
@ -467,7 +571,10 @@ export const highlight = (
return merged
}
const generateHighlightedText = (inputText: string, regions: [number, number][] = []) => {
const generateHighlightedText = (
inputText: string,
regions: [number, number][] = [],
) => {
if (regions.length === 0) {
return inputText
}
@ -484,7 +591,10 @@ export const highlight = (
const lastRegionNextIndex = end + 1
content += [
inputText.substring(nextUnhighlightedRegionStartingIndex, start),
inputText.substring(
nextUnhighlightedRegionStartingIndex,
start,
),
`<span class="${highlightClassName}">`,
inputText.substring(start, lastRegionNextIndex),
"</span>",
@ -504,10 +614,18 @@ export const highlight = (
const highlightedItem = { ...item }
matches?.forEach((match) => {
if (match.key && typeof match.value === "string" && match.indices) {
if (
match.key &&
typeof match.value === "string" &&
match.indices
) {
// Merge overlapping regions before generating highlighted text
const mergedIndices = mergeRegions([...match.indices])
set(highlightedItem, match.key, generateHighlightedText(match.value, mergedIndices))
set(
highlightedItem,
match.key,
generateHighlightedText(match.value, mergedIndices),
)
}
})

View file

@ -20,8 +20,13 @@ const McpResourceRow = ({ item }: McpResourceRowProps) => {
alignItems: "center",
marginBottom: "4px",
}}>
<span className={`codicon codicon-symbol-file`} style={{ marginRight: "6px" }} />
<span style={{ fontWeight: 500, wordBreak: "break-all" }}>{uri}</span>
<span
className={`codicon codicon-symbol-file`}
style={{ marginRight: "6px" }}
/>
<span style={{ fontWeight: 500, wordBreak: "break-all" }}>
{uri}
</span>
</div>
<div
style={{

View file

@ -12,7 +12,9 @@ const McpToolRow = ({ tool }: McpToolRowProps) => {
padding: "3px 0",
}}>
<div style={{ display: "flex" }}>
<span className="codicon codicon-symbol-method" style={{ marginRight: "6px" }}></span>
<span
className="codicon codicon-symbol-method"
style={{ marginRight: "6px" }}></span>
<span style={{ fontWeight: 500 }}>{tool.name}</span>
</div>
{tool.description && (
@ -28,7 +30,8 @@ const McpToolRow = ({ tool }: McpToolRowProps) => {
)}
{tool.inputSchema &&
"properties" in tool.inputSchema &&
Object.keys(tool.inputSchema.properties as Record<string, any>).length > 0 && (
Object.keys(tool.inputSchema.properties as Record<string, any>)
.length > 0 && (
<div
style={{
marginTop: "8px",
@ -38,47 +41,57 @@ const McpToolRow = ({ tool }: McpToolRowProps) => {
padding: "8px",
}}>
<div
style={{ marginBottom: "4px", opacity: 0.8, fontSize: "11px", textTransform: "uppercase" }}>
style={{
marginBottom: "4px",
opacity: 0.8,
fontSize: "11px",
textTransform: "uppercase",
}}>
Parameters
</div>
{Object.entries(tool.inputSchema.properties as Record<string, any>).map(
([paramName, schema]) => {
const isRequired =
tool.inputSchema &&
"required" in tool.inputSchema &&
Array.isArray(tool.inputSchema.required) &&
tool.inputSchema.required.includes(paramName)
{Object.entries(
tool.inputSchema.properties as Record<string, any>,
).map(([paramName, schema]) => {
const isRequired =
tool.inputSchema &&
"required" in tool.inputSchema &&
Array.isArray(tool.inputSchema.required) &&
tool.inputSchema.required.includes(paramName)
return (
<div
key={paramName}
return (
<div
key={paramName}
style={{
display: "flex",
alignItems: "baseline",
marginTop: "4px",
}}>
<code
style={{
display: "flex",
alignItems: "baseline",
marginTop: "4px",
color: "var(--vscode-textPreformat-foreground)",
marginRight: "8px",
}}>
<code
style={{
color: "var(--vscode-textPreformat-foreground)",
marginRight: "8px",
}}>
{paramName}
{isRequired && (
<span style={{ color: "var(--vscode-errorForeground)" }}>*</span>
)}
</code>
<span
style={{
opacity: 0.8,
overflowWrap: "break-word",
wordBreak: "break-word",
}}>
{schema.description || "No description"}
</span>
</div>
)
},
)}
{paramName}
{isRequired && (
<span
style={{
color: "var(--vscode-errorForeground)",
}}>
*
</span>
)}
</code>
<span
style={{
opacity: 0.8,
overflowWrap: "break-word",
wordBreak: "break-word",
}}>
{schema.description || "No description"}
</span>
</div>
)
})}
</div>
)}
</div>

Some files were not shown because too many files have changed in this diff Show more