Compare commits

..
107 changed files with 3802 additions and 5799 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
gRPC over vscode message bus to make messaging better
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
Add command to focus chat input
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
fix vertexai token count
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
Allow user to send context with an option selection
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
Replace testing libraries with Vitest. Remove chai, sinon, should, and proxyquire in favor of Vitest's built-in testing utilities. Keep Mocha types for VSCode test runner compatibility. Use `__tests__` folder convention everywhere.
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
Readme update
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
Add OpenAI o3 & 4o-mini
-6
View File
@@ -1,6 +0,0 @@
[codespell]
# Ref: https://github.com/codespell-project/codespell#using-a-config-file
skip = .git*,*.svg,package-lock.json,*.css,.codespellrc,locales
check-hidden = true
ignore-regex = (\b(optIn|isTaller)\b|https://\S+)
# ignore-words-list =
-25
View File
@@ -1,25 +0,0 @@
# Codespell configuration is within .codespellrc
---
name: Codespell
on:
push:
branches: [main]
pull_request:
branches: [main]
permissions:
contents: read
jobs:
codespell:
name: Check for spelling errors
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Annotate locations with typos
uses: codespell-project/codespell-problem-matcher@v1
- name: Codespell
uses: codespell-project/actions-codespell@v2
+2 -23
View File
@@ -1,26 +1,5 @@
# Changelog
## [3.13.1]
- Fix bug where task cancellation during thinking stream would result in error state
## [3.13.0]
- Add Cline rules popover under the chat field, allowing you to easily add, enable & disable workspace level or global rule files
- Add new slash command menu letting you type “/“ to do quick actions like creating new tasks
- Add ability to edit past messages, with options to restore your workspace back to that point
- Allow sending a message when selecting an option provided by the question or plan tool
- Add command to jump to Cline's chat input
- Add support for OpenAI o3 & 4o-mini (Thanks @PeterDaveHello and @arafatkatze!)
- Add baseURL option for Google Gemini provider (Thanks @owengo and @olivierhub!)
- Add support for Azure's DeepSeek model. (Thanks @yt3trees!)
- Add ability for models that support it to receive image responses from MCP servers (Thanks @rikaaa0928!)
- Improve search and replace diff editing by making it more flexible with models that fail to follow structured output instructions. (Thanks @chi-cat!)
- Add detection of Ctrl+C termination in terminal, improving output reading issues
- Fix issue where some commands with large output would cause UI to freeze
- Fix token usage tracking issues with vertex provider (Thanks @mzsima!)
- Fix issue with xAI reasoning content not being parsed (Thanks @mrubens!)
## [3.12.3]
- Add copy button to MermaidBlock component (Thanks @cacosub7!)
@@ -79,7 +58,7 @@
- Add recommended models for Cline provider
- Add ability to detect when user edits files manually so Cline knows to re-read, leading to reduced diff edit errors
- Add improvements to file mention searching for faster searching
- Add scoring logic to file mentions to sort and exclude results based on relevance
- Add scoring logic to file mentions to sort and exlcude results based on relevance
- Add Support for Bytedance Doubao (Thanks Tunixer!)
- Fix to prevent duplicate BOM (Thanks bamps53!)
@@ -557,7 +536,7 @@
- Adds "Always allow read-only operations" setting to let Claude read files and view directories without needing approval (off by default)
- Implement sliding window context management to keep tasks going past 200k tokens
- Adds Google Cloud Vertex AI support and updates Claude 3.5 Sonnet max output to 8192 tokens for all providers.
- Improves system prompt to guard against lazy edits (less "//rest of code here")
- Improves system prompt to gaurd against lazy edits (less "//rest of code here")
## [1.3.0]
+1 -1
View File
@@ -187,4 +187,4 @@ To contribute to the project, start with our [Contributing Guide](CONTRIBUTING.m
## License
[Apache 2.0 © 2025 Cline Bot Inc.](./LICENSE)
[Apache 2.0 © 2025 Cline Bot Inc.](./LICENSE)
+5 -28
View File
@@ -42,35 +42,12 @@ export async function spawnVSCode(workspacePath: string, vsixPath?: string): Pro
stdio: "inherit",
})
// Find the generated VSIX file(s)
// Find the generated VSIX file
const files = fs.readdirSync(clineRoot)
const vsixFiles = files.filter((file) => file.endsWith(".vsix"))
if (vsixFiles.length > 0) {
// Get file stats to find the most recent one
const vsixFilesWithStats = vsixFiles.map((file) => {
const filePath = path.join(clineRoot, file)
return {
file,
path: filePath,
mtime: fs.statSync(filePath).mtime,
}
})
// Sort by modification time (most recent first)
vsixFilesWithStats.sort((a, b) => b.mtime.getTime() - a.mtime.getTime())
// Use the most recent VSIX
vsixPath = vsixFilesWithStats[0].path
console.log(`Using most recent VSIX: ${vsixPath} (modified ${vsixFilesWithStats[0].mtime.toISOString()})`)
// Log all found VSIX files for debugging
if (vsixFiles.length > 1) {
console.log(`Found ${vsixFiles.length} VSIX files:`)
vsixFilesWithStats.forEach((f) => {
console.log(` - ${f.file} (modified ${f.mtime.toISOString()})`)
})
}
const vsixFile = files.find((file) => file.endsWith(".vsix"))
if (vsixFile) {
vsixPath = path.join(clineRoot, vsixFile)
console.log(`Using built VSIX: ${vsixPath}`)
} else {
console.warn("Could not find generated VSIX file")
}
+10 -10
View File
@@ -26,17 +26,17 @@
</table>
</div>
认识 Cline —— 一个可以使用你的 **终端****编辑器** 的 AI 助手。
认识 Cline一个可以使用你的 **CLI****编辑器** 的 AI 助手。
得益于 [Claude 3.7 Sonnet 的代理编码能力](https://www.anthropic.com/claude/sonnet)Cline 能够逐步处理复杂的软件开发任务。借助于一系列工具,他可以创建和编辑文件、浏览大型项目、使用浏览器,并在你授权后执行终端命令,从而在代码补全或技术支持之外提供更深入的帮助。Cline 甚至还能使用 Model Context ProtocolMCP)来创建新工具并扩展自的能力。虽然传统的自动化 AI 脚本通常运行在沙盒环境中,但这个扩展提供了一个人类参与审核的图形界面(GUI),用于审批每一次文件变更和终端命令,从而为探索代理式 AI 的潜力提供了一种安全且易于使用的方式
感谢 [Claude 3.7 Sonnet 的代理编码能力](https://www.anthropic.com/claude/sonnet)Cline 可以一步步处理复杂的软件开发任务。通过允许他创建和编辑文件、探索大型项目、使用浏览器和执行终端命令(在你授予权限后),他可以提供超越代码完成或技术支持的帮助。Cline 甚至可以使用 Model Context Protocol (MCP) 创建新工具并扩展自的能力。虽然自主 AI 脚本传统上在沙盒环境中运行,但此扩展提供了一个人机交互的 GUI 来批准每个文件更改和终端命令,提供了一种安全且可访问的方式来探索代理 AI 的潜力
1. 输入你的任务并添加图片,以将界面原型(mockup转换为功能应用或通过截图修复 bug
2. Cline 会从分析你的文件结构和源代码的抽象语法树(AST)开始,同时执行正则搜索并读取相关文件,以便尽快熟悉项目上下文。通过精细地管理上下文中引入的信息,即使面对大型复杂项目,Cline 也能在不超出上下文窗口限制的前提下提供有效协助
3. 一旦获取了所需信息,Cline 能够
- 创建和编辑文件,并在过程中监控 linter编译器错误,主动修复诸如缺少导入语法错误等问题。
- 直接在你的终端中执行命令,并在运行过程中监控输出,例如在修改文件后自动响应开发服务器问题。
- 对 Web 开发任务,Cline 可以在无头浏览器中打开网站,进行点击、输入、滚动操作,并采集截图控制台日志,从而修复运行时错误和界面问题
4. 当任务完成Cline 通过类似 `open -a "Google Chrome" index.html` 的终端命令将结果展示给你,你只需点击按钮即可执行
1. 输入你的任务并添加图像,将模型转换为功能应用程序或通过截图修复错误
2. Cline 首先分析你的文件结构和源代码 AST,运行正则表达式搜索,并阅读相关文件以了解现有项目。通过仔细管理添加到上下文中的信息,Cline 即使在大型复杂项目中也能提供有价值的帮助,而不会使上下文窗口过载
3. 一旦 Cline 获得所需信息,他可以
- 创建和编辑文件 + 监控 linter/编译器错误,从而主动修复诸如缺少导入语法错误等问题。
- 直接在你的终端中执行命令监控输出,从而在编辑文件后对开发服务器问题做出反应
- Web 开发任务,Cline 可以在无头浏览器中启动网站,点击、输入、滚动并捕获截图控制台日志,从而修复运行时错误和视觉错误
4. 当任务完成Cline 通过终端命令如 `open -a "Google Chrome" index.html` 向你展示结果,你可以通过点击按钮运行该命令
> [!TIP]
> 使用 `CMD/CTRL + Shift + P` 快捷键打开命令面板并输入 "Cline: Open In New Tab" 将扩展作为标签在编辑器中打开。这让你可以与文件资源管理器并排使用 Cline,更清楚地看到他如何改变你的工作空间。
@@ -49,7 +49,7 @@
Cline 支持 OpenRouter、Anthropic、OpenAI、Google Gemini、AWS Bedrock、Azure 和 GCP Vertex 等 API 提供商。你还可以配置任何兼容 OpenAI 的 API,或通过 LM Studio/Ollama 使用本地模型。如果你使用 OpenRouter,扩展会获取他们的最新模型列表,让你在新模型可用时立即使用。
此外,该扩展还会记录整个任务流程中以及每次请求的总 token 数和 API 使用费用,确保你在每一步都能清楚了解花费情况。
扩展还会跟踪整个任务循环和单个请求的总令牌和 API 使用成本,让你在每一步都了解支出情况。
<!-- 透明像素以在浮动图像后创建换行 -->
+1 -1
View File
@@ -32,7 +32,7 @@
認識 Cline,一個可以使用您的**命令列介面** (CLI) 和**程式編輯器** (Editor) 的 AI 助理。
感謝 [Claude 3.7 Sonnet 的代理式程式設計能力](https://www.anthropic.com/claude/sonnet),Cline 能夠逐步處理複雜的軟體開發任務。透過能讓他建立和編輯檔案、探索大型專案、使用瀏覽器,以及執行終端機指令(在您授權後)的工具,從而在程式碼補全或技術支援之外提供更深入的協助。Cline 甚至能使用模型上下文協定(Model Context ProtocolMCP)來建立新工具並擴展自己的功能。雖然自主 AI 腳本傳統上會在沙箱環境中執行,但這個擴充套件提供了人機互動的圖形介面,讓您可以核准每個檔案變更和終端機指令,提供一個安全且容易使用的方式來探索代理式 AI 的潛力。
感謝 [Claude 3.7 Sonnet 的代理式程式設計能力](https://www.anthropic.com/claude/sonnet),Cline 能夠逐步處理複雜的軟體開發任務。透過能讓他建立和編輯檔案、探索大型專案、使用瀏覽器,以及執行終端機指令(在您授權後)的工具,他能以超越程式碼自動完成或技術支援的方式協助。Cline 甚至能使用模型上下文協定(Model Context ProtocolMCP)來建立新工具並擴展自己的功能。雖然自主 AI 腳本傳統上會在沙箱環境中執行,但這個擴充套件提供了人機互動的圖形介面,讓您可以核准每個檔案變更和終端機指令,提供一個安全且容易使用的方式來探索代理式 AI 的潛力。
1. 輸入您的任務,並可以加入圖片來將設計稿轉換成功能性應用程式,或使用截圖來修正錯誤。
2. Cline 會先分析您的檔案結構和程式碼 AST、執行正規表達式搜尋,並讀取相關檔案,以便在現有專案中快速掌握狀況。透過仔細管理加入上下文的資訊,Cline 可以在不超過上下文視窗的情況下,為大型且複雜的專案提供有價值的協助。
+900 -550
View File
File diff suppressed because it is too large Load Diff
+13 -11
View File
@@ -2,7 +2,7 @@
"name": "claude-dev",
"displayName": "Cline",
"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.13.1",
"version": "3.12.3",
"icon": "assets/icons/icon.png",
"engines": {
"vscode": "^1.84.0"
@@ -306,10 +306,12 @@
"lint": "eslint src --ext ts && eslint webview-ui/src --ext ts",
"format": "prettier . --check",
"format:fix": "prettier . --write",
"test": "npm-run-all test:unit test:integration",
"test": "npm-run-all test:unit test:integration test:webview",
"test:ci": "node scripts/test-ci.js",
"test:unit": "vitest run",
"test:unit:watch": "vitest",
"test:unit:coverage": "vitest run --coverage",
"test:integration": "vscode-test",
"test:unit": "TS_NODE_PROJECT='./tsconfig.unit-test.json' mocha",
"test:coverage": "vscode-test --coverage",
"install:all": "npm install && cd webview-ui && npm install",
"dev:webview": "cd webview-ui && npm run dev",
@@ -323,23 +325,23 @@
},
"devDependencies": {
"@changesets/cli": "^2.27.12",
"@types/chai": "^5.0.1",
"@types/chai": "^5.2.1",
"@types/clone-deep": "^4.0.4",
"@types/diff": "^5.2.1",
"@types/get-folder-size": "^3.0.4",
"@types/mocha": "^10.0.7",
"@types/mocha": "^10.0.10",
"@types/node": "20.x",
"@types/pdf-parse": "^1.1.4",
"@types/proxyquire": "^1.3.31",
"@types/should": "^11.2.0",
"@types/sinon": "^17.0.4",
"@types/turndown": "^5.0.5",
"@types/vscode": "^1.84.0",
"@typescript-eslint/eslint-plugin": "^7.14.1",
"@typescript-eslint/parser": "^7.11.0",
"@vscode/test-cli": "^0.0.9",
"@vitest/coverage-v8": "^3.1.1",
"@vscode/test-cli": "^0.0.10",
"@vscode/test-electron": "^2.4.0",
"chai": "^4.3.10",
"chai": "^4.5.0",
"chalk": "^5.3.0",
"esbuild": "^0.25.0",
"eslint": "^8.57.0",
@@ -347,12 +349,12 @@
"npm-run-all": "^4.1.5",
"prettier": "^3.3.3",
"protoc-gen-ts": "^0.8.7",
"proxyquire": "^2.1.3",
"should": "^13.2.3",
"sinon": "^19.0.2",
"sinon": "^20.0.0",
"ts-node": "^10.9.2",
"ts-proto": "^2.6.1",
"typescript": "^5.4.5"
"typescript": "^5.4.5",
"vitest": "^3.1.1"
},
"dependencies": {
"@anthropic-ai/bedrock-sdk": "^0.12.4",
+1 -8
View File
@@ -6,17 +6,10 @@ import "common.proto";
service BrowserService {
rpc getBrowserConnectionInfo(EmptyRequest) returns (BrowserConnectionInfo);
rpc testBrowserConnection(StringRequest) returns (BrowserConnection);
}
message BrowserConnectionInfo {
bool is_connected = 1;
bool is_remote = 2;
optional string host = 3;
}
message BrowserConnection {
bool success = 1;
string message = 2;
optional string endpoint = 3;
string host = 3; // Optional, may be empty
}
-27
View File
@@ -11,30 +11,3 @@ message EmptyRequest {
message Empty {
}
message StringRequest {
Metadata metadata = 1;
string value = 2;
}
message String {
string value = 1;
}
message Int64Request {
Metadata metadata = 1;
int64 value = 2;
}
message Int64 {
int64 value = 1;
}
message BytesRequest {
Metadata metadata = 1;
bytes value = 2;
}
message Bytes {
bytes value = 1;
}
@@ -1,6 +1,5 @@
import { describe, it } from "mocha"
import "should"
import { withRetry } from "./retry"
import { describe, it, expect, assert } from "vitest"
import { withRetry } from "../retry"
describe("Retry Decorator", () => {
describe("withRetry", () => {
@@ -20,8 +19,8 @@ describe("Retry Decorator", () => {
result.push(value)
}
callCount.should.equal(1)
result.should.deepEqual(["success"])
expect(callCount).toBe(1)
expect(result).toEqual(["success"])
})
it("should retry on rate limit (429) error", async () => {
@@ -45,8 +44,8 @@ describe("Retry Decorator", () => {
result.push(value)
}
callCount.should.equal(2)
result.should.deepEqual(["success after retry"])
expect(callCount).toBe(2)
expect(result).toEqual(["success after retry"])
})
it("should not retry on non-rate-limit errors", async () => {
@@ -66,8 +65,8 @@ describe("Retry Decorator", () => {
}
throw new Error("Should have thrown")
} catch (error: any) {
error.message.should.equal("Regular error")
callCount.should.equal(1)
expect(error.message).toBe("Regular error")
expect(callCount).toBe(1)
}
})
@@ -95,9 +94,9 @@ describe("Retry Decorator", () => {
}
const duration = Date.now() - startTime
duration.should.be.approximately(10, 10) // Allow 10ms variance
callCount.should.equal(2)
result.should.deepEqual(["success after retry"])
assert.closeTo(duration, 10, 10, "duration should be 10 ± 10ms")
expect(callCount).toBe(2)
expect(result).toEqual(["success after retry"])
})
it("should respect retry-after header with Unix timestamp", async () => {
@@ -126,9 +125,9 @@ describe("Retry Decorator", () => {
}
const duration = Date.now() - startTime
duration.should.be.approximately(10, 10) // Allow 10ms variance
callCount.should.equal(2)
result.should.deepEqual(["success after retry"])
assert.closeTo(duration, 10, 10, "duration should be 10 ± 10ms")
expect(callCount).toBe(2)
expect(result).toEqual(["success after retry"])
})
it("should use exponential backoff when no retry-after header", async () => {
@@ -155,9 +154,9 @@ describe("Retry Decorator", () => {
const duration = Date.now() - startTime
// First retry should be after baseDelay (10ms)
duration.should.be.approximately(10, 10)
callCount.should.equal(2)
result.should.deepEqual(["success after retry"])
assert.closeTo(duration, 10, 10, "duration should be 10 ± 10ms")
expect(callCount).toBe(2)
expect(result).toEqual(["success after retry"])
})
it("should respect maxDelay", async () => {
@@ -184,9 +183,9 @@ describe("Retry Decorator", () => {
const duration = Date.now() - startTime
// Both retries should be capped at maxDelay (10ms each)
duration.should.be.approximately(20, 20)
callCount.should.equal(3)
result.should.deepEqual(["success after retries"])
assert.closeTo(duration, 20, 10, "duration should be 20 ± 10ms")
expect(callCount).toBe(3)
expect(result).toEqual(["success after retries"])
})
it("should throw after maxRetries attempts", async () => {
@@ -208,8 +207,8 @@ describe("Retry Decorator", () => {
}
throw new Error("Should have thrown")
} catch (error: any) {
error.message.should.equal("Rate limit exceeded")
callCount.should.equal(2) // Initial attempt + 1 retry
expect(error.message).toBe("Rate limit exceeded")
expect(callCount).toBe(2) // Initial attempt + 1 retry
}
})
})
+224
View File
@@ -0,0 +1,224 @@
import { describe, it, beforeEach, afterEach, beforeAll, expect, vi } from "vitest"
import { Anthropic } from "@anthropic-ai/sdk"
import { OllamaHandler } from "../ollama"
import { ApiHandlerOptions } from "../../../shared/api"
import axios from "axios"
describe("OllamaHandler", () => {
let ollamaAvailable = false
// Check if Ollama is running before running tests
beforeAll(async function () {
try {
await axios.get("http://localhost:11434/api/version", { timeout: 2000 })
ollamaAvailable = true
} catch (error) {
console.log("Ollama server not available, skipping tests")
ollamaAvailable = false
}
}, 5000)
let handler: OllamaHandler
let options: ApiHandlerOptions
beforeEach(() => {
options = {
ollamaModelId: "llama2",
ollamaBaseUrl: "http://localhost:11434",
}
handler = new OllamaHandler(options)
// Use fake timers for testing timeouts
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
vi.restoreAllMocks()
})
describe("createMessage", () => {
it("should handle successful responses", async function ({ skip }) {
if (!ollamaAvailable) {
skip()
}
// Mock the Ollama client's chat method
const chatStub = vi.spyOn(handler["client"], "chat").mockResolvedValue({
[Symbol.asyncIterator]: async function* () {
yield {
message: { content: "Hello, world!" },
eval_count: 10,
prompt_eval_count: 20,
}
},
} as any)
const systemPrompt = "You are a helpful assistant."
const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hello" }]
const result = []
const usageInfo = []
// Collect the results
for await (const chunk of handler.createMessage(systemPrompt, messages)) {
if (chunk.type === "text") {
result.push(chunk.text)
} else if (chunk.type === "usage") {
usageInfo.push({
inputTokens: chunk.inputTokens,
outputTokens: chunk.outputTokens,
})
}
}
// Verify the results
expect(result).toEqual(["Hello, world!"])
expect(usageInfo).toEqual([{ inputTokens: 20, outputTokens: 10 }])
expect(chatStub).toHaveBeenCalledTimes(1)
})
it("should handle timeout errors", { timeout: 10000 }, async function ({ skip }) {
if (!ollamaAvailable) {
skip()
}
// Restore real timers for this test
vi.useRealTimers()
// Create a handler with a very short timeout for testing
const testHandler = new OllamaHandler(options)
// Replace the createMessage method with one that has a shorter timeout
testHandler.createMessage = async function* (systemPrompt, messages) {
try {
// Create a promise that rejects after a short timeout
const timeoutPromise = new Promise<never>((_, reject) => {
setTimeout(() => reject(new Error("Ollama request timed out after 30 seconds")), 100)
})
// Create a promise that never resolves
const neverPromise = new Promise(() => {})
// Race them
await Promise.race([timeoutPromise, neverPromise])
} catch (error: any) {
// Enhance error reporting
console.error(`Ollama API error: ${error.message}`)
throw error
}
}
const systemPrompt = "You are a helpful assistant."
const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hello" }]
// Start the request and catch the error
let errorMessage = ""
try {
for await (const _ of testHandler.createMessage(systemPrompt, messages)) {
// This should not be reached
}
} catch (error: any) {
errorMessage = error.message
}
// Check the result
expect(errorMessage).toBe("Ollama request timed out after 30 seconds")
// Restore the fake timers for other tests
vi.useFakeTimers()
})
it("should retry on errors when using the withRetry decorator", { timeout: 10000 }, async function ({ skip }) {
if (!ollamaAvailable) {
skip()
}
// Restore real timers for this test
vi.useRealTimers()
// Mock the Ollama client's chat method to fail on first call and succeed on second
const chatStub = vi.spyOn(handler["client"], "chat")
// First call throws an error
chatStub.mockRejectedValueOnce(new Error("API Error"))
// Second call succeeds
chatStub.mockResolvedValueOnce({
[Symbol.asyncIterator]: async function* () {
yield {
message: { content: "Success after retry" },
}
},
} as any)
const systemPrompt = "You are a helpful assistant."
const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hello" }]
const result = []
// Add a small delay to ensure the retry mechanism has time to work
await new Promise((resolve) => setTimeout(resolve, 100))
// Collect the results
for await (const chunk of handler.createMessage(systemPrompt, messages)) {
if (chunk.type === "text") {
result.push(chunk.text)
}
}
// Verify the results
expect(result).toEqual(["Success after retry"])
expect(chatStub).toHaveBeenCalledTimes(2)
// Restore the fake timers for other tests
vi.useFakeTimers()
})
it("should handle stream processing errors", { timeout: 10000 }, async function ({ skip }) {
if (!ollamaAvailable) {
skip()
}
// Restore real timers for this test
vi.useRealTimers()
// Create a handler with a custom implementation for testing
const testHandler = new OllamaHandler(options)
// Replace the createMessage method with one that simulates a stream error
testHandler.createMessage = async function* (systemPrompt, messages) {
// First yield a successful chunk
yield {
type: "text",
text: "Partial response",
}
// Then throw an error in the stream
throw new Error("Ollama stream processing error: Stream error")
}
const systemPrompt = "You are a helpful assistant."
const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hello" }]
const result = []
// Collect the results and catch the error
let errorMessage = ""
try {
for await (const chunk of testHandler.createMessage(systemPrompt, messages)) {
if (chunk.type === "text") {
result.push(chunk.text)
}
}
} catch (error: any) {
errorMessage = error.message
}
// Verify the results
expect(errorMessage).toBe("Ollama stream processing error: Stream error")
expect(result).toEqual(["Partial response"])
// Restore the fake timers for other tests
vi.useFakeTimers()
})
})
})
+2 -5
View File
@@ -20,13 +20,10 @@ export class GeminiHandler implements ApiHandler {
@withRetry()
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
const modelOptions = {
const model = this.client.getGenerativeModel({
model: this.getModel().id,
systemInstruction: systemPrompt,
}
const clientOptions = this.options.geminiBaseUrl ? { baseUrl: this.options.geminiBaseUrl } : undefined
const model = this.client.getGenerativeModel(modelOptions, clientOptions)
})
const result = await model.generateContentStream({
contents: messages.map(convertAnthropicMessageToGemini),
generationConfig: {
+1 -1
View File
@@ -50,7 +50,7 @@ export class OpenAiNativeHandler implements ApiHandler {
case "o1":
case "o1-preview":
case "o1-mini": {
// o1 doesn't support streaming, non-1 temp, or system prompt
// o1 doesnt support streaming, non-1 temp, or system prompt
const response = await this.client.chat.completions.create({
model: model.id,
messages: [{ role: "user", content: systemPrompt }, ...convertToOpenAiMessages(messages)],
+1 -7
View File
@@ -16,22 +16,16 @@ export class OpenAiHandler implements ApiHandler {
this.options = options
// Azure API shape slightly differs from the core API shape: https://github.com/openai/openai-node?tab=readme-ov-file#microsoft-azure-openai
// Use azureApiVersion to determine if this is an Azure endpoint, since the URL may not always contain 'azure.com'
if (
this.options.azureApiVersion ||
(this.options.openAiBaseUrl?.toLowerCase().includes("azure.com") &&
!this.options.openAiModelId?.toLowerCase().includes("deepseek"))
) {
if (this.options.azureApiVersion || this.options.openAiBaseUrl?.toLowerCase().includes("azure.com")) {
this.client = new AzureOpenAI({
baseURL: this.options.openAiBaseUrl,
apiKey: this.options.openAiApiKey,
apiVersion: this.options.azureApiVersion || azureOpenAiDefaultApiVersion,
defaultHeaders: this.options.openAiHeaders,
})
} else {
this.client = new OpenAI({
baseURL: this.options.openAiBaseUrl,
apiKey: this.options.openAiApiKey,
defaultHeaders: this.options.openAiHeaders,
})
}
}
+1 -1
View File
@@ -47,7 +47,7 @@ export class XAIHandler implements ApiHandler {
}
}
if (delta && "reasoning_content" in delta && delta.reasoning_content) {
if ("reasoning_content" in delta && delta.reasoning_content) {
yield {
type: "reasoning",
// @ts-ignore-next-line
@@ -0,0 +1,211 @@
// This file contains `declare module "vscode"` so we must import it.
import "../../providers/vscode-lm"
import { describe, it } from "mocha"
import "should"
import * as vscode from "vscode"
import { Anthropic } from "@anthropic-ai/sdk"
import { asObjectSafe, convertToAnthropicRole, convertToVsCodeLmMessages, convertToAnthropicMessage } from "../vscode-lm-format"
describe("asObjectSafe", () => {
it("should handle falsy values", () => {
asObjectSafe(0).should.deepEqual({})
asObjectSafe("").should.deepEqual({})
asObjectSafe(null).should.deepEqual({})
asObjectSafe(undefined).should.deepEqual({})
})
it("should parse valid JSON strings", () => {
asObjectSafe('{"key": "value"}').should.deepEqual({ key: "value" })
})
it("should return an empty object for invalid JSON strings", () => {
asObjectSafe("invalid json").should.deepEqual({})
})
it("should convert objects to plain objects", () => {
const input = { prop: "value" }
asObjectSafe(input).should.deepEqual(input)
asObjectSafe(input).should.not.equal(input) // Should be a new object
})
it("should convert arrays to plain objects", () => {
const input = ["hello world"]
asObjectSafe(input).should.deepEqual({ 0: "hello world" })
})
})
describe("convertToAnthropicRole", () => {
it("should convert VSCode roles to Anthropic roles", () => {
// @ts-expect-errorTesting with an invalid role
const unknownRole = "unknown" as vscode.LanguageModelChatMessageRole
;(convertToAnthropicRole(vscode.LanguageModelChatMessageRole.Assistant) === "assistant").should.be.true()
;(convertToAnthropicRole(vscode.LanguageModelChatMessageRole.User) === "user").should.be.true()
;(convertToAnthropicRole(unknownRole) === null).should.be.true()
})
})
describe("convertToVsCodeLmMessages", () => {
it("should convert simple string messages", () => {
const anthropicMessages: Anthropic.Messages.MessageParam[] = [
{ role: "user", content: "Hello" },
{ role: "assistant", content: "Hi there" },
]
const result = convertToVsCodeLmMessages(anthropicMessages)
result.should.have.length(2)
result[0].role.should.equal(vscode.LanguageModelChatMessageRole.User)
result[0].content[0].should.be.instanceof(vscode.LanguageModelTextPart)
const textPart0 = result[0].content[0] as vscode.LanguageModelTextPart
textPart0.should.have.property("value", "Hello")
result[1].role.should.equal(vscode.LanguageModelChatMessageRole.Assistant)
result[1].content[0].should.be.instanceof(vscode.LanguageModelTextPart)
const textPart1 = result[1].content[0] as vscode.LanguageModelTextPart
textPart1.should.have.property("value", "Hi there")
})
it("should convert complex user messages with tool results", () => {
const anthropicMessages: Anthropic.Messages.MessageParam[] = [
{
role: "user",
content: [
{ type: "text", text: "User text" },
{
type: "tool_result",
tool_use_id: "tool-123",
content: [{ type: "text", text: "Tool result" }],
},
],
},
]
const result = convertToVsCodeLmMessages(anthropicMessages)
result.should.have.length(1)
result[0].role.should.equal(vscode.LanguageModelChatMessageRole.User)
result[0].content.should.have.length(2)
// Check that the first content part is a ToolResultPart
result[0].content[0].should.be.instanceof(vscode.LanguageModelToolResultPart)
const toolResultPart = result[0].content[0] as vscode.LanguageModelToolResultPart
toolResultPart.should.have.property("callId", "tool-123")
// Skip detailed testing of internal structure as it may vary
// Just verify it's the right type with the right ID
// Check the second content part is a TextPart
result[0].content[1].should.be.instanceof(vscode.LanguageModelTextPart)
const textPart = result[0].content[1] as vscode.LanguageModelTextPart
textPart.should.have.property("value", "User text")
})
it("should convert complex assistant messages with tool calls", () => {
const anthropicMessages: Anthropic.Messages.MessageParam[] = [
{
role: "assistant",
content: [
{ type: "text", text: "Assistant text" },
{
type: "tool_use",
id: "tool-123",
name: "testTool",
input: { param: "value" },
},
],
},
]
const result = convertToVsCodeLmMessages(anthropicMessages)
result.should.have.length(1)
result[0].role.should.equal(vscode.LanguageModelChatMessageRole.Assistant)
result[0].content.should.have.length(2)
result[0].content[0].should.be.instanceof(vscode.LanguageModelToolCallPart)
const toolCallPart = result[0].content[0] as vscode.LanguageModelToolCallPart
toolCallPart.should.have.property("callId", "tool-123")
toolCallPart.should.have.property("name", "testTool")
toolCallPart.should.have.property("input")
toolCallPart.input.should.deepEqual({ param: "value" })
result[0].content[1].should.be.instanceof(vscode.LanguageModelTextPart)
const textPart = result[0].content[1] as vscode.LanguageModelTextPart
textPart.should.have.property("value", "Assistant text")
})
it("should handle image blocks with appropriate placeholders", () => {
const anthropicMessages: Anthropic.Messages.MessageParam[] = [
{
role: "user",
content: [
{
type: "image",
source: {
type: "base64",
media_type: "image/jpeg",
data: "base64data",
},
},
],
},
]
const result = convertToVsCodeLmMessages(anthropicMessages)
result.should.have.length(1)
result[0].content[0].should.be.instanceof(vscode.LanguageModelTextPart)
const textPart = result[0].content[0] as vscode.LanguageModelTextPart
textPart.should.have.property("value")
textPart.value.should.match(/Image \(base64\): image\/jpeg not supported by VSCode LM API/)
})
})
describe("convertToAnthropicMessage", () => {
it("should convert VSCode assistant messages to Anthropic format", () => {
const vsCodeMsg = vscode.LanguageModelChatMessage.Assistant([
new vscode.LanguageModelTextPart("Test message"),
new vscode.LanguageModelToolCallPart("tool-id", "testTool", { param: "value" }),
])
const result = convertToAnthropicMessage(vsCodeMsg)
result.should.have.property("role", "assistant")
result.should.have.property("content").which.is.an.Array()
result.content.should.have.length(2)
// Check properties carefully to avoid null reference errors
if (result.content && result.content.length >= 1) {
const textContent = result.content[0]
if (textContent) {
textContent.should.have.property("type", "text")
if (textContent.type === "text") {
textContent.should.have.property("text", "Test message")
}
}
}
if (result.content && result.content.length >= 2) {
const toolContent = result.content[1]
if (toolContent) {
toolContent.should.have.property("type", "tool_use")
if (toolContent.type === "tool_use") {
toolContent.should.have.property("id", "tool-id")
toolContent.should.have.property("name", "testTool")
toolContent.should.have.property("input").which.deepEqual({ param: "value" })
}
}
}
})
it("should throw an error for non-assistant messages", () => {
const vsCodeMsg = vscode.LanguageModelChatMessage.User("User message")
try {
convertToAnthropicMessage(vsCodeMsg)
throw new Error("Should have thrown an error")
} catch (error: any) {
error.message.should.match(/Only assistant messages are supported/)
}
})
})
-178
View File
@@ -1,178 +0,0 @@
import { constructNewFileContent as cnfc2 } from "./diff"
import { describe, it } from "mocha"
import { expect } from "chai"
async function cnfc(diffContent: string, originalContent: string, isFinal: boolean): Promise<string> {
return cnfc2(diffContent, originalContent, isFinal, "v1")
}
describe("constructNewFileContent", () => {
const testCases = [
{
name: "empty file",
original: "",
diff: `<<<<<<< SEARCH
=======
new content
>>>>>>> REPLACE`,
expected: "new content\n",
isFinal: true,
},
{
name: "full file replacement",
original: "old content",
diff: `<<<<<<< SEARCH
=======
new content
>>>>>>> REPLACE`,
expected: "new content\n",
isFinal: true,
},
{
name: "exact match replacement",
original: "line1\nline2\nline3",
diff: `<<<<<<< SEARCH
line2
=======
replaced
>>>>>>> REPLACE`,
expected: "line1\nreplaced\nline3",
isFinal: true,
},
{
name: "line-trimmed match replacement",
original: "line1\n line2 \nline3",
diff: `<<<<<<< SEARCH
line2
=======
replaced
>>>>>>> REPLACE`,
expected: "line1\nreplaced\nline3",
isFinal: true,
},
{
name: "block anchor match replacement",
original: "line1\nstart\nmiddle\nend\nline5",
diff: `<<<<<<< SEARCH
start
middle
end
=======
replaced
>>>>>>> REPLACE`,
expected: "line1\nreplaced\nline5",
isFinal: true,
},
{
name: "incremental processing",
original: "line1\nline2\nline3",
diff: [
`<<<<<<< SEARCH
line2
=======`,
"replaced\n",
">>>>>>> REPLACE",
].join("\n"),
expected: "line1\nreplaced\n\nline3",
isFinal: true,
},
{
name: "final chunk with remaining content",
original: "line1\nline2\nline3",
diff: `<<<<<<< SEARCH
line2
=======
replaced
>>>>>>> REPLACE`,
expected: "line1\nreplaced\nline3",
isFinal: true,
},
{
name: "multiple ordered replacements",
original: "First\nSecond\nThird\nFourth",
diff: `<<<<<<< SEARCH
First
=======
1st
>>>>>>> REPLACE
<<<<<<< SEARCH
Third
=======
3rd
>>>>>>> REPLACE`,
expected: "1st\nSecond\n3rd\nFourth",
isFinal: true,
},
{
name: "replace then delete",
original: "line1\nline2\nline3\nline4",
diff: `<<<<<<< SEARCH
line2
=======
replaced
>>>>>>> REPLACE
<<<<<<< SEARCH
line4
=======
>>>>>>> REPLACE`,
expected: "line1\nreplaced\nline3\n",
isFinal: true,
},
{
name: "delete then replace",
original: "line1\nline2\nline3\nline4",
diff: `<<<<<<< SEARCH
line1
=======
>>>>>>> REPLACE
<<<<<<< SEARCH
line3
=======
replaced
>>>>>>> REPLACE`,
expected: "line2\nreplaced\nline4",
isFinal: true,
},
]
//.filter(({name}) => name === "multiple ordered replacements")
//.filter(({name}) => name === "delete then replace")
testCases.forEach(({ name, original, diff, expected, isFinal }) => {
it(`should handle ${name} case correctly`, async () => {
const result1 = await cnfc(diff, original, isFinal)
const result2 = await cnfc2(diff, original, isFinal)
const equal = result1 === result2
const equal2 = result1 === expected
// Verify both implementations produce same result
expect(result1).to.equal(result2)
// Verify result matches expected
expect(result1).to.equal(expected)
})
})
it("should throw error when no match found", async () => {
const original = "line1\nline2\nline3"
const diff = `<<<<<<< SEARCH
non-existent
=======
replaced
>>>>>>> REPLACE`
try {
await cnfc(diff, original, true)
expect.fail("Expected an error to be thrown")
} catch (err) {
expect(err).to.be.an("error")
}
try {
await cnfc2(diff, original, true)
expect.fail("Expected an error to be thrown")
} catch (err) {
expect(err).to.be.an("error")
}
})
})
+1 -381
View File
@@ -200,31 +200,7 @@ function blockAnchorFallbackMatch(originalContent: string, searchContent: string
* - If the search block cannot be matched using any of the available matching strategies,
* an error is thrown.
*/
export async function constructNewFileContent(
diffContent: string,
originalContent: string,
isFinal: boolean,
version: "v1" | "v2" = "v2",
): Promise<string> {
const constructor = constructNewFileContentVersionMapping[version]
if (!constructor) {
throw new Error(`Invalid version '${version}' for file content constructor`)
}
return constructor(diffContent, originalContent, isFinal)
}
const constructNewFileContentVersionMapping: Record<
string,
(diffContent: string, originalContent: string, isFinal: boolean) => Promise<string>
> = {
v1: constructNewFileContentV1,
v2: constructNewFileContentV2,
} as const
/**
* @deprecated
*/
async function constructNewFileContentV1(diffContent: string, originalContent: string, isFinal: boolean): Promise<string> {
export async function constructNewFileContent(diffContent: string, originalContent: string, isFinal: boolean): Promise<string> {
let result = ""
let lastProcessedIndex = 0
@@ -365,359 +341,3 @@ async function constructNewFileContentV1(diffContent: string, originalContent: s
return result
}
enum ProcessingState {
Idle = 0,
StateSearch = 1 << 0,
StateReplace = 1 << 1,
}
class NewFileContentConstructor {
private originalContent: string
private isFinal: boolean
private state: number
private pendingNonStandardLines: string[]
private result: string
private lastProcessedIndex: number
private currentSearchContent: string
private currentReplaceContent: string
private searchMatchIndex: number
private searchEndIndex: number
constructor(originalContent: string, isFinal: boolean) {
this.originalContent = originalContent
this.isFinal = isFinal
this.pendingNonStandardLines = []
this.result = ""
this.lastProcessedIndex = 0
this.state = ProcessingState.Idle
this.currentSearchContent = ""
this.currentReplaceContent = ""
this.searchMatchIndex = -1
this.searchEndIndex = -1
}
private resetForNextBlock() {
// Reset for next block
this.state = ProcessingState.Idle
this.currentSearchContent = ""
this.currentReplaceContent = ""
this.searchMatchIndex = -1
this.searchEndIndex = -1
}
private findLastMatchingLineIndex(regx: RegExp, lineLimit: number) {
for (let i = lineLimit; i > 0; ) {
i--
if (this.pendingNonStandardLines[i].match(regx)) {
return i
}
}
return -1
}
private updateProcessingState(newState: ProcessingState) {
const isValidTransition =
(this.state === ProcessingState.Idle && newState === ProcessingState.StateSearch) ||
(this.state === ProcessingState.StateSearch && newState === ProcessingState.StateReplace)
if (!isValidTransition) {
throw new Error(
`Invalid state transition.\n` +
"Valid transitions are:\n" +
"- Idle → StateSearch\n" +
"- StateSearch → StateReplace",
)
}
this.state |= newState
}
private isStateActive(state: ProcessingState): boolean {
return (this.state & state) === state
}
private activateReplaceState() {
this.updateProcessingState(ProcessingState.StateReplace)
}
private activateSearchState() {
this.updateProcessingState(ProcessingState.StateSearch)
this.currentSearchContent = ""
this.currentReplaceContent = ""
}
private isSearchingActive(): boolean {
return this.isStateActive(ProcessingState.StateSearch)
}
private isReplacingActive(): boolean {
return this.isStateActive(ProcessingState.StateReplace)
}
private hasPendingNonStandardLines(pendingNonStandardLineLimit: number): boolean {
return this.pendingNonStandardLines.length - pendingNonStandardLineLimit < this.pendingNonStandardLines.length
}
public processLine(line: string) {
this.internalProcessLine(line, true, this.pendingNonStandardLines.length)
}
public getResult() {
// If this is the final chunk, append any remaining original content
if (this.isFinal && this.lastProcessedIndex < this.originalContent.length) {
this.result += this.originalContent.slice(this.lastProcessedIndex)
}
if (this.isFinal && this.state !== ProcessingState.Idle) {
throw new Error("File processing incomplete - SEARCH/REPLACE operations still active during finalization")
}
return this.result
}
private internalProcessLine(
line: string,
canWritependingNonStandardLines: boolean,
pendingNonStandardLineLimit: number,
): number {
let removeLineCount = 0
if (line === "<<<<<<< SEARCH") {
removeLineCount = this.trimPendingNonStandardTrailingEmptyLines(pendingNonStandardLineLimit)
if (removeLineCount > 0) {
pendingNonStandardLineLimit = pendingNonStandardLineLimit - removeLineCount
}
if (this.hasPendingNonStandardLines(pendingNonStandardLineLimit)) {
this.tryFixSearchReplaceBlock(pendingNonStandardLineLimit)
canWritependingNonStandardLines && (this.pendingNonStandardLines.length = 0)
}
this.activateSearchState()
} else if (line === "=======") {
// 校验非标内容
if (!this.isSearchingActive()) {
this.tryFixSearchBlock(pendingNonStandardLineLimit)
canWritependingNonStandardLines && (this.pendingNonStandardLines.length = 0)
}
this.activateReplaceState()
this.beforeReplace()
} else if (line === ">>>>>>> REPLACE") {
if (!this.isReplacingActive()) {
this.tryFixReplaceBlock(pendingNonStandardLineLimit)
canWritependingNonStandardLines && (this.pendingNonStandardLines.length = 0)
}
this.lastProcessedIndex = this.searchEndIndex
this.resetForNextBlock()
} else {
// Accumulate content for search or replace
// (currentReplaceContent is not being used for anything right now since we directly append to result.)
// (We artificially add a linebreak since we split on \n at the beginning. In order to not include a trailing linebreak in the final search/result blocks we need to remove it before using them. This allows for partial line matches to be correctly identified.)
// NOTE: search/replace blocks must be arranged in the order they appear in the file due to how we build the content using lastProcessedIndex. We also cannot strip the trailing newline since for non-partial lines it would remove the linebreak from the original content. (If we remove end linebreak from search, then we'd also have to remove it from replace but we can't know if it's a partial line or not since the model may be using the line break to indicate the end of the block rather than as part of the search content.) We require the model to output full lines in order for our fallbacks to work as well.
if (this.isReplacingActive()) {
this.currentReplaceContent += line + "\n"
// Output replacement lines immediately if we know the insertion point
if (this.searchMatchIndex !== -1) {
this.result += line + "\n"
}
} else if (this.isSearchingActive()) {
this.currentSearchContent += line + "\n"
} else {
let appendToPendingNonStandardLines = canWritependingNonStandardLines
if (appendToPendingNonStandardLines) {
console.log("unstandard line:" + line)
// 处理非标内容
this.pendingNonStandardLines.push(line)
}
}
}
return removeLineCount
}
private beforeReplace() {
// Remove trailing linebreak for adding the === marker
// if (currentSearchContent.endsWith("\r\n")) {
// currentSearchContent = currentSearchContent.slice(0, -2)
// } else if (currentSearchContent.endsWith("\n")) {
// currentSearchContent = currentSearchContent.slice(0, -1)
// }
if (!this.currentSearchContent) {
// Empty search block
if (this.originalContent.length === 0) {
// New file scenario: nothing to match, just start inserting
this.searchMatchIndex = 0
this.searchEndIndex = 0
} else {
// Complete file replacement scenario: treat the entire file as matched
this.searchMatchIndex = 0
this.searchEndIndex = this.originalContent.length
}
} else {
// Add check for inefficient full-file search
// if (currentSearchContent.trim() === originalContent.trim()) {
// throw new Error(
// "The SEARCH block contains the entire file content. Please either:\n" +
// "1. Use an empty SEARCH block to replace the entire file, or\n" +
// "2. Make focused changes to specific parts of the file that need modification.",
// )
// }
// Exact search match scenario
const exactIndex = this.originalContent.indexOf(this.currentSearchContent, this.lastProcessedIndex)
if (exactIndex !== -1) {
this.searchMatchIndex = exactIndex
this.searchEndIndex = exactIndex + this.currentSearchContent.length
} else {
// Attempt fallback line-trimmed matching
const lineMatch = lineTrimmedFallbackMatch(
this.originalContent,
this.currentSearchContent,
this.lastProcessedIndex,
)
if (lineMatch) {
;[this.searchMatchIndex, this.searchEndIndex] = lineMatch
} else {
// Try block anchor fallback for larger blocks
const blockMatch = blockAnchorFallbackMatch(
this.originalContent,
this.currentSearchContent,
this.lastProcessedIndex,
)
if (blockMatch) {
;[this.searchMatchIndex, this.searchEndIndex] = blockMatch
} else {
throw new Error(
`The SEARCH block:\n${this.currentSearchContent.trimEnd()}\n...does not match anything in the file.`,
)
}
}
}
}
if (this.searchMatchIndex < this.lastProcessedIndex) {
throw new Error(
`The SEARCH block:\n${this.currentSearchContent.trimEnd()}\n...matched an incorrect content in the file.`,
)
}
// Output everything up to the match location
this.result += this.originalContent.slice(this.lastProcessedIndex, this.searchMatchIndex)
}
private tryFixSearchBlock(lineLimit: number): number {
let removeLineCount = 0
if (lineLimit < 0) {
lineLimit = this.pendingNonStandardLines.length
}
if (!lineLimit) {
throw new Error("Invalid SEARCH/REPLACE block structure - no lines available to process")
}
let searchTagRegexp = /^[<]{3,} SEARCH$/
const searchTagIndex = this.findLastMatchingLineIndex(searchTagRegexp, lineLimit)
if (searchTagIndex !== -1) {
let fixLines = this.pendingNonStandardLines.slice(searchTagIndex, lineLimit)
fixLines[0] = "<<<<<<< SEARCH"
for (const line of fixLines) {
removeLineCount += this.internalProcessLine(line, false, searchTagIndex)
}
} else {
throw new Error(
`Invalid REPLACE marker detected - could not find matching SEARCH block starting from line ${searchTagIndex + 1}`,
)
}
return removeLineCount
}
private tryFixReplaceBlock(lineLimit: number): number {
let removeLineCount = 0
if (lineLimit < 0) {
lineLimit = this.pendingNonStandardLines.length
}
if (!lineLimit) {
throw new Error()
}
let replaceBeginTagRegexp = /^[=]{3,}$/
const replaceBeginTagIndex = this.findLastMatchingLineIndex(replaceBeginTagRegexp, lineLimit)
if (replaceBeginTagIndex !== -1) {
// // 校验非标内容
// if (!this.isSearchingActive()) {
// removeLineCount += this.tryFixSearchBlock(replaceBeginTagIndex)
// }
let fixLines = this.pendingNonStandardLines.slice(replaceBeginTagIndex - removeLineCount, lineLimit - removeLineCount)
fixLines[0] = "======="
for (const line of fixLines) {
removeLineCount += this.internalProcessLine(line, false, replaceBeginTagIndex - removeLineCount)
}
} else {
throw new Error(`Malformed REPLACE block - missing valid separator after line ${replaceBeginTagIndex + 1}`)
}
return removeLineCount
}
private tryFixSearchReplaceBlock(lineLimit: number): number {
let removeLineCount = 0
if (lineLimit < 0) {
lineLimit = this.pendingNonStandardLines.length
}
if (!lineLimit) {
throw new Error()
}
let replaceEndTagRegexp = /^[>]{3,} REPLACE$/
const replaceEndTagIndex = this.findLastMatchingLineIndex(replaceEndTagRegexp, lineLimit)
const likeReplaceEndTag = replaceEndTagIndex === lineLimit - 1
if (likeReplaceEndTag) {
// // 校验非标内容
// if (!this.isReplacingActive()) {
// removeLineCount += this.tryFixReplaceBlock(replaceEndTagIndex)
// }
let fixLines = this.pendingNonStandardLines.slice(replaceEndTagIndex - removeLineCount, lineLimit - removeLineCount)
fixLines[fixLines.length - 1] = ">>>>>>> REPLACE"
for (const line of fixLines) {
removeLineCount += this.internalProcessLine(line, false, replaceEndTagIndex - removeLineCount)
}
} else {
throw new Error("Malformed SEARCH/REPLACE block structure: Missing valid closing REPLACE marker")
}
return removeLineCount
}
/**
* Removes trailing empty lines from the pendingNonStandardLines array
* @param lineLimit - The index to start checking from (exclusive).
* Removes empty lines from lineLimit-1 backwards.
* @returns The number of empty lines removed
*/
private trimPendingNonStandardTrailingEmptyLines(lineLimit: number): number {
let removedCount = 0
let i = Math.min(lineLimit, this.pendingNonStandardLines.length) - 1
while (i >= 0 && this.pendingNonStandardLines[i].trim() === "") {
this.pendingNonStandardLines.pop()
removedCount++
i--
}
return removedCount
}
}
export async function constructNewFileContentV2(diffContent: string, originalContent: string, isFinal: boolean): Promise<string> {
let newFileContentConstructor = new NewFileContentConstructor(originalContent, isFinal)
let lines = diffContent.split("\n")
// If the last line looks like a partial marker but isn't recognized,
// remove it because it might be incomplete.
const lastLine = lines[lines.length - 1]
if (
lines.length > 0 &&
(lastLine.startsWith("<") || lastLine.startsWith("=") || lastLine.startsWith(">")) &&
lastLine !== "<<<<<<< SEARCH" &&
lastLine !== "=======" &&
lastLine !== ">>>>>>> REPLACE"
) {
lines.pop()
}
for (const line of lines) {
newFileContentConstructor.processLine(line)
}
let result = newFileContentConstructor.getResult()
return result
}
@@ -1,131 +0,0 @@
import { constructNewFileContent as cnfc2 } from "./diff"
import { describe, it } from "mocha"
import { expect } from "chai"
async function cnfc(diffContent: string, originalContent: string, isFinal: boolean): Promise<string> {
return cnfc2(diffContent, originalContent, isFinal, "v1")
}
describe("Diff Format Edge Cases", () => {
it("should handle SEARCH prefix symbols < less than 7", async () => {
const isFinal = true
const original = "before\ncontent\nafter"
const diff = `<<<<< SEARCH
content
=======
new content
>>>>>>> REPLACE`
const result1 = await cnfc(diff, original, isFinal)
const result2 = await cnfc2(diff, original, isFinal)
expect(result1).to.equal("new content\n")
expect(result2).to.equal("before\nnew content\nafter")
})
it("should handle SEARCH prefix symbols < more than 7", async () => {
const isFinal = true
const original = "before\ncontent\nafter"
const diff = `<<<<<<<<<<< SEARCH
content
=======
new content
>>>>>>> REPLACE`
const result1 = await cnfc(diff, original, isFinal)
const result2 = await cnfc2(diff, original, isFinal)
expect(result1).to.equal("new content\n")
expect(result2).to.equal("before\nnew content\nafter")
})
it("should handle SEARCH < less than 7 and REPLACE = less than 7", async () => {
const isFinal = true
const original = "before\ncontent\nafter"
const diff = `<<<<< SEARCH
content
=====
new content
>>>>>>> REPLACE`
const result1 = await cnfc(diff, original, isFinal)
const result2 = await cnfc2(diff, original, isFinal)
expect(result1).to.equal("r")
expect(result2).to.equal("before\nnew content\nafter")
})
it("should handle SEARCH < less than 7 and REPLACE = more than 7", async () => {
const isFinal = true
const original = "before\ncontent\nafter"
const diff = `<<<<< SEARCH
content
========
new content
>>>>>>> REPLACE`
const result1 = await cnfc(diff, original, isFinal)
const result2 = await cnfc2(diff, original, isFinal)
expect(result1).to.equal("r")
expect(result2).to.equal("before\nnew content\nafter")
})
it("should handle SEARCH < more than 7 and REPLACE = more than 7", async () => {
const isFinal = true
const original = "before\ncontent\nafter"
const diff = `<<<<<<<<<<< SEARCH
content
==========
new content
>>>>>>> REPLACE`
const result1 = await cnfc(diff, original, isFinal)
const result2 = await cnfc2(diff, original, isFinal)
expect(result1).to.equal("r")
expect(result2).to.equal("before\nnew content\nafter")
})
it("should handle SEARCH < more than 7 and REPLACE = less than 7", async () => {
const isFinal = true
const original = "before\ncontent\nafter"
const diff = `<<<<<<<<<<< SEARCH
content
=====
new content
>>>>>>> REPLACE`
const result1 = await cnfc(diff, original, isFinal)
const result2 = await cnfc2(diff, original, isFinal)
expect(result1).to.equal("r")
expect(result2).to.equal("before\nnew content\nafter")
})
it("should handle consecutive SEARCH-REPLACE with second block SEARCH < less than 7", async () => {
const isFinal = true
const original = "before\nfirst content\nafter\nsecond content\nend"
const diff = `<<<<<<< SEARCH
first content
=======
first new content
>>>>>>> REPLACE
<<<<< SEARCH
second content
=======
second new content
>>>>>>> REPLACE`
const result1 = await cnfc(diff, original, isFinal)
const result2 = await cnfc2(diff, original, isFinal)
expect(result1).to.equal("before\nfirst new content\nsecond new content\n")
expect(result2).to.equal("before\nfirst new content\nafter\nsecond new content\nend")
})
it("should handle consecutive SEARCH-REPLACE with second block SEARCH < less than 7 and REPLACE = less than 7", async () => {
const isFinal = true
const original = "before\nfirst content\nafter\nsecond content\nend"
const diff = `<<<<<<< SEARCH
first content
=======
first new content
>>>>>>> REPLACE
<<<<< SEARCH
second content
=====
second new content
>>>>>>> REPLACE`
const result1 = await cnfc(diff, original, isFinal)
const result2 = await cnfc2(diff, original, isFinal)
expect(result1).to.equal("before\nfirst new content\nd")
expect(result2).to.equal("before\nfirst new content\nafter\nsecond new content\nend")
})
})
@@ -1,361 +0,0 @@
import { constructNewFileContent as cnfc2 } from "./diff"
import { describe, it } from "mocha"
import { expect } from "chai"
async function cnfc(diffContent: string, originalContent: string, isFinal: boolean): Promise<string> {
return cnfc2(diffContent, originalContent, isFinal, "v1")
}
describe("Diff Format Edge Cases", () => {
it("should handle missing search block", async () => {
const original = "line1\nline2"
const diff = `=======
new content
>>>>>>> REPLACE`
const result1 = await cnfc(diff, original, true)
expect(result1).to.equal("new content\n")
try {
await cnfc2(diff, original, true)
expect.fail("Expected an error to be thrown")
} catch (err) {
expect(err).to.be.an("error")
}
})
it("should handle consecutive search blocks", async () => {
const original = "text"
const diff = `<<<<<<< SEARCH
=======
replaced
>>>>>>> REPLACE
<<<<<<< SEARCH
=======
another
>>>>>>> REPLACE`
const result1 = await cnfc(diff, original, true)
expect(result1).to.equal("replaced\nanother\n")
try {
await cnfc2(diff, original, true)
expect.fail("Expected an error to be thrown")
} catch (err) {
expect(err).to.be.an("error")
}
})
it("should handle reverse markers order", async () => {
const original = "content"
const diff = `>>>>>>> SEARCH
=======
invalid
<<<<<<< REPLACE`
const result1 = await cnfc(diff, original, true)
expect(result1).to.equal("invalid\ncontent")
try {
await cnfc2(diff, original, true)
expect.fail("Expected an error to be thrown")
} catch (err) {
expect(err).to.be.an("error")
}
})
it("should handle incomplete block structure", async () => {
const original = "valid text"
const diff = `<<<<<<< SEARCH
text
>>>>>>> REPLACE`
const result1 = await cnfc(diff, original, true)
expect(result1).to.equal("t")
try {
await cnfc2(diff, original, true)
expect.fail("Expected an error to be thrown")
} catch (err) {
expect(err).to.be.an("error")
}
})
it("should handle empty search block", async () => {
const original = "any content"
const diff = `<<<<<<< SEARCH
=======
inserted
>>>>>>> REPLACE`
const result1 = await cnfc(diff, original, true)
const result2 = await cnfc2(diff, original, true)
expect(result1).to.equal("inserted\n")
expect(result1).to.equal(result2)
})
it("should handle mixed line endings", async () => {
const original = "line1\r\nline2"
const diff = `<<<<<<< SEARCH
line1\r
=======
line1
>>>>>>> REPLACE`
const result1 = await cnfc(diff, original, true)
const result2 = await cnfc2(diff, original, true)
expect(result1).to.equal("line1\nline2")
expect(result1).to.equal(result2)
})
it("should handle special characters in search", async () => {
const original = "text with $^.*\nend"
const diff = `<<<<<<< SEARCH
$^.*
=======
replaced
>>>>>>> REPLACE`
const result1 = await cnfc(diff, original, true)
const result2 = await cnfc2(diff, original, true)
expect(result1).to.equal("text with replaced\nend")
expect(result1).to.equal(result2)
})
it("should handle special regex chars and nested search markers", async () => {
const original = `text with $^.*\n<<< SEARCH\nend`
const diff = `<<<<<<< SEARCH
$^.*
=======
replaced
>>>>>>> REPLACE
<<<<<<< SEARCH
<<< SEARCH
=======
before
>>>>>>> REPLACE`
const result1 = await cnfc(diff, original, true)
const result2 = await cnfc2(diff, original, true)
expect(result1).to.equal("text with replaced\nbefore\nend")
expect(result1).to.equal(result2)
})
it("cnfc2 should handle invalid search marker format", async () => {
const original = `text with $^.*\n<<< SEARCH\nend`
const diff = `<<< SEARCH
$^.*
=======
replaced
>>>>>>> REPLACE
<<<<<<< SEARCH
<<< SEARCH
=======
before
>>>>>>> REPLACE`
try {
await cnfc(diff, original, true)
expect.fail("Expected an error to be thrown")
} catch (err) {
expect(err).to.be.an("error")
}
const result2 = await cnfc2(diff, original, true)
expect(result2).to.equal("text with replaced\nbefore\nend")
})
it("cnfc2 should throw error for incomplete search marker", async () => {
const original = `text with $^.*\n<<< SEARCH\nend`
const diff = `<<< SEARCH
$^.*
=======
replaced
>>>>>>> REPLACE
<<<<<< SEARCH
<<< SEARCH
=======
before
>>>>>>> REPLACE`
const result1 = await cnfc(diff, original, true)
expect(result1).to.equal("replaced\nbefore\n")
try {
await cnfc2(diff, original, true)
expect.fail("Expected an error to be thrown")
} catch (err) {
expect(err).to.be.an("error")
}
})
it("cnfc2 should handle custom nested search markers", async () => {
const original = `text with $^.*\n<<< SEARCH2\nend`
const diff = `<<< SEARCH
$^.*
=======
replaced
>>>>>>> REPLACE
<<<<<< SEARCH
<<< SEARCH2
=======
before
>>>>>>> REPLACE`
const result1 = await cnfc(diff, original, true)
const result2 = await cnfc2(diff, original, true)
expect(result1).to.equal("replaced\nbefore\n")
expect(result2).to.equal("text with replaced\nbefore\nend")
})
it("cnfc2 should handle text containing nested search markers", async () => {
const original = `text with $^.*\ntext with <<< SEARCH2\nend`
const diff = `<<< SEARCH
$^.*
=======
replaced
>>>>>>> REPLACE
<<<<<< SEARCH
text with <<< SEARCH2
=======
before
>>>>>>> REPLACE`
const result1 = await cnfc(diff, original, true)
const result2 = await cnfc2(diff, original, true)
expect(result1).to.equal("replaced\nbefore\n")
expect(result2).to.equal("text with replaced\nbefore\nend")
})
it("cnfc2 should handle missing replacement marker in lenient mode", async () => {
const original = `text with $^.*\ntext with <<< SEARCH2\nend`
const diff = `<<< SEARCH
$^.*
=======
replaced
>>>>>>> REPLACE
<<<<<< SEARCH
text with <<< SEARCH2
=======
before`
const result1 = await cnfc(diff, original, false)
const result2 = await cnfc2(diff, original, false)
expect(result1).to.equal("replaced\nbefore\n")
expect(result2).to.equal("text with replaced\nbefore\n")
})
it("cnfc2 should throw error for missing replacement marker in strict mode", async () => {
const original = `text with $^.*\ntext with <<< SEARCH2\nend`
const diff = `<<< SEARCH
$^.*
=======
replaced
>>>>>>> REPLACE
<<<<<< SEARCH
text with <<< SEARCH2
=======
before`
const result1 = await cnfc(diff, original, true)
expect(result1).to.equal("replaced\nbefore\n")
try {
await cnfc2(diff, original, true)
expect.fail("Expected an error to be thrown")
} catch (err) {
expect(err).to.be.an("error")
}
})
it("cnfc2 should handle long text with multiple search-replace blocks", async () => {
const original = `This is a long text with multiple sections.
Section 1: Lorem ipsum dolor sit amet
Section 2: consectetur adipiscing elit
Section 3: sed do eiusmod tempor
Section 4: incididunt ut labore
Section 5: et dolore magna aliqua`
const diff = `<<< SEARCH
Section 1: Lorem ipsum dolor sit amet
=======
Section 1: Replaced text
>>>>>>> REPLACE
<<<<<<< SEARCH
Section 3: sed do eiusmod tempor
=======
Section 3: Modified content
>>>>>>> REPLACE
<<<<<<< SEARCH
Section 5: et dolore magna aliqua
=======
Section 5: Final replacement
>>>>>>> REPLACE`
const expected = `This is a long text with multiple sections.
Section 1: Replaced text
Section 2: consectetur adipiscing elit
Section 3: Modified content
Section 4: incididunt ut labore
Section 5: Final replacement
`
const result = await cnfc2(diff, original, true)
expect(result).to.equal(expected)
})
// Test diff containing special regex characters and nested search markers
const diff = `<<< SEARCH
$^.*
=======
replaced
>>>>>>> REPLACE
<<<<<< SEARCH
<<< SEARCH
=======
before
>>>>>>> REPLACE`
// expected1 shows the incremental results when processing the diff line by line
// Each element represents the result after processing that line number
const expected1 = [
"",
"",
"",
"replaced\n",
"replaced\n",
"replaced\n",
"replaced\n",
"replaced\n",
"replaced\n",
"replaced\nbefore\n",
]
// expected2 shows the results when processing with original content
// Each element represents the result after processing that line number
const expected2 = [
"",
"",
"text with ",
"text with replaced\n",
"text with replaced\n",
"text with replaced\n",
"text with replaced\n",
"text with replaced\n",
new Error(),
new Error(),
]
const diffLines = diff.split("\n")
for (let i = 1; i < diffLines.length; i++) {
it(`cnfc2 should handle partial diff configuration (line ${i})`, async () => {
const original = `text with $^.*\n<<< SEARCH\nend`
const result1 = await cnfc(diffLines.slice(0, i).join("\n"), original, i === diffLines.length - 1)
expect(result1).to.equal(expected1[i - 1])
})
}
for (let i = 1; i < diffLines.length; i++) {
it(`cnfc2 should handle partial diff configuration (line ${i})`, async () => {
const original = `text with $^.*\n<<< SEARCH\nend`
let expected = expected2[i - 1]
if (expected instanceof Error) {
try {
await cnfc2(diffLines.slice(0, i).join("\n"), original, true)
expect.fail("Expected an error to be thrown")
} catch (err) {
expect(err).to.be.an("error")
}
} else {
const result2 = await cnfc2(diffLines.slice(0, i).join("\n"), original, i === diffLines.length - 1)
expect(result2).to.equal(expected)
}
})
}
})
@@ -123,7 +123,7 @@ export function parseAssistantMessage(assistantMessage: string) {
contentBlocks.push(currentToolUse)
}
// Note: it doesn't matter if check for currentToolUse or currentTextContent, only one of them will be defined since only one can be partial at a time
// Note: it doesnt matter if check for currentToolUse or currentTextContent, only one of them will be defined since only one can be partial at a time
if (currentTextContent) {
// stream did not complete text content, add it as partial
contentBlocks.push(currentTextContent)
@@ -537,7 +537,7 @@ export class ContextManager {
// we can assume that thisExistingFileReads does not have many entries
if (!thisExistingFileReads.includes(filePath)) {
// meaning we haven't already replaced this file read
// meaning we havent already replaced this file read
const entireMatch = match[0] // The entire matched string
@@ -590,7 +590,7 @@ export class ContextManager {
) {
const pattern = new RegExp(`(<final_file_content path="[^"]*">)[\\s\\S]*?(</final_file_content>)`)
// check if this exists in the text, it won't exist if the user rejects the file change for example
// check if this exists in the text, it wont exist if the user rejects the file change for example
if (pattern.test(secondBlockText)) {
const replacementText = secondBlockText.replace(pattern, `$1 ${formatResponse.duplicateFileReadNotice()} $2`)
const indices = fileReadIndices.get(filePath) || []
@@ -741,7 +741,7 @@ export class ContextManager {
let totalCharactersSaved = 0
for (let i = startIndex; i < endIndex; i++) {
// looping over the outer indices of messages
// looping over the outer indicies of messages
const message = apiMessages[i]
if (!message.content) {
@@ -782,7 +782,7 @@ export class ContextManager {
totalCharCount += originalTextLength
} else {
// meaning there was an update to this text previously, but we didn't just alter it
// meaning there was an update to this text previously, but we didnt just alter it
totalCharCount += latestUpdate[2][0].length
}
} else {
@@ -790,7 +790,7 @@ export class ContextManager {
totalCharCount += block.text.length
}
} else {
// reach here if there's no alterations for this outer index, meaning each inner index won't have any changes either
// reach here if there's no alterations for this outer index, meaning each inner index wont have any changes either
totalCharCount += block.text.length
}
} else if (block.type === "image" && block.source) {
@@ -3,9 +3,9 @@ import { expect } from "chai"
import * as sinon from "sinon"
import * as vscode from "vscode"
import * as path from "path"
import { FileContextTracker } from "./FileContextTracker"
import * as diskModule from "../../storage/disk"
import type { TaskMetadata, FileMetadataEntry } from "./ContextTrackerTypes"
import { FileContextTracker } from "../FileContextTracker"
import * as diskModule from "../../../storage/disk"
import type { TaskMetadata, FileMetadataEntry } from "../ContextTrackerTypes"
describe("FileContextTracker", () => {
let sandbox: sinon.SinonSandbox
@@ -0,0 +1,205 @@
import { describe, it, beforeEach, afterEach } from "mocha"
import { expect } from "chai"
import * as sinon from "sinon"
import * as vscode from "vscode"
import { ModelContextTracker } from "../ModelContextTracker"
import * as diskModule from "../../../storage/disk"
import type { TaskMetadata } from "../ContextTrackerTypes"
describe("ModelContextTracker", () => {
let sandbox: sinon.SinonSandbox
let mockContext: vscode.ExtensionContext
let tracker: ModelContextTracker
let taskId: string
let mockTaskMetadata: TaskMetadata
let getTaskMetadataStub: sinon.SinonStub
let saveTaskMetadataStub: sinon.SinonStub
beforeEach(() => {
sandbox = sinon.createSandbox()
// Mock controller and context
mockContext = {
globalStorageUri: { fsPath: "/mock/storage" },
} as unknown as vscode.ExtensionContext
// Mock disk module functions
mockTaskMetadata = { files_in_context: [], model_usage: [] }
getTaskMetadataStub = sandbox.stub(diskModule, "getTaskMetadata").resolves(mockTaskMetadata)
saveTaskMetadataStub = sandbox.stub(diskModule, "saveTaskMetadata").resolves()
// Create tracker instance
taskId = "test-task-id"
tracker = new ModelContextTracker(mockContext, taskId)
})
afterEach(() => {
sandbox.restore()
})
it("should record model usage with correct data", async () => {
// Test data
const apiProviderId = "anthropic"
const modelId = "claude-3-opus"
const mode = "act"
// Use a fake timer to have a predictable timestamp
const fakeNow = 1617293940000 // Some fixed timestamp
const clock = sandbox.useFakeTimers(fakeNow)
try {
// Call the method being tested
await tracker.recordModelUsage(apiProviderId, modelId, mode)
// Verify getTaskMetadata was called with correct parameters
expect(getTaskMetadataStub.calledOnce).to.be.true
expect(getTaskMetadataStub.firstCall.args[1]).to.equal(taskId)
// Verify saveTaskMetadata was called with the correct data
expect(saveTaskMetadataStub.calledOnce).to.be.true
// Extract the saved metadata from the call arguments
const savedMetadata = saveTaskMetadataStub.firstCall.args[2]
// Verify model_usage array has one entry
expect(savedMetadata.model_usage.length).to.equal(1)
// Verify the entry has the correct properties
const modelUsageEntry = savedMetadata.model_usage[0]
expect(modelUsageEntry.ts).to.equal(fakeNow)
expect(modelUsageEntry.model_id).to.equal(modelId)
expect(modelUsageEntry.model_provider_id).to.equal(apiProviderId)
expect(modelUsageEntry.mode).to.equal(mode)
} finally {
// Restore the clock
clock.restore()
}
})
it("should throw an error when controller is dereferenced", async () => {
// Create a new tracker with a controller that will be garbage collected
const weakTracker = new ModelContextTracker(mockContext, taskId)
// Force the WeakRef to return null by overriding the deref method
const weakRef = { deref: sandbox.stub().returns(null) }
sandbox.stub(WeakRef.prototype, "deref").callsFake(() => weakRef.deref())
try {
// Try to call the method - this should throw
await weakTracker.recordModelUsage("any-provider", "any-model", "any-mode")
// If we get here, the test should fail
expect.fail("Expected an error to be thrown")
} catch (error) {
// Verify the error message
expect(error.message).to.equal("Unable to access extension context")
}
})
it("should append model usage to existing entries", async () => {
// Add an existing model usage entry
const existingTimestamp = 1617200000000
mockTaskMetadata.model_usage = [
{
ts: existingTimestamp,
model_id: "existing-model",
model_provider_id: "existing-provider",
mode: "plan",
},
]
// Test data for new entry
const apiProviderId = "anthropic"
const modelId = "claude-3-sonnet"
const mode = "act"
// Use a fake timer
const newTimestamp = 1617300000000
const clock = sandbox.useFakeTimers(newTimestamp)
try {
// Call the method being tested
await tracker.recordModelUsage(apiProviderId, modelId, mode)
// Verify saveTaskMetadata was called
expect(saveTaskMetadataStub.calledOnce).to.be.true
// Extract the saved metadata
const savedMetadata = saveTaskMetadataStub.firstCall.args[2]
// Verify model_usage array now has two entries
expect(savedMetadata.model_usage.length).to.equal(2)
// Verify the existing entry is preserved
expect(savedMetadata.model_usage[0]).to.deep.equal({
ts: existingTimestamp,
model_id: "existing-model",
model_provider_id: "existing-provider",
mode: "plan",
})
// Verify the new entry has correct data
expect(savedMetadata.model_usage[1]).to.deep.equal({
ts: newTimestamp,
model_id: modelId,
model_provider_id: apiProviderId,
mode: mode,
})
} finally {
clock.restore()
}
})
it("should handle multiple model usages in sequence", async () => {
// Test data for sequential calls
const usages = [
{ provider: "anthropic", model: "claude-3-opus", mode: "plan" },
{ provider: "openai", model: "gpt-4", mode: "act" },
{ provider: "anthropic", model: "claude-3-haiku", mode: "plan" },
]
// Use a fake timer that advances with each call
const startTime = 1617300000000
const clock = sandbox.useFakeTimers(startTime)
try {
// Record multiple model usages
for (let i = 0; i < usages.length; i++) {
const { provider, model, mode } = usages[i]
// Advance time by 1 second for each call
clock.tick(1000)
const expectedTime = startTime + (i + 1) * 1000
// Reset history between calls to check individual call behavior
getTaskMetadataStub.resetHistory()
saveTaskMetadataStub.resetHistory()
// Reset mock metadata for each iteration to avoid accumulation
mockTaskMetadata.model_usage = []
// Call the method
await tracker.recordModelUsage(provider, model, mode)
// Verify interaction with disk module
expect(getTaskMetadataStub.calledOnce).to.be.true
expect(saveTaskMetadataStub.calledOnce).to.be.true
// Get the saved metadata
const savedMetadata = saveTaskMetadataStub.firstCall.args[2]
// Since we reset the array for each call, we should always have 1 entry
expect(savedMetadata.model_usage.length).to.equal(1)
// Check the entry
const entry = savedMetadata.model_usage[0]
expect(entry.ts).to.equal(expectedTime)
expect(entry.model_id).to.equal(model)
expect(entry.model_provider_id).to.equal(provider)
expect(entry.mode).to.equal(mode)
}
} finally {
clock.restore()
}
})
})
@@ -1,11 +1,10 @@
import path from "path"
import { ensureRulesDirectoryExists, GlobalFileNames } from "../../../storage/disk"
import { GlobalFileNames } from "../../../storage/disk"
import { fileExistsAtPath, isDirectory, readDirectory } from "../../../../utils/fs"
import { formatResponse } from "../../../prompts/responses"
import fs from "fs/promises"
import { ClineRulesToggles } from "../../../../shared/cline-rules"
import { getGlobalState, getWorkspaceState, updateGlobalState, updateWorkspaceState } from "../../../storage/state"
import * as vscode from "vscode"
export type ClineRulesToggles = Record<string, boolean> // filepath -> enabled/disabled
export const getGlobalClineRules = async (globalClineRulesFilePath: string, toggles: ClineRulesToggles) => {
if (await fileExistsAtPath(globalClineRulesFilePath)) {
@@ -148,100 +147,3 @@ export async function synchronizeRuleToggles(
return updatedToggles
}
export async function refreshClineRulesToggles(
context: vscode.ExtensionContext,
workingDirectory: string,
): Promise<{
globalToggles: ClineRulesToggles
localToggles: ClineRulesToggles
}> {
// Global toggles
const globalClineRulesToggles = ((await getGlobalState(context, "globalClineRulesToggles")) as ClineRulesToggles) || {}
const globalClineRulesFilePath = await ensureRulesDirectoryExists()
const updatedGlobalToggles = await synchronizeRuleToggles(globalClineRulesFilePath, globalClineRulesToggles)
await updateGlobalState(context, "globalClineRulesToggles", updatedGlobalToggles)
// Local toggles
const localClineRulesToggles = ((await getWorkspaceState(context, "localClineRulesToggles")) as ClineRulesToggles) || {}
const localClineRulesFilePath = path.resolve(workingDirectory, GlobalFileNames.clineRules)
const updatedLocalToggles = await synchronizeRuleToggles(localClineRulesFilePath, localClineRulesToggles)
await updateWorkspaceState(context, "localClineRulesToggles", updatedLocalToggles)
return {
globalToggles: updatedGlobalToggles,
localToggles: updatedLocalToggles,
}
}
export const createRuleFile = async (isGlobal: boolean, filename: string, cwd: string) => {
try {
let filePath: string
if (isGlobal) {
const globalClineRulesFilePath = await ensureRulesDirectoryExists()
filePath = path.join(globalClineRulesFilePath, filename)
} else {
const localClineRulesFilePath = path.resolve(cwd, GlobalFileNames.clineRules)
await fs.mkdir(localClineRulesFilePath, { recursive: true })
filePath = path.join(localClineRulesFilePath, filename)
}
const fileExists = await fileExistsAtPath(filePath)
if (fileExists) {
return { filePath, fileExists }
}
await fs.writeFile(filePath, "", "utf8")
return { filePath, fileExists: false }
} catch (error) {
return { filePath: null, fileExists: false }
}
}
export async function deleteRuleFile(
context: vscode.ExtensionContext,
rulePath: string,
isGlobal: boolean,
): Promise<{ success: boolean; message: string }> {
try {
// Check if file exists
const fileExists = await fileExistsAtPath(rulePath)
if (!fileExists) {
return {
success: false,
message: `Rule file does not exist: ${rulePath}`,
}
}
// Delete the file from disk
await fs.unlink(rulePath)
// Get the filename for messages
const fileName = path.basename(rulePath)
// Update the appropriate toggles
if (isGlobal) {
const toggles = ((await getGlobalState(context, "globalClineRulesToggles")) as ClineRulesToggles) || {}
delete toggles[rulePath]
await updateGlobalState(context, "globalClineRulesToggles", toggles)
} else {
const toggles = ((await getWorkspaceState(context, "localClineRulesToggles")) as ClineRulesToggles) || {}
delete toggles[rulePath]
await updateWorkspaceState(context, "localClineRulesToggles", toggles)
}
return {
success: true,
message: `Rule file "${fileName}" deleted successfully`,
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error)
console.error(`Error deleting rule file: ${errorMessage}`, error)
return {
success: false,
message: `Failed to delete rule file.`,
}
}
}
-2
View File
@@ -4,11 +4,9 @@
// Import all method implementations
import { registerMethod } from "./index"
import { getBrowserConnectionInfo } from "./getBrowserConnectionInfo"
import { testBrowserConnection } from "./testBrowserConnection"
// Register all browser service methods
export function registerAllMethods(): void {
// Register each method with the registry
registerMethod("getBrowserConnectionInfo", getBrowserConnectionInfo)
registerMethod("testBrowserConnection", testBrowserConnection)
}
@@ -1,63 +0,0 @@
import { BrowserConnection } from "../../../shared/proto/browser"
import { StringRequest } from "../../../shared/proto/common"
import { Controller } from "../index"
import { getAllExtensionState } from "../../storage/state"
import { BrowserSession } from "../../../services/browser/BrowserSession"
import { discoverChromeInstances } from "../../../services/browser/BrowserDiscovery"
/**
* Test connection to a browser instance
* @param controller The controller instance
* @param request The request message
* @returns The browser connection result
*/
export async function testBrowserConnection(controller: Controller, request: StringRequest): Promise<BrowserConnection> {
try {
const { browserSettings } = await getAllExtensionState(controller.context)
const browserSession = new BrowserSession(controller.context, browserSettings)
const text = request.value || ""
// If no text is provided, try auto-discovery
if (!text) {
try {
const discoveredHost = await discoverChromeInstances()
if (discoveredHost) {
// Test the connection to the discovered host
const result = await browserSession.testConnection(discoveredHost)
return {
success: result.success,
message: `Auto-discovered and tested connection to Chrome at ${discoveredHost}: ${result.message}`,
endpoint: result.endpoint || "",
}
} else {
return {
success: false,
message:
"No Chrome instances found on the network. Make sure Chrome is running with remote debugging enabled (--remote-debugging-port=9222).",
endpoint: "",
}
}
} catch (error) {
return {
success: false,
message: `Error during auto-discovery: ${error instanceof Error ? error.message : String(error)}`,
endpoint: "",
}
}
} else {
// Test the provided URL
const result = await browserSession.testConnection(text)
return {
success: result.success,
message: result.message,
endpoint: result.endpoint || "",
}
}
} catch (error) {
return {
success: false,
message: `Error testing connection: ${error instanceof Error ? error.message : String(error)}`,
endpoint: "",
}
}
}
+58 -92
View File
@@ -40,16 +40,12 @@ import {
getAllExtensionState,
getGlobalState,
getSecret,
getWorkspaceState,
resetExtensionState,
storeSecret,
updateApiConfiguration,
updateGlobalState,
updateWorkspaceState,
} from "../storage/state"
import { Task, cwd } from "../task"
import { ClineRulesToggles } from "../../shared/cline-rules"
import { createRuleFile, deleteRuleFile, refreshClineRulesToggles } from "../context/instructions/user-instructions/cline-rules"
import { Task } from "../task"
/*
https://github.com/microsoft/vscode-webview-ui-toolkit-samples/blob/main/default/weather-webview/src/providers/WeatherViewProvider.ts
@@ -65,7 +61,7 @@ export class Controller {
workspaceTracker: WorkspaceTracker
mcpHub: McpHub
accountService: ClineAccountService
private latestAnnouncementId = "april-18-2025_21:15::00" // update to some unique identifier when we add a new announcement
private latestAnnouncementId = "april-11-2025" // update to some unique identifier when we add a new announcement
constructor(
readonly context: vscode.ExtensionContext,
@@ -311,6 +307,58 @@ export class Controller {
await this.postStateToWebview()
}
break
case "testBrowserConnection":
try {
const { browserSettings } = await getAllExtensionState(this.context)
const browserSession = new BrowserSession(this.context, browserSettings)
// If no text is provided, try auto-discovery
if (!message.text) {
try {
const discoveredHost = await discoverChromeInstances()
if (discoveredHost) {
// Test the connection to the discovered host
const result = await browserSession.testConnection(discoveredHost)
// Send the result back to the webview
await this.postMessageToWebview({
type: "browserConnectionResult",
success: result.success,
text: `Auto-discovered and tested connection to Chrome at ${discoveredHost}: ${result.message}`,
endpoint: result.endpoint,
})
} else {
await this.postMessageToWebview({
type: "browserConnectionResult",
success: false,
text: "No Chrome instances found on the network. Make sure Chrome is running with remote debugging enabled (--remote-debugging-port=9222).",
})
}
} catch (error) {
await this.postMessageToWebview({
type: "browserConnectionResult",
success: false,
text: `Error during auto-discovery: ${error instanceof Error ? error.message : String(error)}`,
})
}
} else {
// Test the provided URL
const result = await browserSession.testConnection(message.text)
// Send the result back to the webview
await this.postMessageToWebview({
type: "browserConnectionResult",
success: result.success,
text: result.message,
endpoint: result.endpoint,
})
}
} catch (error) {
await this.postMessageToWebview({
type: "browserConnectionResult",
success: false,
text: `Error testing connection: ${error instanceof Error ? error.message : String(error)}`,
})
}
break
case "discoverBrowser":
try {
const discoveredHost = await discoverChromeInstances()
@@ -429,10 +477,6 @@ export class Controller {
const openAiModels = await this.getOpenAiModels(apiConfiguration.openAiBaseUrl, apiConfiguration.openAiApiKey)
this.postMessageToWebview({ type: "openAiModels", openAiModels })
break
case "refreshClineRules":
await refreshClineRulesToggles(this.context, cwd)
await this.postStateToWebview()
break
case "openImage":
openImage(message.text!)
break
@@ -449,36 +493,6 @@ export class Controller {
break
case "openFile":
openFile(message.text!)
break
case "createRuleFile":
if (typeof message.isGlobal !== "boolean" || typeof message.filename !== "string" || !message.filename) {
console.error("createRuleFile: Missing or invalid parameters", {
isGlobal:
typeof message.isGlobal === "boolean" ? message.isGlobal : `Invalid: ${typeof message.isGlobal}`,
filename: typeof message.filename === "string" ? message.filename : `Invalid: ${typeof message.filename}`,
})
return
}
const { filePath, fileExists } = await createRuleFile(message.isGlobal, message.filename, cwd)
if (fileExists && filePath) {
vscode.window.showWarningMessage(`Rule file "${message.filename}" already exists.`)
// Still open it for editing
openFile(filePath)
return
} else if (filePath && !fileExists) {
await refreshClineRulesToggles(this.context, cwd)
await this.postStateToWebview()
openFile(filePath)
vscode.window.showInformationMessage(
`Created new ${message.isGlobal ? "global" : "workspace"} rule file: ${message.filename}`,
)
} else {
// null filePath
vscode.window.showErrorMessage(`Failed to create rule file.`)
}
break
case "openMention":
openMention(message.text)
@@ -490,7 +504,7 @@ export class Controller {
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 superseded by a new message eg add deleted_api_reqs
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
@@ -500,7 +514,7 @@ export class Controller {
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.task?.restoreCheckpoint(message.number, message.text! as ClineCheckpointRestore, message.offset)
await this.task?.restoreCheckpoint(message.number, message.text! as ClineCheckpointRestore)
}
break
}
@@ -640,48 +654,6 @@ export class Controller {
}
break
}
case "toggleClineRule": {
const { isGlobal, rulePath, enabled } = message
if (rulePath && typeof enabled === "boolean" && typeof isGlobal === "boolean") {
if (isGlobal) {
const toggles =
((await getGlobalState(this.context, "globalClineRulesToggles")) as ClineRulesToggles) || {}
toggles[rulePath] = enabled
await updateGlobalState(this.context, "globalClineRulesToggles", toggles)
} else {
const toggles =
((await getWorkspaceState(this.context, "localClineRulesToggles")) as ClineRulesToggles) || {}
toggles[rulePath] = enabled
await updateWorkspaceState(this.context, "localClineRulesToggles", toggles)
}
await this.postStateToWebview()
} else {
console.error("toggleClineRule: Missing or invalid parameters", {
rulePath,
isGlobal: typeof isGlobal === "boolean" ? isGlobal : `Invalid: ${typeof isGlobal}`,
enabled: typeof enabled === "boolean" ? enabled : `Invalid: ${typeof enabled}`,
})
}
break
}
case "deleteClineRule": {
const { isGlobal, rulePath } = message
if (rulePath && typeof isGlobal === "boolean") {
const result = await deleteRuleFile(this.context, rulePath, isGlobal)
if (result.success) {
await refreshClineRulesToggles(this.context, cwd)
await this.postStateToWebview()
} else {
console.error("Failed to delete rule file:", result.message)
}
} else {
console.error("deleteClineRule: Missing or invalid parameters", {
rulePath,
isGlobal: typeof isGlobal === "boolean" ? isGlobal : `Invalid: ${typeof isGlobal}`,
})
}
break
}
case "requestTotalTasksSize": {
this.refreshTotalTasksSize()
break
@@ -1768,7 +1740,7 @@ Here is the project's README to help you get started:\n\n${mcpDetails.readmeCont
}
}
// if we tried to get a task that doesn't exist, remove it from state
// FIXME: this seems to happen sometimes when the json file doesn't save to disk for some reason
// FIXME: this seems to happen sometimes when the json file doesnt save to disk for some reason
await this.deleteTaskFromState(id)
throw new Error("Task not found")
}
@@ -1900,12 +1872,8 @@ Here is the project's README to help you get started:\n\n${mcpDetails.readmeCont
mcpMarketplaceEnabled,
telemetrySetting,
planActSeparateModelsSetting,
globalClineRulesToggles,
} = await getAllExtensionState(this.context)
const localClineRulesToggles =
((await getWorkspaceState(this.context, "localClineRulesToggles")) as ClineRulesToggles) || {}
return {
version: this.context.extension?.packageJSON?.version ?? "",
apiConfiguration,
@@ -1928,8 +1896,6 @@ Here is the project's README to help you get started:\n\n${mcpDetails.readmeCont
telemetrySetting,
planActSeparateModelsSetting,
vscMachineId: vscode.env.machineId,
globalClineRulesToggles: globalClineRulesToggles || {},
localClineRulesToggles: localClineRulesToggles || {},
}
}
@@ -1951,7 +1917,7 @@ Here is the project's README to help you get started:\n\n${mcpDetails.readmeCont
// conversation history to send in API requests
/*
It seems that some API messages do not comply with vscode state requirements. Either the Anthropic library is manipulating these values somehow in the backend in a way that's creating cyclic references, or the API returns a function or a Symbol as part of the message content.
It seems that some API messages do not comply with vscode state requirements. Either the Anthropic library is manipulating these values somehow in the backend in a way thats creating cyclic references, or the API returns a function or a Symbol as part of the message content.
VSCode docs about state: "The value must be JSON-stringifyable ... value — A value. MUST not contain cyclic references."
For now we'll store the conversation history in memory, and if we need to store in state directly we'd need to do a manual conversion to ensure proper json stringification.
*/
@@ -1,9 +1,8 @@
import { ClineIgnoreController } from "./ClineIgnoreController"
import { ClineIgnoreController } from "../ClineIgnoreController"
import fs from "fs/promises"
import path from "path"
import os from "os"
import { after, beforeEach, describe, it } from "mocha"
import "should"
import { describe, it, beforeEach, afterAll, expect } from "vitest"
describe("ClineIgnoreController", () => {
let tempDir: string
@@ -26,7 +25,7 @@ describe("ClineIgnoreController", () => {
await controller.initialize()
})
after(async () => {
afterAll(async () => {
// Clean up temp directory
await fs.rm(tempDir, { recursive: true, force: true })
})
@@ -38,7 +37,7 @@ describe("ClineIgnoreController", () => {
// controller.validateAccess(".git/config"),
// controller.validateAccess("node_modules/package.json"),
// ]
// results.forEach((result) => result.should.be.false())
// results.forEach((result) => expect(result).toBe(false))
// })
it("should allow access to regular files", async () => {
@@ -47,12 +46,12 @@ describe("ClineIgnoreController", () => {
controller.validateAccess("README.md"),
controller.validateAccess("package.json"),
]
results.forEach((result) => result.should.be.true())
results.forEach((result) => expect(result).toBe(true))
})
it("should block access to .clineignore file", async () => {
const result = controller.validateAccess(".clineignore")
result.should.be.false()
expect(result).toBe(false)
})
})
@@ -65,7 +64,7 @@ describe("ClineIgnoreController", () => {
controller.validateAccess("nested/deep/file.secret"),
controller.validateAccess("private/nested/deep/file.txt"),
]
results.forEach((result) => result.should.be.false())
results.forEach((result) => expect(result).toBe(false))
})
it("should allow access to non-ignored files", async () => {
@@ -76,7 +75,7 @@ describe("ClineIgnoreController", () => {
controller.validateAccess("nested/deep/file.txt"),
controller.validateAccess("not-private/data.txt"),
]
results.forEach((result) => result.should.be.true())
results.forEach((result) => expect(result).toBe(true))
})
it("should handle pattern edge cases", async () => {
@@ -94,9 +93,9 @@ describe("ClineIgnoreController", () => {
controller.validateAccess("script.tmp"), // Should be false (extension match)
]
results[0].should.be.false() // data-123.json
results[1].should.be.true() // data.json
results[2].should.be.false() // script.tmp
expect(results[0]).toBe(false) // data-123.json
expect(results[1]).toBe(true) // data.json
expect(results[2]).toBe(false) // script.tmp
})
// ToDo: handle negation patterns successfully
@@ -136,16 +135,16 @@ describe("ClineIgnoreController", () => {
// controller.validateAccess("assets/public/data.json"), // Should be true (in negated public/)
// ]
// results[0].should.be.false() // temp/file.txt
// results[1].should.be.true() // temp/allowed/file.txt
// results[2].should.be.true() // temp/allowed/nested/file.txt
// results[3].should.be.false() // docs/guide.md
// results[4].should.be.true() // docs/README.md
// results[5].should.be.true() // docs/CONTRIBUTING.md
// results[6].should.be.false() // docs/api/guide.md
// results[7].should.be.false() // assets/logo.png
// results[8].should.be.true() // assets/public/logo.png
// results[9].should.be.true() // assets/public/data.json
// expect(results[0]).toBe(false) // temp/file.txt
// expect(results[1]).toBe(true) // temp/allowed/file.txt
// expect(results[2]).toBe(true) // temp/allowed/nested/file.txt
// expect(results[3]).toBe(false) // docs/guide.md
// expect(results[4]).toBe(true) // docs/README.md
// expect(results[5]).toBe(true) // docs/CONTRIBUTING.md
// expect(results[6]).toBe(false) // docs/api/guide.md
// expect(results[7]).toBe(false) // assets/logo.png
// expect(results[8]).toBe(true) // assets/public/logo.png
// expect(results[9]).toBe(true) // assets/public/data.json
// })
it("should handle comments in .clineignore", async () => {
@@ -159,7 +158,7 @@ describe("ClineIgnoreController", () => {
await controller.initialize()
const result = controller.validateAccess("test.secret")
result.should.be.false()
expect(result).toBe(false)
})
})
@@ -168,36 +167,36 @@ describe("ClineIgnoreController", () => {
// Test absolute path that should be allowed
const allowedPath = path.join(tempDir, "src/file.ts")
const allowedResult = controller.validateAccess(allowedPath)
allowedResult.should.be.true()
expect(allowedResult).toBe(true)
// Test absolute path that matches an ignore pattern (*.secret)
const ignoredPath = path.join(tempDir, "config.secret")
const ignoredResult = controller.validateAccess(ignoredPath)
ignoredResult.should.be.false()
expect(ignoredResult).toBe(false)
// Test absolute path in ignored directory (private/)
const ignoredDirPath = path.join(tempDir, "private/data.txt")
const ignoredDirResult = controller.validateAccess(ignoredDirPath)
ignoredDirResult.should.be.false()
expect(ignoredDirResult).toBe(false)
})
it("should handle relative paths and match ignore patterns", async () => {
// Test relative path that should be allowed
const allowedResult = controller.validateAccess("./src/file.ts")
allowedResult.should.be.true()
expect(allowedResult).toBe(true)
// Test relative path that matches an ignore pattern (*.secret)
const ignoredResult = controller.validateAccess("./config.secret")
ignoredResult.should.be.false()
expect(ignoredResult).toBe(false)
// Test relative path in ignored directory (private/)
const ignoredDirResult = controller.validateAccess("./private/data.txt")
ignoredDirResult.should.be.false()
expect(ignoredDirResult).toBe(false)
})
it("should normalize paths with backslashes", async () => {
const result = controller.validateAccess("src\\file.ts")
result.should.be.true()
expect(result).toBe(true)
})
})
@@ -206,7 +205,7 @@ describe("ClineIgnoreController", () => {
const paths = ["src/index.ts", ".env", "lib/utils.ts", ".git/config", "dist/bundle.js"]
const filtered = controller.filterPaths(paths)
filtered.should.deepEqual(["src/index.ts", "lib/utils.ts", "dist/bundle.js"])
expect(filtered).toEqual(["src/index.ts", "lib/utils.ts", "dist/bundle.js"])
})
})
@@ -214,7 +213,7 @@ describe("ClineIgnoreController", () => {
it("should handle invalid paths", async () => {
// Test with an invalid path containing null byte
const result = controller.validateAccess("\0invalid")
result.should.be.true()
expect(result).toBe(true)
})
it("should handle missing .clineignore gracefully", async () => {
@@ -226,7 +225,7 @@ describe("ClineIgnoreController", () => {
const controller = new ClineIgnoreController(emptyDir)
await controller.initialize()
const result = controller.validateAccess("file.txt")
result.should.be.true()
expect(result).toBe(true)
} finally {
await fs.rm(emptyDir, { recursive: true, force: true })
}
@@ -239,7 +238,7 @@ describe("ClineIgnoreController", () => {
await controller.initialize()
const result = controller.validateAccess("regular-file.txt")
result.should.be.true()
expect(result).toBe(true)
})
})
})
-43
View File
@@ -1,43 +0,0 @@
export const newTaskToolResponse = () =>
`<explicit_instructions type="new_task">
The user has explicitly asked you to help them create a new task with preloaded context, which you will create. In this message the user has potentially added instructions or context which you should consider, if given, when creating the new task.
Irrespective of whether additional information or instructions are given, you are only allowed to respond to this message by calling the new_task tool.
To refresh your memory, the tool definition for new_task and an example for calling the tool is described below:
## new_task tool definition:
Description: Request to create a new task with preloaded context. The user will be presented with a preview of the context and can choose to create a new task or keep chatting in the current conversation. The user may choose to start a new task at any point.
Parameters:
- context: (required) The context to preload the new task with. This should include:
* Comprehensively explain what has been accomplished in the current task - mention specific file names that are relevant
* The specific next steps or focus for the new task - mention specific file names that are relevant
* Any critical information needed to continue the work
* Clear indication of how this new task relates to the overall workflow
* This should be akin to a long handoff file, enough for a totally new developer to be able to pick up where you left off and know exactly what to do next and which files to look at.
Usage:
<new_task>
<context>context to preload new task with</context>
</new_task>
## Tool use example:
<new_task>
<context>
Authentication System Implementation:
- We've implemented the basic user model with email/password
- Password hashing is working with bcrypt
- Login endpoint is functional with proper validation
- JWT token generation is implemented
Next Steps:
- Implement refresh token functionality
- Add token validation middleware
- Create password reset flow
- Implement role-based access control
</context>
</new_task>
Below is the the user's input when they indicated that they wanted to create a new task.
</explicit_instructions>\n
`
-1
View File
@@ -2,7 +2,6 @@ import { Anthropic } from "@anthropic-ai/sdk"
import * as diff from "diff"
import * as path from "path"
import { ClineIgnoreController, LOCK_TEXT_SYMBOL } from "../ignore/ClineIgnoreController"
import { McpToolCallResponse } from "../../shared/mcp"
export const formatResponse = {
duplicateFileReadNotice: () =>
-55
View File
@@ -1,55 +0,0 @@
import { newTaskToolResponse } from "../prompts/commands"
/**
* Processes text for slash commands and transforms them with appropriate instructions
* This is called after parseMentions() to process any slash commands in the user's message
*/
export function parseSlashCommands(text: string): string {
const SUPPORTED_COMMANDS = ["newtask"]
const commandReplacements: Record<string, string> = {
newtask: newTaskToolResponse(),
}
// this currently allows matching prepended whitespace prior to /slash-command
const tagPatterns = [
{ tag: "task", regex: /<task>(\s*\/([a-zA-Z0-9_-]+))(\s+.+?)?\s*<\/task>/is },
{ tag: "feedback", regex: /<feedback>(\s*\/([a-zA-Z0-9_-]+))(\s+.+?)?\s*<\/feedback>/is },
{ tag: "answer", regex: /<answer>(\s*\/([a-zA-Z0-9_-]+))(\s+.+?)?\s*<\/answer>/is },
{ tag: "user_message", regex: /<user_message>(\s*\/([a-zA-Z0-9_-]+))(\s+.+?)?\s*<\/user_message>/is },
]
// if we find a valid match, we will return inside that block
for (const { tag, regex } of tagPatterns) {
const regexObj = new RegExp(regex.source, regex.flags)
const match = regexObj.exec(text)
if (match) {
// match[1] is the command with any leading whitespace (e.g. " /newtask")
// match[2] is just the command name (e.g. "newtask")
const commandName = match[2] // casing matters
if (SUPPORTED_COMMANDS.includes(commandName)) {
const fullMatchStartIndex = match.index
// find position of slash command within the full match
const fullMatch = match[0]
const relativeStartIndex = fullMatch.indexOf(match[1])
// calculate absolute indices in the original string
const slashCommandStartIndex = fullMatchStartIndex + relativeStartIndex
const slashCommandEndIndex = slashCommandStartIndex + match[1].length
// remove the slash command and add custom instructions at the top of this message
const textWithoutSlashCommand = text.substring(0, slashCommandStartIndex) + text.substring(slashCommandEndIndex)
const processedText = commandReplacements[commandName] + textWithoutSlashCommand
return processedText
}
}
}
// if no supported commands are found, return the original text
return text
}
-2
View File
@@ -37,14 +37,12 @@ export type GlobalStateKey =
| "openAiBaseUrl"
| "openAiModelId"
| "openAiModelInfo"
| "openAiHeaders"
| "ollamaModelId"
| "ollamaBaseUrl"
| "ollamaApiOptionsCtxNum"
| "lmStudioModelId"
| "lmStudioBaseUrl"
| "anthropicBaseUrl"
| "geminiBaseUrl"
| "azureApiVersion"
| "openRouterModelId"
| "openRouterModelInfo"
+4 -17
View File
@@ -10,7 +10,7 @@ import { BrowserSettings } from "../../shared/BrowserSettings"
import { ChatSettings } from "../../shared/ChatSettings"
import { TelemetrySetting } from "../../shared/TelemetrySetting"
import { UserInfo } from "../../shared/UserInfo"
import { ClineRulesToggles } from "../../shared/cline-rules"
import { ClineRulesToggles } from "../context/instructions/user-instructions/cline-rules"
/*
Storage
https://dev.to/kompotkot/how-to-use-secretstorage-in-your-vscode-extensions-2hco
@@ -73,7 +73,6 @@ export async function getAllExtensionState(context: vscode.ExtensionContext) {
openAiApiKey,
openAiModelId,
openAiModelInfo,
openAiHeaders,
ollamaModelId,
ollamaBaseUrl,
ollamaApiOptionsCtxNum,
@@ -81,7 +80,6 @@ export async function getAllExtensionState(context: vscode.ExtensionContext) {
lmStudioBaseUrl,
anthropicBaseUrl,
geminiApiKey,
geminiBaseUrl,
openAiNativeApiKey,
deepSeekApiKey,
requestyApiKey,
@@ -100,6 +98,7 @@ export async function getAllExtensionState(context: vscode.ExtensionContext) {
customInstructions,
taskHistory,
autoApprovalSettings,
globalClineRulesToggles,
browserSettings,
chatSettings,
vsCodeLmModelSelector,
@@ -124,7 +123,6 @@ export async function getAllExtensionState(context: vscode.ExtensionContext) {
sambanovaApiKey,
planActSeparateModelsSettingRaw,
favoritedModelIds,
globalClineRulesToggles,
] = await Promise.all([
getGlobalState(context, "apiProvider") as Promise<ApiProvider | undefined>,
getGlobalState(context, "apiModelId") as Promise<string | undefined>,
@@ -146,7 +144,6 @@ export async function getAllExtensionState(context: vscode.ExtensionContext) {
getSecret(context, "openAiApiKey") as Promise<string | undefined>,
getGlobalState(context, "openAiModelId") as Promise<string | undefined>,
getGlobalState(context, "openAiModelInfo") as Promise<ModelInfo | undefined>,
getGlobalState(context, "openAiHeaders") as Promise<Record<string, string> | undefined>,
getGlobalState(context, "ollamaModelId") as Promise<string | undefined>,
getGlobalState(context, "ollamaBaseUrl") as Promise<string | undefined>,
getGlobalState(context, "ollamaApiOptionsCtxNum") as Promise<string | undefined>,
@@ -154,7 +151,6 @@ export async function getAllExtensionState(context: vscode.ExtensionContext) {
getGlobalState(context, "lmStudioBaseUrl") as Promise<string | undefined>,
getGlobalState(context, "anthropicBaseUrl") as Promise<string | undefined>,
getSecret(context, "geminiApiKey") as Promise<string | undefined>,
getGlobalState(context, "geminiBaseUrl") as Promise<string | undefined>,
getSecret(context, "openAiNativeApiKey") as Promise<string | undefined>,
getSecret(context, "deepSeekApiKey") as Promise<string | undefined>,
getSecret(context, "requestyApiKey") as Promise<string | undefined>,
@@ -173,6 +169,7 @@ export async function getAllExtensionState(context: vscode.ExtensionContext) {
getGlobalState(context, "customInstructions") as Promise<string | undefined>,
getGlobalState(context, "taskHistory") as Promise<HistoryItem[] | undefined>,
getGlobalState(context, "autoApprovalSettings") as Promise<AutoApprovalSettings | undefined>,
getGlobalState(context, "globalClineRulesToggles") as Promise<ClineRulesToggles | undefined>,
getGlobalState(context, "browserSettings") as Promise<BrowserSettings | undefined>,
getGlobalState(context, "chatSettings") as Promise<ChatSettings | undefined>,
getGlobalState(context, "vsCodeLmModelSelector") as Promise<vscode.LanguageModelChatSelector | undefined>,
@@ -197,7 +194,6 @@ export async function getAllExtensionState(context: vscode.ExtensionContext) {
getSecret(context, "sambanovaApiKey") as Promise<string | undefined>,
getGlobalState(context, "planActSeparateModelsSetting") as Promise<boolean | undefined>,
getGlobalState(context, "favoritedModelIds") as Promise<string[] | undefined>,
getGlobalState(context, "globalClineRulesToggles") as Promise<ClineRulesToggles | undefined>,
])
let apiProvider: ApiProvider
@@ -214,8 +210,6 @@ export async function getAllExtensionState(context: vscode.ExtensionContext) {
}
}
const localClineRulesToggles = (await getWorkspaceState(context, "localClineRulesToggles")) as ClineRulesToggles
const o3MiniReasoningEffort = vscode.workspace.getConfiguration("cline.modelSettings.o3Mini").get("reasoningEffort", "medium")
const mcpMarketplaceEnabled = vscode.workspace.getConfiguration("cline").get<boolean>("mcpMarketplace.enabled", true)
@@ -260,7 +254,6 @@ export async function getAllExtensionState(context: vscode.ExtensionContext) {
openAiApiKey,
openAiModelId,
openAiModelInfo,
openAiHeaders: openAiHeaders || {},
ollamaModelId,
ollamaBaseUrl,
ollamaApiOptionsCtxNum,
@@ -268,7 +261,6 @@ export async function getAllExtensionState(context: vscode.ExtensionContext) {
lmStudioBaseUrl,
anthropicBaseUrl,
geminiApiKey,
geminiBaseUrl,
openAiNativeApiKey,
deepSeekApiKey,
requestyApiKey,
@@ -302,8 +294,7 @@ export async function getAllExtensionState(context: vscode.ExtensionContext) {
customInstructions,
taskHistory,
autoApprovalSettings: autoApprovalSettings || DEFAULT_AUTO_APPROVAL_SETTINGS, // default value can be 0 or empty string
globalClineRulesToggles: globalClineRulesToggles || {},
localClineRulesToggles: localClineRulesToggles || {},
clineRulesToggles: globalClineRulesToggles || {},
browserSettings: { ...DEFAULT_BROWSER_SETTINGS, ...browserSettings }, // this will ensure that older versions of browserSettings (e.g. before remoteBrowserEnabled was added) are merged with the default values (false for remoteBrowserEnabled)
chatSettings: chatSettings || DEFAULT_CHAT_SETTINGS,
userInfo,
@@ -340,7 +331,6 @@ export async function updateApiConfiguration(context: vscode.ExtensionContext, a
openAiApiKey,
openAiModelId,
openAiModelInfo,
openAiHeaders,
ollamaModelId,
ollamaBaseUrl,
ollamaApiOptionsCtxNum,
@@ -348,7 +338,6 @@ export async function updateApiConfiguration(context: vscode.ExtensionContext, a
lmStudioBaseUrl,
anthropicBaseUrl,
geminiApiKey,
geminiBaseUrl,
openAiNativeApiKey,
deepSeekApiKey,
requestyApiKey,
@@ -397,7 +386,6 @@ export async function updateApiConfiguration(context: vscode.ExtensionContext, a
await storeSecret(context, "openAiApiKey", openAiApiKey)
await updateGlobalState(context, "openAiModelId", openAiModelId)
await updateGlobalState(context, "openAiModelInfo", openAiModelInfo)
await updateGlobalState(context, "openAiHeaders", openAiHeaders || {})
await updateGlobalState(context, "ollamaModelId", ollamaModelId)
await updateGlobalState(context, "ollamaBaseUrl", ollamaBaseUrl)
await updateGlobalState(context, "ollamaApiOptionsCtxNum", ollamaApiOptionsCtxNum)
@@ -405,7 +393,6 @@ export async function updateApiConfiguration(context: vscode.ExtensionContext, a
await updateGlobalState(context, "lmStudioBaseUrl", lmStudioBaseUrl)
await updateGlobalState(context, "anthropicBaseUrl", anthropicBaseUrl)
await storeSecret(context, "geminiApiKey", geminiApiKey)
await updateGlobalState(context, "geminiBaseUrl", geminiBaseUrl)
await storeSecret(context, "openAiNativeApiKey", openAiNativeApiKey)
await storeSecret(context, "deepSeekApiKey", deepSeekApiKey)
await storeSecret(context, "requestyApiKey", requestyApiKey)
+45 -207
View File
@@ -1,6 +1,5 @@
import { Anthropic } from "@anthropic-ai/sdk"
import cloneDeep from "clone-deep"
import { execa } from "execa"
import getFolderSize from "get-folder-size"
import { setTimeout as setTimeoutPromise } from "node:timers/promises"
import os from "os"
@@ -9,8 +8,6 @@ import pWaitFor from "p-wait-for"
import * as path from "path"
import { serializeError } from "serialize-error"
import * as vscode from "vscode"
import { Logger } from "../../services/logging/Logger"
const { IS_TEST } = process.env
import { ApiHandler, buildApiHandler } from "../../api"
import { AnthropicHandler } from "../../api/providers/anthropic"
import { ClineHandler } from "../../api/providers/cline"
@@ -80,21 +77,21 @@ import {
ensureTaskDirectoryExists,
getSavedApiConversationHistory,
getSavedClineMessages,
GlobalFileNames,
saveApiConversationHistory,
saveClineMessages,
} from "../storage/disk"
import { McpHub } from "../../services/mcp/McpHub"
import WorkspaceTracker from "../../integrations/workspace/WorkspaceTracker"
import {
ClineRulesToggles,
getGlobalClineRules,
getLocalClineRules,
refreshClineRulesToggles,
synchronizeRuleToggles,
} from "../context/instructions/user-instructions/cline-rules"
import { getGlobalState } from "../storage/state"
import { parseSlashCommands } from ".././slash-commands"
import WorkspaceTracker from "../../integrations/workspace/WorkspaceTracker"
import { McpHub } from "../../services/mcp/McpHub"
import { getGlobalState, getWorkspaceState, updateGlobalState, updateWorkspaceState } from "../storage/state"
export const cwd =
vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath).at(0) ?? path.join(os.homedir(), "Desktop") // may or may not exist but fs checking existence would immediately ask for permission which would be bad UX, need to come up with a better solution
const cwd = vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath).at(0) ?? path.join(os.homedir(), "Desktop") // may or may not exist but fs checking existence would immediately ask for permission which would be bad UX, need to come up with a better solution
type ToolResponse = string | Array<Anthropic.TextBlockParam | Anthropic.ImageBlockParam>
type UserContent = Array<Anthropic.ContentBlockParam>
@@ -311,13 +308,9 @@ export class Task {
}
}
async restoreCheckpoint(messageTs: number, restoreType: ClineCheckpointRestore, offset?: number) {
const messageIndex = this.clineMessages.findIndex((m) => m.ts === messageTs) - (offset || 0)
// Find the last message before messageIndex that has a lastCheckpointHash
const lastHashIndex = findLastIndex(this.clineMessages.slice(0, messageIndex), (m) => m.lastCheckpointHash !== undefined)
async restoreCheckpoint(messageTs: number, restoreType: ClineCheckpointRestore) {
const messageIndex = this.clineMessages.findIndex((m) => m.ts === messageTs)
const message = this.clineMessages[messageIndex]
const lastMessageWithHash = this.clineMessages[lastHashIndex]
if (!message) {
console.error("Message not found", this.clineMessages)
return
@@ -350,14 +343,6 @@ export class Task {
vscode.window.showErrorMessage("Failed to restore checkpoint: " + errorMessage)
didWorkspaceRestoreFail = true
}
} else if (offset && lastMessageWithHash.lastCheckpointHash && this.checkpointTracker) {
try {
await this.checkpointTracker.resetHead(lastMessageWithHash.lastCheckpointHash)
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "Unknown error"
vscode.window.showErrorMessage("Failed to restore offsetcheckpoint: " + errorMessage)
didWorkspaceRestoreFail = true
}
}
break
}
@@ -455,7 +440,7 @@ export class Task {
return
}
// TODO: handle if this is called from outside original workspace, in which case we need to show user error message we can't show diff outside of workspace?
// TODO: handle if this is called from outside original workspace, in which case we need to show user error message we cant show diff outside of workspace?
if (!this.checkpointTracker && !this.checkpointTrackerErrorMessage) {
try {
this.checkpointTracker = await CheckpointTracker.create(this.taskId, this.context.globalStorageUri.fsPath)
@@ -900,7 +885,7 @@ export class Task {
await this.overwriteClineMessages(modifiedClineMessages)
this.clineMessages = await getSavedClineMessages(this.getContext(), this.taskId)
// Now present the cline messages to the user and ask if they want to resume (NOTE: we ran into a bug before where the apiconversationhistory wouldn't be initialized when opening a old task, and it was because we were waiting for resume)
// Now present the cline messages to the user and ask if they want to resume (NOTE: we ran into a bug before where the apiconversationhistory wouldnt be initialized when opening a old task, and it was because we were waiting for resume)
// This is important in case the user deletes messages without resuming the task first
this.apiConversationHistory = await getSavedApiConversationHistory(this.getContext(), this.taskId)
@@ -1134,176 +1119,41 @@ export class Task {
// Tools
/**
* Executes a command directly in Node.js using execa
* This is used in test mode to capture the full output without using the VS Code terminal
* Commands are automatically terminated after 30 seconds using Promise.race
*/
private async executeCommandInNode(command: string): Promise<[boolean, ToolResponse]> {
try {
// Create a child process
const childProcess = execa(command, {
shell: true,
cwd,
reject: false,
all: true, // Merge stdout and stderr
})
// Set up variables to collect output
let output = ""
// Collect output in real-time
if (childProcess.all) {
childProcess.all.on("data", (data) => {
output += data.toString()
})
}
// Create a timeout promise that rejects after 30 seconds
const timeoutPromise = new Promise<never>((_, reject) => {
setTimeout(() => {
if (childProcess.pid) {
childProcess.kill("SIGKILL") // Use SIGKILL for more forceful termination
}
reject(new Error("Command timeout after 30s"))
}, 30000)
})
// Race between command completion and timeout
const result = await Promise.race([childProcess, timeoutPromise]).catch((error) => {
// If we get here due to timeout, return a partial result with timeout flag
Logger.info(`Command timed out after 30s: ${command}`)
return {
stdout: "",
stderr: "",
exitCode: 124, // Standard timeout exit code
timedOut: true,
}
})
// Check if timeout occurred
const wasTerminated = result.timedOut === true
// Use collected output or result output
if (!output) {
output = result.stdout || result.stderr || ""
}
Logger.info(`Command executed in Node: ${command}\nOutput:\n${output}`)
// Add termination message if the command was terminated
if (wasTerminated) {
output += "\nCommand was taking a while to run so it was auto terminated after 30s"
}
// Format the result similar to terminal output
return [
false,
`Command executed${wasTerminated ? " (terminated after 30s)" : ""} with exit code ${
result.exitCode
}.${output.length > 0 ? `\nOutput:\n${output}` : ""}`,
]
} catch (error) {
// Handle any errors that might occur
const errorMessage = error instanceof Error ? error.message : String(error)
return [false, `Error executing command: ${errorMessage}`]
}
}
async executeCommandTool(command: string): Promise<[boolean, ToolResponse]> {
// Check if we're in test mode
if (IS_TEST === "true") {
// In test mode, execute the command directly in Node
Logger.info("Executing command in Node: " + command)
return this.executeCommandInNode(command)
}
Logger.info("Executing command in VS code terminal: " + command)
const terminalInfo = await this.terminalManager.getOrCreateTerminal(cwd)
terminalInfo.terminal.show() // weird visual bug when creating new terminals (even manually) where there's an empty space at the top.
const process = this.terminalManager.runCommand(terminalInfo, command)
let userFeedback: { text?: string; images?: string[] } | undefined
let didContinue = false
// Chunked terminal output buffering
const CHUNK_LINE_COUNT = 20
const CHUNK_BYTE_SIZE = 2048 // 2KB
const CHUNK_DEBOUNCE_MS = 100
let outputBuffer: string[] = []
let outputBufferSize: number = 0
let chunkTimer: NodeJS.Timeout | null = null
let chunkEnroute = false
const flushBuffer = async (force = false) => {
if (chunkEnroute || outputBuffer.length === 0) {
if (force && !chunkEnroute && outputBuffer.length > 0) {
// If force is true and no chunkEnroute, flush anyway
} else {
return
}
}
const chunk = outputBuffer.join("\n")
outputBuffer = []
outputBufferSize = 0
chunkEnroute = true
const sendCommandOutput = async (line: string): Promise<void> => {
try {
const { response, text, images } = await this.ask("command_output", chunk)
const { response, text, images } = await this.ask("command_output", line)
if (response === "yesButtonClicked") {
// proceed while running
} else {
userFeedback = { text, images }
}
didContinue = true
process.continue()
process.continue() // continue past the await
} catch {
// ask promise was ignored
} finally {
chunkEnroute = false
// If more output accumulated while chunkEnroute, flush again
if (outputBuffer.length > 0) {
await flushBuffer()
}
// This can only happen if this ask promise was ignored, so ignore this error
}
}
const scheduleFlush = () => {
if (chunkTimer) {
clearTimeout(chunkTimer)
}
chunkTimer = setTimeout(() => flushBuffer(), CHUNK_DEBOUNCE_MS)
}
let result = ""
process.on("line", (line) => {
result += line + "\n"
if (!didContinue) {
outputBuffer.push(line)
outputBufferSize += Buffer.byteLength(line, "utf8")
// Flush if buffer is large enough
if (outputBuffer.length >= CHUNK_LINE_COUNT || outputBufferSize >= CHUNK_BYTE_SIZE) {
flushBuffer()
} else {
scheduleFlush()
}
sendCommandOutput(line)
} else {
this.say("command_output", line)
}
})
let completed = false
process.once("completed", async () => {
process.once("completed", () => {
completed = true
// Flush any remaining buffered output
if (!didContinue && outputBuffer.length > 0) {
if (chunkTimer) {
clearTimeout(chunkTimer)
chunkTimer = null
}
await flushBuffer(true)
}
})
process.once("no_shell_integration", async () => {
@@ -1436,12 +1286,19 @@ export class Task {
? `# Preferred Language\n\nSpeak in ${preferredLanguage}.`
: ""
const { globalToggles, localToggles } = await refreshClineRulesToggles(this.getContext(), cwd)
const globalClineRulesToggles =
((await getGlobalState(this.getContext(), "globalClineRulesToggles")) as ClineRulesToggles) || {}
const globalClineRulesFilePath = await ensureRulesDirectoryExists()
const globalClineRulesFileInstructions = await getGlobalClineRules(globalClineRulesFilePath, globalToggles)
const updatedGlobalToggles = await synchronizeRuleToggles(globalClineRulesFilePath, globalClineRulesToggles)
await updateGlobalState(this.getContext(), "globalClineRulesToggles", updatedGlobalToggles)
const globalClineRulesFileInstructions = await getGlobalClineRules(globalClineRulesFilePath, updatedGlobalToggles)
const localClineRulesFileInstructions = await getLocalClineRules(cwd, localToggles)
const localClineRulesToggles =
((await getWorkspaceState(this.getContext(), "localClineRulesToggles")) as ClineRulesToggles) || {}
const localClineRulesFilePath = path.resolve(cwd, GlobalFileNames.clineRules)
const updatedLocalToggles = await synchronizeRuleToggles(localClineRulesFilePath, localClineRulesToggles)
await updateWorkspaceState(this.getContext(), "localClineRulesToggles", updatedLocalToggles)
const localClineRulesFileInstructions = await getLocalClineRules(cwd, updatedLocalToggles)
const clineIgnoreContent = this.clineIgnoreController.clineIgnoreContent
let clineIgnoreInstructions: string | undefined
@@ -2576,7 +2433,7 @@ export class Task {
try {
if (block.partial) {
if (this.shouldAutoApproveTool(block.name)) {
// since depending on an upcoming parameter, requiresApproval this may become an ask - we can't partially stream a say prematurely. So in this particular case we have to wait for the requiresApproval parameter to be completed before presenting it.
// since depending on an upcoming parameter, requiresApproval this may become an ask - we cant partially stream a say prematurely. So in this particular case we have to wait for the requiresApproval parameter to be completed before presenting it.
// await this.say(
// "command",
// removeClosingTag("command", command),
@@ -2622,7 +2479,7 @@ export class Task {
let didAutoApprove = false
// If the model says this command is safe and auto approval for safe commands is true, execute the command
// If the model says this command is safe and auto aproval for safe commands is true, execute the command
// If the model says the command is risky, but *BOTH* auto approve settings are true, execute the command
const autoApproveResult = this.shouldAutoApproveTool(block.name)
const [autoApproveSafe, autoApproveAll] = Array.isArray(autoApproveResult)
@@ -2778,13 +2635,8 @@ export class Task {
await this.say("mcp_server_request_started") // same as browser_action_result
const toolResult = await this.mcpHub.callTool(server_name, tool_name, parsedArguments)
// TODO: add progress indicator
const toolResultImages =
toolResult?.content
.filter((item) => item.type === "image")
.map((item) => `data:${item.mimeType};base64,${item.data}`) || []
let toolResultText =
// TODO: add progress indicator and ability to parse images and non-text responses
const toolResultPretty =
(toolResult?.isError ? "Error:\n" : "") +
toolResult?.content
.map((item) => {
@@ -2799,21 +2651,8 @@ export class Task {
})
.filter(Boolean)
.join("\n\n") || "(No response)"
// webview extracts images from the text response to display in the UI
const toolResultToDisplay =
toolResultText + toolResultImages?.map((image) => `\n\n${image}`).join("")
await this.say("mcp_server_response", toolResultToDisplay)
// MCP's might return images to display to the user, but the model may not support them
const supportsImages = this.api.getModel().info.supportsImages ?? false
if (toolResultImages.length > 0 && !supportsImages) {
toolResultText += `\n\n[${toolResultImages.length} images were provided in the response, and while they are displayed to the user, you do not have the ability to view them.]`
}
// only passes in images if model supports them
pushToolResult(
formatResponse.toolResult(toolResultText, supportsImages ? toolResultImages : undefined),
)
await this.say("mcp_server_response", toolResultPretty)
pushToolResult(formatResponse.toolResult(toolResultPretty))
await this.saveCheckpoint()
@@ -3164,7 +3003,7 @@ export class Task {
)
} else {
// last message is completion_result
// we have command string, which means we have the result as well, so finish it (doesn't have to exist yet)
// we have command string, which means we have the result as well, so finish it (doesnt have to exist yet)
await this.say("completion_result", removeClosingTag("result", result), undefined, false)
await this.saveCheckpoint(true)
await addNewChangesFlagToLastCompletionResultMessage()
@@ -3200,7 +3039,7 @@ export class Task {
let commandResult: ToolResponse | undefined
if (command) {
if (lastMessage && lastMessage.ask !== "command") {
// haven't sent a command message yet so first send completion_result then command
// havent sent a command message yet so first send completion_result then command
await this.say("completion_result", result, undefined, false)
await this.saveCheckpoint(true)
await addNewChangesFlagToLastCompletionResultMessage()
@@ -3538,10 +3377,7 @@ export class Task {
case "reasoning":
// reasoning will always come before assistant message
reasoningMessage += chunk.reasoning
// fixes bug where cancelling task > aborts task > for loop may be in middle of streaming reasoning > say function throws error before we get a chance to properly clean up and cancel the task.
if (!this.abort) {
await this.say("reasoning", reasoningMessage, undefined, true)
}
await this.say("reasoning", reasoningMessage, undefined, true)
break
case "text":
if (reasoningMessage && assistantMessage.length === 0) {
@@ -3572,7 +3408,7 @@ export class Task {
if (this.didRejectTool) {
// userContent has a tool rejection, so interrupt the assistant's response to present the user's feedback
assistantMessage += "\n\n[Response interrupted by user feedback]"
// this.userMessageContentReady = true // instead of setting this preemptively, we allow the present iterator to finish and set userMessageContentReady when its ready
// this.userMessageContentReady = true // instead of setting this premptively, we allow the present iterator to finish and set userMessageContentReady when its ready
break
}
@@ -3627,7 +3463,7 @@ export class Task {
partialBlocks.forEach((block) => {
block.partial = false
})
// this.assistantMessageContent.forEach((e) => (e.partial = false)) // can't just do this bc a tool could be in the middle of executing ()
// this.assistantMessageContent.forEach((e) => (e.partial = false)) // cant just do this bc a tool could be in the middle of executing ()
if (partialBlocks.length > 0) {
this.presentAssistantMessage() // if there is content to update then it will complete and update this.userMessageContentReady to true, which we pwaitfor before making the next request. all this is really doing is presenting the last partial message that we just set to complete
}
@@ -3710,10 +3546,12 @@ export class Task {
block.text.includes("<task>") ||
block.text.includes("<user_message>")
) {
let parsedText = await parseMentions(block.text, cwd, this.urlContentFetcher, this.fileContextTracker)
// when parsing slash commands, we still want to allow the user to provide their desired context
parsedText = parseSlashCommands(parsedText)
const parsedText = await parseMentions(
block.text,
cwd,
this.urlContentFetcher,
this.fileContextTracker,
)
return {
...block,
+1 -1
View File
@@ -193,7 +193,7 @@ export class WebviewProvider implements vscode.WebviewViewProvider {
/*
content security policy of your webview to only allow scripts that have a specific nonce
create a content security policy meta tag so that only loading scripts with a nonce is allowed
As your extension grows you will likely want to add custom styles, fonts, and/or images to your webview. If you do, you will need to update the content security policy meta tag to explicitly allow for these resources. E.g.
As your extension grows you will likely want to add custom styles, fonts, and/or images to your webview. If you do, you will need to update the content security policy meta tag to explicity allow for these resources. E.g.
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src ${webview.cspSource}; font-src ${webview.cspSource}; img-src ${webview.cspSource} https:; script-src 'nonce-${nonce}';">
- 'unsafe-inline' is required for styles due to vscode-webview-toolkit's dynamic style injection
- since we pass base64 images to the webview, we need to specify img-src ${webview.cspSource} data:;
@@ -61,7 +61,7 @@ class DiagnosticsMonitor {
return currentDiagnostics
}
let timeout = 300 // only way this happens is if there's no errors
let timeout = 300 // only way this happens is if theres no errors
// if diagnostics contain existing errors (since the check above didn't trigger) then it's likely cline just did something that should have fixed the error, so we'll give a longer grace period for diagnostics to catch up
const hasErrors = currentDiagnostics.some(([_, diagnostics]) =>
+1 -1
View File
@@ -289,7 +289,7 @@ export class DiffViewProvider {
updatedDocument.positionAt(updatedDocument.getText().length),
)
edit.replace(updatedDocument.uri, fullRange, this.originalContent ?? "")
// Apply the edit and save, since contents shouldn't have changed this won't show in local history unless of course the user made changes and saved during the edit
// 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.`)
+2 -11
View File
@@ -94,17 +94,8 @@ export class TerminalProcess extends EventEmitter<TerminalProcessEvents> {
data = stripAnsi(data)
}
// Ctrl+C detection: if user presses Ctrl+C, treat as command terminated
if (data.includes("^C") || data.includes("\u0003")) {
if (this.hotTimer) {
clearTimeout(this.hotTimer)
}
this.isHot = false
break
}
// first few chunks could be the command being echoed back, so we must ignore
// note this means that 'echo' commands won't work
// note this means that 'echo' commands wont work
if (!didOutputNonCommand) {
const lines = data.split("\n")
for (let i = 0; i < lines.length; i++) {
@@ -154,7 +145,7 @@ export class TerminalProcess extends EventEmitter<TerminalProcessEvents> {
isCompiling ? PROCESS_HOT_TIMEOUT_COMPILING : PROCESS_HOT_TIMEOUT_NORMAL,
)
// For non-immediately returning commands we want to show loading spinner right away but this wouldn't happen until it emits a line break, so as soon as we get any output we emit "" to let webview know to show spinner
// 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
if (!didEmitEmptyLine && !this.fullOutput && data) {
this.emit("line", "") // empty line to indicate start of command output stream
didEmitEmptyLine = true
@@ -1,9 +1,9 @@
import { describe, it, beforeEach, afterEach } from "mocha"
import "should"
import * as sinon from "sinon"
import { TerminalProcess } from "./TerminalProcess"
import { TerminalProcess } from "../TerminalProcess"
import * as vscode from "vscode"
import { TerminalRegistry } from "./TerminalRegistry"
import { TerminalRegistry } from "../TerminalRegistry"
import { EventEmitter } from "events"
declare module "vscode" {
+1 -1
View File
@@ -255,7 +255,7 @@ export class McpHub {
stderrStream.on("data", async (data: Buffer) => {
const output = data.toString()
// Check if output contains INFO level log
const isInfoLog = !/\berror\b/i.test(output)
const isInfoLog = /^\s*INFO\b/.test(output)
if (isInfoLog) {
// Log normal informational messages
@@ -0,0 +1,174 @@
import * as vscode from "vscode"
import * as path from "path"
import * as fs from "fs"
import * as childProcess from "child_process"
import * as readline from "readline"
import { getBinPath } from "../../ripgrep"
import type { Fzf, FzfResultItem } from "fzf"
// Wrapper function for childProcess.spawn
export type SpawnFunction = typeof childProcess.spawn
export const getSpawnFunction = (): SpawnFunction => childProcess.spawn
export async function executeRipgrepForFiles(
rgPath: string,
workspacePath: string,
limit: number = 5000,
): Promise<{ path: string; type: "file" | "folder"; label?: string }[]> {
return new Promise((resolve, reject) => {
// Arguments for ripgrep to list files, follow symlinks, include hidden, and exclude common directories
const args = [
"--files",
"--follow",
"--hidden",
"-g",
"!**/{node_modules,.git,.github,out,dist,__pycache__,.venv,.env,venv,env,.cache,tmp,temp}/**",
workspacePath,
]
// Spawn the ripgrep process with the specified arguments
const rgProcess = getSpawnFunction()(rgPath, args)
const rl = readline.createInterface({ input: rgProcess.stdout })
// Array to store file results and Set to track unique directories
const fileResults: { path: string; type: "file" | "folder"; label?: string }[] = []
const dirSet = new Set<string>()
let count = 0
// Handle each line of output from ripgrep (each line is a file path)
rl.on("line", (line) => {
if (count >= limit) {
rl.close()
rgProcess.kill()
return
}
// Convert absolute path to a relative path from workspace root
const relativePath = path.relative(workspacePath, line)
// Add file result to array
fileResults.push({
path: relativePath,
type: "file",
label: path.basename(relativePath),
})
// Extract and add parent directories to the set
let dirPath = path.dirname(relativePath)
while (dirPath && dirPath !== "." && dirPath !== "/") {
dirSet.add(dirPath)
dirPath = path.dirname(dirPath)
}
count++
})
// Capture any error output from ripgrep
let errorOutput = ""
rgProcess.stderr.on("data", (data) => (errorOutput += data.toString()))
// When ripgrep finishes or is closed
rl.on("close", () => {
if (errorOutput && fileResults.length === 0) {
reject(new Error(`ripgrep process error: ${errorOutput.trim()}`))
return
}
// Transform directory paths from Set into structured results
const dirResults = Array.from(dirSet, (dirPath): { path: string; type: "folder"; label?: string } => ({
path: dirPath,
type: "folder",
label: path.basename(dirPath),
}))
// Resolve combined results of files and directories
resolve([...fileResults, ...dirResults])
})
// Handle process-level errors
rgProcess.on("error", (error) => reject(new Error(`ripgrep process error: ${error.message}`)))
})
}
export async function searchWorkspaceFiles(
query: string,
workspacePath: string,
limit: number = 20,
): Promise<{ path: string; type: "file" | "folder"; label?: string }[]> {
try {
const rgPath = await getBinPath(vscode.env.appRoot)
if (!rgPath) {
throw new Error("Could not find ripgrep binary")
}
// Get all files and directories
const allItems = await executeRipgrepForFiles(rgPath, workspacePath, 5000)
// If no query, just return the top items
if (!query.trim()) {
return allItems.slice(0, limit)
}
// Match Scoring - Prioritize the label (filename) by including it twice in the search string
// Use multiple tiebreakers in order of importance: Match score, then length of match (shorter=better)
// Get more (2x) results than needed for filtering, we pick the top half after sorting
const fzfModule = await import("fzf")
const fzf = new fzfModule.Fzf(allItems, {
selector: (item: { label?: string; path: string }) => `${item.label || ""} ${item.label || ""} ${item.path}`,
tiebreakers: [OrderbyMatchScore, fzfModule.byLengthAsc],
limit: limit * 2,
})
// The min threshold value will require some testing and tuning as the scores are exponential, and exagerated
const MIN_SCORE_THRESHOLD = 100
// Filter results by score and map to original items
// Use exponential scaling for normalization
// This gives a more dramatic difference between good and bad matches
const filteredResults = fzf
.find(query)
.filter(({ score }: { score: number }) => Math.exp(score / 20) >= MIN_SCORE_THRESHOLD)
.slice(0, limit)
// Verify if the path exists and is actually a directory
const verifiedResultsPromises = filteredResults.map(
async ({ item }: { item: { path: string; type: "file" | "folder"; label?: string } }) => {
const fullPath = path.join(workspacePath, item.path)
let type = item.type
try {
const stats = await fs.promises.lstat(fullPath)
type = stats.isDirectory() ? "folder" : "file"
} catch {
// Keep original type if path doesn't exist
}
return { ...item, type }
},
)
return await Promise.all(verifiedResultsPromises)
} catch (error) {
console.error("Error in searchWorkspaceFiles:", error)
return []
}
}
// Custom match scoring for results ordering
// Candidate score tiebreaker - fewer gaps between matched characters scores higher
export const OrderbyMatchScore = (a: FzfResultItem<any>, b: FzfResultItem<any>) => {
const countGaps = (positions: Iterable<number>) => {
let gaps = 0,
prev = -Infinity
for (const pos of positions) {
if (prev !== -Infinity && pos - prev > 1) {
gaps++
}
prev = pos
}
return gaps
}
return countGaps(a.positions) - countGaps(b.positions)
}
+1 -1
View File
@@ -120,7 +120,7 @@ export async function searchWorkspaceFiles(
limit: limit * 2,
})
// The min threshold value will require some testing and tuning as the scores are exponential, and exaggerated
// The min threshold value will require some testing and tuning as the scores are exponential, and exagerated
const MIN_SCORE_THRESHOLD = 100
// Filter results by score and map to original items
+2
View File
@@ -503,6 +503,8 @@ export function createMessageCatcher(webviewProvider: WebviewProvider): vscode.D
// Intercept outgoing messages from extension to webview
webviewProvider.controller.postMessageToWebview = async (message: ExtensionMessage) => {
Logger.log("Cline message received: " + JSON.stringify(message))
// Check for completion_result message
if (message.type === "partialMessage" && message.partialMessage?.say === "completion_result") {
// Complete the current task
+1 -1
View File
@@ -157,7 +157,7 @@ async function parseFile(
formattedOutput += "|----\n"
}
// Only add the first line of the definition
// query captures includes the definition name and the definition implementation, but we only want the name (I found discrepancies in the naming structure for various languages, i.e. javascript names would be 'name' and typescript names would be 'name.definition)
// query captures includes the definition name and the definition implementation, but we only want the name (I found discrepencies in the naming structure for various languages, i.e. javascript names would be 'name' and typescript names would be 'name.definition)
if (name.includes("name") && lines[startLine]) {
formattedOutput += `${lines[startLine]}\n`
}
+6 -3
View File
@@ -9,7 +9,6 @@ import { HistoryItem } from "./HistoryItem"
import { McpServer, McpMarketplaceCatalog, McpMarketplaceItem, McpDownloadResponse, McpViewTab } from "./mcp"
import { TelemetrySetting } from "./TelemetrySetting"
import type { BalanceResponse, UsageTransaction, PaymentTransaction } from "../shared/ClineAccount"
import { ClineRulesToggles } from "./cline-rules"
// webview will hold state
export interface ExtensionMessage {
@@ -148,8 +147,6 @@ export interface ExtensionState {
}
version: string
vscMachineId: string
globalClineRulesToggles: ClineRulesToggles
localClineRulesToggles: ClineRulesToggles
}
export interface ClineMessage {
@@ -245,6 +242,12 @@ export type BrowserActionResult = {
currentMousePosition?: string
}
export interface BrowserConnectionInfo {
isConnected: boolean
isRemote: boolean
host?: string
}
export interface ClineAskUseMcpServer {
serverName: string
type: "use_mcp_tool" | "access_mcp_resource"
+2 -12
View File
@@ -27,20 +27,20 @@ export interface WebviewMessage {
| "openImage"
| "openInBrowser"
| "openFile"
| "createRuleFile"
| "openMention"
| "cancelTask"
| "showChatView"
| "refreshOpenRouterModels"
| "refreshRequestyModels"
| "refreshOpenAiModels"
| "refreshClineRules"
| "openMcpSettings"
| "restartMcpServer"
| "deleteMcpServer"
| "autoApprovalSettings"
| "browserSettings"
| "discoverBrowser"
| "testBrowserConnection"
| "browserConnectionResult"
| "browserRelaunchResult"
| "togglePlanActMode"
| "checkpointDiff"
@@ -82,9 +82,6 @@ export interface WebviewMessage {
| "searchFiles"
| "toggleFavoriteModel"
| "grpc_request"
| "toggleClineRule"
| "deleteClineRule"
// | "relaunchChromeDebugMode"
text?: string
uris?: string[] // Used for getRelativePaths
@@ -127,13 +124,6 @@ export interface WebviewMessage {
message: any // JSON serialized protobuf message
request_id: string // For correlating requests and responses
}
// For cline rules
isGlobal?: boolean
rulePath?: string
enabled?: boolean
filename?: string
offset?: number
}
export type ClineAskResponse = "yesButtonClicked" | "noButtonClicked" | "messageResponse"
@@ -1,31 +1,30 @@
import { describe, it } from "mocha"
import "should"
import { findLastIndex, findLast } from "./array"
import { describe, it, expect } from "vitest"
import { findLastIndex, findLast } from "../array"
describe("Array Utilities", () => {
describe("findLastIndex", () => {
it("should find last matching element's index", () => {
const array = [1, 2, 3, 2, 1]
const index = findLastIndex(array, (x) => x === 2)
index.should.equal(3) // last '2' is at index 3
expect(index).toBe(3) // last '2' is at index 3
})
it("should return -1 when no element matches", () => {
const array = [1, 2, 3]
const index = findLastIndex(array, (x) => x === 4)
index.should.equal(-1)
expect(index).toBe(-1)
})
it("should handle empty arrays", () => {
const array: number[] = []
const index = findLastIndex(array, (x) => x === 1)
index.should.equal(-1)
expect(index).toBe(-1)
})
it("should work with different types", () => {
const array = ["a", "b", "c", "b", "a"]
const index = findLastIndex(array, (x) => x === "b")
index.should.equal(3)
expect(index).toBe(3)
})
it("should provide correct index in predicate", () => {
@@ -35,13 +34,13 @@ describe("Array Utilities", () => {
indices.push(index)
return false
})
indices.should.deepEqual([2, 1, 0]) // Should iterate in reverse
expect(indices).toEqual([2, 1, 0]) // Should iterate in reverse
})
it("should provide array reference in predicate", () => {
const array = [1, 2, 3]
findLastIndex(array, (_, __, arr) => {
arr.should.equal(array) // Should pass original array
expect(arr).toBe(array) // Should pass original array
return false
})
})
@@ -51,20 +50,20 @@ describe("Array Utilities", () => {
it("should find last matching element", () => {
const array = [1, 2, 3, 2, 1]
const element = findLast(array, (x) => x === 2)
should(element).not.be.undefined()
element!.should.equal(2)
expect(element).toBeDefined()
expect(element).toBe(2)
})
it("should return undefined when no element matches", () => {
const array = [1, 2, 3]
const element = findLast(array, (x) => x === 4)
should(element).be.undefined()
expect(element).toBeUndefined()
})
it("should handle empty arrays", () => {
const array: number[] = []
const element = findLast(array, (x) => x === 1)
should(element).be.undefined()
expect(element).toBeUndefined()
})
it("should work with object arrays", () => {
@@ -74,8 +73,8 @@ describe("Array Utilities", () => {
{ id: 3, value: "a" },
]
const element = findLast(array, (x) => x.value === "a")
should(element).not.be.undefined()
element!.should.deepEqual({ id: 3, value: "a" })
expect(element).toBeDefined()
expect(element).toEqual({ id: 3, value: "a" })
})
it("should provide correct index in predicate", () => {
@@ -85,7 +84,7 @@ describe("Array Utilities", () => {
indices.push(index)
return false
})
indices.should.deepEqual([2, 1, 0]) // Should iterate in reverse
expect(indices).toEqual([2, 1, 0]) // Should iterate in reverse
})
})
})
@@ -1,4 +1,4 @@
import { expect } from "chai"
import { describe, it, expect } from "vitest"
import { mentionRegex, mentionRegexGlobal } from "../context-mentions"
@@ -16,7 +16,7 @@ function testMention(input: string, expected: string | null): TestResult {
}
function assertMatch(result: TestResult) {
expect(result.actual).eq(result.expected)
expect(result.actual).toBe(result.expected)
return true
}
@@ -140,7 +140,7 @@ describe("Mention Regex", () => {
it("finds all mentions in a string using global regex", () => {
const text = "Check @/path/file1.txt and @/C:\\folder\\file2.txt and report any @problems to @git-changes"
const matches = text.match(mentionRegexGlobal)
expect(matches).deep.eq(["@/path/file1.txt", "@/C:\\folder\\file2.txt", "@problems", "@git-changes"])
expect(matches).toEqual(["@/path/file1.txt", "@/C:\\folder\\file2.txt", "@problems", "@git-changes"])
})
})
@@ -164,12 +164,12 @@ describe("Mention Regex", () => {
it("correctly identifies the first path in a string with multiple path types", () => {
const text = "Check both @/unix/path and @/C:\\windows\\path for details."
const result = mentionRegex.exec(text) || []
expect(result[0]).eq("@/unix/path")
expect(result[0]).toBe("@/unix/path")
// Test starting from after the first match
const secondSearchStart = text.indexOf("@/C:")
const secondResult = mentionRegex.exec(text.substring(secondSearchStart)) || []
expect(secondResult[0]).eq("@/C:\\windows\\path")
expect(secondResult[0]).toBe("@/C:\\windows\\path")
})
})
-2
View File
@@ -32,7 +32,6 @@ export interface ApiHandlerOptions {
liteLlmModelId?: string
liteLlmApiKey?: string
liteLlmUsePromptCache?: boolean
openAiHeaders?: Record<string, string> // Custom headers for OpenAI requests
anthropicBaseUrl?: string
openRouterApiKey?: string
openRouterModelId?: string
@@ -59,7 +58,6 @@ export interface ApiHandlerOptions {
lmStudioModelId?: string
lmStudioBaseUrl?: string
geminiApiKey?: string
geminiBaseUrl?: string
openAiNativeApiKey?: string
deepSeekApiKey?: string
requestyApiKey?: string
-1
View File
@@ -1 +0,0 @@
export type ClineRulesToggles = Record<string, boolean> // filepath -> enabled/disabled
+8 -113
View File
@@ -6,24 +6,19 @@
/* eslint-disable */
import { BinaryReader, BinaryWriter } from "@bufbuild/protobuf/wire"
import { EmptyRequest, StringRequest } from "./common"
import { EmptyRequest } from "./common"
export const protobufPackage = "cline"
export interface BrowserConnectionInfo {
isConnected: boolean
isRemote: boolean
host?: string | undefined
}
export interface BrowserConnection {
success: boolean
message: string
endpoint?: string | undefined
/** Optional, may be empty */
host: string
}
function createBaseBrowserConnectionInfo(): BrowserConnectionInfo {
return { isConnected: false, isRemote: false, host: undefined }
return { isConnected: false, isRemote: false, host: "" }
}
export const BrowserConnectionInfo: MessageFns<BrowserConnectionInfo> = {
@@ -34,7 +29,7 @@ export const BrowserConnectionInfo: MessageFns<BrowserConnectionInfo> = {
if (message.isRemote !== false) {
writer.uint32(16).bool(message.isRemote)
}
if (message.host !== undefined) {
if (message.host !== "") {
writer.uint32(26).string(message.host)
}
return writer
@@ -84,7 +79,7 @@ export const BrowserConnectionInfo: MessageFns<BrowserConnectionInfo> = {
return {
isConnected: isSet(object.isConnected) ? globalThis.Boolean(object.isConnected) : false,
isRemote: isSet(object.isRemote) ? globalThis.Boolean(object.isRemote) : false,
host: isSet(object.host) ? globalThis.String(object.host) : undefined,
host: isSet(object.host) ? globalThis.String(object.host) : "",
}
},
@@ -96,7 +91,7 @@ export const BrowserConnectionInfo: MessageFns<BrowserConnectionInfo> = {
if (message.isRemote !== false) {
obj.isRemote = message.isRemote
}
if (message.host !== undefined) {
if (message.host !== "") {
obj.host = message.host
}
return obj
@@ -109,99 +104,7 @@ export const BrowserConnectionInfo: MessageFns<BrowserConnectionInfo> = {
const message = createBaseBrowserConnectionInfo()
message.isConnected = object.isConnected ?? false
message.isRemote = object.isRemote ?? false
message.host = object.host ?? undefined
return message
},
}
function createBaseBrowserConnection(): BrowserConnection {
return { success: false, message: "", endpoint: undefined }
}
export const BrowserConnection: MessageFns<BrowserConnection> = {
encode(message: BrowserConnection, writer: BinaryWriter = new BinaryWriter()): BinaryWriter {
if (message.success !== false) {
writer.uint32(8).bool(message.success)
}
if (message.message !== "") {
writer.uint32(18).string(message.message)
}
if (message.endpoint !== undefined) {
writer.uint32(26).string(message.endpoint)
}
return writer
},
decode(input: BinaryReader | Uint8Array, length?: number): BrowserConnection {
const reader = input instanceof BinaryReader ? input : new BinaryReader(input)
let end = length === undefined ? reader.len : reader.pos + length
const message = createBaseBrowserConnection()
while (reader.pos < end) {
const tag = reader.uint32()
switch (tag >>> 3) {
case 1: {
if (tag !== 8) {
break
}
message.success = reader.bool()
continue
}
case 2: {
if (tag !== 18) {
break
}
message.message = reader.string()
continue
}
case 3: {
if (tag !== 26) {
break
}
message.endpoint = reader.string()
continue
}
}
if ((tag & 7) === 4 || tag === 0) {
break
}
reader.skip(tag & 7)
}
return message
},
fromJSON(object: any): BrowserConnection {
return {
success: isSet(object.success) ? globalThis.Boolean(object.success) : false,
message: isSet(object.message) ? globalThis.String(object.message) : "",
endpoint: isSet(object.endpoint) ? globalThis.String(object.endpoint) : undefined,
}
},
toJSON(message: BrowserConnection): unknown {
const obj: any = {}
if (message.success !== false) {
obj.success = message.success
}
if (message.message !== "") {
obj.message = message.message
}
if (message.endpoint !== undefined) {
obj.endpoint = message.endpoint
}
return obj
},
create<I extends Exact<DeepPartial<BrowserConnection>, I>>(base?: I): BrowserConnection {
return BrowserConnection.fromPartial(base ?? ({} as any))
},
fromPartial<I extends Exact<DeepPartial<BrowserConnection>, I>>(object: I): BrowserConnection {
const message = createBaseBrowserConnection()
message.success = object.success ?? false
message.message = object.message ?? ""
message.endpoint = object.endpoint ?? undefined
message.host = object.host ?? ""
return message
},
}
@@ -219,14 +122,6 @@ export const BrowserServiceDefinition = {
responseStream: false,
options: {},
},
testBrowserConnection: {
name: "testBrowserConnection",
requestType: StringRequest,
requestStream: false,
responseType: BrowserConnection,
responseStream: false,
options: {},
},
},
} as const
-451
View File
@@ -17,33 +17,6 @@ export interface EmptyRequest {
export interface Empty {}
export interface StringRequest {
metadata?: Metadata | undefined
value: string
}
export interface String {
value: string
}
export interface Int64Request {
metadata?: Metadata | undefined
value: number
}
export interface Int64 {
value: number
}
export interface BytesRequest {
metadata?: Metadata | undefined
value: Buffer
}
export interface Bytes {
value: Buffer
}
function createBaseMetadata(): Metadata {
return {}
}
@@ -189,419 +162,6 @@ export const Empty: MessageFns<Empty> = {
},
}
function createBaseStringRequest(): StringRequest {
return { metadata: undefined, value: "" }
}
export const StringRequest: MessageFns<StringRequest> = {
encode(message: StringRequest, writer: BinaryWriter = new BinaryWriter()): BinaryWriter {
if (message.metadata !== undefined) {
Metadata.encode(message.metadata, writer.uint32(10).fork()).join()
}
if (message.value !== "") {
writer.uint32(18).string(message.value)
}
return writer
},
decode(input: BinaryReader | Uint8Array, length?: number): StringRequest {
const reader = input instanceof BinaryReader ? input : new BinaryReader(input)
let end = length === undefined ? reader.len : reader.pos + length
const message = createBaseStringRequest()
while (reader.pos < end) {
const tag = reader.uint32()
switch (tag >>> 3) {
case 1: {
if (tag !== 10) {
break
}
message.metadata = Metadata.decode(reader, reader.uint32())
continue
}
case 2: {
if (tag !== 18) {
break
}
message.value = reader.string()
continue
}
}
if ((tag & 7) === 4 || tag === 0) {
break
}
reader.skip(tag & 7)
}
return message
},
fromJSON(object: any): StringRequest {
return {
metadata: isSet(object.metadata) ? Metadata.fromJSON(object.metadata) : undefined,
value: isSet(object.value) ? globalThis.String(object.value) : "",
}
},
toJSON(message: StringRequest): unknown {
const obj: any = {}
if (message.metadata !== undefined) {
obj.metadata = Metadata.toJSON(message.metadata)
}
if (message.value !== "") {
obj.value = message.value
}
return obj
},
create<I extends Exact<DeepPartial<StringRequest>, I>>(base?: I): StringRequest {
return StringRequest.fromPartial(base ?? ({} as any))
},
fromPartial<I extends Exact<DeepPartial<StringRequest>, I>>(object: I): StringRequest {
const message = createBaseStringRequest()
message.metadata =
object.metadata !== undefined && object.metadata !== null ? Metadata.fromPartial(object.metadata) : undefined
message.value = object.value ?? ""
return message
},
}
function createBaseString(): String {
return { value: "" }
}
export const String: MessageFns<String> = {
encode(message: String, writer: BinaryWriter = new BinaryWriter()): BinaryWriter {
if (message.value !== "") {
writer.uint32(10).string(message.value)
}
return writer
},
decode(input: BinaryReader | Uint8Array, length?: number): String {
const reader = input instanceof BinaryReader ? input : new BinaryReader(input)
let end = length === undefined ? reader.len : reader.pos + length
const message = createBaseString()
while (reader.pos < end) {
const tag = reader.uint32()
switch (tag >>> 3) {
case 1: {
if (tag !== 10) {
break
}
message.value = reader.string()
continue
}
}
if ((tag & 7) === 4 || tag === 0) {
break
}
reader.skip(tag & 7)
}
return message
},
fromJSON(object: any): String {
return { value: isSet(object.value) ? globalThis.String(object.value) : "" }
},
toJSON(message: String): unknown {
const obj: any = {}
if (message.value !== "") {
obj.value = message.value
}
return obj
},
create<I extends Exact<DeepPartial<String>, I>>(base?: I): String {
return String.fromPartial(base ?? ({} as any))
},
fromPartial<I extends Exact<DeepPartial<String>, I>>(object: I): String {
const message = createBaseString()
message.value = object.value ?? ""
return message
},
}
function createBaseInt64Request(): Int64Request {
return { metadata: undefined, value: 0 }
}
export const Int64Request: MessageFns<Int64Request> = {
encode(message: Int64Request, writer: BinaryWriter = new BinaryWriter()): BinaryWriter {
if (message.metadata !== undefined) {
Metadata.encode(message.metadata, writer.uint32(10).fork()).join()
}
if (message.value !== 0) {
writer.uint32(16).int64(message.value)
}
return writer
},
decode(input: BinaryReader | Uint8Array, length?: number): Int64Request {
const reader = input instanceof BinaryReader ? input : new BinaryReader(input)
let end = length === undefined ? reader.len : reader.pos + length
const message = createBaseInt64Request()
while (reader.pos < end) {
const tag = reader.uint32()
switch (tag >>> 3) {
case 1: {
if (tag !== 10) {
break
}
message.metadata = Metadata.decode(reader, reader.uint32())
continue
}
case 2: {
if (tag !== 16) {
break
}
message.value = longToNumber(reader.int64())
continue
}
}
if ((tag & 7) === 4 || tag === 0) {
break
}
reader.skip(tag & 7)
}
return message
},
fromJSON(object: any): Int64Request {
return {
metadata: isSet(object.metadata) ? Metadata.fromJSON(object.metadata) : undefined,
value: isSet(object.value) ? globalThis.Number(object.value) : 0,
}
},
toJSON(message: Int64Request): unknown {
const obj: any = {}
if (message.metadata !== undefined) {
obj.metadata = Metadata.toJSON(message.metadata)
}
if (message.value !== 0) {
obj.value = Math.round(message.value)
}
return obj
},
create<I extends Exact<DeepPartial<Int64Request>, I>>(base?: I): Int64Request {
return Int64Request.fromPartial(base ?? ({} as any))
},
fromPartial<I extends Exact<DeepPartial<Int64Request>, I>>(object: I): Int64Request {
const message = createBaseInt64Request()
message.metadata =
object.metadata !== undefined && object.metadata !== null ? Metadata.fromPartial(object.metadata) : undefined
message.value = object.value ?? 0
return message
},
}
function createBaseInt64(): Int64 {
return { value: 0 }
}
export const Int64: MessageFns<Int64> = {
encode(message: Int64, writer: BinaryWriter = new BinaryWriter()): BinaryWriter {
if (message.value !== 0) {
writer.uint32(8).int64(message.value)
}
return writer
},
decode(input: BinaryReader | Uint8Array, length?: number): Int64 {
const reader = input instanceof BinaryReader ? input : new BinaryReader(input)
let end = length === undefined ? reader.len : reader.pos + length
const message = createBaseInt64()
while (reader.pos < end) {
const tag = reader.uint32()
switch (tag >>> 3) {
case 1: {
if (tag !== 8) {
break
}
message.value = longToNumber(reader.int64())
continue
}
}
if ((tag & 7) === 4 || tag === 0) {
break
}
reader.skip(tag & 7)
}
return message
},
fromJSON(object: any): Int64 {
return { value: isSet(object.value) ? globalThis.Number(object.value) : 0 }
},
toJSON(message: Int64): unknown {
const obj: any = {}
if (message.value !== 0) {
obj.value = Math.round(message.value)
}
return obj
},
create<I extends Exact<DeepPartial<Int64>, I>>(base?: I): Int64 {
return Int64.fromPartial(base ?? ({} as any))
},
fromPartial<I extends Exact<DeepPartial<Int64>, I>>(object: I): Int64 {
const message = createBaseInt64()
message.value = object.value ?? 0
return message
},
}
function createBaseBytesRequest(): BytesRequest {
return { metadata: undefined, value: Buffer.alloc(0) }
}
export const BytesRequest: MessageFns<BytesRequest> = {
encode(message: BytesRequest, writer: BinaryWriter = new BinaryWriter()): BinaryWriter {
if (message.metadata !== undefined) {
Metadata.encode(message.metadata, writer.uint32(10).fork()).join()
}
if (message.value.length !== 0) {
writer.uint32(18).bytes(message.value)
}
return writer
},
decode(input: BinaryReader | Uint8Array, length?: number): BytesRequest {
const reader = input instanceof BinaryReader ? input : new BinaryReader(input)
let end = length === undefined ? reader.len : reader.pos + length
const message = createBaseBytesRequest()
while (reader.pos < end) {
const tag = reader.uint32()
switch (tag >>> 3) {
case 1: {
if (tag !== 10) {
break
}
message.metadata = Metadata.decode(reader, reader.uint32())
continue
}
case 2: {
if (tag !== 18) {
break
}
message.value = Buffer.from(reader.bytes())
continue
}
}
if ((tag & 7) === 4 || tag === 0) {
break
}
reader.skip(tag & 7)
}
return message
},
fromJSON(object: any): BytesRequest {
return {
metadata: isSet(object.metadata) ? Metadata.fromJSON(object.metadata) : undefined,
value: isSet(object.value) ? Buffer.from(bytesFromBase64(object.value)) : Buffer.alloc(0),
}
},
toJSON(message: BytesRequest): unknown {
const obj: any = {}
if (message.metadata !== undefined) {
obj.metadata = Metadata.toJSON(message.metadata)
}
if (message.value.length !== 0) {
obj.value = base64FromBytes(message.value)
}
return obj
},
create<I extends Exact<DeepPartial<BytesRequest>, I>>(base?: I): BytesRequest {
return BytesRequest.fromPartial(base ?? ({} as any))
},
fromPartial<I extends Exact<DeepPartial<BytesRequest>, I>>(object: I): BytesRequest {
const message = createBaseBytesRequest()
message.metadata =
object.metadata !== undefined && object.metadata !== null ? Metadata.fromPartial(object.metadata) : undefined
message.value = object.value ?? Buffer.alloc(0)
return message
},
}
function createBaseBytes(): Bytes {
return { value: Buffer.alloc(0) }
}
export const Bytes: MessageFns<Bytes> = {
encode(message: Bytes, writer: BinaryWriter = new BinaryWriter()): BinaryWriter {
if (message.value.length !== 0) {
writer.uint32(10).bytes(message.value)
}
return writer
},
decode(input: BinaryReader | Uint8Array, length?: number): Bytes {
const reader = input instanceof BinaryReader ? input : new BinaryReader(input)
let end = length === undefined ? reader.len : reader.pos + length
const message = createBaseBytes()
while (reader.pos < end) {
const tag = reader.uint32()
switch (tag >>> 3) {
case 1: {
if (tag !== 10) {
break
}
message.value = Buffer.from(reader.bytes())
continue
}
}
if ((tag & 7) === 4 || tag === 0) {
break
}
reader.skip(tag & 7)
}
return message
},
fromJSON(object: any): Bytes {
return { value: isSet(object.value) ? Buffer.from(bytesFromBase64(object.value)) : Buffer.alloc(0) }
},
toJSON(message: Bytes): unknown {
const obj: any = {}
if (message.value.length !== 0) {
obj.value = base64FromBytes(message.value)
}
return obj
},
create<I extends Exact<DeepPartial<Bytes>, I>>(base?: I): Bytes {
return Bytes.fromPartial(base ?? ({} as any))
},
fromPartial<I extends Exact<DeepPartial<Bytes>, I>>(object: I): Bytes {
const message = createBaseBytes()
message.value = object.value ?? Buffer.alloc(0)
return message
},
}
function bytesFromBase64(b64: string): Uint8Array {
return Uint8Array.from(globalThis.Buffer.from(b64, "base64"))
}
function base64FromBytes(arr: Uint8Array): string {
return globalThis.Buffer.from(arr).toString("base64")
}
type Builtin = Date | Function | Uint8Array | string | number | boolean | undefined
export type DeepPartial<T> = T extends Builtin
@@ -619,17 +179,6 @@ export type Exact<P, I extends P> = P extends Builtin
? P
: P & { [K in keyof P]: Exact<P[K], I[K]> } & { [K in Exclude<keyof I, KeysOfUnion<P>>]: never }
function longToNumber(int64: { toString(): string }): number {
const num = globalThis.Number(int64.toString())
if (num > globalThis.Number.MAX_SAFE_INTEGER) {
throw new globalThis.Error("Value is larger than Number.MAX_SAFE_INTEGER")
}
if (num < globalThis.Number.MIN_SAFE_INTEGER) {
throw new globalThis.Error("Value is smaller than Number.MIN_SAFE_INTEGER")
}
return num
}
function isSet(value: any): boolean {
return value !== null && value !== undefined
}
@@ -1,202 +0,0 @@
import { describe, it } from "mocha"
import should from "should"
import sinon from "sinon"
import { Readable } from "stream"
import type { FzfResultItem } from "fzf"
import * as childProcess from "child_process"
import * as vscode from "vscode"
import * as fs from "fs"
import * as path from "path"
import * as fileSearch from "../../../services/search/file-search"
import * as ripgrep from "../../../services/ripgrep"
describe("File Search", function () {
let sandbox: sinon.SinonSandbox
let spawnStub: sinon.SinonStub
beforeEach(function () {
sandbox = sinon.createSandbox()
spawnStub = sandbox.stub()
// Create a wrapper function that matches the signature of childProcess.spawn
const spawnWrapper: typeof childProcess.spawn = function (command, options) {
return spawnStub(command, options)
}
sandbox.stub(fileSearch, "getSpawnFunction").returns(spawnWrapper)
// Use replaceGetter instead of stub().value() for non-configurable properties
sandbox.replaceGetter(vscode.env, "appRoot", () => "mock/app/root")
sandbox.stub(fs.promises, "lstat").resolves({ isDirectory: () => false } as fs.Stats)
sandbox.stub(ripgrep, "getBinPath").resolves("mock/ripgrep/path")
})
afterEach(function () {
sandbox.restore()
})
describe("executeRipgrepForFiles", function () {
it("should correctly process and return file and folder results", async function () {
const mockFiles = ["file1.txt", "folder1/file2.js", "folder1/subfolder/file3.py"]
// Create a proper mock for the child process
const mockStdout = new Readable({
read() {
this.push(mockFiles.join("\n"))
this.push(null) // Signal the end of the stream
},
})
const mockStderr = new Readable({
read() {
this.push(null) // Empty stream
},
})
spawnStub.returns({
stdout: mockStdout,
stderr: mockStderr,
on: sinon.stub().returns({}),
} as unknown as childProcess.ChildProcess)
// Instead of stubbing path functions, we'll stub the executeRipgrepForFiles function
// to return a predictable result for this test
const expectedResult: { path: string; type: "file" | "folder"; label?: string }[] = [
{ path: "file1.txt", type: "file", label: "file1.txt" },
{ path: "folder1/file2.js", type: "file", label: "file2.js" },
{ path: "folder1/subfolder/file3.py", type: "file", label: "file3.py" },
{ path: "folder1", type: "folder", label: "folder1" },
{ path: "folder1/subfolder", type: "folder", label: "subfolder" },
]
// Create a new stub for executeRipgrepForFiles
sandbox.stub(fileSearch, "executeRipgrepForFiles").resolves(expectedResult)
const result = await fileSearch.executeRipgrepForFiles("mock/path", "/workspace", 5000)
should(result).be.an.Array()
// Don't assert on the exact length as it may vary
const files = result.filter((item) => item.type === "file")
const folders = result.filter((item) => item.type === "folder")
// Verify we have at least the expected files and folders
should(files.length).be.greaterThanOrEqual(3)
should(folders.length).be.greaterThanOrEqual(2)
should(files[0]).have.properties({
path: "file1.txt",
type: "file",
label: "file1.txt",
})
should(folders).containDeep([
{ path: "folder1", type: "folder", label: "folder1" },
{ path: "folder1/subfolder", type: "folder", label: "subfolder" },
])
})
it("should handle errors from ripgrep", async function () {
const mockError = "Mock ripgrep error"
// Create proper mock streams for error case
const mockStdout = new Readable({
read() {
this.push(null) // Empty stream
},
})
const mockStderr = new Readable({
read() {
this.push(mockError)
this.push(null) // Signal the end of the stream
},
})
spawnStub.returns({
stdout: mockStdout,
stderr: mockStderr,
on: function (event: string, callback: Function) {
if (event === "error") {
callback(new Error(mockError))
}
return this
},
} as unknown as childProcess.ChildProcess)
await should(fileSearch.executeRipgrepForFiles("mock/path", "/workspace", 5000)).be.rejectedWith(
`ripgrep process error: ${mockError}`,
)
})
})
describe("searchWorkspaceFiles", function () {
it("should return top N results for empty query", async function () {
const mockItems: { path: string; type: "file" | "folder"; label?: string }[] = [
{ path: "file1.txt", type: "file", label: "file1.txt" },
{ path: "folder1", type: "folder", label: "folder1" },
{ path: "file2.js", type: "file", label: "file2.js" },
]
// Directly stub the searchWorkspaceFiles function for this test
// This avoids issues with the executeRipgrepForFiles function
const searchStub = sandbox.stub(fileSearch, "searchWorkspaceFiles")
searchStub.withArgs("", "/workspace", 2).resolves(mockItems.slice(0, 2))
const result = await fileSearch.searchWorkspaceFiles("", "/workspace", 2)
should(result).be.an.Array()
should(result).have.length(2)
should(result).deepEqual(mockItems.slice(0, 2))
})
it("should apply fuzzy matching for non-empty query", async function () {
const mockItems: { path: string; type: "file" | "folder"; label?: string }[] = [
{ path: "file1.txt", type: "file", label: "file1.txt" },
{ path: "folder1/important.js", type: "file", label: "important.js" },
{ path: "file2.js", type: "file", label: "file2.js" },
]
sandbox.stub(fileSearch, "executeRipgrepForFiles").resolves(mockItems)
const fzfStub = {
find: sinon.stub().returns([{ item: mockItems[1], score: 0 }]),
}
// Create a mock for the fzf module
const fzfModuleStub = {
Fzf: sinon.stub().returns(fzfStub),
byLengthAsc: sinon.stub(),
}
// Use a more reliable approach to mock dynamic imports
// This replaces the actual implementation of searchWorkspaceFiles to avoid the dynamic import
sandbox.stub(fileSearch, "searchWorkspaceFiles").callsFake(async (query, workspacePath, limit) => {
if (!query.trim()) {
return mockItems.slice(0, limit)
}
// Simulate the fuzzy search behavior
return [mockItems[1]]
})
const result = await fileSearch.searchWorkspaceFiles("imp", "/workspace", 2)
should(result).be.an.Array()
should(result).have.length(1)
should(result[0]).have.properties({
path: "folder1/important.js",
type: "file",
label: "important.js",
})
})
})
describe("OrderbyMatchScore", function () {
it("should prioritize results with fewer gaps between matched characters", function () {
const mockItemA: FzfResultItem<any> = { item: {}, positions: new Set([0, 1, 2, 5]), start: 0, end: 5, score: 0 }
const mockItemB: FzfResultItem<any> = { item: {}, positions: new Set([0, 2, 4, 6]), start: 0, end: 6, score: 0 }
const result = fileSearch.OrderbyMatchScore(mockItemA, mockItemB)
should(result).be.lessThan(0)
})
})
})
+1
View File
@@ -2,6 +2,7 @@ import * as vscode from "vscode"
import { describe, it, beforeEach, afterEach } from "mocha"
import { strict as assert } from "assert"
import { join } from "path"
describe("Chat Integration Tests", () => {
let panel: vscode.WebviewPanel
let disposables: vscode.Disposable[] = []
@@ -1,7 +1,6 @@
import { describe, it } from "mocha"
import "should"
import { calculateApiCostAnthropic, calculateApiCostOpenAI } from "./cost"
import { ModelInfo } from "../shared/api"
import { describe, it, expect } from "vitest"
import { calculateApiCostAnthropic, calculateApiCostOpenAI } from "../cost"
import { ModelInfo } from "../../shared/api"
describe("Cost Utilities", () => {
describe("calculateApiCostAnthropic", () => {
@@ -16,7 +15,7 @@ describe("Cost Utilities", () => {
// Input: (3.0 / 1_000_000) * 1000 = 0.003
// Output: (15.0 / 1_000_000) * 500 = 0.0075
// Total: 0.003 + 0.0075 = 0.0105
cost.should.equal(0.0105)
expect(cost).toBe(0.0105)
})
it("should handle missing prices", () => {
@@ -26,7 +25,7 @@ describe("Cost Utilities", () => {
}
const cost = calculateApiCostAnthropic(modelInfo, 1000, 500)
cost.should.equal(0)
expect(cost).toBe(0)
})
it("should use real model configuration (Claude 3.5 Sonnet)", () => {
@@ -48,7 +47,7 @@ describe("Cost Utilities", () => {
// Input: (3.0 / 1_000_000) * 2000 = 0.006
// Output: (15.0 / 1_000_000) * 1000 = 0.015
// Total: 0.005625 + 0.00015 + 0.006 + 0.015 = 0.026775
cost.should.equal(0.026775)
expect(cost).toBe(0.026775)
})
it("should handle zero token counts", () => {
@@ -61,7 +60,7 @@ describe("Cost Utilities", () => {
}
const cost = calculateApiCostAnthropic(modelInfo, 0, 0, 0, 0)
cost.should.equal(0)
expect(cost).toBe(0)
})
})
@@ -77,7 +76,7 @@ describe("Cost Utilities", () => {
// Input: (3.0 / 1_000_000) * 1000 = 0.003
// Output: (15.0 / 1_000_000) * 500 = 0.0075
// Total: 0.003 + 0.0075 = 0.0105
cost.should.equal(0.0105)
expect(cost).toBe(0.0105)
})
it("should handle missing prices", () => {
@@ -87,7 +86,7 @@ describe("Cost Utilities", () => {
}
const cost = calculateApiCostOpenAI(modelInfo, 1000, 500)
cost.should.equal(0)
expect(cost).toBe(0)
})
it("should use real model configuration (Claude 3.5 Sonnet)", () => {
@@ -109,7 +108,7 @@ describe("Cost Utilities", () => {
// Input: (3.0 / 1_000_000) * (2100 - 1500 - 500) = 0.0003
// Output: (15.0 / 1_000_000) * 1000 = 0.015
// Total: 0.005625 + 0.00015 + 0.0003 + 0.015 = 0.021075
cost.should.equal(0.021075)
expect(cost).toBe(0.021075)
})
it("should handle zero token counts", () => {
@@ -122,7 +121,7 @@ describe("Cost Utilities", () => {
}
const cost = calculateApiCostOpenAI(modelInfo, 0, 0, 0, 0)
cost.should.equal(0)
expect(cost).toBe(0)
})
})
})
@@ -1,15 +1,14 @@
import { describe, it, expect, afterAll } from "vitest"
import * as fs from "fs/promises"
import { after, describe, it } from "mocha"
import * as os from "os"
import * as path from "path"
import "should"
import { createDirectoriesForFile, fileExistsAtPath, isDirectory } from "./fs"
import { createDirectoriesForFile, fileExistsAtPath, isDirectory } from "../fs"
describe("Filesystem Utilities", () => {
const tmpDir = path.join(os.tmpdir(), "cline-test-" + Math.random().toString(36).slice(2))
// Clean up after tests
after(async () => {
afterAll(async () => {
try {
await fs.rm(tmpDir, { recursive: true, force: true })
} catch {
@@ -24,13 +23,13 @@ describe("Filesystem Utilities", () => {
await fs.writeFile(testFile, "test")
const exists = await fileExistsAtPath(testFile)
exists.should.be.true()
expect(exists).toBe(true)
})
it("should return false for non-existing paths", async () => {
const nonExistentPath = path.join(tmpDir, "does-not-exist.txt")
const exists = await fileExistsAtPath(nonExistentPath)
exists.should.be.false()
expect(exists).toBe(false)
})
})
@@ -40,10 +39,10 @@ describe("Filesystem Utilities", () => {
const createdDirs = await createDirectoriesForFile(deepPath)
// Verify directories were created
createdDirs.length.should.be.greaterThan(0)
expect(createdDirs.length).toBeGreaterThan(0)
for (const dir of createdDirs) {
const exists = await fileExistsAtPath(dir)
exists.should.be.true()
expect(exists).toBe(true)
}
})
@@ -55,7 +54,7 @@ describe("Filesystem Utilities", () => {
const createdDirs = await createDirectoriesForFile(filePath)
// Should not create any new directories
createdDirs.length.should.equal(0)
expect(createdDirs.length).toBe(0)
})
it("should normalize paths", async () => {
@@ -63,29 +62,29 @@ describe("Filesystem Utilities", () => {
const createdDirs = await createDirectoriesForFile(unnormalizedPath)
// Should create only the necessary directory
createdDirs.length.should.equal(1)
expect(createdDirs.length).toBe(1)
const exists = await fileExistsAtPath(path.join(tmpDir, "b"))
exists.should.be.true()
expect(exists).toBe(true)
})
})
describe("isDirectory", () => {
it("should return true for directories", async () => {
await fs.mkdir(tmpDir, { recursive: true })
const isDir = await isDirectory(tmpDir)
isDir.should.be.true()
expect(isDir).toBe(true)
})
it("should return false for files", async () => {
const testFile = path.join(tmpDir, "test.txt")
await fs.writeFile(testFile, "test")
const isDir = await isDirectory(testFile)
isDir.should.be.false()
expect(isDir).toBe(false)
})
it("should return false for non-existent paths", async () => {
const nonExistentPath = path.join(tmpDir, "does-not-exist")
const isDir = await isDirectory(nonExistentPath)
isDir.should.be.false()
expect(isDir).toBe(false)
})
})
})
@@ -2,7 +2,7 @@ import { describe, it } from "mocha"
import * as os from "os"
import * as path from "path"
import "should"
import { arePathsEqual, getReadablePath } from "./path"
import { arePathsEqual, getReadablePath } from "../path"
describe("Path Utilities", () => {
describe("arePathsEqual", () => {
@@ -1,6 +1,6 @@
import { describe, it, beforeEach, afterEach } from "mocha"
import { expect } from "chai"
import { getShell } from "../utils/shell"
import { getShell } from "../shell"
import * as vscode from "vscode"
import { userInfo } from "os"
+56
View File
@@ -0,0 +1,56 @@
import { describe, it, expect } from "vitest"
import { fixModelHtmlEscaping, removeInvalidChars } from "../string"
describe("fixModelHtmlEscaping", () => {
it("should convert &gt; to >", () => {
expect(fixModelHtmlEscaping("foo &gt; bar")).toBe("foo > bar")
})
it("should convert &lt; to <", () => {
expect(fixModelHtmlEscaping("foo &lt; bar")).toBe("foo < bar")
})
it('should convert &quot; to "', () => {
expect(fixModelHtmlEscaping("foo &quot;bar&quot;")).toBe('foo "bar"')
})
it("should convert &amp; to &", () => {
expect(fixModelHtmlEscaping("foo &amp; bar")).toBe("foo & bar")
})
it("should convert &apos; to '", () => {
expect(fixModelHtmlEscaping("foo &apos;bar&apos;")).toBe("foo 'bar'")
})
it("should handle multiple entities in the same string", () => {
expect(fixModelHtmlEscaping("&lt;div&gt;Hello &quot;World&quot; &amp; &apos;Universe&apos;&lt;/div&gt;")).toBe(
"<div>Hello \"World\" & 'Universe'</div>",
)
})
it("should return unchanged string when no HTML entities are present", () => {
expect(fixModelHtmlEscaping("normal string")).toBe("normal string")
})
})
describe("removeInvalidChars", () => {
it("should remove replacement characters", () => {
expect(removeInvalidChars("hello\uFFFDworld")).toBe("helloworld")
})
it("should remove characters", () => {
expect(removeInvalidChars("helloworld")).toBe("helloworld")
})
it("should remove multiple replacement characters", () => {
expect(removeInvalidChars("h\uFFFDe\uFFFDl\uFFFDl\uFFFDo")).toBe("hello")
})
it("should remove multiple characters", () => {
expect(removeInvalidChars("hello")).toBe("hello")
})
it("should return unchanged string when no replacement characters are present", () => {
expect(removeInvalidChars("normal string")).toBe("normal string")
})
})
+11 -7
View File
@@ -1,6 +1,7 @@
import * as path from "path"
import os from "os"
import * as vscode from "vscode"
import { realpathSync } from "fs"
/*
The Node.js 'path' module resolves and normalizes paths differently depending on the platform:
@@ -119,12 +120,15 @@ export const isLocatedInWorkspace = (pathToCheck: string = ""): boolean => {
return pathToCheck.startsWith(workspacePath)
}
// Normalize paths without resolving symlinks
const normalizedWorkspace = path.normalize(workspacePath)
const normalizedPath = path.normalize(path.resolve(workspacePath, pathToCheck))
const resolvedPath = path.resolve(workspacePath, pathToCheck)
// Use path.relative to check if the path is within the workspace
const relativePath = path.relative(normalizedWorkspace, normalizedPath)
return !relativePath.startsWith("..") && !path.isAbsolute(relativePath)
// Using realpathSync to resolve any symbolic links
try {
const realWorkspacePath = realpathSync(workspacePath)
const realPath = realpathSync(resolvedPath)
return realPath.startsWith(realWorkspacePath)
} catch (error) {
console.error("Error resolving paths:", error)
return false
}
}
+1 -1
View File
@@ -91,7 +91,7 @@ function getWindowsShellFromVSCode(): string | null {
// If the profile name indicates PowerShell, do version-based detection.
// In testing it was found these typically do not have a path, and this
// implementation manages to deductively get the correct version of PowerShell
// implementation manages to deductively get the corect version of PowerShell
if (defaultProfileName.toLowerCase().includes("powershell")) {
if (profile?.path) {
// If there's an explicit PowerShell path, return that
-57
View File
@@ -1,57 +0,0 @@
import { describe, it } from "mocha"
import "should"
import { fixModelHtmlEscaping, removeInvalidChars } from "./string"
describe("fixModelHtmlEscaping", () => {
it("should convert &gt; to >", () => {
fixModelHtmlEscaping("foo &gt; bar").should.equal("foo > bar")
})
it("should convert &lt; to <", () => {
fixModelHtmlEscaping("foo &lt; bar").should.equal("foo < bar")
})
it('should convert &quot; to "', () => {
fixModelHtmlEscaping("foo &quot;bar&quot;").should.equal('foo "bar"')
})
it("should convert &amp; to &", () => {
fixModelHtmlEscaping("foo &amp; bar").should.equal("foo & bar")
})
it("should convert &apos; to '", () => {
fixModelHtmlEscaping("foo &apos;bar&apos;").should.equal("foo 'bar'")
})
it("should handle multiple entities in the same string", () => {
fixModelHtmlEscaping("&lt;div&gt;Hello &quot;World&quot; &amp; &apos;Universe&apos;&lt;/div&gt;").should.equal(
"<div>Hello \"World\" & 'Universe'</div>",
)
})
it("should return unchanged string when no HTML entities are present", () => {
fixModelHtmlEscaping("normal string").should.equal("normal string")
})
})
describe("removeInvalidChars", () => {
it("should remove replacement characters", () => {
removeInvalidChars("hello\uFFFDworld").should.equal("helloworld")
})
it("should remove characters", () => {
removeInvalidChars("helloworld").should.equal("helloworld")
})
it("should remove multiple replacement characters", () => {
removeInvalidChars("h\uFFFDe\uFFFDl\uFFFDl\uFFFDo").should.equal("hello")
})
it("should remove multiple characters", () => {
removeInvalidChars("hello").should.equal("hello")
})
it("should return unchanged string when no replacement characters are present", () => {
removeInvalidChars("normal string").should.equal("normal string")
})
})
+1 -1
View File
@@ -14,5 +14,5 @@
"rootDir": "src"
},
"include": ["src/**/*.test.ts"],
"exclude": ["src/test/**/*.js", "src/**/__tests__/*"]
"exclude": ["src/test/**/*.js"]
}
-8
View File
@@ -1,8 +0,0 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"module": "commonjs"
},
"include": ["test/**/*.ts"],
"exclude": ["node_modules"]
}
+22
View File
@@ -0,0 +1,22 @@
import { defineConfig } from "vitest/config"
import { resolve } from "path"
export default defineConfig({
test: {
globals: true,
environment: "node",
include: ["src/**/*.spec.ts"],
exclude: ["webview-ui/**/*"],
reporters: "verbose",
coverage: {
provider: "v8",
reporter: ["text", "json", "html"],
exclude: ["webview-ui/**/*"],
},
},
resolve: {
alias: {
"@": resolve(__dirname, "./src"),
},
},
})
+1605 -1289
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -60,6 +60,6 @@
"typescript": "^5.7.3",
"typescript-eslint": "^8.18.2",
"vite": "^6.2.6",
"vitest": "^3.0.5"
"vitest": "^3.1.1"
}
}
+16 -22
View File
@@ -29,7 +29,7 @@ const linkContainerStyle: CSSProperties = { margin: "0" }
const linkStyle: CSSProperties = { display: "inline" }
/*
You must update the latestAnnouncementId in ClineProvider for new announcements to show to users. This new id will be compared with what's in state for the 'last announcement shown', and if it's different then the announcement will render. As soon as an announcement is shown, the id will be updated in state. This ensures that announcements are not shown more than once, even if the user doesn't close it themselves.
You must update the latestAnnouncementId in ClineProvider for new announcements to show to users. This new id will be compared with whats in state for the 'last announcement shown', and if it's different then the announcement will render. As soon as an announcement is shown, the id will be updated in state. This ensures that announcements are not shown more than once, even if the user doesn't close it themselves.
*/
const Announcement = ({ version, hideAnnouncement }: AnnouncementProps) => {
const minorVersion = version.split(".").slice(0, 2).join(".") // 2.0.0 -> 2.0
@@ -41,24 +41,6 @@ const Announcement = ({ version, hideAnnouncement }: AnnouncementProps) => {
<h3 style={h3TitleStyle}>
🎉{" "}New in v{minorVersion}
</h3>
<ul style={ulStyle}>
<li>
<b>Global Cline Rules:</b> store multiple rules files in Documents/Cline/Rules to share between projects.
</li>
<li>
<b>Cline Rules Popup:</b> New button in the chat area to view workspace and global cline rules files to plug
and play specific rules for the task
</li>
<li>
<b>Slash Commands:</b> Type <code>/</code> in chat to see the list of quick actions, like starting a new task
(more coming soon!)
</li>
<li>
<b>Edit Messages:</b> You can now edit a message you sent previously by clicking on it. Optionally restore
your project when the message was sent!
</li>
</ul>
<h4 style={{ margin: "5px 0 5px" }}>Previous Updates:</h4>
<ul style={ulStyle}>
<li>
<b>Model Favorites:</b> You can now mark your favorite models when using Cline & OpenRouter providers for
@@ -72,9 +54,21 @@ const Announcement = ({ version, hideAnnouncement }: AnnouncementProps) => {
<b>New Auto-Approve Options:</b> Turn off Cline's ability to read and edit files outside your workspace.
</li>
</ul>
{/*
// Leave this here for an example of how to structure the announcement
<ul style={{ margin: "0 0 8px", paddingLeft: "12px" }}>
<h4 style={{ margin: "5px 0 5px" }}>Previous Updates:</h4>
<ul style={ulStyle}>
<li>
<b>Browser Tool Upgrades:</b> Use your local Chrome browser for session-based browsing, enabling debugging and
productivity workflows tied to your actual browser state.
</li>
<li>
<b>Auto-Approve Commands:</b> New option to automatically approve <b>ALL</b> commands (use at your own risk!)
</li>
<li>
<b>Easily Toggle MCP's:</b> New popover in the chat area to easily enable/disable MCP servers.
</li>
</ul>
{/*<ul style={{ margin: "0 0 8px", paddingLeft: "12px" }}>
<li>
OpenRouter now supports prompt caching! They also have much higher rate limits than other providers,
so I recommend trying them out.
@@ -192,12 +192,7 @@ const BrowserSessionRow = memo((props: BrowserSessionRowProps) => {
// Reset for next page
currentStateMessages = []
nextActionMessages = []
} else if (
message.say === "api_req_started" ||
message.say === "text" ||
message.say === "reasoning" ||
message.say === "browser_action"
) {
} else if (message.say === "api_req_started" || message.say === "text" || message.say === "browser_action") {
// These messages lead to the next result, so they should always go in nextActionMessages
nextActionMessages.push(message)
} else {
@@ -516,7 +511,6 @@ const BrowserSessionRowContent = ({
switch (message.say) {
case "api_req_started":
case "text":
case "reasoning":
return (
<div style={chatRowContentContainerStyle}>
<ChatRowContent
+22 -17
View File
@@ -27,12 +27,11 @@ import McpToolRow from "@/components/mcp/configuration/tabs/installed/server-row
import McpResponseDisplay from "@/components/mcp/chat-display/McpResponseDisplay"
import CreditLimitError from "@/components/chat/CreditLimitError"
import { OptionsButtons } from "@/components/chat/OptionsButtons"
import { highlightText } from "./TaskHeader"
import { highlightMentions } from "./TaskHeader"
import SuccessButton from "@/components/common/SuccessButton"
import TaskFeedbackButtons from "@/components/chat/TaskFeedbackButtons"
import NewTaskPreview from "./NewTaskPreview"
import McpResourceRow from "@/components/mcp/configuration/tabs/installed/server-row/McpResourceRow"
import UserMessage from "./UserMessage"
const ChatRowContainer = styled.div`
padding: 10px 6px 10px 15px;
@@ -51,7 +50,6 @@ interface ChatRowProps {
isLast: boolean
onHeightChange: (isTaller: boolean) => void
inputValue?: string
sendMessageFromChatRow?: (text: string, images: string[]) => void
}
interface ChatRowContentProps extends Omit<ChatRowProps, "onHeightChange"> {}
@@ -127,7 +125,6 @@ export const ChatRowContent = ({
lastModifiedMessage,
isLast,
inputValue,
sendMessageFromChatRow,
}: ChatRowContentProps) => {
const { mcpServers, mcpMarketplaceCatalog } = useExtensionState()
const [seeNewChangesDisabled, setSeeNewChangesDisabled] = useState(false)
@@ -361,7 +358,7 @@ export const ChatRowContent = ({
<>
<div style={headerStyle}>
{toolIcon("edit")}
{tool.operationIsLocatedInWorkspace === false &&
{!tool.operationIsLocatedInWorkspace &&
toolIcon("sign-out", "yellow", -90, "This file is outside of your workspace")}
<span style={{ fontWeight: "bold" }}>Cline wants to edit this file:</span>
</div>
@@ -379,7 +376,7 @@ export const ChatRowContent = ({
<>
<div style={headerStyle}>
{toolIcon("new-file")}
{tool.operationIsLocatedInWorkspace === false &&
{!tool.operationIsLocatedInWorkspace &&
toolIcon("sign-out", "yellow", -90, "This file is outside of your workspace")}
<span style={{ fontWeight: "bold" }}>Cline wants to create a new file:</span>
</div>
@@ -397,7 +394,7 @@ export const ChatRowContent = ({
<>
<div style={headerStyle}>
{toolIcon("file-code")}
{tool.operationIsLocatedInWorkspace === false &&
{!tool.operationIsLocatedInWorkspace &&
toolIcon("sign-out", "yellow", -90, "This file is outside of your workspace")}
<span style={{ fontWeight: "bold" }}>
{/* {message.type === "ask" ? "" : "Cline read this file:"} */}
@@ -457,7 +454,7 @@ export const ChatRowContent = ({
<>
<div style={headerStyle}>
{toolIcon("folder-opened")}
{tool.operationIsLocatedInWorkspace === false &&
{!tool.operationIsLocatedInWorkspace &&
toolIcon("sign-out", "yellow", -90, "This is outside of your workspace")}
<span style={{ fontWeight: "bold" }}>
{message.type === "ask"
@@ -479,7 +476,7 @@ export const ChatRowContent = ({
<>
<div style={headerStyle}>
{toolIcon("folder-opened")}
{tool.operationIsLocatedInWorkspace === false &&
{!tool.operationIsLocatedInWorkspace &&
toolIcon("sign-out", "yellow", -90, "This is outside of your workspace")}
<span style={{ fontWeight: "bold" }}>
{message.type === "ask"
@@ -501,7 +498,7 @@ export const ChatRowContent = ({
<>
<div style={headerStyle}>
{toolIcon("file-code")}
{tool.operationIsLocatedInWorkspace === false &&
{!tool.operationIsLocatedInWorkspace &&
toolIcon("sign-out", "yellow", -90, "This is outside of your workspace")}
<span style={{ fontWeight: "bold" }}>
{message.type === "ask"
@@ -522,7 +519,7 @@ export const ChatRowContent = ({
<>
<div style={headerStyle}>
{toolIcon("search")}
{tool.operationIsLocatedInWorkspace === false &&
{!tool.operationIsLocatedInWorkspace &&
toolIcon("sign-out", "yellow", -90, "This is outside of your workspace")}
<span style={{ fontWeight: "bold" }}>
Cline wants to search this directory for <code>{tool.regex}</code>:
@@ -875,12 +872,20 @@ export const ChatRowContent = ({
)
case "user_feedback":
return (
<UserMessage
text={message.text}
images={message.images}
messageTs={message.ts}
sendMessageFromChatRow={sendMessageFromChatRow}
/>
<div
style={{
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>
{message.images && message.images.length > 0 && (
<Thumbnails images={message.images} style={{ marginTop: "8px" }} />
)}
</div>
)
case "user_feedback_diff":
const tool = JSON.parse(message.text || "{}") as ClineSayTool
+4 -127
View File
@@ -15,13 +15,6 @@ import {
shouldShowContextMenu,
SearchResult,
} from "@/utils/context-mentions"
import {
SlashCommand,
shouldShowSlashCommandsMenu,
getMatchingSlashCommands,
insertSlashCommand,
validateSlashCommand,
} from "@/utils/slash-commands"
import { useMetaKeyDetection, useShortcut } from "@/utils/hooks"
import { validateApiConfiguration, validateModelId } from "@/utils/validate"
import { vscode } from "@/utils/vscode"
@@ -31,10 +24,8 @@ import Tooltip from "@/components/common/Tooltip"
import ApiOptions, { normalizeApiConfiguration } from "@/components/settings/ApiOptions"
import { MAX_IMAGES_PER_MESSAGE } from "@/components/chat/ChatView"
import ContextMenu from "@/components/chat/ContextMenu"
import SlashCommandMenu from "@/components/chat/SlashCommandMenu"
import { ChatSettings } from "@shared/ChatSettings"
import ServersToggleModal from "./ServersToggleModal"
import ClineRulesToggleModal from "../cline-rules/ClineRulesToggleModal"
interface ChatTextAreaProps {
inputValue: string
@@ -237,11 +228,6 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
const [isTextAreaFocused, setIsTextAreaFocused] = useState(false)
const [gitCommits, setGitCommits] = useState<GitCommit[]>([])
const [showSlashCommandsMenu, setShowSlashCommandsMenu] = useState(false)
const [selectedSlashCommandsIndex, setSelectedSlashCommandsIndex] = useState(0)
const [slashCommandsQuery, setSlashCommandsQuery] = useState("")
const slashCommandsMenuContainerRef = useRef<HTMLDivElement>(null)
const [thumbnailsHeight, setThumbnailsHeight] = useState(0)
const [textAreaBaseHeight, setTextAreaBaseHeight] = useState<number | undefined>(undefined)
const [showContextMenu, setShowContextMenu] = useState(false)
@@ -402,62 +388,8 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
[setInputValue, cursorPosition],
)
const handleSlashCommandsSelect = useCallback(
(command: SlashCommand) => {
setShowSlashCommandsMenu(false)
if (textAreaRef.current) {
const { newValue, commandIndex } = insertSlashCommand(textAreaRef.current.value, command.name)
const newCursorPosition = newValue.indexOf(" ", commandIndex + 1 + command.name.length) + 1
setInputValue(newValue)
setCursorPosition(newCursorPosition)
setIntendedCursorPosition(newCursorPosition)
setTimeout(() => {
if (textAreaRef.current) {
textAreaRef.current.blur()
textAreaRef.current.focus()
}
}, 0)
}
},
[setInputValue],
)
const handleKeyDown = useCallback(
(event: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (showSlashCommandsMenu) {
if (event.key === "Escape") {
setShowSlashCommandsMenu(false)
return
}
if (event.key === "ArrowUp" || event.key === "ArrowDown") {
event.preventDefault()
setSelectedSlashCommandsIndex((prevIndex) => {
const direction = event.key === "ArrowUp" ? -1 : 1
const commands = getMatchingSlashCommands(slashCommandsQuery)
if (commands.length === 0) {
return prevIndex
}
const newIndex = (prevIndex + direction + commands.length) % commands.length
return newIndex
})
return
}
if ((event.key === "Enter" || event.key === "Tab") && selectedSlashCommandsIndex !== -1) {
event.preventDefault()
const commands = getMatchingSlashCommands(slashCommandsQuery)
if (commands.length > 0) {
handleSlashCommandsSelect(commands[selectedSlashCommandsIndex])
}
return
}
}
if (showContextMenu) {
if (event.key === "Escape") {
// event.preventDefault()
@@ -609,28 +541,9 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
const newCursorPosition = e.target.selectionStart
setInputValue(newValue)
setCursorPosition(newCursorPosition)
let showMenu = shouldShowContextMenu(newValue, newCursorPosition)
const showSlashCommandsMenu = shouldShowSlashCommandsMenu(newValue, newCursorPosition)
const showMenu = shouldShowContextMenu(newValue, newCursorPosition)
// we do not allow both menus to be shown at the same time
// the slash commands menu has precedence bc its a narrower component
if (showSlashCommandsMenu) {
showMenu = false
}
setShowSlashCommandsMenu(showSlashCommandsMenu)
setShowContextMenu(showMenu)
if (showSlashCommandsMenu) {
const slashIndex = newValue.indexOf("/")
const query = newValue.slice(slashIndex + 1, newCursorPosition)
setSlashCommandsQuery(query)
setSelectedSlashCommandsIndex(0)
} else {
setSlashCommandsQuery("")
setSelectedSlashCommandsIndex(0)
}
if (showMenu) {
const lastAtIndex = newValue.lastIndexOf("@", newCursorPosition - 1)
const query = newValue.slice(lastAtIndex + 1, newCursorPosition)
@@ -677,7 +590,6 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
// Only hide the context menu if the user didn't click on it
if (!isMouseDownOnMenu) {
setShowContextMenu(false)
setShowSlashCommandsMenu(false)
}
setIsTextAreaFocused(false)
}, [isMouseDownOnMenu])
@@ -769,35 +681,13 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
const updateHighlights = useCallback(() => {
if (!textAreaRef.current || !highlightLayerRef.current) return
let processedText = textAreaRef.current.value
const text = textAreaRef.current.value
processedText = processedText
highlightLayerRef.current.innerHTML = text
.replace(/\n$/, "\n\n")
.replace(/[<>&]/g, (c) => ({ "<": "&lt;", ">": "&gt;", "&": "&amp;" })[c] || c)
// highlight @mentions
.replace(mentionRegexGlobal, '<mark class="mention-context-textarea-highlight">$&</mark>')
// check for highlighting /slash-commands
if (/^\s*\//.test(processedText)) {
const slashIndex = processedText.indexOf("/")
// end of command is end of text or first whitespace
const spaceIndex = processedText.indexOf(" ", slashIndex)
const endIndex = spaceIndex > -1 ? spaceIndex : processedText.length
// extract and validate the exact command text
const commandText = processedText.substring(slashIndex + 1, endIndex)
const isValidCommand = validateSlashCommand(commandText)
if (isValidCommand) {
const fullCommand = processedText.substring(slashIndex, endIndex) // includes slash
const highlighted = `<mark class="slash-command-match-textarea-highlight">${fullCommand}</mark>`
processedText = processedText.substring(0, slashIndex) + highlighted + processedText.substring(endIndex)
}
}
highlightLayerRef.current.innerHTML = processedText
highlightLayerRef.current.scrollTop = textAreaRef.current.scrollTop
highlightLayerRef.current.scrollLeft = textAreaRef.current.scrollLeft
}, [])
@@ -1121,18 +1011,6 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
}}
onDrop={onDrop}
onDragOver={onDragOver}>
{showSlashCommandsMenu && (
<div ref={slashCommandsMenuContainerRef}>
<SlashCommandMenu
onSelect={handleSlashCommandsSelect}
selectedIndex={selectedSlashCommandsIndex}
setSelectedIndex={setSelectedSlashCommandsIndex}
onMouseDown={handleMenuMouseDown}
query={slashCommandsQuery}
/>
</div>
)}
{showContextMenu && (
<div ref={contextMenuContainerRef}>
<ContextMenu
@@ -1275,7 +1153,7 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
display: "flex",
alignItems: "flex-center",
height: textAreaBaseHeight || 31,
bottom: 9.5, // should be 10 but doesn't look good on mac
bottom: 9.5, // should be 10 but doesnt look good on mac
zIndex: 2,
}}>
<div
@@ -1347,7 +1225,6 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
</ButtonContainer>
</VSCodeButton>
<ServersToggleModal />
<ClineRulesToggleModal />
<ModelContainer ref={modelSelectorRef}>
<ModelButtonWrapper ref={buttonRef}>
@@ -575,7 +575,6 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
"browser_action",
"browser_action_result",
"checkpoint_created",
"reasoning",
].includes(message.say!)
}
return false
@@ -798,7 +797,6 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
isLast={index === groupedMessages.length - 1}
onHeightChange={handleRowHeightChange}
inputValue={inputValue}
sendMessageFromChatRow={handleSendMessage}
/>
)
},
@@ -74,7 +74,7 @@ const ServersToggleModal: React.FC = () => {
/>
<div className="flex justify-between items-center mb-2.5">
<div className="m-0 text-base font-semibold">MCP Servers</div>
<div className="m-0">MCP Servers</div>
<VSCodeButton
appearance="icon"
onClick={() => {
@@ -1,80 +0,0 @@
import React, { useCallback, useRef, useEffect } from "react"
import { SlashCommand, getMatchingSlashCommands } from "@/utils/slash-commands"
interface SlashCommandMenuProps {
onSelect: (command: SlashCommand) => void
selectedIndex: number
setSelectedIndex: (index: number) => void
onMouseDown: () => void
query: string
}
const SlashCommandMenu: React.FC<SlashCommandMenuProps> = ({ onSelect, selectedIndex, setSelectedIndex, onMouseDown, query }) => {
const menuRef = useRef<HTMLDivElement>(null)
const handleClick = useCallback(
(command: SlashCommand) => {
onSelect(command)
},
[onSelect],
)
// Auto-scroll logic remains the same...
useEffect(() => {
if (menuRef.current) {
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
} else if (selectedRect.top < menuRect.top) {
menuRef.current.scrollTop -= menuRect.top - selectedRect.top
}
}
}
}, [selectedIndex])
// Filter commands based on query
const filteredCommands = getMatchingSlashCommands(query)
return (
<div
className="absolute bottom-[calc(100%-10px)] left-[15px] right-[15px] overflow-x-hidden z-[1000]"
onMouseDown={onMouseDown}>
<div
ref={menuRef}
className="bg-[var(--vscode-dropdown-background)] border border-[var(--vscode-editorGroup-border)] rounded-[3px] shadow-[0_4px_10px_rgba(0,0,0,0.25)] flex flex-col max-h-[200px] overflow-y-auto" // Corrected rounded and shadow
>
{filteredCommands.length > 0 ? (
filteredCommands.map((command, index) => (
<div
key={command.name}
className={`py-2 px-3 cursor-pointer flex flex-col border-b border-[var(--vscode-editorGroup-border)] ${
// Corrected padding
index === selectedIndex
? "bg-[var(--vscode-quickInputList-focusBackground)] text-[var(--vscode-quickInputList-focusForeground)]"
: "" // Removed bg-transparent
} hover:bg-[var(--vscode-list-hoverBackground)]`}
onClick={() => handleClick(command)}
onMouseEnter={() => setSelectedIndex(index)}>
<div className="font-bold whitespace-nowrap overflow-hidden text-ellipsis">/{command.name}</div>
<div className="text-[0.85em] text-[var(--vscode-descriptionForeground)] whitespace-normal overflow-hidden text-ellipsis">
{command.description}
</div>
</div>
))
) : (
<div className="py-2 px-3 cursor-default flex flex-col">
{" "}
{/* Corrected padding, removed border, changed cursor */}
<div className="text-[0.85em] text-[var(--vscode-descriptionForeground)]">No matching commands found</div>
</div>
)}
</div>
</div>
)
}
export default SlashCommandMenu
+4 -61
View File
@@ -9,7 +9,6 @@ import { formatSize } from "@/utils/format"
import { vscode } from "@/utils/vscode"
import Thumbnails from "@/components/common/Thumbnails"
import { normalizeApiConfiguration } from "@/components/settings/ApiOptions"
import { validateSlashCommand } from "@/utils/slash-commands"
interface TaskHeaderProps {
task: ClineMessage
@@ -255,7 +254,7 @@ const TaskHeader: React.FC<TaskHeaderProps> = ({
Task
{!isTaskExpanded && ":"}
</span>
{!isTaskExpanded && <span style={{ marginLeft: 4 }}>{highlightText(task.text, false)}</span>}
{!isTaskExpanded && <span style={{ marginLeft: 4 }}>{highlightMentions(task.text, false)}</span>}
</div>
</div>
{!isTaskExpanded && isCostAvailable && (
@@ -301,7 +300,7 @@ const TaskHeader: React.FC<TaskHeaderProps> = ({
wordBreak: "break-word",
overflowWrap: "anywhere",
}}>
{highlightText(task.text, false)}
{highlightMentions(task.text, false)}
</div>
{!isTextExpanded && showSeeMore && (
<div
@@ -555,41 +554,9 @@ const TaskHeader: React.FC<TaskHeaderProps> = ({
)
}
/**
* Highlights slash-command in this text if it exists
*/
const highlightSlashCommands = (text: string, withShadow = true) => {
const match = text.match(/^\s*\/([a-zA-Z0-9_-]+)(\s*|$)/)
if (!match) {
return text
}
const commandName = match[1]
const validationResult = validateSlashCommand(commandName)
if (!validationResult || validationResult !== "full") {
return text
}
const commandEndIndex = match[0].length
const beforeCommand = text.substring(0, text.indexOf("/"))
const afterCommand = match[2] + text.substring(commandEndIndex)
return [
beforeCommand,
<span key="slashCommand" className={withShadow ? "mention-context-highlight-with-shadow" : "mention-context-highlight"}>
/{commandName}
</span>,
afterCommand,
]
}
/**
* Highlights & formats all mentions inside this text
*/
export const highlightMentions = (text: string, withShadow = true) => {
export const highlightMentions = (text?: string, withShadow = true) => {
if (!text) return text
const parts = text.split(mentionRegexGlobal)
return parts.map((part, index) => {
if (index % 2 === 0) {
// This is regular text
@@ -609,30 +576,6 @@ export const highlightMentions = (text: string, withShadow = true) => {
})
}
/**
* Handles parsing both mentions and slash-commands
*/
export const highlightText = (text?: string, withShadow = true) => {
if (!text) {
return text
}
const resultWithSlashHighlighting = highlightSlashCommands(text, withShadow)
if (resultWithSlashHighlighting === text) {
// no highlighting done
return highlightMentions(resultWithSlashHighlighting, withShadow)
}
if (Array.isArray(resultWithSlashHighlighting) && resultWithSlashHighlighting.length === 3) {
const [beforeCommand, commandElement, afterCommand] = resultWithSlashHighlighting as [string, JSX.Element, string]
return [beforeCommand, commandElement, ...highlightMentions(afterCommand, withShadow)]
}
return [text]
}
const DeleteButton: React.FC<{
taskSize: string
taskId?: string
@@ -1,185 +0,0 @@
import React, { useState, useRef, forwardRef, useCallback } from "react"
import Thumbnails from "@/components/common/Thumbnails"
import { highlightText } from "./TaskHeader"
import { vscode } from "@/utils/vscode"
import DynamicTextArea from "react-textarea-autosize"
import { useExtensionState } from "@/context/ExtensionStateContext"
interface UserMessageProps {
text?: string
images?: string[]
messageTs?: number // Timestamp for the message, needed for checkpoint restore
sendMessageFromChatRow?: (text: string, images: string[]) => void
}
const UserMessage: React.FC<UserMessageProps> = ({ text, images, messageTs, sendMessageFromChatRow }) => {
const [isEditing, setIsEditing] = useState(false)
const [editedText, setEditedText] = useState(text || "")
const textAreaRef = useRef<HTMLTextAreaElement>(null)
const { checkpointTrackerErrorMessage } = useExtensionState()
// Create refs for the buttons to check in the blur handler
const restoreAllButtonRef = useRef<HTMLButtonElement>(null)
const restoreChatButtonRef = useRef<HTMLButtonElement>(null)
const handleClick = () => {
if (!isEditing) {
setIsEditing(true)
}
}
// Select all text when entering edit mode
React.useEffect(() => {
if (isEditing && textAreaRef.current) {
textAreaRef.current.select()
}
}, [isEditing])
const handleRestoreWorkspace = (type: string) => {
const delay = type === "task" ? 500 : 1000 // Delay for task and workspace restore
setIsEditing(false)
if (text === editedText) {
return
}
vscode.postMessage({
type: "checkpointRestore",
number: messageTs,
text: type,
offset: 1,
})
setTimeout(() => {
sendMessageFromChatRow?.(editedText, images || [])
}, delay)
}
const handleBlur = (e: React.FocusEvent<HTMLTextAreaElement>) => {
// Check if focus is moving to one of our button elements
if (e.relatedTarget === restoreAllButtonRef.current || e.relatedTarget === restoreChatButtonRef.current) {
// Don't close edit mode if focus is moving to one of our buttons
return
}
// Otherwise, close edit mode
setIsEditing(false)
}
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === "Escape") {
setIsEditing(false)
} else if (e.key === "Enter" && e.metaKey && !checkpointTrackerErrorMessage) {
handleRestoreWorkspace("taskAndWorkspace")
} else if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault()
handleRestoreWorkspace("task")
}
}
return (
<div
style={{
backgroundColor: isEditing ? "unset" : "var(--vscode-badge-background)",
color: "var(--vscode-badge-foreground)",
borderRadius: "3px",
padding: "9px",
whiteSpace: "pre-line",
wordWrap: "break-word",
}}
onClick={handleClick}>
{isEditing ? (
<>
<DynamicTextArea
ref={textAreaRef}
value={editedText}
onChange={(e) => setEditedText(e.target.value)}
onBlur={(e) => handleBlur(e)}
onKeyDown={handleKeyDown}
autoFocus
style={{
width: "100%",
backgroundColor: "var(--vscode-input-background)",
color: "var(--vscode-input-foreground)",
borderColor: "var(--vscode-input-border)",
border: "1px solid",
borderRadius: "2px",
padding: "6px",
fontFamily: "inherit",
fontSize: "inherit",
lineHeight: "inherit",
boxSizing: "border-box",
resize: "none",
overflowX: "hidden",
overflowY: "scroll",
scrollbarWidth: "none",
}}
/>
<div style={{ display: "flex", gap: "8px", marginTop: "8px", justifyContent: "flex-end" }}>
{!checkpointTrackerErrorMessage && (
<RestoreButton
ref={restoreAllButtonRef}
type="taskAndWorkspace"
label="Restore All"
isPrimary={false}
onClick={handleRestoreWorkspace}
title="Restore both the chat and workspace files to this checkpoint and send your edited message"
/>
)}
<RestoreButton
ref={restoreChatButtonRef}
type="task"
label="Restore Chat"
isPrimary={true}
onClick={handleRestoreWorkspace}
title="Restore just the chat to this checkpoint and send your edited message"
/>
</div>
</>
) : (
<span style={{ display: "block" }}>{highlightText(editedText || text)}</span>
)}
{images && images.length > 0 && <Thumbnails images={images} style={{ marginTop: "8px" }} />}
</div>
)
}
// Reusable button component for restore actions
interface RestoreButtonProps {
type: string
label: string
isPrimary: boolean
onClick: (type: string) => void
title?: string
}
const RestoreButton = forwardRef<HTMLButtonElement, RestoreButtonProps>(({ type, label, isPrimary, onClick, title }, ref) => {
const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation()
onClick(type)
}
return (
<button
ref={ref}
onClick={handleClick}
title={title}
style={{
backgroundColor: isPrimary
? "var(--vscode-button-background)"
: "var(--vscode-button-secondaryBackground, var(--vscode-descriptionForeground))",
color: isPrimary
? "var(--vscode-button-foreground)"
: "var(--vscode-button-secondaryForeground, var(--vscode-foreground))",
border: "none",
padding: "4px 8px",
borderRadius: "2px",
fontSize: "9px",
cursor: "pointer",
}}>
{label}
</button>
)
})
export default UserMessage
@@ -1,133 +0,0 @@
import React, { useRef, useState, useEffect } from "react"
import { useClickAway, useWindowSize } from "react-use"
import { useExtensionState } from "@/context/ExtensionStateContext"
import { CODE_BLOCK_BG_COLOR } from "@/components/common/CodeBlock"
import { vscode } from "@/utils/vscode"
import { VSCodeButton } from "@vscode/webview-ui-toolkit/react"
import RulesToggleList from "./RulesToggleList"
const ClineRulesToggleModal: React.FC = () => {
const { globalClineRulesToggles = {}, localClineRulesToggles = {} } = useExtensionState()
const [isVisible, setIsVisible] = useState(false)
const buttonRef = useRef<HTMLDivElement>(null)
const modalRef = useRef<HTMLDivElement>(null)
const { width: viewportWidth, height: viewportHeight } = useWindowSize()
const [arrowPosition, setArrowPosition] = useState(0)
const [menuPosition, setMenuPosition] = useState(0)
useEffect(() => {
if (isVisible) {
vscode.postMessage({ type: "refreshClineRules" })
}
}, [isVisible])
// Format global rules for display with proper typing
const globalRules = Object.entries(globalClineRulesToggles || {})
.map(([path, enabled]): [string, boolean] => [path, enabled as boolean])
.sort(([a], [b]) => a.localeCompare(b))
// Format local rules for display with proper typing
const localRules = Object.entries(localClineRulesToggles || {})
.map(([path, enabled]): [string, boolean] => [path, enabled as boolean])
.sort(([a], [b]) => a.localeCompare(b))
// Handle toggle rule
const toggleRule = (isGlobal: boolean, rulePath: string, enabled: boolean) => {
vscode.postMessage({
type: "toggleClineRule",
isGlobal,
rulePath,
enabled,
})
}
// Close modal when clicking outside
useClickAway(modalRef, () => {
setIsVisible(false)
})
// Calculate positions for modal and arrow
useEffect(() => {
if (isVisible && buttonRef.current) {
const buttonRect = buttonRef.current.getBoundingClientRect()
const buttonCenter = buttonRect.left + buttonRect.width / 2
const rightPosition = document.documentElement.clientWidth - buttonCenter - 5
setArrowPosition(rightPosition)
setMenuPosition(buttonRect.top + 1)
}
}, [isVisible, viewportWidth, viewportHeight])
return (
<div ref={modalRef}>
<div ref={buttonRef} className="inline-flex min-w-0 max-w-full">
<VSCodeButton
appearance="icon"
aria-label="Cline Rules"
onClick={() => setIsVisible(!isVisible)}
style={{ padding: "0px 0px", height: "20px" }}>
<div className="flex items-center gap-1 text-xs whitespace-nowrap min-w-0 w-full">
<span className="codicon codicon-law flex items-center" style={{ fontSize: "12.5px", marginBottom: 1 }} />
</div>
</VSCodeButton>
</div>
{isVisible && (
<div
className="fixed left-[15px] right-[15px] border border-[var(--vscode-editorGroup-border)] p-3 rounded z-[1000] overflow-y-auto"
style={{
bottom: `calc(100vh - ${menuPosition}px + 6px)`,
background: CODE_BLOCK_BG_COLOR,
maxHeight: "calc(100vh - 100px)",
overscrollBehavior: "contain",
}}>
<div
className="fixed w-[10px] h-[10px] z-[-1] rotate-45 border-r border-b border-[var(--vscode-editorGroup-border)]"
style={{
bottom: `calc(100vh - ${menuPosition}px)`,
right: arrowPosition,
background: CODE_BLOCK_BG_COLOR,
}}
/>
<div className="flex justify-between items-center mb-2.5">
<div className="m-0 text-base font-semibold">Cline Rules</div>
<VSCodeButton
appearance="icon"
onClick={() => {
vscode.postMessage({
type: "openExtensionSettings",
})
setIsVisible(false)
}}></VSCodeButton>
</div>
{/* Global Rules Section */}
<div className="mb-3">
<div className="text-sm font-normal mb-2">Global Rules</div>
<RulesToggleList
rules={globalRules}
toggleRule={(rulePath, enabled) => toggleRule(true, rulePath, enabled)}
listGap="small"
isGlobal={true}
/>
</div>
{/* Local Rules Section */}
<div style={{ marginBottom: -10 }}>
<div className="text-sm font-normal mb-2">Workspace Rules</div>
<RulesToggleList
rules={localRules}
toggleRule={(rulePath, enabled) => toggleRule(false, rulePath, enabled)}
listGap="small"
isGlobal={false}
/>
</div>
</div>
)}
</div>
)
}
export default ClineRulesToggleModal
@@ -1,136 +0,0 @@
import { useState, useRef, useEffect } from "react"
import { vscode } from "@/utils/vscode"
import { VSCodeButton } from "@vscode/webview-ui-toolkit/react"
interface NewRuleRowProps {
isGlobal: boolean // To determine where to create the file
}
const NewRuleRow: React.FC<NewRuleRowProps> = ({ isGlobal }) => {
const [isExpanded, setIsExpanded] = useState(false)
const [filename, setFilename] = useState("")
const inputRef = useRef<HTMLInputElement>(null)
const [error, setError] = useState<string | null>(null)
// Focus the input when expanded
useEffect(() => {
if (isExpanded && inputRef.current) {
inputRef.current.focus()
}
}, [isExpanded])
const getExtension = (filename: string): string => {
if (filename.startsWith(".") && !filename.includes(".", 1)) return ""
const match = filename.match(/\.[^.]+$/)
return match ? match[0].toLowerCase() : ""
}
const isValidExtension = (ext: string): boolean => {
// Valid if it's empty (no extension) or .md or .txt
return ext === "" || ext === ".md" || ext === ".txt"
}
const handleCreateRule = () => {
if (filename.trim()) {
const trimmedFilename = filename.trim()
const extension = getExtension(trimmedFilename)
if (!isValidExtension(extension)) {
setError("Only .md, .txt, or no file extension allowed")
return
}
let finalFilename = trimmedFilename
if (extension === "") {
finalFilename = `${trimmedFilename}.md`
}
vscode.postMessage({
type: "createRuleFile",
isGlobal,
filename: finalFilename,
})
setFilename("")
setError(null)
setIsExpanded(false)
}
}
const handleBlur = () => {
setIsExpanded(false)
setError(null)
setFilename("")
}
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter") {
handleCreateRule()
} else if (e.key === "Escape") {
setIsExpanded(false)
setFilename("")
}
}
return (
<div
className={`mb-2.5 transition-all duration-300 ease-in-out ${isExpanded ? "opacity-100" : "opacity-70 hover:opacity-100"}`}
onClick={() => !isExpanded && setIsExpanded(true)}>
<div
className={`flex items-center p-2 rounded bg-[var(--vscode-input-background)] transition-all duration-300 ease-in-out h-[18px] ${
isExpanded ? "shadow-sm" : ""
}`}>
{isExpanded ? (
<>
<input
ref={inputRef}
type="text"
placeholder="rule-name (.md, .txt, or no extension)"
value={filename}
onChange={(e) => setFilename(e.target.value)}
onBlur={handleBlur}
onKeyDown={handleKeyDown}
className="flex-1 bg-[var(--vscode-input-background)] text-[var(--vscode-input-foreground)] border-0 outline-0 rounded focus:outline-none focus:ring-0 focus:border-transparent"
style={{
outline: "none",
}}
/>
<div className="flex items-center ml-2 space-x-2">
<VSCodeButton
appearance="icon"
aria-label="Create rule file"
title="Create rule file"
onClick={handleCreateRule}
style={{ padding: "0px" }}>
<span className="codicon codicon-add text-[14px]" />
</VSCodeButton>
</div>
</>
) : (
<>
<span className="flex-1 text-[var(--vscode-descriptionForeground)] bg-[var(--vscode-input-background)] italic text-xs">
New rule file...
</span>
<div className="flex items-center ml-2 space-x-2">
<VSCodeButton
appearance="icon"
aria-label="New rule file"
title="New rule file"
onClick={(e) => {
e.stopPropagation()
setIsExpanded(true)
}}
style={{ padding: "0px" }}>
<span className="codicon codicon-add text-[14px]" />
</VSCodeButton>
</div>
</>
)}
</div>
{isExpanded && error && <div className="text-[var(--vscode-errorForeground)] text-xs mt-1 ml-2">{error}</div>}
</div>
)
}
export default NewRuleRow
@@ -1,84 +0,0 @@
import { vscode } from "@/utils/vscode"
import { VSCodeButton } from "@vscode/webview-ui-toolkit/react"
const RuleRow: React.FC<{
rulePath: string
enabled: boolean
isGlobal: boolean
toggleRule: (rulePath: string, enabled: boolean) => void
}> = ({ rulePath, enabled, isGlobal, toggleRule }) => {
// Get the filename from the path for display
const displayName = rulePath.split("/").pop() || rulePath
const handleEditClick = () => {
vscode.postMessage({
type: "openFile",
text: rulePath,
})
}
const handleDeleteClick = () => {
vscode.postMessage({
type: "deleteClineRule",
rulePath: rulePath,
isGlobal: isGlobal,
})
}
return (
<div className="mb-2.5">
<div
className={`flex items-center p-2 rounded bg-[var(--vscode-textCodeBlock-background)] h-[18px] ${
enabled ? "opacity-100" : "opacity-60"
}`}>
<span className="flex-1 overflow-hidden break-all whitespace-normal flex items-center mr-1" title={rulePath}>
{displayName}
</span>
{/* Toggle Switch */}
<div className="flex items-center ml-2 space-x-2">
<div
role="switch"
aria-checked={enabled}
tabIndex={0}
className={`w-[20px] h-[10px] rounded-[5px] relative cursor-pointer transition-colors duration-200 ${
enabled
? "bg-[var(--vscode-testing-iconPassed)] opacity-90"
: "bg-[var(--vscode-titleBar-inactiveForeground)] opacity-50"
}`}
onClick={() => toggleRule(rulePath, !enabled)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault()
toggleRule(rulePath, !enabled)
}
}}>
<div
className={`w-[6px] h-[6px] bg-white border border-[#66666699] rounded-full absolute top-[1px] transition-all duration-200 ${
enabled ? "left-[12px]" : "left-[2px]"
}`}
/>
</div>
<VSCodeButton
appearance="icon"
aria-label="Edit rule file"
title="Edit rule file"
onClick={handleEditClick}
style={{ height: "20px" }}>
<span className="codicon codicon-edit" style={{ fontSize: "14px" }} />
</VSCodeButton>
<VSCodeButton
appearance="icon"
aria-label="Delete rule file"
title="Delete rule file"
onClick={handleDeleteClick}
style={{ height: "20px" }}>
<span className="codicon codicon-trash" style={{ fontSize: "14px" }} />
</VSCodeButton>
</div>
</div>
</div>
)
}
export default RuleRow
@@ -1,50 +0,0 @@
import NewRuleRow from "./NewRuleRow"
import RuleRow from "./RuleRow"
const RulesToggleList = ({
rules,
toggleRule,
listGap = "medium",
isGlobal,
}: {
rules: [string, boolean][]
toggleRule: (rulePath: string, enabled: boolean) => void
listGap?: "small" | "medium" | "large"
isGlobal: boolean
}) => {
const gapClasses = {
small: "gap-0",
medium: "gap-2.5",
large: "gap-5",
}
const gapClass = gapClasses[listGap]
return (
<div className={`flex flex-col ${gapClass}`}>
{rules.length > 0 ? (
<>
{rules.map(([rulePath, enabled]) => (
<RuleRow
key={rulePath}
rulePath={rulePath}
enabled={enabled}
isGlobal={isGlobal}
toggleRule={toggleRule}
/>
))}
<NewRuleRow isGlobal={isGlobal} />
</>
) : (
<>
<div className="flex flex-col items-center gap-3 my-3 text-[var(--vscode-descriptionForeground)]">
No rules found
</div>
<NewRuleRow isGlobal={isGlobal} />
</>
)}
</div>
)
}
export default RulesToggleList
@@ -162,7 +162,7 @@ const McpResponseDisplay: React.FC<McpResponseDisplayProps> = ({ responseText })
try {
const text = responseText || ""
const matches: UrlMatch[] = []
const urlRegex = /(?:https?:\/\/|data:image)[^\s<>"']+/g
const urlRegex = /https?:\/\/[^\s<>"']+/g
let urlMatch: RegExpExecArray | null
let urlCount = 0
@@ -1,5 +1,4 @@
import {
VSCodeButton,
VSCodeCheckbox,
VSCodeDropdown,
VSCodeLink,
@@ -98,7 +97,6 @@ const ApiOptions = ({ showModelOptions, apiErrorMessage, modelIdErrorMessage, is
const [lmStudioModels, setLmStudioModels] = useState<string[]>([])
const [vsCodeLmModels, setVsCodeLmModels] = useState<vscodemodels.LanguageModelChatSelector[]>([])
const [anthropicBaseUrlSelected, setAnthropicBaseUrlSelected] = useState(!!apiConfiguration?.anthropicBaseUrl)
const [geminiBaseUrlSelected, setGeminiBaseUrlSelected] = useState(!!apiConfiguration?.geminiBaseUrl)
const [azureApiVersionSelected, setAzureApiVersionSelected] = useState(!!apiConfiguration?.azureApiVersion)
const [awsEndpointSelected, setAwsEndpointSelected] = useState(!!apiConfiguration?.awsBedrockEndpoint)
const [modelConfigurationSelected, setModelConfigurationSelected] = useState(false)
@@ -116,7 +114,7 @@ const ApiOptions = ({ showModelOptions, apiErrorMessage, modelIdErrorMessage, is
})
// If the field is the provider, save it immediately
// Necessary for favorite model selection to work without undoing provider changes
// Neccesary for favorite model selection to work without undoing provider changes
if (field === "apiProvider") {
vscode.postMessage({
type: "apiConfiguration",
@@ -780,32 +778,6 @@ const ApiOptions = ({ showModelOptions, apiErrorMessage, modelIdErrorMessage, is
placeholder="Enter API Key...">
<span style={{ fontWeight: 500 }}>Gemini API Key</span>
</VSCodeTextField>
<VSCodeCheckbox
checked={geminiBaseUrlSelected}
onChange={(e: any) => {
const isChecked = e.target.checked === true
setGeminiBaseUrlSelected(isChecked)
if (!isChecked) {
setApiConfiguration({
...apiConfiguration,
geminiBaseUrl: "",
})
}
}}>
Use custom base URL
</VSCodeCheckbox>
{geminiBaseUrlSelected && (
<VSCodeTextField
value={apiConfiguration?.geminiBaseUrl || ""}
style={{ width: "100%", marginTop: 3 }}
type="url"
onInput={handleInputChange("geminiBaseUrl")}
placeholder="Default: https://generativelanguage.googleapis.com"
/>
)}
<p
style={{
fontSize: "12px",
@@ -831,7 +803,7 @@ const ApiOptions = ({ showModelOptions, apiErrorMessage, modelIdErrorMessage, is
<div>
<VSCodeTextField
value={apiConfiguration?.openAiBaseUrl || ""}
style={{ width: "100%", marginBottom: 10 }}
style={{ width: "100%" }}
type="url"
onInput={handleInputChange("openAiBaseUrl")}
placeholder={"Enter base URL..."}>
@@ -839,7 +811,7 @@ const ApiOptions = ({ showModelOptions, apiErrorMessage, modelIdErrorMessage, is
</VSCodeTextField>
<VSCodeTextField
value={apiConfiguration?.openAiApiKey || ""}
style={{ width: "100%", marginBottom: 10 }}
style={{ width: "100%" }}
type="password"
onInput={handleInputChange("openAiApiKey")}
placeholder="Enter API Key...">
@@ -847,91 +819,11 @@ const ApiOptions = ({ showModelOptions, apiErrorMessage, modelIdErrorMessage, is
</VSCodeTextField>
<VSCodeTextField
value={apiConfiguration?.openAiModelId || ""}
style={{ width: "100%", marginBottom: 10 }}
style={{ width: "100%" }}
onInput={handleInputChange("openAiModelId")}
placeholder={"Enter Model ID..."}>
<span style={{ fontWeight: 500 }}>Model ID</span>
</VSCodeTextField>
{/* OpenAI Compatible Custom Headers */}
{(() => {
const headerEntries = Object.entries(apiConfiguration?.openAiHeaders ?? {})
return (
<div style={{ marginBottom: 10 }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<span style={{ fontWeight: 500 }}>Custom Headers</span>
<VSCodeButton
onClick={() => {
const currentHeaders = { ...(apiConfiguration?.openAiHeaders || {}) }
const headerCount = Object.keys(currentHeaders).length
const newKey = `header${headerCount + 1}`
currentHeaders[newKey] = ""
handleInputChange("openAiHeaders")({
target: {
value: currentHeaders,
},
})
}}>
Add Header
</VSCodeButton>
</div>
<div>
{headerEntries.map(([key, value], index) => (
<div key={index} style={{ display: "flex", gap: 5, marginTop: 5 }}>
<VSCodeTextField
value={key}
style={{ width: "40%" }}
placeholder="Header name"
onInput={(e: any) => {
const currentHeaders = apiConfiguration?.openAiHeaders ?? {}
const newValue = e.target.value
if (newValue && newValue !== key) {
const { [key]: _, ...rest } = currentHeaders
handleInputChange("openAiHeaders")({
target: {
value: {
...rest,
[newValue]: value,
},
},
})
}
}}
/>
<VSCodeTextField
value={value}
style={{ width: "40%" }}
placeholder="Header value"
onInput={(e: any) => {
handleInputChange("openAiHeaders")({
target: {
value: {
...(apiConfiguration?.openAiHeaders ?? {}),
[key]: e.target.value,
},
},
})
}}
/>
<VSCodeButton
appearance="secondary"
onClick={() => {
const { [key]: _, ...rest } = apiConfiguration?.openAiHeaders ?? {}
handleInputChange("openAiHeaders")({
target: {
value: rest,
},
})
}}>
Remove
</VSCodeButton>
</div>
))}
</div>
</div>
)
})()}
<VSCodeCheckbox
checked={azureApiVersionSelected}
onChange={(e: any) => {

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