Compare commits

...
Author SHA1 Message Date
Saoud Rizwan 703a0fc334 Create happy-lies-dress.md 2025-04-19 01:49:21 -07:00
Saoud Rizwan 30459086b7 Fix task cancellation handling to prevent errors during reasoning message streaming 2025-04-19 01:47:56 -07:00
github-actions[bot] 07d2057486 v3.13.0 Release Notes
v3.13.0 Release Notes
2025-04-19 00:47:49 -07:00
Toshii 3b0326e4dd comma (#2983)
* comma

* changeset
2025-04-18 19:36:15 -07:00
Toshii 32c70e59f4 slash new_task (#2959)
* base

* format

* test base

* new model

* menu base

* highlights

* nits

* menu wrap

* consider cursor

* cursor position

* color

* spacing

* highlighting boxes

* styles

* formatting new call

* rm

* changeset

* css styles

* format
2025-04-18 16:43:58 -07:00
canvrno e52dd22b65 fix flicker on external files icon (#2977) 2025-04-18 16:42:42 -07:00
canvrno 1022057316 removed symlink handling from isLocatedInWorkspace (#2974) 2025-04-18 16:40:22 -07:00
Evan 487081f128 MOAR RULES (#2973)
* add create new rule row to modal

* changeset

* fix missing boolean check

* fix merge issues causing duplicates

* remove commented out code

* update placeholder

* tighten validation
2025-04-18 16:39:57 -07:00
Khalil Yao 9a39cbd475 Doc/cn readme update (#2759)
* doc: update zn-cn readme.

* doc: update zh-tw readme.

* doc: update zn-cn readme.

* doc: update zh-tw readme.
2025-04-18 16:38:27 -07:00
570646fda3 MCP Image: Support image type messages / base64 text (#2962)
* feat: MCP ImageContent support

* feat: MCP ImageContent support

* feat: MCP ImageContent support changeset

* Update src/core/prompts/responses.ts

Co-authored-by: Dennis Bartlett <bartlett.dc.1@gmail.com>

* Update src/core/Cline.ts

Co-authored-by: Dennis Bartlett <bartlett.dc.1@gmail.com>

* feat: MCP ImageContent support run format:fix

* Remove mcpToolResult

* Fix: Display original data:image URLs in rich display mode to maintain transparency

---------

Co-authored-by: rikaaa0928 <wangzhidong1@xiaomi.com>
Co-authored-by: rikaaa0928 <8528731+rikaaa0928@users.noreply.github.com>
Co-authored-by: Dennis Bartlett <bartlett.dc.1@gmail.com>
Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
Co-authored-by: Andrei Edell <andrei@nugbase.com>
2025-04-18 16:33:40 -07:00
owengoandOlivier Schiavo e8b21690ab Add baseUrl configuration for gemini api requests (#2843)
* Add baseUrl configuration for gemini api requests

* Add changeset

---------

Co-authored-by: Olivier Schiavo <olivier.schiavo@wengo.com>
2025-04-18 16:29:41 -07:00
起司猫 3ef81cdf38 fix: Refactor the function constructNewFileContent using a state switching mechanism, and fix the issue of inaccurate SEARCH-REPLACE delimiters generated by some large models through lookahead processing (#2334)
* Fix the chat context menu removing UTF8 characters causing pure UTF8 character filenames not to display in the menu

* fix: Refactor the function constructNewFileContent using a state switching mechanism, and fix the issue of inaccurate SEARCH-REPLACE delimiters generated by some large models through lookahead processing

* Merge diff.ts with diff2.ts; Mark the original constructNewFileContent as @deprecated.

* Add detailed comments to explain test cases for nested markers
2025-04-18 16:26:27 -07:00
suntpandellipsis-dev[bot] b5f4460db3 fix: Non-error logs from the MCP server are also output as error logs, causing abnormal server display. (#2900)
* fix: Non-error logs from the MCP server are also output as error logs, causing abnormal server display.(#2589)

* Modified to make 'error' case-insensitive.(#2589)

Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>

* fix: use Prettier code style

---------

Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>
2025-04-18 14:52:56 -07:00
yt3trees 9a5a0e15b1 Add support for Azure's DeepSeek model. (#1808)
* Fixed to be able to use DeepSeek model in Azure.

* fix

* fix .changeset

* fix src\api\providers\deepseek.ts

* fix src\api\providers\openai.ts

* Fixed to be able to use DeepSeek model in Azure.

* fix

* fix .changeset

* fix src\api\providers\deepseek.ts

* fix src\api\providers\openai.ts

* fix package-lock.json

* Revert "fix package-lock.json"

This reverts commit dc52e97057.

* fix

* fix
2025-04-18 14:17:31 -07:00
6abf0be8d1 Allow setting extra headers for openai compatible api (#1136)
* Allow setting extra headers for openai compatible api

* Fix to the extra headers form

* Properly store header state

* fix prettier

* Cleanup styles

---------

Co-authored-by: mbradshaw <mbradshaw@indeed.com>
Co-authored-by: frostbournesb <frostbournesb@protonmail.com>
2025-04-17 18:31:45 -07:00
Evan c8b234ab06 Add delete rule button (#2958)
* add a delete button to the cline rules modal

* changeset
2025-04-17 17:26:27 -07:00
canvrno 022fdf47c0 ENG-501/Detection of command termination using Ctrl+C (#2960)
* Detect Ctrl C input when users terminal a long running terminal command

* Remove terminal-output-truncation.md documentation file
2025-04-17 17:23:29 -07:00
canvrnoandellipsis-dev[bot] fb3105f7bf ENG-470/Chunking for large terminal outputs (#2935)
* initial terminal output chunking

* changeset

* Update src/core/task/index.ts

Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>

* cleanup

---------

Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>
2025-04-17 16:22:27 -07:00
Saoud Rizwan bc87fdb4b3 fix: BrowserSessionRow to include 'reasoning' message type handling (#2953) 2025-04-17 12:58:10 -10:00
Trevor Hudson c021b6464b Trevor/eng 416 add editing ability to older user messages in chat (#2954)
* User message editing

* restore and send

* dont redo if the message is the same

* select by default

* resolve conflicts

* handle workspace restore

* add title to buttons

* don't allow restoring files if there is no workspace

* fix

* fix messaging

* fix text

* fix type
2025-04-17 15:38:30 -07:00
Evan 180ebdad74 Add edit cline rule button (#2956)
* add button to open rule file

* changeset
2025-04-17 13:33:11 -07:00
Andrei EdellandAndrei Edell 2a80fedf7d proto migration for testBrowserConnection.ts (#2922)
* proto migration for testBrowserConnection.ts

* format fix

---------

Co-authored-by: Andrei Edell <andrei@nugbase.com>
2025-04-17 09:31:48 -10:00
Evan 450583c81d à la mode (#2912)
* add fetching global cline rules files

* add toggle functionality to clinerules

* add toggles modal

* changeset

* change codicon

* fix bad merged files

* fix duplicate globalClineRulesToggles declaration in state.ts from merge

* refresh cline rules on modal open
2025-04-16 19:58:19 -07:00
Yaroslav HalchenkoandDennis Bartlett 45b1666325 Add codespell support (config, workflow to detect/not fix) and make it fix some typos (#2939)
* Add github action to codespell main on push and PRs

* Add rudimentary codespell config

* run codespell throughout fixing typos automagically (but ignoring overall fail due to ambigous ones)

=== Do not change lines below ===
{
 "chain": [],
 "cmd": "codespell -w || :",
 "exit": 0,
 "extra_inputs": [],
 "inputs": [],
 "outputs": [],
 "pwd": "."
}
^^^ Do not change lines above ^^^

* Do interactive fixing of some ambigous typos

=== Do not change lines below ===
{
 "chain": [],
 "cmd": "codespell -w -i 3 -C 4",
 "exit": 0,
 "extra_inputs": [],
 "inputs": [],
 "outputs": [],
 "pwd": "."
}
^^^ Do not change lines above ^^^

* Fix Formatting

---------

Co-authored-by: Dennis Bartlett <bartlett.dc.1@gmail.com>
2025-04-16 19:53:16 -07:00
Matt Rubens 6c5b99d304 Safer check for xAI reasoning content (#2936) 2025-04-16 18:45:07 -07:00
Ara 02120810ce Adding o3 and o4-mini models (#2932)
* Revert "Add OpenAI o3 & 4o-mini (#2927)"

This reverts commit 89cbbe95e3.

* Adding O3 and O4-mini Models

* Adding O3 and O4-mini Models

* Adding O3 and O4-mini Models
2025-04-16 17:27:12 -07:00
Frostbourne 612744394a [ENG-417] Add command to focus chat input (#2910)
* Make command to focus on chat input

* Allow cmd to focus from anywhere

* changeset

* fix unit test

* Jump to chat input from anywhere

* fix focusChatInput call after opening ext
2025-04-16 16:00:10 -07:00
Trevor Hudson 01a48736eb Add ability to send context with an options selection (#2379)
* - add ability to send context with an options selection
- add sourcemaps for debugging in the webview

* remove colon if there is no message

* resolve conflicts

* remove sourcemap
2025-04-16 15:40:30 -07:00
Evan 4add38032e Add Accurate Title (#2934)
* Add openrouter ranking

* changeset
2025-04-16 12:55:17 -07:00
Evan 73078d63ce Pirate Mode Activated (#2890)
* add fetching global cline rules files

* add toggle functionality to clinerules

* selectively filter out OS generated files from read directory

* remove .file filtering

* remove duplicate imports

* pass path to global rules directory in system prompt

* empty commit to trigger tests
2025-04-16 12:44:04 -07:00
Peter Dave Hello 89cbbe95e3 Add OpenAI o3 & 4o-mini (#2927)
Reference:
- https://platform.openai.com/docs/models/o3
- https://platform.openai.com/docs/models/o4-mini
2025-04-16 11:16:55 -07:00
71 changed files with 3649 additions and 249 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
fix: task cancellation during thinking stream would result in 'Cline aborted stream' error
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
gRPC over vscode message bus to make messaging better
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
fix vertexai token count
+6
View File
@@ -0,0 +1,6 @@
[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
@@ -0,0 +1,25 @@
# 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
+20 -2
View File
@@ -1,5 +1,23 @@
# Changelog
## [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!)
@@ -58,7 +76,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 exlcude results based on relevance
- Add scoring logic to file mentions to sort and exclude results based on relevance
- Add Support for Bytedance Doubao (Thanks Tunixer!)
- Fix to prevent duplicate BOM (Thanks bamps53!)
@@ -536,7 +554,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 gaurd against lazy edits (less "//rest of code here")
- Improves system prompt to guard against lazy edits (less "//rest of code here")
## [1.3.0]
+1 -1
View File
@@ -2,7 +2,7 @@
English | <a href="https://github.com/cline/cline/blob/main/locales/es/README.md" target="_blank">Español</a> | <a href="https://github.com/cline/cline/blob/main/locales/de/README.md" target="_blank">Deutsch</a> | <a href="https://github.com/cline/cline/blob/main/locales/ja/README.md" target="_blank">日本語</a> | <a href="https://github.com/cline/cline/blob/main/locales/zh-cn/README.md" target="_blank">简体中文</a> | <a href="https://github.com/cline/cline/blob/main/locales/zh-tw/README.md" target="_blank">繁體中文</a> | <a href="https://github.com/cline/cline/blob/main/locales/ko/README.md" target="_blank">한국어</a>
</sub></div>
# Cline
# Cline \#1 on OpenRouter
<p align="center">
<img src="https://media.githubusercontent.com/media/cline/cline/main/assets/docs/demo.gif" width="100%" />
+10 -10
View File
@@ -26,17 +26,17 @@
</table>
</div>
认识 Cline一个可以使用你的 **CLI****编辑器** 的 AI 助手。
认识 Cline —— 一个可以使用你的 **终端****编辑器** 的 AI 助手。
感谢 [Claude 3.7 Sonnet 的代理编码能力](https://www.anthropic.com/claude/sonnet)Cline 可以一步步处理复杂的软件开发任务。通过允许他创建和编辑文件、探索大型项目、使用浏览器和执行终端命令(在你授予权限后),他可以提供超越代码完成或技术支持的帮助。Cline 甚至可以使用 Model Context Protocol (MCP) 创建新工具并扩展自的能力。虽然自主 AI 脚本传统上在沙盒环境中运行,但此扩展提供了一个人机交互的 GUI 来批准每个文件更改和终端命令,提供了一种安全且可访问的方式来探索代理 AI 的潜力。
得益于 [Claude 3.7 Sonnet 的代理编码能力](https://www.anthropic.com/claude/sonnet)Cline 能够逐步处理复杂的软件开发任务。借助于一系列工具,他可以创建和编辑文件、浏览大型项目、使用浏览器,并在你授权后执行终端命令,从而在代码补全或技术支持之外提供更深入的帮助。Cline 甚至还能使用 Model Context ProtocolMCP)来创建新工具并扩展自的能力。虽然传统的自动化 AI 脚本通常运行在沙盒环境中,但这个扩展提供了一个人类参与审核的图形界面(GUI),用于审批每一次文件变更和终端命令,从而为探索代理 AI 的潜力提供了一种安全且易于使用的方式
1. 输入你的任务并添加图像,将模型转换为功能应用程序或通过截图修复错误
2. Cline 首先分析你的文件结构和源代码 AST,运行正则表达式搜索,并阅读相关文件以了解现有项目。通过仔细管理添加到上下文中的信息,Cline 即使在大型复杂项目中也能提供有价值的帮助,而不会使上下文窗口过载
3. 一旦 Cline 获得所需信息,他可以
- 创建和编辑文件 + 监控 linter/编译器错误,从而主动修复诸如缺少导入语法错误等问题。
- 直接在你的终端中执行命令监控输出,从而在编辑文件后对开发服务器问题做出反应
- Web 开发任务,Cline 可以在无头浏览器中启动网站,点击、输入、滚动并捕获截图控制台日志,从而修复运行时错误和视觉错误
4. 当任务完成Cline 通过终端命令如 `open -a "Google Chrome" index.html` 向你展示结果,你可以通过点击按钮运行该命令
1. 输入你的任务并添加图片,以将界面原型(mockup转换为功能应用或通过截图修复 bug
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,扩展会获取他们的最新模型列表,让你在新模型可用时立即使用。
扩展还会跟踪整个任务循环和单个请求的总令牌和 API 使用成本,让你在每一步都了解支出情况。
此外,该扩展还会记录整个任务流程中以及每次请求的总 token 数和 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 可以在不超過上下文視窗的情況下,為大型且複雜的專案提供有價值的協助。
+6 -1
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.12.3",
"version": "3.13.0",
"icon": "assets/icons/icon.png",
"engines": {
"vscode": "^1.84.0"
@@ -116,6 +116,11 @@
"command": "cline.fixWithCline",
"title": "Fix with Cline",
"category": "Cline"
},
{
"command": "cline.focusChatInput",
"title": "Jump to Chat Input",
"category": "Cline"
}
],
"keybindings": [
+8 -1
View File
@@ -6,10 +6,17 @@ 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;
string host = 3; // Optional, may be empty
optional string host = 3;
}
message BrowserConnection {
bool success = 1;
string message = 2;
optional string endpoint = 3;
}
+27
View File
@@ -11,3 +11,30 @@ 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;
}
+5 -2
View File
@@ -20,10 +20,13 @@ export class GeminiHandler implements ApiHandler {
@withRetry()
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
const model = this.client.getGenerativeModel({
const modelOptions = {
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: {
+3 -1
View File
@@ -50,7 +50,7 @@ export class OpenAiNativeHandler implements ApiHandler {
case "o1":
case "o1-preview":
case "o1-mini": {
// o1 doesnt support streaming, non-1 temp, or system prompt
// o1 doesn't 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)],
@@ -64,6 +64,8 @@ export class OpenAiNativeHandler implements ApiHandler {
break
}
case "o4-mini":
case "o3":
case "o3-mini": {
const stream = await this.client.chat.completions.create({
model: model.id,
+9 -3
View File
@@ -16,16 +16,22 @@ 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")) {
if (
this.options.azureApiVersion ||
(this.options.openAiBaseUrl?.toLowerCase().includes("azure.com") &&
!this.options.openAiModelId?.toLowerCase().includes("deepseek"))
) {
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,
})
}
}
@@ -35,7 +41,7 @@ export class OpenAiHandler implements ApiHandler {
const modelId = this.options.openAiModelId ?? ""
const isDeepseekReasoner = modelId.includes("deepseek-reasoner")
const isR1FormatRequired = this.options.openAiModelInfo?.isR1FormatRequired ?? false
const isO3Mini = modelId.includes("o3-mini")
const isReasoningModelFamily = modelId.includes("o3") || modelId.includes("o4")
let openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [
{ role: "system", content: systemPrompt },
@@ -55,7 +61,7 @@ export class OpenAiHandler implements ApiHandler {
openAiMessages = convertToR1Format([{ role: "user", content: systemPrompt }, ...messages])
}
if (isO3Mini) {
if (isReasoningModelFamily) {
openAiMessages = [{ role: "developer", content: systemPrompt }, ...convertToOpenAiMessages(messages)]
temperature = undefined // does not support temperature
reasoningEffort = (this.options.o3MiniReasoningEffort as ChatCompletionReasoningEffort) || "medium"
+2 -2
View File
@@ -4,7 +4,7 @@ import { ApiHandler } from "../"
import { ApiHandlerOptions, XAIModelId, ModelInfo, xaiDefaultModelId, xaiModels } from "../../shared/api"
import { convertToOpenAiMessages } from "../transform/openai-format"
import { ApiStream } from "../transform/stream"
import { ChatCompletionReasoningEffort } from "openai/resources/chat/completions.mjs"
import { ChatCompletionReasoningEffort } from "openai/resources/chat/completions"
export class XAIHandler implements ApiHandler {
private options: ApiHandlerOptions
@@ -47,7 +47,7 @@ export class XAIHandler implements ApiHandler {
}
}
if ("reasoning_content" in delta && delta.reasoning_content) {
if (delta && "reasoning_content" in delta && delta.reasoning_content) {
yield {
type: "reasoning",
// @ts-ignore-next-line
+178
View File
@@ -0,0 +1,178 @@
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")
}
})
})
+381 -1
View File
@@ -200,7 +200,31 @@ 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): Promise<string> {
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> {
let result = ""
let lastProcessedIndex = 0
@@ -341,3 +365,359 @@ export async function constructNewFileContent(diffContent: string, originalConte
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
}
@@ -0,0 +1,131 @@
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")
})
})
@@ -0,0 +1,361 @@
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 doesnt 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 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
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 havent already replaced this file read
// meaning we haven't 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 wont exist if the user rejects the file change for example
// check if this exists in the text, it won't 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 indicies of messages
// looping over the outer indices 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 didnt just alter it
// meaning there was an update to this text previously, but we didn't 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 wont have any changes either
// reach here if there's no alterations for this outer index, meaning each inner index won't have any changes either
totalCharCount += block.text.length
}
} else if (block.type === "image" && block.source) {
@@ -1,17 +1,29 @@
import path from "path"
import { GlobalFileNames } from "../../../storage/disk"
import { ensureRulesDirectoryExists, 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 const getGlobalClineRules = async (globalClineRulesFilePath: string) => {
export const getGlobalClineRules = async (globalClineRulesFilePath: string, toggles: ClineRulesToggles) => {
if (await fileExistsAtPath(globalClineRulesFilePath)) {
if (await isDirectory(globalClineRulesFilePath)) {
try {
const rulesFilePaths = await readDirectory(globalClineRulesFilePath)
const rulesFilesTotalContent = await getClineRulesFilesTotalContent(rulesFilePaths, globalClineRulesFilePath)
const clineRulesFileInstructions = formatResponse.clineRulesGlobalDirectoryInstructions(rulesFilesTotalContent)
return clineRulesFileInstructions
const rulesFilesTotalContent = await getClineRulesFilesTotalContent(
rulesFilePaths,
globalClineRulesFilePath,
toggles,
)
if (rulesFilesTotalContent) {
const clineRulesFileInstructions = formatResponse.clineRulesGlobalDirectoryInstructions(
globalClineRulesFilePath,
rulesFilesTotalContent,
)
return clineRulesFileInstructions
}
} catch {
console.error(`Failed to read .clinerules directory at ${globalClineRulesFilePath}`)
}
@@ -24,7 +36,7 @@ export const getGlobalClineRules = async (globalClineRulesFilePath: string) => {
return undefined
}
export const getLocalClineRules = async (cwd: string) => {
export const getLocalClineRules = async (cwd: string, toggles: ClineRulesToggles) => {
const clineRulesFilePath = path.resolve(cwd, GlobalFileNames.clineRules)
let clineRulesFileInstructions: string | undefined
@@ -32,17 +44,21 @@ export const getLocalClineRules = async (cwd: string) => {
if (await fileExistsAtPath(clineRulesFilePath)) {
if (await isDirectory(clineRulesFilePath)) {
try {
const rulesFilePaths = await readDirectory(path.join(cwd, GlobalFileNames.clineRules))
const rulesFilesTotalContent = await getClineRulesFilesTotalContent(rulesFilePaths, cwd)
clineRulesFileInstructions = formatResponse.clineRulesLocalDirectoryInstructions(cwd, rulesFilesTotalContent)
const rulesFilePaths = await readDirectory(clineRulesFilePath)
const rulesFilesTotalContent = await getClineRulesFilesTotalContent(rulesFilePaths, cwd, toggles)
if (rulesFilesTotalContent) {
clineRulesFileInstructions = formatResponse.clineRulesLocalDirectoryInstructions(cwd, rulesFilesTotalContent)
}
} catch {
console.error(`Failed to read .clinerules directory at ${clineRulesFilePath}`)
}
} else {
try {
const ruleFileContent = (await fs.readFile(clineRulesFilePath, "utf8")).trim()
if (ruleFileContent) {
clineRulesFileInstructions = formatResponse.clineRulesLocalFileInstructions(cwd, ruleFileContent)
if (clineRulesFilePath in toggles && toggles[clineRulesFilePath] !== false) {
const ruleFileContent = (await fs.readFile(clineRulesFilePath, "utf8")).trim()
if (ruleFileContent) {
clineRulesFileInstructions = formatResponse.clineRulesLocalFileInstructions(cwd, ruleFileContent)
}
}
} catch {
console.error(`Failed to read .clinerules file at ${clineRulesFilePath}`)
@@ -53,13 +69,179 @@ export const getLocalClineRules = async (cwd: string) => {
return clineRulesFileInstructions
}
const getClineRulesFilesTotalContent = async (rulesFilePaths: string[], basePath: string) => {
const getClineRulesFilesTotalContent = async (rulesFilePaths: string[], basePath: string, toggles: ClineRulesToggles) => {
const ruleFilesTotalContent = await Promise.all(
rulesFilePaths.map(async (filePath) => {
const ruleFilePath = path.resolve(basePath, filePath)
const ruleFilePathRelative = path.relative(basePath, ruleFilePath)
if (ruleFilePath in toggles && toggles[ruleFilePath] === false) {
return null
}
return `${ruleFilePathRelative}\n` + (await fs.readFile(ruleFilePath, "utf8")).trim()
}),
).then((contents) => contents.join("\n\n"))
).then((contents) => contents.filter(Boolean).join("\n\n"))
return ruleFilesTotalContent
}
export async function synchronizeRuleToggles(
rulesDirectoryPath: string,
currentToggles: ClineRulesToggles,
): Promise<ClineRulesToggles> {
// Create a copy of toggles to modify
const updatedToggles = { ...currentToggles }
try {
const pathExists = await fileExistsAtPath(rulesDirectoryPath)
if (pathExists) {
const isDir = await isDirectory(rulesDirectoryPath)
if (isDir) {
// DIRECTORY CASE
const filePaths = await readDirectory(rulesDirectoryPath)
const existingRulePaths = new Set<string>()
for (const filePath of filePaths) {
const ruleFilePath = path.resolve(rulesDirectoryPath, filePath)
existingRulePaths.add(ruleFilePath)
const pathHasToggle = ruleFilePath in updatedToggles
if (!pathHasToggle) {
updatedToggles[ruleFilePath] = true
}
}
// Clean up toggles for non-existent files
for (const togglePath in updatedToggles) {
const pathExists = existingRulePaths.has(togglePath)
if (!pathExists) {
delete updatedToggles[togglePath]
}
}
} else {
// FILE CASE
// Add toggle for this file
const pathHasToggle = rulesDirectoryPath in updatedToggles
if (!pathHasToggle) {
updatedToggles[rulesDirectoryPath] = true
}
// Remove toggles for any other paths
for (const togglePath in updatedToggles) {
if (togglePath !== rulesDirectoryPath) {
delete updatedToggles[togglePath]
}
}
}
} else {
// PATH DOESN'T EXIST CASE
// Clear all toggles since the path doesn't exist
for (const togglePath in updatedToggles) {
delete updatedToggles[togglePath]
}
}
} catch (error) {
console.error(`Failed to synchronize rule toggles for path: ${rulesDirectoryPath}`, error)
}
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,9 +4,11 @@
// 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)
}
@@ -0,0 +1,63 @@
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: "",
}
}
}
+99 -58
View File
@@ -40,12 +40,16 @@ import {
getAllExtensionState,
getGlobalState,
getSecret,
getWorkspaceState,
resetExtensionState,
storeSecret,
updateApiConfiguration,
updateGlobalState,
updateWorkspaceState,
} from "../storage/state"
import { Task } from "../task"
import { Task, cwd } from "../task"
import { ClineRulesToggles } from "../../shared/cline-rules"
import { createRuleFile, deleteRuleFile, refreshClineRulesToggles } from "../context/instructions/user-instructions/cline-rules"
/*
https://github.com/microsoft/vscode-webview-ui-toolkit-samples/blob/main/default/weather-webview/src/providers/WeatherViewProvider.ts
@@ -61,7 +65,7 @@ export class Controller {
workspaceTracker: WorkspaceTracker
mcpHub: McpHub
accountService: ClineAccountService
private latestAnnouncementId = "april-11-2025" // update to some unique identifier when we add a new announcement
private latestAnnouncementId = "april-18-2025_21:15::00" // update to some unique identifier when we add a new announcement
constructor(
readonly context: vscode.ExtensionContext,
@@ -255,6 +259,13 @@ export class Controller {
telemetryService.updateTelemetryState(isOptedIn)
})
break
case "showChatView": {
this.postMessageToWebview({
type: "action",
action: "chatButtonClicked",
})
break
}
case "newTask":
// Code that should run in response to the hello message command
//vscode.window.showInformationMessage(message.text!)
@@ -300,58 +311,6 @@ 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()
@@ -470,6 +429,10 @@ 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
@@ -486,6 +449,36 @@ 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)
@@ -497,7 +490,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 superceded 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 superseded 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
@@ -507,7 +500,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)
await this.task?.restoreCheckpoint(message.number, message.text! as ClineCheckpointRestore, message.offset)
}
break
}
@@ -647,6 +640,48 @@ 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
@@ -1733,7 +1768,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 doesnt save to disk for some reason
// FIXME: this seems to happen sometimes when the json file doesn't save to disk for some reason
await this.deleteTaskFromState(id)
throw new Error("Task not found")
}
@@ -1865,8 +1900,12 @@ 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,
@@ -1889,6 +1928,8 @@ 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 || {},
}
}
@@ -1910,7 +1951,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 thats 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 that's 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.
*/
+43
View File
@@ -0,0 +1,43 @@
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
`
+3 -2
View File
@@ -2,6 +2,7 @@ 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: () =>
@@ -204,8 +205,8 @@ Otherwise, if you have not completed the task and do not need additional informa
clineIgnoreInstructions: (content: string) =>
`# .clineignore\n\n(The following is provided by a root-level .clineignore file where the user has specified files and directories that should not be accessed. When using list_files, you'll notice a ${LOCK_TEXT_SYMBOL} next to files that are blocked. Attempting to access the file's contents e.g. through read_file will result in an error.)\n\n${content}\n.clineignore`,
clineRulesGlobalDirectoryInstructions: (content: string) =>
`# .clinerules/\n\nThe following is provided by a global .clinerules/ directory where the user has specified instructions:\n\n${content}`,
clineRulesGlobalDirectoryInstructions: (globalClineRulesFilePath: string, content: string) =>
`# .clinerules/\n\nThe following is provided by a global .clinerules/ directory, located at ${globalClineRulesFilePath.toPosix()}, where the user has specified instructions for all working directories:\n\n${content}`,
clineRulesLocalDirectoryInstructions: (cwd: string, content: string) =>
`# .clinerules/\n\nThe following is provided by a root-level .clinerules/ directory where the user has specified instructions for this working directory (${cwd.toPosix()})\n\n${content}`,
+55
View File
@@ -0,0 +1,55 @@
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
}
+6
View File
@@ -19,6 +19,7 @@ export type SecretKey =
| "asksageApiKey"
| "xaiApiKey"
| "sambanovaApiKey"
export type GlobalStateKey =
| "apiProvider"
| "apiModelId"
@@ -36,17 +37,20 @@ export type GlobalStateKey =
| "openAiBaseUrl"
| "openAiModelId"
| "openAiModelInfo"
| "openAiHeaders"
| "ollamaModelId"
| "ollamaBaseUrl"
| "ollamaApiOptionsCtxNum"
| "lmStudioModelId"
| "lmStudioBaseUrl"
| "anthropicBaseUrl"
| "geminiBaseUrl"
| "azureApiVersion"
| "openRouterModelId"
| "openRouterModelInfo"
| "openRouterProviderSorting"
| "autoApprovalSettings"
| "globalClineRulesToggles"
| "browserSettings"
| "chatSettings"
| "vsCodeLmModelSelector"
@@ -71,3 +75,5 @@ export type GlobalStateKey =
| "reasoningEffort"
| "planActSeparateModelsSetting"
| "favoritedModelIds"
export type LocalStateKey = "localClineRulesToggles"
+17
View File
@@ -10,6 +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"
/*
Storage
https://dev.to/kompotkot/how-to-use-secretstorage-in-your-vscode-extensions-2hco
@@ -72,6 +73,7 @@ export async function getAllExtensionState(context: vscode.ExtensionContext) {
openAiApiKey,
openAiModelId,
openAiModelInfo,
openAiHeaders,
ollamaModelId,
ollamaBaseUrl,
ollamaApiOptionsCtxNum,
@@ -79,6 +81,7 @@ export async function getAllExtensionState(context: vscode.ExtensionContext) {
lmStudioBaseUrl,
anthropicBaseUrl,
geminiApiKey,
geminiBaseUrl,
openAiNativeApiKey,
deepSeekApiKey,
requestyApiKey,
@@ -121,6 +124,7 @@ 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>,
@@ -142,6 +146,7 @@ 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>,
@@ -149,6 +154,7 @@ 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>,
@@ -191,6 +197,7 @@ 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
@@ -207,6 +214,8 @@ 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)
@@ -251,6 +260,7 @@ export async function getAllExtensionState(context: vscode.ExtensionContext) {
openAiApiKey,
openAiModelId,
openAiModelInfo,
openAiHeaders: openAiHeaders || {},
ollamaModelId,
ollamaBaseUrl,
ollamaApiOptionsCtxNum,
@@ -258,6 +268,7 @@ export async function getAllExtensionState(context: vscode.ExtensionContext) {
lmStudioBaseUrl,
anthropicBaseUrl,
geminiApiKey,
geminiBaseUrl,
openAiNativeApiKey,
deepSeekApiKey,
requestyApiKey,
@@ -291,6 +302,8 @@ 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 || {},
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,
@@ -327,6 +340,7 @@ export async function updateApiConfiguration(context: vscode.ExtensionContext, a
openAiApiKey,
openAiModelId,
openAiModelInfo,
openAiHeaders,
ollamaModelId,
ollamaBaseUrl,
ollamaApiOptionsCtxNum,
@@ -334,6 +348,7 @@ export async function updateApiConfiguration(context: vscode.ExtensionContext, a
lmStudioBaseUrl,
anthropicBaseUrl,
geminiApiKey,
geminiBaseUrl,
openAiNativeApiKey,
deepSeekApiKey,
requestyApiKey,
@@ -382,6 +397,7 @@ 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)
@@ -389,6 +405,7 @@ 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)
+123 -33
View File
@@ -70,8 +70,6 @@ import {
checkIsAnthropicContextWindowError,
checkIsOpenRouterContextWindowError,
} from "../context/context-management/context-error-handling"
import WorkspaceTracker from "../../integrations/workspace/WorkspaceTracker"
import { McpHub } from "../../services/mcp/McpHub"
import { ContextManager } from "../context/context-management/ContextManager"
import { loadMcpDocumentation } from "../prompts/loadMcpDocumentation"
import {
@@ -82,10 +80,18 @@ import {
saveApiConversationHistory,
saveClineMessages,
} from "../storage/disk"
import { getGlobalClineRules, getLocalClineRules } from "../context/instructions/user-instructions/cline-rules"
import {
getGlobalClineRules,
getLocalClineRules,
refreshClineRulesToggles,
} 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"
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
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
type ToolResponse = string | Array<Anthropic.TextBlockParam | Anthropic.ImageBlockParam>
type UserContent = Array<Anthropic.ContentBlockParam>
@@ -302,9 +308,13 @@ export class Task {
}
}
async restoreCheckpoint(messageTs: number, restoreType: ClineCheckpointRestore) {
const messageIndex = this.clineMessages.findIndex((m) => m.ts === messageTs)
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)
const message = this.clineMessages[messageIndex]
const lastMessageWithHash = this.clineMessages[lastHashIndex]
if (!message) {
console.error("Message not found", this.clineMessages)
return
@@ -337,6 +347,14 @@ 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
}
@@ -434,7 +452,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 cant 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 can't show diff outside of workspace?
if (!this.checkpointTracker && !this.checkpointTrackerErrorMessage) {
try {
this.checkpointTracker = await CheckpointTracker.create(this.taskId, this.context.globalStorageUri.fsPath)
@@ -879,7 +897,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 wouldnt 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 wouldn't 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)
@@ -1120,34 +1138,85 @@ export class Task {
let userFeedback: { text?: string; images?: string[] } | undefined
let didContinue = false
const sendCommandOutput = async (line: string): Promise<void> => {
// 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
try {
const { response, text, images } = await this.ask("command_output", line)
const { response, text, images } = await this.ask("command_output", chunk)
if (response === "yesButtonClicked") {
// proceed while running
} else {
userFeedback = { text, images }
}
didContinue = true
process.continue() // continue past the await
process.continue()
} catch {
// This can only happen if this ask promise was ignored, so ignore this error
// ask promise was ignored
} finally {
chunkEnroute = false
// If more output accumulated while chunkEnroute, flush again
if (outputBuffer.length > 0) {
await flushBuffer()
}
}
}
const scheduleFlush = () => {
if (chunkTimer) {
clearTimeout(chunkTimer)
}
chunkTimer = setTimeout(() => flushBuffer(), CHUNK_DEBOUNCE_MS)
}
let result = ""
process.on("line", (line) => {
result += line + "\n"
if (!didContinue) {
sendCommandOutput(line)
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()
}
} else {
this.say("command_output", line)
}
})
let completed = false
process.once("completed", () => {
process.once("completed", async () => {
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 () => {
@@ -1280,10 +1349,12 @@ export class Task {
? `# Preferred Language\n\nSpeak in ${preferredLanguage}.`
: ""
const localClineRulesFileInstructions = await getLocalClineRules(cwd)
const { globalToggles, localToggles } = await refreshClineRulesToggles(this.getContext(), cwd)
const globalClineRulesFilePath = await ensureRulesDirectoryExists()
const globalClineRulesFileInstructions = await getGlobalClineRules(globalClineRulesFilePath)
const globalClineRulesFileInstructions = await getGlobalClineRules(globalClineRulesFilePath, globalToggles)
const localClineRulesFileInstructions = await getLocalClineRules(cwd, localToggles)
const clineIgnoreContent = this.clineIgnoreController.clineIgnoreContent
let clineIgnoreInstructions: string | undefined
@@ -2418,7 +2489,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 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.
// 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.
// await this.say(
// "command",
// removeClosingTag("command", command),
@@ -2464,7 +2535,7 @@ export class Task {
let didAutoApprove = false
// If the model says this command is safe and auto aproval for safe commands is true, execute the command
// If the model says this command is safe and auto approval 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)
@@ -2620,8 +2691,13 @@ 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 and ability to parse images and non-text responses
const toolResultPretty =
// TODO: add progress indicator
const toolResultImages =
toolResult?.content
.filter((item) => item.type === "image")
.map((item) => `data:${item.mimeType};base64,${item.data}`) || []
let toolResultText =
(toolResult?.isError ? "Error:\n" : "") +
toolResult?.content
.map((item) => {
@@ -2636,8 +2712,21 @@ export class Task {
})
.filter(Boolean)
.join("\n\n") || "(No response)"
await this.say("mcp_server_response", toolResultPretty)
pushToolResult(formatResponse.toolResult(toolResultPretty))
// 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.saveCheckpoint()
@@ -2988,7 +3077,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 (doesnt have to exist yet)
// we have command string, which means we have the result as well, so finish it (doesn't have to exist yet)
await this.say("completion_result", removeClosingTag("result", result), undefined, false)
await this.saveCheckpoint(true)
await addNewChangesFlagToLastCompletionResultMessage()
@@ -3024,7 +3113,7 @@ export class Task {
let commandResult: ToolResponse | undefined
if (command) {
if (lastMessage && lastMessage.ask !== "command") {
// havent sent a command message yet so first send completion_result then command
// haven't 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()
@@ -3362,7 +3451,10 @@ export class Task {
case "reasoning":
// reasoning will always come before assistant message
reasoningMessage += chunk.reasoning
await this.say("reasoning", reasoningMessage, undefined, true)
// 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)
}
break
case "text":
if (reasoningMessage && assistantMessage.length === 0) {
@@ -3393,7 +3485,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 premptively, we allow the present iterator to finish and set userMessageContentReady when its ready
// this.userMessageContentReady = true // instead of setting this preemptively, we allow the present iterator to finish and set userMessageContentReady when its ready
break
}
@@ -3448,7 +3540,7 @@ export class Task {
partialBlocks.forEach((block) => {
block.partial = false
})
// this.assistantMessageContent.forEach((e) => (e.partial = false)) // cant just do this bc a tool could be in the middle of executing ()
// this.assistantMessageContent.forEach((e) => (e.partial = false)) // can't 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
}
@@ -3531,12 +3623,10 @@ export class Task {
block.text.includes("<task>") ||
block.text.includes("<user_message>")
) {
const parsedText = await parseMentions(
block.text,
cwd,
this.urlContentFetcher,
this.fileContextTracker,
)
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)
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 explicity 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 explicitly 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:;
+18
View File
@@ -397,6 +397,24 @@ export function activate(context: vscode.ExtensionContext) {
}),
)
// Register the focusChatInput command handler
context.subscriptions.push(
vscode.commands.registerCommand("cline.focusChatInput", () => {
let visibleWebview = WebviewProvider.getVisibleInstance()
if (!visibleWebview) {
vscode.commands.executeCommand("claude-dev.SidebarProvider.focus")
visibleWebview = WebviewProvider.getSidebarInstance()
// showing the extension will call didBecomeVisible which focuses it already
// but it doesn't focus if a tab is selected which focusChatInput accounts for
}
visibleWebview?.controller.postMessageToWebview({
type: "action",
action: "focusChatInput",
})
}),
)
// Set up test server if in test mode
if (IS_TEST === "true") {
createTestServer(sidebarWebview)
@@ -61,7 +61,7 @@ class DiagnosticsMonitor {
return currentDiagnostics
}
let timeout = 300 // only way this happens is if theres no errors
let timeout = 300 // only way this happens is if there's 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 shouldnt have changed this wont show in local history unless of course the user made changes and saved during the edit
// 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
await vscode.workspace.applyEdit(edit)
await updatedDocument.save()
console.log(`File ${absolutePath} has been reverted to its original content.`)
+11 -2
View File
@@ -94,8 +94,17 @@ 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 wont work
// note this means that 'echo' commands won't work
if (!didOutputNonCommand) {
const lines = data.split("\n")
for (let i = 0; i < lines.length; i++) {
@@ -145,7 +154,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 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
// 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
if (!didEmitEmptyLine && !this.fullOutput && data) {
this.emit("line", "") // empty line to indicate start of command output stream
didEmitEmptyLine = true
+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 = /^\s*INFO\b/.test(output)
const isInfoLog = !/\berror\b/i.test(output)
if (isInfoLog) {
// Log normal informational messages
+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 exagerated
// The min threshold value will require some testing and tuning as the scores are exponential, and exaggerated
const MIN_SCORE_THRESHOLD = 100
// Filter results by score and map to original items
+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 discrepencies 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 discrepancies 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`
}
+4
View File
@@ -9,6 +9,7 @@ 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 {
@@ -60,6 +61,7 @@ export interface ExtensionMessage {
| "accountLoginClicked"
| "accountLogoutClicked"
| "accountButtonClicked"
| "focusChatInput"
invoke?: Invoke
state?: ExtensionState
images?: string[]
@@ -146,6 +148,8 @@ export interface ExtensionState {
}
version: string
vscMachineId: string
globalClineRulesToggles: ClineRulesToggles
localClineRulesToggles: ClineRulesToggles
}
export interface ClineMessage {
+13 -2
View File
@@ -27,19 +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"
@@ -81,6 +82,9 @@ export interface WebviewMessage {
| "searchFiles"
| "toggleFavoriteModel"
| "grpc_request"
| "toggleClineRule"
| "deleteClineRule"
// | "relaunchChromeDebugMode"
text?: string
uris?: string[] // Used for getRelativePaths
@@ -123,6 +127,13 @@ 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"
+20
View File
@@ -32,6 +32,7 @@ export interface ApiHandlerOptions {
liteLlmModelId?: string
liteLlmApiKey?: string
liteLlmUsePromptCache?: boolean
openAiHeaders?: Record<string, string> // Custom headers for OpenAI requests
anthropicBaseUrl?: string
openRouterApiKey?: string
openRouterModelId?: string
@@ -58,6 +59,7 @@ export interface ApiHandlerOptions {
lmStudioModelId?: string
lmStudioBaseUrl?: string
geminiApiKey?: string
geminiBaseUrl?: string
openAiNativeApiKey?: string
deepSeekApiKey?: string
requestyApiKey?: string
@@ -606,6 +608,24 @@ export const geminiModels = {
export type OpenAiNativeModelId = keyof typeof openAiNativeModels
export const openAiNativeDefaultModelId: OpenAiNativeModelId = "gpt-4.1"
export const openAiNativeModels = {
o3: {
maxTokens: 100_000,
contextWindow: 200_000,
supportsImages: true,
supportsPromptCache: true,
inputPrice: 10.0,
outputPrice: 40.0,
cacheReadsPrice: 2.5,
},
"o4-mini": {
maxTokens: 100_000,
contextWindow: 200_000,
supportsImages: true,
supportsPromptCache: true,
inputPrice: 1.1,
outputPrice: 4.4,
cacheReadsPrice: 0.275,
},
"gpt-4.1": {
maxTokens: 32_768,
contextWindow: 1_047_576,
+1
View File
@@ -0,0 +1 @@
export type ClineRulesToggles = Record<string, boolean> // filepath -> enabled/disabled
+113 -8
View File
@@ -6,19 +6,24 @@
/* eslint-disable */
import { BinaryReader, BinaryWriter } from "@bufbuild/protobuf/wire"
import { EmptyRequest } from "./common"
import { EmptyRequest, StringRequest } from "./common"
export const protobufPackage = "cline"
export interface BrowserConnectionInfo {
isConnected: boolean
isRemote: boolean
/** Optional, may be empty */
host: string
host?: string | undefined
}
export interface BrowserConnection {
success: boolean
message: string
endpoint?: string | undefined
}
function createBaseBrowserConnectionInfo(): BrowserConnectionInfo {
return { isConnected: false, isRemote: false, host: "" }
return { isConnected: false, isRemote: false, host: undefined }
}
export const BrowserConnectionInfo: MessageFns<BrowserConnectionInfo> = {
@@ -29,7 +34,7 @@ export const BrowserConnectionInfo: MessageFns<BrowserConnectionInfo> = {
if (message.isRemote !== false) {
writer.uint32(16).bool(message.isRemote)
}
if (message.host !== "") {
if (message.host !== undefined) {
writer.uint32(26).string(message.host)
}
return writer
@@ -79,7 +84,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) : "",
host: isSet(object.host) ? globalThis.String(object.host) : undefined,
}
},
@@ -91,7 +96,7 @@ export const BrowserConnectionInfo: MessageFns<BrowserConnectionInfo> = {
if (message.isRemote !== false) {
obj.isRemote = message.isRemote
}
if (message.host !== "") {
if (message.host !== undefined) {
obj.host = message.host
}
return obj
@@ -104,7 +109,99 @@ export const BrowserConnectionInfo: MessageFns<BrowserConnectionInfo> = {
const message = createBaseBrowserConnectionInfo()
message.isConnected = object.isConnected ?? false
message.isRemote = object.isRemote ?? false
message.host = object.host ?? ""
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
return message
},
}
@@ -122,6 +219,14 @@ export const BrowserServiceDefinition = {
responseStream: false,
options: {},
},
testBrowserConnection: {
name: "testBrowserConnection",
requestType: StringRequest,
requestStream: false,
responseType: BrowserConnection,
responseStream: false,
options: {},
},
},
} as const
+451
View File
@@ -17,6 +17,33 @@ 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 {}
}
@@ -162,6 +189,419 @@ 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
@@ -179,6 +619,17 @@ 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
}
+9 -1
View File
@@ -75,6 +75,13 @@ export async function getFileSizeInKB(filePath: string): Promise<number> {
}
}
// Common OS-generated files that would appear in an otherwise clean directory
const OS_GENERATED_FILES = [
".DS_Store", // macOS Finder
"Thumbs.db", // Windows Explorer thumbnails
"desktop.ini", // Windows folder settings
]
/**
* Recursively reads a directory and returns an array of absolute file paths.
*
@@ -86,7 +93,8 @@ export const readDirectory = async (directoryPath: string) => {
try {
const filePaths = await fs
.readdir(directoryPath, { withFileTypes: true, recursive: true })
.then((files) => files.filter((file) => file.isFile()))
.then((entries) => entries.filter((entry) => !OS_GENERATED_FILES.includes(entry.name)))
.then((entries) => entries.filter((entry) => entry.isFile()))
.then((files) => files.map((file) => path.resolve(file.parentPath, file.name)))
return filePaths
} catch {
+7 -11
View File
@@ -1,7 +1,6 @@
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:
@@ -120,15 +119,12 @@ export const isLocatedInWorkspace = (pathToCheck: string = ""): boolean => {
return pathToCheck.startsWith(workspacePath)
}
const resolvedPath = path.resolve(workspacePath, pathToCheck)
// Normalize paths without resolving symlinks
const normalizedWorkspace = path.normalize(workspacePath)
const normalizedPath = path.normalize(path.resolve(workspacePath, pathToCheck))
// 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
}
// Use path.relative to check if the path is within the workspace
const relativePath = path.relative(normalizedWorkspace, normalizedPath)
return !relativePath.startsWith("..") && !path.isAbsolute(relativePath)
}
+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 corect version of PowerShell
// implementation manages to deductively get the correct version of PowerShell
if (defaultProfileName.toLowerCase().includes("powershell")) {
if (profile?.path) {
// If there's an explicit PowerShell path, return that
+22 -16
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 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.
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.
*/
const Announcement = ({ version, hideAnnouncement }: AnnouncementProps) => {
const minorVersion = version.split(".").slice(0, 2).join(".") // 2.0.0 -> 2.0
@@ -41,6 +41,24 @@ 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
@@ -54,21 +72,9 @@ 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>
<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" }}>
{/*
// Leave this here for an example of how to structure the announcement
<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,7 +192,12 @@ const BrowserSessionRow = memo((props: BrowserSessionRowProps) => {
// Reset for next page
currentStateMessages = []
nextActionMessages = []
} else if (message.say === "api_req_started" || message.say === "text" || message.say === "browser_action") {
} else if (
message.say === "api_req_started" ||
message.say === "text" ||
message.say === "reasoning" ||
message.say === "browser_action"
) {
// These messages lead to the next result, so they should always go in nextActionMessages
nextActionMessages.push(message)
} else {
@@ -511,6 +516,7 @@ const BrowserSessionRowContent = ({
switch (message.say) {
case "api_req_started":
case "text":
case "reasoning":
return (
<div style={chatRowContentContainerStyle}>
<ChatRowContent
+29 -24
View File
@@ -27,11 +27,12 @@ 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 { highlightMentions } from "./TaskHeader"
import { highlightText } 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;
@@ -49,6 +50,8 @@ interface ChatRowProps {
lastModifiedMessage?: ClineMessage
isLast: boolean
onHeightChange: (isTaller: boolean) => void
inputValue?: string
sendMessageFromChatRow?: (text: string, images: string[]) => void
}
interface ChatRowContentProps extends Omit<ChatRowProps, "onHeightChange"> {}
@@ -84,7 +87,7 @@ const Markdown = memo(({ markdown }: { markdown?: string }) => {
const ChatRow = memo(
(props: ChatRowProps) => {
const { isLast, onHeightChange, message, lastModifiedMessage } = props
const { isLast, onHeightChange, message, lastModifiedMessage, inputValue } = props
// Store the previous height to compare with the current height
// This allows us to detect changes without causing re-renders
const prevHeightRef = useRef(0)
@@ -117,7 +120,15 @@ const ChatRow = memo(
export default ChatRow
export const ChatRowContent = ({ message, isExpanded, onToggleExpand, lastModifiedMessage, isLast }: ChatRowContentProps) => {
export const ChatRowContent = ({
message,
isExpanded,
onToggleExpand,
lastModifiedMessage,
isLast,
inputValue,
sendMessageFromChatRow,
}: ChatRowContentProps) => {
const { mcpServers, mcpMarketplaceCatalog } = useExtensionState()
const [seeNewChangesDisabled, setSeeNewChangesDisabled] = useState(false)
@@ -350,7 +361,7 @@ export const ChatRowContent = ({ message, isExpanded, onToggleExpand, lastModifi
<>
<div style={headerStyle}>
{toolIcon("edit")}
{!tool.operationIsLocatedInWorkspace &&
{tool.operationIsLocatedInWorkspace === false &&
toolIcon("sign-out", "yellow", -90, "This file is outside of your workspace")}
<span style={{ fontWeight: "bold" }}>Cline wants to edit this file:</span>
</div>
@@ -368,7 +379,7 @@ export const ChatRowContent = ({ message, isExpanded, onToggleExpand, lastModifi
<>
<div style={headerStyle}>
{toolIcon("new-file")}
{!tool.operationIsLocatedInWorkspace &&
{tool.operationIsLocatedInWorkspace === false &&
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>
@@ -386,7 +397,7 @@ export const ChatRowContent = ({ message, isExpanded, onToggleExpand, lastModifi
<>
<div style={headerStyle}>
{toolIcon("file-code")}
{!tool.operationIsLocatedInWorkspace &&
{tool.operationIsLocatedInWorkspace === false &&
toolIcon("sign-out", "yellow", -90, "This file is outside of your workspace")}
<span style={{ fontWeight: "bold" }}>
{/* {message.type === "ask" ? "" : "Cline read this file:"} */}
@@ -446,7 +457,7 @@ export const ChatRowContent = ({ message, isExpanded, onToggleExpand, lastModifi
<>
<div style={headerStyle}>
{toolIcon("folder-opened")}
{!tool.operationIsLocatedInWorkspace &&
{tool.operationIsLocatedInWorkspace === false &&
toolIcon("sign-out", "yellow", -90, "This is outside of your workspace")}
<span style={{ fontWeight: "bold" }}>
{message.type === "ask"
@@ -468,7 +479,7 @@ export const ChatRowContent = ({ message, isExpanded, onToggleExpand, lastModifi
<>
<div style={headerStyle}>
{toolIcon("folder-opened")}
{!tool.operationIsLocatedInWorkspace &&
{tool.operationIsLocatedInWorkspace === false &&
toolIcon("sign-out", "yellow", -90, "This is outside of your workspace")}
<span style={{ fontWeight: "bold" }}>
{message.type === "ask"
@@ -490,7 +501,7 @@ export const ChatRowContent = ({ message, isExpanded, onToggleExpand, lastModifi
<>
<div style={headerStyle}>
{toolIcon("file-code")}
{!tool.operationIsLocatedInWorkspace &&
{tool.operationIsLocatedInWorkspace === false &&
toolIcon("sign-out", "yellow", -90, "This is outside of your workspace")}
<span style={{ fontWeight: "bold" }}>
{message.type === "ask"
@@ -511,7 +522,7 @@ export const ChatRowContent = ({ message, isExpanded, onToggleExpand, lastModifi
<>
<div style={headerStyle}>
{toolIcon("search")}
{!tool.operationIsLocatedInWorkspace &&
{tool.operationIsLocatedInWorkspace === false &&
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>:
@@ -864,20 +875,12 @@ export const ChatRowContent = ({ message, isExpanded, onToggleExpand, lastModifi
)
case "user_feedback":
return (
<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>
<UserMessage
text={message.text}
images={message.images}
messageTs={message.ts}
sendMessageFromChatRow={sendMessageFromChatRow}
/>
)
case "user_feedback_diff":
const tool = JSON.parse(message.text || "{}") as ClineSayTool
@@ -1248,6 +1251,7 @@ export const ChatRowContent = ({ message, isExpanded, onToggleExpand, lastModifi
options={options}
selected={selected}
isActive={isLast && lastModifiedMessage?.ask === "followup"}
inputValue={inputValue}
/>
</div>
</>
@@ -1287,6 +1291,7 @@ export const ChatRowContent = ({ message, isExpanded, onToggleExpand, lastModifi
options={options}
selected={selected}
isActive={isLast && lastModifiedMessage?.ask === "plan_mode_respond"}
inputValue={inputValue}
/>
</div>
)
+127 -4
View File
@@ -15,6 +15,13 @@ 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"
@@ -24,8 +31,10 @@ 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
@@ -228,6 +237,11 @@ 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)
@@ -388,8 +402,62 @@ 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()
@@ -541,9 +609,28 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
const newCursorPosition = e.target.selectionStart
setInputValue(newValue)
setCursorPosition(newCursorPosition)
const showMenu = shouldShowContextMenu(newValue, newCursorPosition)
let showMenu = shouldShowContextMenu(newValue, newCursorPosition)
const showSlashCommandsMenu = shouldShowSlashCommandsMenu(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)
@@ -590,6 +677,7 @@ 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])
@@ -681,13 +769,35 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
const updateHighlights = useCallback(() => {
if (!textAreaRef.current || !highlightLayerRef.current) return
const text = textAreaRef.current.value
let processedText = textAreaRef.current.value
highlightLayerRef.current.innerHTML = text
processedText = processedText
.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
}, [])
@@ -1011,6 +1121,18 @@ 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
@@ -1153,7 +1275,7 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
display: "flex",
alignItems: "flex-center",
height: textAreaBaseHeight || 31,
bottom: 9.5, // should be 10 but doesnt look good on mac
bottom: 9.5, // should be 10 but doesn't look good on mac
zIndex: 2,
}}>
<div
@@ -1225,6 +1347,7 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
</ButtonContainer>
</VSCodeButton>
<ServersToggleModal />
<ClineRulesToggleModal />
<ModelContainer ref={modelSelectorRef}>
<ModelButtonWrapper ref={buttonRef}>
+11 -1
View File
@@ -461,6 +461,13 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
textAreaRef.current?.focus()
}
break
case "focusChatInput":
textAreaRef.current?.focus()
if (isHidden) {
// Send message back to extension to show chat view
vscode.postMessage({ type: "showChatView" })
}
break
}
break
case "selectedImages":
@@ -568,6 +575,7 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
"browser_action",
"browser_action_result",
"checkpoint_created",
"reasoning",
].includes(message.say!)
}
return false
@@ -789,10 +797,12 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
lastModifiedMessage={modifiedMessages.at(-1)}
isLast={index === groupedMessages.length - 1}
onHeightChange={handleRowHeightChange}
inputValue={inputValue}
sendMessageFromChatRow={handleSendMessage}
/>
)
},
[expandedRows, modifiedMessages, groupedMessages.length, toggleRowExpansion, handleRowHeightChange],
[expandedRows, modifiedMessages, groupedMessages.length, toggleRowExpansion, handleRowHeightChange, inputValue],
)
return (
@@ -26,10 +26,12 @@ export const OptionsButtons = ({
options,
selected,
isActive,
inputValue,
}: {
options?: string[]
selected?: string
isActive?: boolean
inputValue?: string
}) => {
if (!options?.length) return null
@@ -58,7 +60,7 @@ export const OptionsButtons = ({
}
vscode.postMessage({
type: "optionsResponse",
text: option,
text: option + (inputValue ? `: ${inputValue?.trim()}` : ""),
})
}}>
{option}
@@ -74,7 +74,7 @@ const ServersToggleModal: React.FC = () => {
/>
<div className="flex justify-between items-center mb-2.5">
<div className="m-0">MCP Servers</div>
<div className="m-0 text-base font-semibold">MCP Servers</div>
<VSCodeButton
appearance="icon"
onClick={() => {
@@ -0,0 +1,80 @@
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
+61 -4
View File
@@ -9,6 +9,7 @@ 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
@@ -254,7 +255,7 @@ const TaskHeader: React.FC<TaskHeaderProps> = ({
Task
{!isTaskExpanded && ":"}
</span>
{!isTaskExpanded && <span style={{ marginLeft: 4 }}>{highlightMentions(task.text, false)}</span>}
{!isTaskExpanded && <span style={{ marginLeft: 4 }}>{highlightText(task.text, false)}</span>}
</div>
</div>
{!isTaskExpanded && isCostAvailable && (
@@ -300,7 +301,7 @@ const TaskHeader: React.FC<TaskHeaderProps> = ({
wordBreak: "break-word",
overflowWrap: "anywhere",
}}>
{highlightMentions(task.text, false)}
{highlightText(task.text, false)}
</div>
{!isTextExpanded && showSeeMore && (
<div
@@ -554,9 +555,41 @@ const TaskHeader: React.FC<TaskHeaderProps> = ({
)
}
export const highlightMentions = (text?: string, withShadow = true) => {
if (!text) return text
/**
* 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) => {
const parts = text.split(mentionRegexGlobal)
return parts.map((part, index) => {
if (index % 2 === 0) {
// This is regular text
@@ -576,6 +609,30 @@ 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
@@ -0,0 +1,185 @@
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
@@ -0,0 +1,133 @@
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
@@ -0,0 +1,136 @@
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
@@ -0,0 +1,84 @@
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
@@ -0,0 +1,50 @@
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?:\/\/[^\s<>"']+/g
const urlRegex = /(?:https?:\/\/|data:image)[^\s<>"']+/g
let urlMatch: RegExpExecArray | null
let urlCount = 0
@@ -1,4 +1,5 @@
import {
VSCodeButton,
VSCodeCheckbox,
VSCodeDropdown,
VSCodeLink,
@@ -97,6 +98,7 @@ 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)
@@ -114,7 +116,7 @@ const ApiOptions = ({ showModelOptions, apiErrorMessage, modelIdErrorMessage, is
})
// If the field is the provider, save it immediately
// Neccesary for favorite model selection to work without undoing provider changes
// Necessary for favorite model selection to work without undoing provider changes
if (field === "apiProvider") {
vscode.postMessage({
type: "apiConfiguration",
@@ -778,6 +780,32 @@ 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",
@@ -803,7 +831,7 @@ const ApiOptions = ({ showModelOptions, apiErrorMessage, modelIdErrorMessage, is
<div>
<VSCodeTextField
value={apiConfiguration?.openAiBaseUrl || ""}
style={{ width: "100%" }}
style={{ width: "100%", marginBottom: 10 }}
type="url"
onInput={handleInputChange("openAiBaseUrl")}
placeholder={"Enter base URL..."}>
@@ -811,7 +839,7 @@ const ApiOptions = ({ showModelOptions, apiErrorMessage, modelIdErrorMessage, is
</VSCodeTextField>
<VSCodeTextField
value={apiConfiguration?.openAiApiKey || ""}
style={{ width: "100%" }}
style={{ width: "100%", marginBottom: 10 }}
type="password"
onInput={handleInputChange("openAiApiKey")}
placeholder="Enter API Key...">
@@ -819,11 +847,91 @@ const ApiOptions = ({ showModelOptions, apiErrorMessage, modelIdErrorMessage, is
</VSCodeTextField>
<VSCodeTextField
value={apiConfiguration?.openAiModelId || ""}
style={{ width: "100%" }}
style={{ width: "100%", marginBottom: 10 }}
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) => {
@@ -5,6 +5,7 @@ import { BROWSER_VIEWPORT_PRESETS } from "../../../../src/shared/BrowserSettings
import { useExtensionState } from "../../context/ExtensionStateContext"
import { vscode } from "../../utils/vscode"
import styled from "styled-components"
import { BrowserServiceClient } from "../../services/grpc-client"
const ConnectionStatusIndicator = ({
isChecking,
@@ -93,10 +94,24 @@ export const BrowserSettingsSection: React.FC = () => {
if (browserSettings.remoteBrowserEnabled) {
setIsCheckingConnection(true)
setConnectionStatus(null)
vscode.postMessage({
type: browserSettings.remoteBrowserHost ? "testBrowserConnection" : "discoverBrowser",
text: browserSettings.remoteBrowserHost,
})
if (browserSettings.remoteBrowserHost) {
// Use gRPC for testBrowserConnection
BrowserServiceClient.testBrowserConnection({ value: browserSettings.remoteBrowserHost })
.then((result) => {
setConnectionStatus(result.success)
setIsCheckingConnection(false)
})
.catch((error) => {
console.error("Error testing browser connection:", error)
setConnectionStatus(false)
setIsCheckingConnection(false)
})
} else {
// Use old message passing for discoverBrowser (not yet migrated)
vscode.postMessage({
type: "discoverBrowser",
})
}
}
}, 1000),
[browserSettings.remoteBrowserEnabled, browserSettings.remoteBrowserHost],
@@ -153,10 +168,22 @@ export const BrowserSettingsSection: React.FC = () => {
const checkConnectionOnce = useCallback(() => {
// Don't show the spinner for every check to avoid UI flicker
// We'll rely on the response to update the connectionStatus
vscode.postMessage({
type: browserSettings.remoteBrowserHost ? "testBrowserConnection" : "discoverBrowser",
text: browserSettings.remoteBrowserHost,
})
if (browserSettings.remoteBrowserHost) {
// Use gRPC for testBrowserConnection
BrowserServiceClient.testBrowserConnection({ value: browserSettings.remoteBrowserHost })
.then((result) => {
setConnectionStatus(result.success)
})
.catch((error) => {
console.error("Error testing browser connection:", error)
setConnectionStatus(false)
})
} else {
// Use old message passing for discoverBrowser (not yet migrated)
vscode.postMessage({
type: "discoverBrowser",
})
}
}, [browserSettings.remoteBrowserHost])
// Setup continuous polling for connection status when remote browser is enabled
@@ -17,7 +17,7 @@ import { vscode } from "../utils/vscode"
import { DEFAULT_BROWSER_SETTINGS } from "@shared/BrowserSettings"
import { DEFAULT_CHAT_SETTINGS } from "@shared/ChatSettings"
import { TelemetrySetting } from "@shared/TelemetrySetting"
import { ClineRulesToggles } from "@shared/cline-rules"
interface ExtensionStateContextType extends ExtensionState {
didHydrateState: boolean
showWelcome: boolean
@@ -53,6 +53,8 @@ export const ExtensionStateContextProvider: React.FC<{
telemetrySetting: "unset",
vscMachineId: "",
planActSeparateModelsSetting: true,
globalClineRulesToggles: {},
localClineRulesToggles: {},
})
const [didHydrateState, setDidHydrateState] = useState(false)
const [showWelcome, setShowWelcome] = useState(false)
@@ -128,6 +130,7 @@ export const ExtensionStateContextProvider: React.FC<{
})
break
}
case "openRouterModels": {
const updatedModels = message.openRouterModels ?? {}
setOpenRouterModels({
@@ -184,6 +187,8 @@ export const ExtensionStateContextProvider: React.FC<{
mcpMarketplaceCatalog,
filePaths,
totalTasksSize,
globalClineRulesToggles: state.globalClineRulesToggles || {},
localClineRulesToggles: state.localClineRulesToggles || {},
setApiConfiguration: (value) =>
setState((prevState) => ({
...prevState,
+7
View File
@@ -173,3 +173,10 @@ vscode-dropdown::part(listbox) {
border-radius: 3px;
box-shadow: 0 0 0 0.5px color-mix(in srgb, var(--vscode-badge-foreground) 30%, transparent);
}
.slash-command-match-textarea-highlight {
background-color: color-mix(in srgb, var(--vscode-focusBorder) 30%, transparent);
border-radius: 3px;
box-shadow: 0 0 0 0.5px color-mix(in srgb, var(--vscode-focusBorder) 30%, transparent);
color: transparent;
}
+14 -3
View File
@@ -58,10 +58,21 @@ function createGrpcClient<T extends ProtoService>(service: T): GrpcClientType<T>
window.addEventListener("message", handleResponse)
let encodedRequest = "{}"
if (Object.keys(request).length !== 0) {
let encodedRequest = {}
// Handle different types of requests
if (request === null || request === undefined) {
// Empty request
encodedRequest = {}
} else if (typeof request.toJSON === "function") {
// Proper protobuf object
encodedRequest = request.toJSON()
console.log("[DEBUG] request is", request, encodedRequest)
} else if (typeof request === "object") {
// Plain JavaScript object
encodedRequest = { ...request }
} else {
// Fallback
encodedRequest = { value: request }
}
// Send the request
+98
View File
@@ -0,0 +1,98 @@
export interface SlashCommand {
name: string
description: string
}
export const SUPPORTED_SLASH_COMMANDS: SlashCommand[] = [
{
name: "newtask",
description: "Create a new task with context from the current task",
},
]
// Regex for detecting slash commands in text
export const slashCommandRegex = /\/([a-zA-Z0-9_-]+)(\s|$)/
export const slashCommandRegexGlobal = new RegExp(slashCommandRegex.source, "g")
/**
* Determines whether the slash command menu should be displayed based on text input
*/
export function shouldShowSlashCommandsMenu(text: string, cursorPosition: number): boolean {
const beforeCursor = text.slice(0, cursorPosition)
// first check if there is a slash before the cursor
const slashIndex = beforeCursor.lastIndexOf("/")
if (slashIndex === -1) {
return false
}
// check if slash is at the very beginning (with optional whitespace)
const textBeforeSlash = beforeCursor.slice(0, slashIndex)
if (!/^\s*$/.test(textBeforeSlash)) {
return false
}
// potential partial or full command
const textAfterSlash = beforeCursor.slice(slashIndex + 1)
// don't show menu if there's whitespace after the slash but before the cursor
if (/\s/.test(textAfterSlash)) {
return false
}
return true
}
/**
* Gets filtered slash commands that match the current input
*/
export function getMatchingSlashCommands(query: string): SlashCommand[] {
if (!query) {
return [...SUPPORTED_SLASH_COMMANDS]
}
// filter commands that start with the query (case sensitive)
return SUPPORTED_SLASH_COMMANDS.filter((cmd) => cmd.name.startsWith(query))
}
/**
* Insert a slash command at position or replace partial command
*/
export function insertSlashCommand(text: string, commandName: string): { newValue: string; commandIndex: number } {
const slashIndex = text.indexOf("/")
// where the command ends, at the end of entire text or first space
const commandEndIndex = text.indexOf(" ", slashIndex)
// replace the partial command with the full command
const newValue =
text.substring(0, slashIndex + 1) + commandName + (commandEndIndex > -1 ? text.substring(commandEndIndex) : " ") // add extra space at the end if only slash command
return { newValue, commandIndex: slashIndex }
}
/**
* Determines the validation state of a slash command
* Returns partial if we have a partial match against valid commands, or full for full match
*/
export function validateSlashCommand(command: string): "full" | "partial" | null {
if (!command) {
return null
}
// case sensitive matching
const exactMatch = SUPPORTED_SLASH_COMMANDS.some((cmd) => cmd.name === command)
if (exactMatch) {
return "full"
}
const partialMatch = SUPPORTED_SLASH_COMMANDS.some((cmd) => cmd.name.startsWith(command))
if (partialMatch) {
return "partial"
}
return null // no match
}
+1 -1
View File
@@ -27,7 +27,7 @@ class VSCodeAPIWrapper {
* @remarks When running webview code inside a web browser, postMessage will instead
* log the given message to the console.
*
* @param message Abitrary data (must be JSON serializable) to send to the extension context.
* @param message Arbitrary data (must be JSON serializable) to send to the extension context.
*/
public postMessage(message: WebviewMessage) {
if (this.vsCodeApi) {