mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-06 08:18:39 +00:00
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:
parent
a7e9d47375
commit
9de7253998
113 changed files with 7141 additions and 1684 deletions
6
.vscode/tasks.json
vendored
6
.vscode/tasks.json
vendored
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
12
README.md
12
README.md
|
|
@ -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)!
|
||||
|
|
|
|||
27
esbuild.js
27
esbuild.js
|
|
@ -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
58
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
2259
src/core/Cline.ts
2259
src/core/Cline.ts
File diff suppressed because it is too large
Load diff
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
: []
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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?")
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
435
src/integrations/checkpoints/CheckpointTracker.ts
Normal file
435
src/integrations/checkpoints/CheckpointTracker.ts
Normal 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
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 }))
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = []
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -7,4 +7,8 @@ export type HistoryItem = {
|
|||
cacheWrites?: number
|
||||
cacheReads?: number
|
||||
totalCost: number
|
||||
|
||||
size?: number
|
||||
shadowGitConfigWorkTree?: string
|
||||
conversationHistoryDeletedRange?: [number, number]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
33
webview-ui/package-lock.json
generated
33
webview-ui/package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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/
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 tools–just say "add a tool that...", and watch him create the MCP
|
||||
server and install it in the extension, ready to use in future tasks.
|
||||
</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!
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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) => ({ "<": "<", ">": ">", "&": "&" })[c] || c)
|
||||
.replace(mentionRegexGlobal, '<mark class="mention-context-textarea-highlight">$&</mark>')
|
||||
.replace(
|
||||
/[<>&]/g,
|
||||
(c) => ({ "<": "<", ">": ">", "&": "&" })[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" : ""
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
}
|
||||
`
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
294
webview-ui/src/components/common/CheckpointControls.tsx
Normal file
294
webview-ui/src/components/common/CheckpointControls.tsx
Normal 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;
|
||||
}
|
||||
`
|
||||
|
|
@ -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) && (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
),
|
||||
},
|
||||
},
|
||||
})
|
||||
|
|
|
|||
31
webview-ui/src/components/common/SuccessButton.tsx
Normal file
31
webview-ui/src/components/common/SuccessButton.tsx
Normal 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
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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()}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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={{
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Add table
Reference in a new issue