mirror of
https://github.com/cline/cline.git
synced 2026-09-13 09:50:12 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
703a0fc334 | ||
|
|
30459086b7 | ||
|
|
07d2057486 | ||
|
|
3b0326e4dd | ||
|
|
32c70e59f4 | ||
|
|
e52dd22b65 | ||
|
|
1022057316 | ||
|
|
487081f128 | ||
|
|
9a39cbd475 | ||
|
|
570646fda3 | ||
|
|
e8b21690ab | ||
|
|
3ef81cdf38 | ||
|
|
b5f4460db3 | ||
|
|
9a5a0e15b1 | ||
|
|
6abf0be8d1 | ||
|
|
c8b234ab06 | ||
|
|
022fdf47c0 | ||
|
|
fb3105f7bf | ||
|
|
bc87fdb4b3 | ||
|
|
c021b6464b | ||
|
|
180ebdad74 | ||
|
|
2a80fedf7d | ||
|
|
450583c81d | ||
|
|
45b1666325 | ||
|
|
6c5b99d304 | ||
|
|
02120810ce | ||
|
|
612744394a | ||
|
|
01a48736eb | ||
|
|
4add38032e | ||
|
|
73078d63ce | ||
|
|
89cbbe95e3 |
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
fix: task cancellation during thinking stream would result in 'Cline aborted stream' error
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
gRPC over vscode message bus to make messaging better
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
fix vertexai token count
|
||||
@@ -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 =
|
||||
@@ -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
@@ -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]
|
||||
|
||||
|
||||
@@ -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
@@ -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 Protocol(MCP)来创建新工具,并扩展自身的能力。虽然传统的自动化 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 使用费用,确保你在每一步都能清楚了解花费情况。
|
||||
|
||||
<!-- 透明像素以在浮动图像后创建换行 -->
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
|
||||
認識 Cline,一個可以使用您的**命令列介面** (CLI) 和**程式編輯器** (Editor) 的 AI 助理。
|
||||
|
||||
感謝 [Claude 3.7 Sonnet 的代理式程式設計能力](https://www.anthropic.com/claude/sonnet),Cline 能夠逐步處理複雜的軟體開發任務。透過能讓他建立和編輯檔案、探索大型專案、使用瀏覽器,以及執行終端機指令(在您授權後)的工具,他能以超越程式碼自動完成或技術支援的方式協助您。Cline 甚至能使用模型上下文協定(Model Context Protocol,MCP)來建立新工具並擴展自己的功能。雖然自主 AI 腳本傳統上會在沙箱環境中執行,但這個擴充套件提供了人機互動的圖形介面,讓您可以核准每個檔案變更和終端機指令,提供一個安全且容易使用的方式來探索代理式 AI 的潛力。
|
||||
感謝 [Claude 3.7 Sonnet 的代理式程式設計能力](https://www.anthropic.com/claude/sonnet),Cline 能夠逐步處理複雜的軟體開發任務。透過能讓他建立和編輯檔案、探索大型專案、使用瀏覽器,以及執行終端機指令(在您授權後)的工具,從而在程式碼補全或技術支援之外提供更深入的協助。Cline 甚至能使用模型上下文協定(Model Context Protocol,MCP)來建立新工具並擴展自己的功能。雖然自主 AI 腳本傳統上會在沙箱環境中執行,但這個擴充套件提供了人機互動的圖形介面,讓您可以核准每個檔案變更和終端機指令,提供一個安全且容易使用的方式來探索代理式 AI 的潛力。
|
||||
|
||||
1. 輸入您的任務,並可以加入圖片來將設計稿轉換成功能性應用程式,或使用截圖來修正錯誤。
|
||||
2. Cline 會先分析您的檔案結構和程式碼 AST、執行正規表達式搜尋,並讀取相關檔案,以便在現有專案中快速掌握狀況。透過仔細管理加入上下文的資訊,Cline 可以在不超過上下文視窗的情況下,為大型且複雜的專案提供有價值的協助。
|
||||
|
||||
+6
-1
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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.`,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: "",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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
|
||||
`
|
||||
@@ -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}`,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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:;
|
||||
|
||||
@@ -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]) =>
|
||||
|
||||
@@ -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.`)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export type ClineRulesToggles = Record<string, boolean> // filepath -> enabled/disabled
|
||||
+113
-8
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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) => ({ "<": "<", ">": ">", "&": "&" })[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}>
|
||||
|
||||
@@ -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
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user