mirror of
https://github.com/cline/cline.git
synced 2026-09-04 20:02:30 +08:00
Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 74cbf31cc3 | |||
| c2354c9832 | |||
| df36557c04 | |||
| dc9d3ae407 | |||
| 3210c4bc4b | |||
| 9f3daa4151 | |||
| ac2db41815 | |||
| df1d33c751 | |||
| 361494d18f |
@@ -0,0 +1,9 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Fix two bugs in DiffViewProvider file editing:
|
||||
|
||||
1. **Line boundary validation**: Add `safelyTruncateDocument()` to prevent out-of-bounds line errors on JetBrains hosts (fixes #8423, #8429). The gRPC protocol strictly validates line numbers, causing "truncateDocument INTERNAL: Wrong line" errors when `truncateDocument()` was called with a line number >= document line count.
|
||||
|
||||
2. **Content concatenation on final update**: When replacing content without a trailing newline, the old content at line N+1 was concatenated to the new content. Fixed by extending the replacement range to cover the entire document on final update.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Improve history view filter menu
|
||||
@@ -47,7 +47,10 @@ func (s *DiffService) generateDiffID() string {
|
||||
return fmt.Sprintf("diff_%d_%d", os.Getpid(), id)
|
||||
}
|
||||
|
||||
// splitLines splits content into lines, preserving line ending information
|
||||
// splitLines splits content into lines, preserving trailing newlines.
|
||||
// This matches the behavior of JavaScript's String.split("\n"):
|
||||
// - "hello\nworld\n" -> ["hello", "world", ""]
|
||||
// - "hello\nworld" -> ["hello", "world"]
|
||||
func splitLines(content string) []string {
|
||||
if content == "" {
|
||||
return []string{}
|
||||
@@ -65,10 +68,9 @@ func splitLines(content string) []string {
|
||||
}
|
||||
}
|
||||
|
||||
// Add the last line if it doesn't end with newline
|
||||
if current != "" {
|
||||
lines = append(lines, current)
|
||||
}
|
||||
// Always add the last segment - if content ends with newline, this will be
|
||||
// an empty string which preserves the trailing newline when joined back
|
||||
lines = append(lines, current)
|
||||
|
||||
return lines
|
||||
}
|
||||
@@ -176,9 +178,19 @@ func (s *DiffService) ReplaceText(ctx context.Context, req *proto.ReplaceTextReq
|
||||
endLine = startLine
|
||||
}
|
||||
|
||||
// Check if we're replacing to the end of the document
|
||||
replacingToEnd := endLine >= len(session.lines)
|
||||
|
||||
// Split new content into lines
|
||||
newLines := splitLines(newContent)
|
||||
|
||||
// Remove trailing empty line for proper splicing, BUT only when NOT replacing
|
||||
// to the end of the document. When replacing to the end, keep the trailing
|
||||
// empty string to preserve trailing newlines from the content.
|
||||
if !replacingToEnd && len(newLines) > 0 && newLines[len(newLines)-1] == "" {
|
||||
newLines = newLines[:len(newLines)-1]
|
||||
}
|
||||
|
||||
// Ensure we have enough lines in the current content
|
||||
for len(session.lines) < endLine {
|
||||
session.lines = append(session.lines, "")
|
||||
|
||||
Generated
+94
-83
@@ -84,6 +84,7 @@
|
||||
"p-timeout": "^6.1.4",
|
||||
"p-wait-for": "^5.0.2",
|
||||
"pdf-parse": "^1.1.1",
|
||||
"picomatch": "^4.0.3",
|
||||
"posthog-node": "^5.8.0",
|
||||
"puppeteer-chromium-resolver": "^23.0.0",
|
||||
"puppeteer-core": "^23.4.0",
|
||||
@@ -1182,6 +1183,7 @@
|
||||
"integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.27.1",
|
||||
"@babel/generator": "^7.28.3",
|
||||
@@ -2644,6 +2646,7 @@
|
||||
"node_modules/@grpc/grpc-js": {
|
||||
"version": "1.9.15",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@grpc/proto-loader": "^0.7.8",
|
||||
"@types/node": ">=12.12.47"
|
||||
@@ -3227,6 +3230,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.25.1.tgz",
|
||||
"integrity": "sha512-yO28oVFFC7EBoiKdAn+VqRm+plcfv4v0xp6osG/VsCB0NlPZWi87ajbCZZ8f/RvOFLEu7//rSRmuZZ7lMoe3gQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@hono/node-server": "^1.19.7",
|
||||
"ajv": "^8.17.1",
|
||||
@@ -3295,6 +3299,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz",
|
||||
"integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=8.0.0"
|
||||
}
|
||||
@@ -4910,8 +4915,7 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"peer": true
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-android-arm64": {
|
||||
"version": "4.52.4",
|
||||
@@ -4924,8 +4928,7 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"peer": true
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-darwin-arm64": {
|
||||
"version": "4.52.4",
|
||||
@@ -4938,8 +4941,7 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"peer": true
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-darwin-x64": {
|
||||
"version": "4.52.4",
|
||||
@@ -4952,8 +4954,7 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"peer": true
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-freebsd-arm64": {
|
||||
"version": "4.52.4",
|
||||
@@ -4966,8 +4967,7 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"peer": true
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-freebsd-x64": {
|
||||
"version": "4.52.4",
|
||||
@@ -4980,8 +4980,7 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"peer": true
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
|
||||
"version": "4.52.4",
|
||||
@@ -4994,8 +4993,7 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
|
||||
"version": "4.52.4",
|
||||
@@ -5008,8 +5006,7 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm64-gnu": {
|
||||
"version": "4.52.4",
|
||||
@@ -5022,8 +5019,7 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm64-musl": {
|
||||
"version": "4.52.4",
|
||||
@@ -5036,8 +5032,7 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-loong64-gnu": {
|
||||
"version": "4.52.4",
|
||||
@@ -5050,8 +5045,7 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
|
||||
"version": "4.52.4",
|
||||
@@ -5064,8 +5058,7 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
|
||||
"version": "4.52.4",
|
||||
@@ -5078,8 +5071,7 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-riscv64-musl": {
|
||||
"version": "4.52.4",
|
||||
@@ -5092,8 +5084,7 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-s390x-gnu": {
|
||||
"version": "4.52.4",
|
||||
@@ -5106,8 +5097,7 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-x64-gnu": {
|
||||
"version": "4.52.4",
|
||||
@@ -5120,8 +5110,7 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-x64-musl": {
|
||||
"version": "4.52.4",
|
||||
@@ -5134,8 +5123,7 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-openharmony-arm64": {
|
||||
"version": "4.52.4",
|
||||
@@ -5148,8 +5136,7 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openharmony"
|
||||
],
|
||||
"peer": true
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-arm64-msvc": {
|
||||
"version": "4.52.4",
|
||||
@@ -5162,8 +5149,7 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"peer": true
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-ia32-msvc": {
|
||||
"version": "4.52.4",
|
||||
@@ -5176,8 +5162,7 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"peer": true
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-x64-gnu": {
|
||||
"version": "4.52.4",
|
||||
@@ -5190,8 +5175,7 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"peer": true
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-x64-msvc": {
|
||||
"version": "4.52.4",
|
||||
@@ -5204,8 +5188,7 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"peer": true
|
||||
]
|
||||
},
|
||||
"node_modules/@sap-ai-sdk/ai-api": {
|
||||
"version": "2.1.0",
|
||||
@@ -6768,8 +6751,7 @@
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/get-folder-size": {
|
||||
"version": "3.0.4",
|
||||
@@ -6801,6 +6783,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.21.tgz",
|
||||
"integrity": "sha512-CsGG2P3I5y48RPMfprQGfy4JPRZ6csfC3ltBZSRItG3ngggmNY/qs2uZKp4p9VbrpqNNSMzUZNFZKzgOGnd/VA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
@@ -7006,6 +6989,19 @@
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/@vscode/test-cli/node_modules/picomatch": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
|
||||
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8.6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/@vscode/test-cli/node_modules/readdirp": {
|
||||
"version": "3.6.0",
|
||||
"dev": true,
|
||||
@@ -7504,6 +7500,7 @@
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
|
||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@@ -7651,6 +7648,19 @@
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/anymatch/node_modules/picomatch": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
|
||||
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8.6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/append-transform": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/append-transform/-/append-transform-2.0.0.tgz",
|
||||
@@ -8270,6 +8280,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.8.3",
|
||||
"caniuse-lite": "^1.0.30001741",
|
||||
@@ -9483,7 +9494,8 @@
|
||||
},
|
||||
"node_modules/devtools-protocol": {
|
||||
"version": "0.0.1342118",
|
||||
"license": "BSD-3-Clause"
|
||||
"license": "BSD-3-Clause",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/diff": {
|
||||
"version": "5.2.0",
|
||||
@@ -12459,6 +12471,7 @@
|
||||
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
|
||||
"integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"jiti": "lib/jiti-cli.mjs"
|
||||
}
|
||||
@@ -12692,6 +12705,7 @@
|
||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.1.tgz",
|
||||
"integrity": "sha512-xi6IyHML+c9+Q3W0S4fCQJOym42pyurFiJUHEcEyHS0CeKzia4yZDEsLlqOFykxOdHpNy0NmvVO31vcSqAxJCg==",
|
||||
"license": "MPL-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"detect-libc": "^2.0.3"
|
||||
},
|
||||
@@ -13568,6 +13582,18 @@
|
||||
"node": ">=8.6"
|
||||
}
|
||||
},
|
||||
"node_modules/micromatch/node_modules/picomatch": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
|
||||
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8.6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/mime": {
|
||||
"version": "1.6.0",
|
||||
"dev": true,
|
||||
@@ -13760,6 +13786,19 @@
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/mocha/node_modules/picomatch": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
|
||||
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8.6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/mocha/node_modules/readdirp": {
|
||||
"version": "3.6.0",
|
||||
"dev": true,
|
||||
@@ -15389,10 +15428,13 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/picomatch": {
|
||||
"version": "2.3.1",
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=8.6"
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
@@ -15572,7 +15614,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.11",
|
||||
"picocolors": "^1.1.1",
|
||||
@@ -15593,7 +15634,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"nanoid": "bin/nanoid.cjs"
|
||||
},
|
||||
@@ -16261,7 +16301,6 @@
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.52.4.tgz",
|
||||
"integrity": "sha512-CLEVl+MnPAiKh5pl4dEWSyMTpuflgNQiLGhMv8ezD5W/qP8AKvmYpCOKRRNOh7oRKnauBZ4SyeYkMS+1VSyKwQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/estree": "1.0.8"
|
||||
},
|
||||
@@ -17687,7 +17726,6 @@
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
|
||||
"integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"fdir": "^6.5.0",
|
||||
"picomatch": "^4.0.3"
|
||||
@@ -17704,7 +17742,6 @@
|
||||
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
||||
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
},
|
||||
@@ -17717,19 +17754,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/tinyglobby/node_modules/picomatch": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/tmp": {
|
||||
"version": "0.2.5",
|
||||
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz",
|
||||
@@ -18068,6 +18092,7 @@
|
||||
"version": "5.5.3",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -18327,7 +18352,6 @@
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.2.2.tgz",
|
||||
"integrity": "sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.25.0",
|
||||
"fdir": "^6.5.0",
|
||||
@@ -18402,7 +18426,6 @@
|
||||
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
||||
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
},
|
||||
@@ -18415,19 +18438,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/vite/node_modules/picomatch": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/voca": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/voca/-/voca-1.4.1.tgz",
|
||||
@@ -19098,6 +19108,7 @@
|
||||
"node_modules/zod": {
|
||||
"version": "3.25.76",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
@@ -19112,4 +19123,4 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -403,6 +403,10 @@
|
||||
"storybook": "cd webview-ui && npm run storybook"
|
||||
},
|
||||
"lint-staged": {
|
||||
"src/shared/storage/state-keys.ts": [
|
||||
"node scripts/generate-state-proto.mjs",
|
||||
"git add proto/cline/state.proto"
|
||||
],
|
||||
"*": [
|
||||
"biome check --write --staged --no-errors-on-unmatched --files-ignore-unknown=true"
|
||||
]
|
||||
@@ -528,6 +532,7 @@
|
||||
"p-timeout": "^6.1.4",
|
||||
"p-wait-for": "^5.0.2",
|
||||
"pdf-parse": "^1.1.1",
|
||||
"picomatch": "^4.0.3",
|
||||
"posthog-node": "^5.8.0",
|
||||
"puppeteer-chromium-resolver": "^23.0.0",
|
||||
"puppeteer-core": "^23.4.0",
|
||||
|
||||
+216
-171
@@ -54,184 +54,229 @@ message AutoApprovalSettings {
|
||||
optional bool enable_notifications = 3;
|
||||
}
|
||||
|
||||
// NOTE: Add the new secret fields under SECRETS_KEYS in src/shared/storage/state-keys.ts
|
||||
// and use the scripts/generate-state-proto.mjs script to regenerate this list.
|
||||
message Secrets {
|
||||
optional string api_key = 1;
|
||||
optional string open_router_api_key = 4;
|
||||
optional string aws_access_key = 5;
|
||||
optional string aws_secret_key = 6;
|
||||
optional string aws_session_token = 7;
|
||||
optional string aws_bedrock_api_key = 8;
|
||||
optional string open_ai_api_key = 9;
|
||||
optional string gemini_api_key = 10;
|
||||
optional string open_ai_native_api_key = 11;
|
||||
optional string ollama_api_key = 12;
|
||||
optional string deep_seek_api_key = 13;
|
||||
optional string requesty_api_key = 14;
|
||||
optional string together_api_key = 15;
|
||||
optional string fireworks_api_key = 16;
|
||||
optional string qwen_api_key = 17;
|
||||
optional string doubao_api_key = 18;
|
||||
optional string mistral_api_key = 19;
|
||||
optional string lite_llm_api_key = 20;
|
||||
optional string auth_nonce = 21;
|
||||
optional string asksage_api_key = 22;
|
||||
optional string xai_api_key = 23;
|
||||
optional string moonshot_api_key = 24;
|
||||
optional string zai_api_key = 25;
|
||||
optional string hugging_face_api_key = 26;
|
||||
optional string nebius_api_key = 27;
|
||||
optional string sambanova_api_key = 28;
|
||||
optional string cerebras_api_key = 29;
|
||||
optional string sap_ai_core_client_id = 30;
|
||||
optional string sap_ai_core_client_secret = 31;
|
||||
optional string groq_api_key = 32;
|
||||
optional string huawei_cloud_maas_api_key = 33;
|
||||
optional string baseten_api_key = 34;
|
||||
optional string vercel_ai_gateway_api_key = 35;
|
||||
optional string dify_api_key = 36;
|
||||
optional string oca_api_key = 37;
|
||||
optional string oca_refresh_token = 38;
|
||||
optional string hicap_api_key = 39;
|
||||
optional string mcp_oauth_secrets = 40;
|
||||
optional string cline_account_id = 2;
|
||||
optional string open_router_api_key = 3;
|
||||
optional string aws_access_key = 4;
|
||||
optional string aws_secret_key = 5;
|
||||
optional string aws_session_token = 6;
|
||||
optional string aws_bedrock_api_key = 7;
|
||||
optional string open_ai_api_key = 8;
|
||||
optional string gemini_api_key = 9;
|
||||
optional string open_ai_native_api_key = 10;
|
||||
optional string ollama_api_key = 11;
|
||||
optional string deep_seek_api_key = 12;
|
||||
optional string requesty_api_key = 13;
|
||||
optional string together_api_key = 14;
|
||||
optional string fireworks_api_key = 15;
|
||||
optional string qwen_api_key = 16;
|
||||
optional string doubao_api_key = 17;
|
||||
optional string mistral_api_key = 18;
|
||||
optional string lite_llm_api_key = 19;
|
||||
optional string auth_nonce = 20;
|
||||
optional string asksage_api_key = 21;
|
||||
optional string xai_api_key = 22;
|
||||
optional string moonshot_api_key = 23;
|
||||
optional string zai_api_key = 24;
|
||||
optional string hugging_face_api_key = 25;
|
||||
optional string nebius_api_key = 26;
|
||||
optional string sambanova_api_key = 27;
|
||||
optional string cerebras_api_key = 28;
|
||||
optional string sap_ai_core_client_id = 29;
|
||||
optional string sap_ai_core_client_secret = 30;
|
||||
optional string groq_api_key = 31;
|
||||
optional string huawei_cloud_maas_api_key = 32;
|
||||
optional string baseten_api_key = 33;
|
||||
optional string vercel_ai_gateway_api_key = 34;
|
||||
optional string dify_api_key = 35;
|
||||
optional string minimax_api_key = 36;
|
||||
optional string hicap_api_key = 37;
|
||||
optional string aihubmix_api_key = 38;
|
||||
optional string nous_research_api_key = 39;
|
||||
optional string remote_lite_llm_api_key = 40;
|
||||
optional string oca_api_key = 41;
|
||||
optional string oca_refresh_token = 42;
|
||||
optional string mcp_o_auth_secrets = 43;
|
||||
}
|
||||
|
||||
// NOTE: Add new fields under API_HANDLER_SETTINGS_FIELDS or USER_SETTINGS_FIELDS
|
||||
// in src/shared/storage/state-keys.ts and use the scripts/generate-state-proto.mjs
|
||||
// script to regenerate this list.
|
||||
message Settings {
|
||||
optional string aws_region = 1;
|
||||
optional bool aws_use_cross_region_inference = 2;
|
||||
optional bool aws_bedrock_use_prompt_cache = 3;
|
||||
optional string aws_bedrock_endpoint = 4;
|
||||
optional string aws_profile = 5;
|
||||
optional string aws_authentication = 6;
|
||||
optional bool aws_use_profile = 7;
|
||||
optional string vertex_project_id = 8;
|
||||
optional string vertex_region = 9;
|
||||
optional string requesty_base_url = 10;
|
||||
optional string open_ai_base_url = 11;
|
||||
// map<string, string> open_ai_headers = 12;
|
||||
optional string ollama_base_url = 13;
|
||||
optional string ollama_api_options_ctx_num = 14;
|
||||
optional string lm_studio_base_url = 15;
|
||||
optional string lm_studio_max_tokens = 16;
|
||||
optional string anthropic_base_url = 17;
|
||||
optional string gemini_base_url = 18;
|
||||
optional string azure_api_version = 19;
|
||||
optional string open_router_provider_sorting = 20;
|
||||
optional AutoApprovalSettings auto_approval_settings = 21;
|
||||
optional BrowserSettings browser_settings = 24;
|
||||
optional string lite_llm_base_url = 25;
|
||||
optional bool lite_llm_use_prompt_cache = 26;
|
||||
optional int32 fireworks_model_max_completion_tokens = 27;
|
||||
optional int32 fireworks_model_max_tokens = 28;
|
||||
optional string lite_llm_base_url = 1;
|
||||
optional bool lite_llm_use_prompt_cache = 2;
|
||||
map<string, string> open_ai_headers = 3;
|
||||
optional string anthropic_base_url = 4;
|
||||
optional string open_router_provider_sorting = 5;
|
||||
optional string aws_region = 6;
|
||||
optional bool aws_use_cross_region_inference = 7;
|
||||
optional bool aws_use_global_inference = 8;
|
||||
optional bool aws_bedrock_use_prompt_cache = 9;
|
||||
optional string aws_authentication = 10;
|
||||
optional bool aws_use_profile = 11;
|
||||
optional string aws_profile = 12;
|
||||
optional string aws_bedrock_endpoint = 13;
|
||||
optional string claude_code_path = 14;
|
||||
optional string vertex_project_id = 15;
|
||||
optional string vertex_region = 16;
|
||||
optional string open_ai_base_url = 17;
|
||||
optional string ollama_base_url = 18;
|
||||
optional string ollama_api_options_ctx_num = 19;
|
||||
optional string lm_studio_base_url = 20;
|
||||
optional string lm_studio_max_tokens = 21;
|
||||
optional string gemini_base_url = 22;
|
||||
optional string requesty_base_url = 23;
|
||||
optional int32 fireworks_model_max_completion_tokens = 24;
|
||||
optional int32 fireworks_model_max_tokens = 25;
|
||||
optional string qwen_code_oauth_path = 26;
|
||||
optional string azure_api_version = 27;
|
||||
optional bool azure_identity = 28;
|
||||
optional string qwen_api_line = 29;
|
||||
optional string moonshot_api_line = 30;
|
||||
optional string zai_api_line = 31;
|
||||
optional string telemetry_setting = 32;
|
||||
optional string asksage_api_url = 33;
|
||||
optional bool plan_act_separate_models_setting = 34;
|
||||
optional bool enable_checkpoints_setting = 35;
|
||||
optional int32 request_timeout_ms = 36;
|
||||
optional int32 shell_integration_timeout = 37;
|
||||
optional string default_terminal_profile = 38;
|
||||
optional int32 terminal_output_line_limit = 39;
|
||||
optional string sap_ai_core_token_url = 40;
|
||||
optional string sap_ai_core_base_url = 41;
|
||||
optional string sap_ai_resource_group = 42;
|
||||
optional bool sap_ai_core_use_orchestration_mode = 43;
|
||||
optional string claude_code_path = 44;
|
||||
optional string qwen_code_oauth_path = 45;
|
||||
optional bool strict_plan_mode_enabled = 46;
|
||||
optional bool yolo_mode_toggled = 47;
|
||||
optional bool use_auto_condense = 48;
|
||||
optional string preferred_language = 49;
|
||||
optional OpenaiReasoningEffort openai_reasoning_effort = 50;
|
||||
optional PlanActMode mode = 51;
|
||||
optional DictationSettings dictation_settings = 52;
|
||||
optional FocusChainSettings focus_chain_settings = 53;
|
||||
optional string custom_prompt = 54;
|
||||
optional string dify_base_url = 55;
|
||||
optional double auto_condense_threshold = 56;
|
||||
optional string oca_base_url = 57;
|
||||
optional ApiProvider plan_mode_api_provider = 58;
|
||||
optional string plan_mode_api_model_id = 59;
|
||||
optional int64 plan_mode_thinking_budget_tokens = 60;
|
||||
optional string plan_mode_reasoning_effort = 61;
|
||||
optional LanguageModelChatSelector plan_mode_vs_code_lm_model_selector = 62;
|
||||
optional bool plan_mode_aws_bedrock_custom_selected = 63;
|
||||
optional string plan_mode_aws_bedrock_custom_model_base_id = 64;
|
||||
optional string plan_mode_open_router_model_id = 65;
|
||||
optional OpenRouterModelInfo plan_mode_open_router_model_info = 66;
|
||||
optional string plan_mode_open_ai_model_id = 67;
|
||||
optional OpenAiCompatibleModelInfo plan_mode_open_ai_model_info = 68;
|
||||
optional string plan_mode_ollama_model_id = 69;
|
||||
optional string plan_mode_lm_studio_model_id = 70;
|
||||
optional string plan_mode_lite_llm_model_id = 71;
|
||||
optional LiteLLMModelInfo plan_mode_lite_llm_model_info = 72;
|
||||
optional string plan_mode_requesty_model_id = 73;
|
||||
optional OpenRouterModelInfo plan_mode_requesty_model_info = 74;
|
||||
optional string plan_mode_together_model_id = 75;
|
||||
optional string plan_mode_fireworks_model_id = 76;
|
||||
optional string plan_mode_sap_ai_core_model_id = 77;
|
||||
optional string plan_mode_sap_ai_core_deployment_id = 78;
|
||||
optional string plan_mode_groq_model_id = 79;
|
||||
optional OpenRouterModelInfo plan_mode_groq_model_info = 80;
|
||||
optional string plan_mode_baseten_model_id = 81;
|
||||
optional OpenRouterModelInfo plan_mode_baseten_model_info = 82;
|
||||
optional string plan_mode_hugging_face_model_id = 83;
|
||||
optional OpenRouterModelInfo plan_mode_hugging_face_model_info = 84;
|
||||
optional string plan_mode_huawei_cloud_maas_model_id = 85;
|
||||
optional OpenRouterModelInfo plan_mode_huawei_cloud_maas_model_info = 86;
|
||||
optional string plan_mode_oca_model_id = 87;
|
||||
optional OcaModelInfo plan_mode_oca_model_info = 88;
|
||||
optional ApiProvider act_mode_api_provider = 89;
|
||||
optional string act_mode_api_model_id = 90;
|
||||
optional int64 act_mode_thinking_budget_tokens = 91;
|
||||
optional string act_mode_reasoning_effort = 92;
|
||||
optional LanguageModelChatSelector act_mode_vs_code_lm_model_selector = 93;
|
||||
optional bool act_mode_aws_bedrock_custom_selected = 94;
|
||||
optional string act_mode_aws_bedrock_custom_model_base_id = 95;
|
||||
optional string act_mode_open_router_model_id = 96;
|
||||
optional OpenRouterModelInfo act_mode_open_router_model_info = 97;
|
||||
optional string act_mode_open_ai_model_id = 98;
|
||||
optional OpenAiCompatibleModelInfo act_mode_open_ai_model_info = 99;
|
||||
optional string act_mode_ollama_model_id = 100;
|
||||
optional string act_mode_lm_studio_model_id = 101;
|
||||
optional string act_mode_lite_llm_model_id = 102;
|
||||
optional LiteLLMModelInfo act_mode_lite_llm_model_info = 103;
|
||||
optional string act_mode_requesty_model_id = 104;
|
||||
optional OpenRouterModelInfo act_mode_requesty_model_info = 105;
|
||||
optional string act_mode_together_model_id = 106;
|
||||
optional string act_mode_fireworks_model_id = 107;
|
||||
optional string act_mode_sap_ai_core_model_id = 108;
|
||||
optional string act_mode_sap_ai_core_deployment_id = 109;
|
||||
optional string act_mode_groq_model_id = 110;
|
||||
optional OpenRouterModelInfo act_mode_groq_model_info = 111;
|
||||
optional string act_mode_baseten_model_id = 112;
|
||||
optional OpenRouterModelInfo act_mode_baseten_model_info = 113;
|
||||
optional string act_mode_hugging_face_model_id = 114;
|
||||
optional OpenRouterModelInfo act_mode_hugging_face_model_info = 115;
|
||||
optional string act_mode_huawei_cloud_maas_model_id = 116;
|
||||
optional OpenRouterModelInfo act_mode_huawei_cloud_maas_model_info = 117;
|
||||
optional string plan_mode_vercel_ai_gateway_model_id = 118;
|
||||
optional OpenRouterModelInfo plan_mode_vercel_ai_gateway_model_info = 119;
|
||||
optional string act_mode_vercel_ai_gateway_model_id = 120;
|
||||
optional OpenRouterModelInfo act_mode_vercel_ai_gateway_model_info = 121;
|
||||
optional string act_mode_oca_model_id = 122;
|
||||
optional OcaModelInfo act_mode_oca_model_info = 123;
|
||||
optional int32 max_consecutive_mistakes = 124;
|
||||
optional bool subagents_enabled = 125;
|
||||
optional int32 subagent_terminal_output_line_limit = 126;
|
||||
optional string aihubmix_api_key = 127;
|
||||
optional string aihubmix_base_url = 128;
|
||||
optional string aihubmix_app_code = 129;
|
||||
optional string plan_mode_aihubmix_model_id = 130;
|
||||
optional OpenAiCompatibleModelInfo plan_mode_aihubmix_model_info = 131;
|
||||
optional string act_mode_aihubmix_model_id = 132;
|
||||
optional OpenAiCompatibleModelInfo act_mode_aihubmix_model_info = 133;
|
||||
optional bool cline_web_tools_enabled = 134;
|
||||
optional bool hooks_enabled = 135;
|
||||
optional bool azure_identity = 136;
|
||||
optional bool skills_enabled = 137;
|
||||
optional bool opt_out_of_remote_config = 138;
|
||||
optional string asksage_api_url = 31;
|
||||
optional int32 request_timeout_ms = 32;
|
||||
optional string sap_ai_resource_group = 33;
|
||||
optional string sap_ai_core_token_url = 34;
|
||||
optional string sap_ai_core_base_url = 35;
|
||||
optional bool sap_ai_core_use_orchestration_mode = 36;
|
||||
optional string dify_base_url = 37;
|
||||
optional string zai_api_line = 38;
|
||||
optional string oca_base_url = 39;
|
||||
optional string minimax_api_line = 40;
|
||||
optional string oca_mode = 41;
|
||||
optional string aihubmix_base_url = 42;
|
||||
optional string aihubmix_app_code = 43;
|
||||
optional string plan_mode_api_model_id = 44;
|
||||
optional int64 plan_mode_thinking_budget_tokens = 45;
|
||||
optional string gemini_plan_mode_thinking_level = 46;
|
||||
optional string plan_mode_reasoning_effort = 47;
|
||||
optional string plan_mode_verbosity = 48;
|
||||
optional LanguageModelChatSelector plan_mode_vs_code_lm_model_selector = 49;
|
||||
optional bool plan_mode_aws_bedrock_custom_selected = 50;
|
||||
optional string plan_mode_aws_bedrock_custom_model_base_id = 51;
|
||||
optional string plan_mode_open_router_model_id = 52;
|
||||
optional OpenRouterModelInfo plan_mode_open_router_model_info = 53;
|
||||
optional string plan_mode_open_ai_model_id = 54;
|
||||
optional OpenAiCompatibleModelInfo plan_mode_open_ai_model_info = 55;
|
||||
optional string plan_mode_ollama_model_id = 56;
|
||||
optional string plan_mode_lm_studio_model_id = 57;
|
||||
optional string plan_mode_lite_llm_model_id = 58;
|
||||
optional LiteLLMModelInfo plan_mode_lite_llm_model_info = 59;
|
||||
optional string plan_mode_requesty_model_id = 60;
|
||||
optional OpenRouterModelInfo plan_mode_requesty_model_info = 61;
|
||||
optional string plan_mode_together_model_id = 62;
|
||||
optional string plan_mode_fireworks_model_id = 63;
|
||||
optional string plan_mode_sap_ai_core_model_id = 64;
|
||||
optional string plan_mode_sap_ai_core_deployment_id = 65;
|
||||
optional string plan_mode_groq_model_id = 66;
|
||||
optional OpenRouterModelInfo plan_mode_groq_model_info = 67;
|
||||
optional string plan_mode_baseten_model_id = 68;
|
||||
optional OpenRouterModelInfo plan_mode_baseten_model_info = 69;
|
||||
optional string plan_mode_hugging_face_model_id = 70;
|
||||
optional OpenRouterModelInfo plan_mode_hugging_face_model_info = 71;
|
||||
optional string plan_mode_huawei_cloud_maas_model_id = 72;
|
||||
optional OpenRouterModelInfo plan_mode_huawei_cloud_maas_model_info = 73;
|
||||
optional string plan_mode_oca_model_id = 74;
|
||||
optional OcaModelInfo plan_mode_oca_model_info = 75;
|
||||
optional string plan_mode_oca_reasoning_effort = 76;
|
||||
optional string plan_mode_aihubmix_model_id = 77;
|
||||
optional OpenAiCompatibleModelInfo plan_mode_aihubmix_model_info = 78;
|
||||
optional string plan_mode_hicap_model_id = 79;
|
||||
optional OpenRouterModelInfo plan_mode_hicap_model_info = 80;
|
||||
optional string plan_mode_nous_research_model_id = 81;
|
||||
optional string plan_mode_vercel_ai_gateway_model_id = 82;
|
||||
optional OpenRouterModelInfo plan_mode_vercel_ai_gateway_model_info = 83;
|
||||
optional string act_mode_api_model_id = 84;
|
||||
optional int64 act_mode_thinking_budget_tokens = 85;
|
||||
optional string gemini_act_mode_thinking_level = 86;
|
||||
optional string act_mode_reasoning_effort = 87;
|
||||
optional string act_mode_verbosity = 88;
|
||||
optional LanguageModelChatSelector act_mode_vs_code_lm_model_selector = 89;
|
||||
optional bool act_mode_aws_bedrock_custom_selected = 90;
|
||||
optional string act_mode_aws_bedrock_custom_model_base_id = 91;
|
||||
optional string act_mode_open_router_model_id = 92;
|
||||
optional OpenRouterModelInfo act_mode_open_router_model_info = 93;
|
||||
optional string act_mode_open_ai_model_id = 94;
|
||||
optional OpenAiCompatibleModelInfo act_mode_open_ai_model_info = 95;
|
||||
optional string act_mode_ollama_model_id = 96;
|
||||
optional string act_mode_lm_studio_model_id = 97;
|
||||
optional string act_mode_lite_llm_model_id = 98;
|
||||
optional LiteLLMModelInfo act_mode_lite_llm_model_info = 99;
|
||||
optional string act_mode_requesty_model_id = 100;
|
||||
optional OpenRouterModelInfo act_mode_requesty_model_info = 101;
|
||||
optional string act_mode_together_model_id = 102;
|
||||
optional string act_mode_fireworks_model_id = 103;
|
||||
optional string act_mode_sap_ai_core_model_id = 104;
|
||||
optional string act_mode_sap_ai_core_deployment_id = 105;
|
||||
optional string act_mode_groq_model_id = 106;
|
||||
optional OpenRouterModelInfo act_mode_groq_model_info = 107;
|
||||
optional string act_mode_baseten_model_id = 108;
|
||||
optional OpenRouterModelInfo act_mode_baseten_model_info = 109;
|
||||
optional string act_mode_hugging_face_model_id = 110;
|
||||
optional OpenRouterModelInfo act_mode_hugging_face_model_info = 111;
|
||||
optional string act_mode_huawei_cloud_maas_model_id = 112;
|
||||
optional OpenRouterModelInfo act_mode_huawei_cloud_maas_model_info = 113;
|
||||
optional string act_mode_oca_model_id = 114;
|
||||
optional OcaModelInfo act_mode_oca_model_info = 115;
|
||||
optional string act_mode_oca_reasoning_effort = 116;
|
||||
optional string act_mode_aihubmix_model_id = 117;
|
||||
optional OpenAiCompatibleModelInfo act_mode_aihubmix_model_info = 118;
|
||||
optional string act_mode_hicap_model_id = 119;
|
||||
optional OpenRouterModelInfo act_mode_hicap_model_info = 120;
|
||||
optional string act_mode_nous_research_model_id = 121;
|
||||
optional string act_mode_vercel_ai_gateway_model_id = 122;
|
||||
optional OpenRouterModelInfo act_mode_vercel_ai_gateway_model_info = 123;
|
||||
optional ApiProvider plan_mode_api_provider = 124;
|
||||
optional ApiProvider act_mode_api_provider = 125;
|
||||
optional string hicap_model_id = 126;
|
||||
optional string lm_studio_model_id = 127;
|
||||
optional AutoApprovalSettings auto_approval_settings = 128;
|
||||
optional string global_cline_rules_toggles = 129;
|
||||
optional string global_workflow_toggles = 130;
|
||||
optional string global_skills_toggles = 131;
|
||||
optional BrowserSettings browser_settings = 132;
|
||||
optional string telemetry_setting = 133;
|
||||
optional bool plan_act_separate_models_setting = 134;
|
||||
optional bool enable_checkpoints_setting = 135;
|
||||
optional int32 shell_integration_timeout = 136;
|
||||
optional string default_terminal_profile = 137;
|
||||
optional int32 terminal_output_line_limit = 138;
|
||||
optional int32 max_consecutive_mistakes = 139;
|
||||
optional int32 subagent_terminal_output_line_limit = 140;
|
||||
optional bool strict_plan_mode_enabled = 141;
|
||||
optional bool yolo_mode_toggled = 142;
|
||||
optional bool use_auto_condense = 143;
|
||||
optional bool cline_web_tools_enabled = 144;
|
||||
optional string preferred_language = 145;
|
||||
optional OpenaiReasoningEffort openai_reasoning_effort = 146;
|
||||
optional PlanActMode mode = 147;
|
||||
optional DictationSettings dictation_settings = 148;
|
||||
optional FocusChainSettings focus_chain_settings = 149;
|
||||
optional string custom_prompt = 150;
|
||||
optional double auto_condense_threshold = 151;
|
||||
optional bool hooks_enabled = 152;
|
||||
optional bool subagents_enabled = 153;
|
||||
optional bool enable_parallel_tool_calling = 154;
|
||||
optional bool background_edit_enabled = 155;
|
||||
optional bool skills_enabled = 156;
|
||||
optional bool opt_out_of_remote_config = 157;
|
||||
optional bool open_telemetry_enabled = 158;
|
||||
optional string open_telemetry_metrics_exporter = 159;
|
||||
optional string open_telemetry_logs_exporter = 160;
|
||||
optional string open_telemetry_otlp_protocol = 161;
|
||||
optional string open_telemetry_otlp_endpoint = 162;
|
||||
optional string open_telemetry_otlp_metrics_protocol = 163;
|
||||
optional string open_telemetry_otlp_metrics_endpoint = 164;
|
||||
optional string open_telemetry_otlp_logs_protocol = 165;
|
||||
optional string open_telemetry_otlp_logs_endpoint = 166;
|
||||
optional int32 open_telemetry_metric_export_interval = 167;
|
||||
optional bool open_telemetry_otlp_insecure = 168;
|
||||
optional int32 open_telemetry_log_batch_size = 169;
|
||||
optional int32 open_telemetry_log_batch_timeout = 170;
|
||||
optional int32 open_telemetry_log_max_queue_size = 171;
|
||||
}
|
||||
|
||||
message DictationSettings {
|
||||
|
||||
@@ -0,0 +1,413 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Generates proto message definitions from TypeScript source of truth.
|
||||
*
|
||||
* This script reads the field definitions from src/shared/storage/state-keys.ts
|
||||
* and generates the corresponding proto message definitions for Secrets and Settings.
|
||||
*
|
||||
* Usage: node scripts/generate-state-proto.mjs
|
||||
*
|
||||
* The generated proto content is written to proto/cline/state.proto,
|
||||
* replacing only the Secrets and Settings messages while preserving
|
||||
* the rest of the file (services, enums, other messages).
|
||||
*/
|
||||
|
||||
import * as fs from "node:fs/promises"
|
||||
import { Project, SyntaxKind } from "ts-morph"
|
||||
|
||||
const STATE_KEYS_PATH = "src/shared/storage/state-keys.ts"
|
||||
const STATE_PROTO_PATH = "proto/cline/state.proto"
|
||||
|
||||
/**
|
||||
* Convert camelCase to snake_case for proto field names
|
||||
*/
|
||||
function camelToSnake(str) {
|
||||
return str.replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`)
|
||||
}
|
||||
|
||||
// Fields that should use int64 instead of int32
|
||||
const INT64_FIELDS = new Set(["planModeThinkingBudgetTokens", "actModeThinkingBudgetTokens"])
|
||||
|
||||
// Fields that should use double instead of int32
|
||||
const DOUBLE_FIELDS = new Set(["autoCondenseThreshold"])
|
||||
|
||||
/**
|
||||
* Infer proto type from TypeScript type expression
|
||||
* @param {string} typeText - The TypeScript type expression
|
||||
* @param {string} [fieldName] - Optional field name for field-specific overrides
|
||||
*/
|
||||
function inferProtoType(typeText, fieldName) {
|
||||
// Remove 'undefined' from union types
|
||||
const cleanType = typeText
|
||||
.replace(/\s*\|\s*undefined/g, "")
|
||||
.replace(/undefined\s*\|\s*/g, "")
|
||||
.trim()
|
||||
|
||||
// Handle common types
|
||||
if (cleanType === "string") {
|
||||
return "string"
|
||||
}
|
||||
if (cleanType === "boolean") {
|
||||
return "bool"
|
||||
}
|
||||
if (cleanType === "number") {
|
||||
// Some number fields need specific numeric types
|
||||
if (fieldName && INT64_FIELDS.has(fieldName)) {
|
||||
return "int64"
|
||||
}
|
||||
if (fieldName && DOUBLE_FIELDS.has(fieldName)) {
|
||||
return "double"
|
||||
}
|
||||
return "int32"
|
||||
}
|
||||
|
||||
// Handle Record<string, string> as map<string, string>
|
||||
if (/Record\s*<\s*string\s*,\s*string\s*>/.test(cleanType)) {
|
||||
return "map<string, string>"
|
||||
}
|
||||
|
||||
// Handle specific known types that map to proto messages/enums
|
||||
// Order matters! More specific types must come before generic ones
|
||||
// (e.g., OpenAiCompatibleModelInfo before ModelInfo)
|
||||
// Check known types BEFORE string literals, since types like `"act" as Mode`
|
||||
// contain quotes but should map to proto enums
|
||||
const knownTypes = [
|
||||
// Specific model info types first
|
||||
["OpenAiCompatibleModelInfo", "OpenAiCompatibleModelInfo"],
|
||||
["LiteLLMModelInfo", "LiteLLMModelInfo"],
|
||||
["OcaModelInfo", "OcaModelInfo"],
|
||||
// Generic ModelInfo last (catches OpenRouterModelInfo, etc.)
|
||||
["ModelInfo", "OpenRouterModelInfo"],
|
||||
// Other types - order matters for substring matching
|
||||
["AutoApprovalSettings", "AutoApprovalSettings"],
|
||||
["BrowserSettings", "BrowserSettings"],
|
||||
["DictationSettings", "DictationSettings"],
|
||||
["FocusChainSettings", "FocusChainSettings"],
|
||||
["OpenaiReasoningEffort", "OpenaiReasoningEffort"],
|
||||
["PlanActMode", "PlanActMode"],
|
||||
["ApiProvider", "ApiProvider"],
|
||||
["LanguageModelChatSelector", "LanguageModelChatSelector"], // Must come before "Mode" check
|
||||
]
|
||||
|
||||
for (const [tsType, protoType] of knownTypes) {
|
||||
if (cleanType.includes(tsType)) {
|
||||
return protoType
|
||||
}
|
||||
}
|
||||
|
||||
// Check for Mode type separately with word boundary to avoid matching "VsCodeLmModelSelector"
|
||||
// This handles TS `Mode` type which maps to proto `PlanActMode`
|
||||
if (/\bMode\b/.test(cleanType)) {
|
||||
return "PlanActMode"
|
||||
}
|
||||
|
||||
// Handle specific string literal unions (treat as string)
|
||||
// This comes after known types check since some types like `"act" as Mode` contain quotes
|
||||
if (cleanType.includes('"') || cleanType.includes("'")) {
|
||||
return "string"
|
||||
}
|
||||
|
||||
// Default to string for complex types we can't map
|
||||
return "string"
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the SECRETS_KEYS array from state-keys.ts
|
||||
*/
|
||||
function parseSecretsKeys(sourceFile) {
|
||||
const secretsDecl = sourceFile.getVariableDeclaration("SECRETS_KEYS")
|
||||
if (!secretsDecl) {
|
||||
throw new Error("Could not find SECRETS_KEYS declaration")
|
||||
}
|
||||
|
||||
let initializer = secretsDecl.getInitializer()
|
||||
if (!initializer) {
|
||||
throw new Error("SECRETS_KEYS has no initializer")
|
||||
}
|
||||
|
||||
// Handle 'as const' expression
|
||||
if (initializer.getKind() === SyntaxKind.AsExpression) {
|
||||
initializer = initializer.getExpression()
|
||||
}
|
||||
|
||||
if (initializer.getKind() !== SyntaxKind.ArrayLiteralExpression) {
|
||||
throw new Error(`SECRETS_KEYS is not an array literal (got ${SyntaxKind[initializer.getKind()]})`)
|
||||
}
|
||||
|
||||
const keys = []
|
||||
for (const element of initializer.getElements()) {
|
||||
const text = element.getText()
|
||||
// Remove quotes and handle special prefixes
|
||||
const key = text.replace(/^['"]|['"]$/g, "")
|
||||
// Skip prefixed keys like "cline:clineAccountId"
|
||||
if (!key.includes(":")) {
|
||||
keys.push(key)
|
||||
}
|
||||
}
|
||||
|
||||
return keys
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse field definitions from an object literal in state-keys.ts
|
||||
*/
|
||||
function parseFieldDefinitions(sourceFile, variableName) {
|
||||
const decl = sourceFile.getVariableDeclaration(variableName)
|
||||
if (!decl) {
|
||||
throw new Error(`Could not find ${variableName} declaration`)
|
||||
}
|
||||
|
||||
const initializer = decl.getInitializer()
|
||||
if (!initializer) {
|
||||
throw new Error(`${variableName} has no initializer`)
|
||||
}
|
||||
|
||||
// Handle 'satisfies' expression
|
||||
let objectLiteral = initializer
|
||||
if (initializer.getKind() === SyntaxKind.SatisfiesExpression) {
|
||||
objectLiteral = initializer.getExpression()
|
||||
}
|
||||
|
||||
if (objectLiteral.getKind() !== SyntaxKind.ObjectLiteralExpression) {
|
||||
throw new Error(`${variableName} is not an object literal`)
|
||||
}
|
||||
|
||||
const fields = []
|
||||
for (const prop of objectLiteral.getProperties()) {
|
||||
if (prop.getKind() !== SyntaxKind.PropertyAssignment) {
|
||||
continue
|
||||
}
|
||||
|
||||
const name = prop.getName()
|
||||
const propInit = prop.getInitializer()
|
||||
|
||||
if (!propInit || propInit.getKind() !== SyntaxKind.ObjectLiteralExpression) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Get the 'default' property to infer the type
|
||||
const defaultProp = propInit.getProperty("default")
|
||||
if (!defaultProp) {
|
||||
continue
|
||||
}
|
||||
|
||||
let typeText = "string"
|
||||
const defaultInit = defaultProp.getInitializer()
|
||||
if (defaultInit) {
|
||||
// Check for 'as' expression to get the type
|
||||
if (defaultInit.getKind() === SyntaxKind.AsExpression) {
|
||||
const typeNode = defaultInit.getTypeNode()
|
||||
if (typeNode) {
|
||||
typeText = typeNode.getText()
|
||||
}
|
||||
} else {
|
||||
// Infer from literal
|
||||
const text = defaultInit.getText()
|
||||
if (text === "true" || text === "false") {
|
||||
typeText = "boolean"
|
||||
} else if (/^\d+$/.test(text)) {
|
||||
typeText = "number"
|
||||
} else if (/^\d+\.\d+$/.test(text)) {
|
||||
typeText = "number"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fields.push({
|
||||
name,
|
||||
tsType: typeText,
|
||||
protoType: inferProtoType(typeText, name),
|
||||
})
|
||||
}
|
||||
|
||||
return fields
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert snake_case to camelCase for mapping proto fields back to TS keys
|
||||
*/
|
||||
function snakeToCamel(str) {
|
||||
return str.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase())
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse field numbers from an existing proto message definition
|
||||
* Returns a map of camelCase field names to their field numbers
|
||||
*/
|
||||
function parseProtoMessageFieldNumbers(protoContent, messageName) {
|
||||
const fieldNumbers = {}
|
||||
|
||||
// Match the message block (handles single-level nesting for now)
|
||||
const messageRegex = new RegExp(`message\\s+${messageName}\\s*\\{([^}]*(?:\\{[^}]*\\}[^}]*)*)\\}`, "s")
|
||||
const match = protoContent.match(messageRegex)
|
||||
|
||||
if (!match) {
|
||||
return fieldNumbers
|
||||
}
|
||||
|
||||
const messageBody = match[1]
|
||||
|
||||
// Match field definitions: optional/required/repeated type name = number;
|
||||
const fieldRegex = /(?:optional|required|repeated)?\s*\w+\s+(\w+)\s*=\s*(\d+)\s*;/g
|
||||
const matches = messageBody.matchAll(fieldRegex)
|
||||
|
||||
for (const fieldMatch of matches) {
|
||||
const snakeName = fieldMatch[1]
|
||||
const fieldNum = parseInt(fieldMatch[2], 10)
|
||||
const camelName = snakeToCamel(snakeName)
|
||||
fieldNumbers[camelName] = fieldNum
|
||||
}
|
||||
|
||||
return fieldNumbers
|
||||
}
|
||||
|
||||
/**
|
||||
* Load field number mappings from existing proto file
|
||||
*/
|
||||
async function loadFieldNumbersFromProto() {
|
||||
try {
|
||||
const protoContent = await fs.readFile(STATE_PROTO_PATH, "utf-8")
|
||||
const secrets = parseProtoMessageFieldNumbers(protoContent, "Secrets")
|
||||
const settings = parseProtoMessageFieldNumbers(protoContent, "Settings")
|
||||
|
||||
console.log(` Found ${Object.keys(secrets).length} existing Secrets fields`)
|
||||
console.log(` Found ${Object.keys(settings).length} existing Settings fields`)
|
||||
|
||||
return { Secrets: secrets, Settings: settings }
|
||||
} catch {
|
||||
// Proto file doesn't exist, start fresh
|
||||
return { Secrets: {}, Settings: {} }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Assign field numbers, preserving existing assignments and adding new ones
|
||||
*/
|
||||
function assignFieldNumbers(fields, existingNumbers, startNumber = 1) {
|
||||
const result = {}
|
||||
let nextNumber = startNumber
|
||||
|
||||
// Find the highest existing number
|
||||
for (const num of Object.values(existingNumbers)) {
|
||||
if (num >= nextNumber) {
|
||||
nextNumber = num + 1
|
||||
}
|
||||
}
|
||||
|
||||
// Preserve existing assignments
|
||||
for (const field of fields) {
|
||||
if (existingNumbers[field.name] !== undefined) {
|
||||
result[field.name] = existingNumbers[field.name]
|
||||
}
|
||||
}
|
||||
|
||||
// Assign new numbers for new fields
|
||||
for (const field of fields) {
|
||||
if (result[field.name] === undefined) {
|
||||
result[field.name] = nextNumber++
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate proto message definition
|
||||
*/
|
||||
function generateProtoMessage(messageName, fields, fieldNumbers) {
|
||||
const lines = [`message ${messageName} {`]
|
||||
|
||||
// Sort fields by field number for consistent output
|
||||
const sortedFields = [...fields].sort((a, b) => fieldNumbers[a.name] - fieldNumbers[b.name])
|
||||
|
||||
for (const field of sortedFields) {
|
||||
const snakeName = camelToSnake(field.name)
|
||||
const fieldNum = fieldNumbers[field.name]
|
||||
// Map types cannot have the 'optional' modifier in proto3
|
||||
const prefix = field.protoType.startsWith("map<") ? "" : "optional "
|
||||
lines.push(` ${prefix}${field.protoType} ${snakeName} = ${fieldNum};`)
|
||||
}
|
||||
|
||||
lines.push("}")
|
||||
return lines.join("\n")
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate Secrets message from SECRETS_KEYS
|
||||
*/
|
||||
function generateSecretsMessage(secretsKeys, fieldNumbers) {
|
||||
const fields = secretsKeys.map((key) => ({
|
||||
name: key,
|
||||
protoType: "string",
|
||||
}))
|
||||
|
||||
return generateProtoMessage("Secrets", fields, fieldNumbers)
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace a message in the proto file content
|
||||
*/
|
||||
function replaceMessage(protoContent, messageName, newMessageContent) {
|
||||
// Match the message definition including nested braces
|
||||
const messageRegex = new RegExp(`message\\s+${messageName}\\s*\\{[^}]*(?:\\{[^}]*\\}[^}]*)*\\}`, "g")
|
||||
|
||||
if (messageRegex.test(protoContent)) {
|
||||
return protoContent.replace(messageRegex, newMessageContent)
|
||||
} else {
|
||||
// Message doesn't exist, append before the first message or at end
|
||||
console.warn(`Warning: ${messageName} message not found in proto file, appending`)
|
||||
return protoContent + "\n\n" + newMessageContent
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log("Generating proto definitions from TypeScript source...")
|
||||
|
||||
// Parse TypeScript source
|
||||
const project = new Project({
|
||||
tsConfigFilePath: "tsconfig.json",
|
||||
})
|
||||
const sourceFile = project.addSourceFileAtPath(STATE_KEYS_PATH)
|
||||
|
||||
// Parse definitions
|
||||
const secretsKeys = parseSecretsKeys(sourceFile)
|
||||
console.log(`Found ${secretsKeys.length} secret keys`)
|
||||
|
||||
const apiHandlerFields = parseFieldDefinitions(sourceFile, "API_HANDLER_SETTINGS_FIELDS")
|
||||
const userSettingsFields = parseFieldDefinitions(sourceFile, "USER_SETTINGS_FIELDS")
|
||||
const settingsFields = [...apiHandlerFields, ...userSettingsFields]
|
||||
console.log(`Found ${settingsFields.length} settings fields`)
|
||||
|
||||
// Load existing field numbers from proto file
|
||||
const existingFieldNumbers = await loadFieldNumbersFromProto()
|
||||
|
||||
// Assign field numbers (preserving existing, adding new ones)
|
||||
const secretsFieldNumbers = assignFieldNumbers(
|
||||
secretsKeys.map((k) => ({ name: k })),
|
||||
existingFieldNumbers.Secrets,
|
||||
1,
|
||||
)
|
||||
const settingsFieldNumbers = assignFieldNumbers(settingsFields, existingFieldNumbers.Settings, 1)
|
||||
|
||||
// Generate messages
|
||||
const secretsMessage = generateSecretsMessage(secretsKeys, secretsFieldNumbers)
|
||||
const settingsMessage = generateProtoMessage("Settings", settingsFields, settingsFieldNumbers)
|
||||
|
||||
// Read existing proto file
|
||||
let protoContent = await fs.readFile(STATE_PROTO_PATH, "utf-8")
|
||||
|
||||
// Replace messages
|
||||
protoContent = replaceMessage(protoContent, "Secrets", secretsMessage)
|
||||
protoContent = replaceMessage(protoContent, "Settings", settingsMessage)
|
||||
|
||||
// Write updated proto file
|
||||
await fs.writeFile(STATE_PROTO_PATH, protoContent)
|
||||
console.log(`Updated ${STATE_PROTO_PATH}`)
|
||||
|
||||
console.log("\nGeneration complete! Run 'npm run protos' to regenerate TypeScript from protos.")
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error("Error:", error)
|
||||
process.exit(1)
|
||||
})
|
||||
@@ -0,0 +1,54 @@
|
||||
import { expect } from "chai"
|
||||
import { evaluateRuleConditionals, extractPathLikeStrings } from "../rule-conditionals"
|
||||
|
||||
describe("rule-conditionals", () => {
|
||||
describe("evaluateRuleConditionals(paths)", () => {
|
||||
it("treats missing paths as universal", () => {
|
||||
const res = evaluateRuleConditionals({}, { paths: [] })
|
||||
expect(res.passed).to.equal(true)
|
||||
})
|
||||
|
||||
it("treats empty paths list in frontmatter as match-nothing (fail-closed)", () => {
|
||||
const res = evaluateRuleConditionals({ paths: [] }, { paths: ["src/index.ts"] })
|
||||
expect(res.passed).to.equal(false)
|
||||
})
|
||||
|
||||
it("does not activate path-scoped rules with empty context", () => {
|
||||
const res = evaluateRuleConditionals({ paths: ["src/**"] }, { paths: [] })
|
||||
expect(res.passed).to.equal(false)
|
||||
})
|
||||
|
||||
it("matches when any candidate path matches any glob", () => {
|
||||
const res = evaluateRuleConditionals({ paths: ["src/**", "apps/**"] }, { paths: ["src/index.ts"] })
|
||||
expect(res.passed).to.equal(true)
|
||||
expect(res.matchedConditions.paths).to.deep.equal(["src/**"])
|
||||
})
|
||||
|
||||
it("ignores invalid paths type (fail-open)", () => {
|
||||
const res = evaluateRuleConditionals({ paths: "src/**" as any }, { paths: [] })
|
||||
expect(res.passed).to.equal(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("extractPathLikeStrings", () => {
|
||||
it("extracts basic relative paths", () => {
|
||||
const res = extractPathLikeStrings("edit apps/web/src/App.tsx and packages/foo/src")
|
||||
expect(res).to.deep.equal(["apps/web/src/App.tsx", "packages/foo/src"])
|
||||
})
|
||||
|
||||
it("extracts simple filenames with extensions (no slashes)", () => {
|
||||
const res = extractPathLikeStrings("Does foo.md exist? If not, create foo.md")
|
||||
expect(res).to.deep.equal(["foo.md"])
|
||||
})
|
||||
|
||||
it("does not extract bare words without an extension", () => {
|
||||
const res = extractPathLikeStrings("Please create foo and then update bar")
|
||||
expect(res).to.deep.equal([])
|
||||
})
|
||||
|
||||
it("ignores URLs", () => {
|
||||
const res = extractPathLikeStrings("see https://example.com/a/b and edit src/index.ts")
|
||||
expect(res).to.deep.equal(["src/index.ts"])
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,117 @@
|
||||
import { expect } from "chai"
|
||||
import fs from "fs/promises"
|
||||
import os from "os"
|
||||
import path from "path"
|
||||
import { getRuleFilesTotalContentWithMetadata } from "../rule-helpers"
|
||||
|
||||
describe("rule loading with paths frontmatter", () => {
|
||||
it("filters rules by evaluationContext.paths", async () => {
|
||||
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "cline-rules-test-"))
|
||||
try {
|
||||
const rulesDir = path.join(tmp, ".clinerules")
|
||||
await fs.mkdir(rulesDir, { recursive: true })
|
||||
await fs.writeFile(path.join(rulesDir, "universal.md"), "Always on")
|
||||
await fs.writeFile(path.join(rulesDir, "scoped.md"), `---\npaths:\n - "src/**"\n---\n\nOnly for src`)
|
||||
|
||||
const files = ["universal.md", "scoped.md"]
|
||||
const toggles: Record<string, boolean> = {
|
||||
[path.join(rulesDir, "universal.md")]: true,
|
||||
[path.join(rulesDir, "scoped.md")]: true,
|
||||
}
|
||||
|
||||
const res1 = await getRuleFilesTotalContentWithMetadata(files, rulesDir, toggles, {
|
||||
evaluationContext: { paths: ["src/index.ts"] },
|
||||
})
|
||||
expect(res1.content).to.contain("universal.md")
|
||||
expect(res1.content).to.contain("scoped.md")
|
||||
expect(res1.content).to.not.contain("paths:")
|
||||
expect(res1.activatedConditionalRules.map((r) => r.name)).to.include("scoped.md")
|
||||
|
||||
const res2 = await getRuleFilesTotalContentWithMetadata(files, rulesDir, toggles, {
|
||||
evaluationContext: { paths: ["docs/readme.md"] },
|
||||
})
|
||||
expect(res2.content).to.contain("universal.md")
|
||||
expect(res2.content).to.not.contain("scoped.md")
|
||||
} finally {
|
||||
await fs.rm(tmp, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it("treats invalid YAML frontmatter as fail-open and preserves the raw frontmatter for the LLM", async () => {
|
||||
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "cline-rules-test-"))
|
||||
try {
|
||||
const rulesDir = path.join(tmp, ".clinerules")
|
||||
await fs.mkdir(rulesDir, { recursive: true })
|
||||
// Intentionally invalid YAML (unquoted '*' is a YAML alias indicator)
|
||||
await fs.writeFile(
|
||||
path.join(rulesDir, "invalid.md"),
|
||||
`---\npaths: *\n---\n\nInvalid YAML, but should still be included`,
|
||||
)
|
||||
|
||||
const files = ["invalid.md"]
|
||||
const toggles: Record<string, boolean> = {
|
||||
[path.join(rulesDir, "invalid.md")]: true,
|
||||
}
|
||||
|
||||
const res = await getRuleFilesTotalContentWithMetadata(files, rulesDir, toggles, {
|
||||
evaluationContext: { paths: ["src/index.ts"] },
|
||||
})
|
||||
|
||||
// Fail-open: included even though frontmatter cannot be parsed.
|
||||
expect(res.content).to.contain("invalid.md")
|
||||
// Preserve raw frontmatter fence/content for the LLM.
|
||||
expect(res.content).to.contain("---")
|
||||
expect(res.content).to.contain("paths:")
|
||||
} finally {
|
||||
await fs.rm(tmp, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it("treats paths: [] as match-nothing (fail-closed)", async () => {
|
||||
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "cline-rules-test-"))
|
||||
try {
|
||||
const rulesDir = path.join(tmp, ".clinerules")
|
||||
await fs.mkdir(rulesDir, { recursive: true })
|
||||
await fs.writeFile(path.join(rulesDir, "scoped-empty.md"), `---\npaths: []\n---\n\nShould never activate`)
|
||||
|
||||
const files = ["scoped-empty.md"]
|
||||
const toggles: Record<string, boolean> = {
|
||||
[path.join(rulesDir, "scoped-empty.md")]: true,
|
||||
}
|
||||
|
||||
const res = await getRuleFilesTotalContentWithMetadata(files, rulesDir, toggles, {
|
||||
evaluationContext: { paths: ["src/index.ts"] },
|
||||
})
|
||||
|
||||
expect(res.content).to.not.contain("scoped-empty.md")
|
||||
} finally {
|
||||
await fs.rm(tmp, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it("keeps activatedConditionalRules order stable (matches input file order)", async () => {
|
||||
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "cline-rules-test-"))
|
||||
try {
|
||||
const rulesDir = path.join(tmp, ".clinerules")
|
||||
await fs.mkdir(rulesDir, { recursive: true })
|
||||
await fs.writeFile(path.join(rulesDir, "a.md"), `---\npaths:\n - "src/**"\n---\n\nA`)
|
||||
await fs.writeFile(path.join(rulesDir, "b.md"), `---\npaths:\n - "src/**"\n---\n\nB`)
|
||||
await fs.writeFile(path.join(rulesDir, "c.md"), `---\npaths:\n - "src/**"\n---\n\nC`)
|
||||
|
||||
const files = ["a.md", "b.md", "c.md"]
|
||||
const toggles: Record<string, boolean> = {
|
||||
[path.join(rulesDir, "a.md")]: true,
|
||||
[path.join(rulesDir, "b.md")]: true,
|
||||
[path.join(rulesDir, "c.md")]: true,
|
||||
}
|
||||
|
||||
const res = await getRuleFilesTotalContentWithMetadata(files, rulesDir, toggles, {
|
||||
evaluationContext: { paths: ["src/index.ts"] },
|
||||
})
|
||||
|
||||
expect(res.activatedConditionalRules.map((r) => r.name)).to.deep.equal(files)
|
||||
} finally {
|
||||
await fs.rm(tmp, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* Rule frontmatter conditional evaluation.
|
||||
*
|
||||
* This module implements a small conditional "DSL" for Cline Rules YAML frontmatter.
|
||||
* It is used to decide whether a rule should be activated for a given request context.
|
||||
*
|
||||
* Notes:
|
||||
* - Unknown conditional keys are ignored for forward compatibility.
|
||||
* - The `paths` conditional matches if any candidate path matches any glob pattern.
|
||||
* - Candidate paths are expected to be workspace-root-relative POSIX paths.
|
||||
*/
|
||||
import * as path from "path"
|
||||
import picomatch from "picomatch"
|
||||
|
||||
export type RuleEvaluationContext = {
|
||||
/**
|
||||
* Candidate workspace-relative paths that represent the current request context.
|
||||
* These should be POSIX-style paths, relative to their workspace root.
|
||||
*/
|
||||
paths?: string[]
|
||||
}
|
||||
|
||||
export type ConditionalEvaluator = (frontmatterValue: unknown, context: RuleEvaluationContext) => boolean
|
||||
|
||||
type MatchedConditions = Record<string, string[]>
|
||||
|
||||
type ConditionalEvaluatorResult = {
|
||||
passed: boolean
|
||||
matched?: string[]
|
||||
}
|
||||
|
||||
type ConditionalEvaluatorWithMatch = (frontmatterValue: unknown, context: RuleEvaluationContext) => ConditionalEvaluatorResult
|
||||
|
||||
function toPosix(p: string): string {
|
||||
return p.replace(/\\/g, "/")
|
||||
}
|
||||
|
||||
function isNonEmptyStringArray(value: unknown): value is string[] {
|
||||
return Array.isArray(value) && value.every((v) => typeof v === "string" && v.length > 0)
|
||||
}
|
||||
|
||||
const evaluatePathsConditional: ConditionalEvaluatorWithMatch = (frontmatterValue: unknown, context: RuleEvaluationContext) => {
|
||||
// Invalid type -> ignore conditional (fail-open)
|
||||
if (!isNonEmptyStringArray(frontmatterValue)) {
|
||||
return { passed: true }
|
||||
}
|
||||
|
||||
const patterns = frontmatterValue.map((p) => p.trim()).filter(Boolean)
|
||||
// Policy:
|
||||
// - `paths` omitted => universal (because this evaluator is never invoked)
|
||||
// - `paths: []` (or `paths` that trims to no usable patterns) => match nothing (fail-closed)
|
||||
// This gives users an explicit way to disable a rule via frontmatter, while omission
|
||||
// remains the mechanism for "always on" rules.
|
||||
if (patterns.length === 0) {
|
||||
return { passed: false }
|
||||
}
|
||||
|
||||
const candidatePaths = (context.paths || []).map((p) => toPosix(p)).filter(Boolean)
|
||||
// Conservative: no evidence => do not activate path-scoped rules
|
||||
if (candidatePaths.length === 0) {
|
||||
return { passed: false }
|
||||
}
|
||||
|
||||
const matchedPatterns: string[] = []
|
||||
|
||||
for (const pattern of patterns) {
|
||||
const matcher = picomatch(pattern, { dot: true })
|
||||
if (candidatePaths.some((candidate) => matcher(candidate))) {
|
||||
matchedPatterns.push(pattern)
|
||||
}
|
||||
}
|
||||
|
||||
return { passed: matchedPatterns.length > 0, matched: matchedPatterns.length > 0 ? matchedPatterns : undefined }
|
||||
}
|
||||
|
||||
const conditionalEvaluators: Record<string, ConditionalEvaluatorWithMatch> = {
|
||||
paths: evaluatePathsConditional,
|
||||
}
|
||||
|
||||
export function evaluateRuleConditionals(
|
||||
frontmatter: Record<string, unknown>,
|
||||
context: RuleEvaluationContext,
|
||||
): {
|
||||
passed: boolean
|
||||
matchedConditions: MatchedConditions
|
||||
} {
|
||||
const matchedConditions: MatchedConditions = {}
|
||||
|
||||
for (const [key, value] of Object.entries(frontmatter)) {
|
||||
const evaluator = conditionalEvaluators[key]
|
||||
if (!evaluator) {
|
||||
continue // unknown conditional: ignore
|
||||
}
|
||||
|
||||
const result = evaluator(value, context)
|
||||
if (!result.passed) {
|
||||
return { passed: false, matchedConditions: {} }
|
||||
}
|
||||
if (result.matched && result.matched.length > 0) {
|
||||
matchedConditions[key] = result.matched
|
||||
}
|
||||
}
|
||||
|
||||
return { passed: true, matchedConditions }
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts path-like strings from user text to help enable first-turn activation.
|
||||
* This is intentionally heuristic and conservative.
|
||||
*/
|
||||
export function extractPathLikeStrings(text: string): string[] {
|
||||
if (!text) return []
|
||||
|
||||
// 1) Remove URLs to avoid false positives.
|
||||
const withoutUrls = text.replace(/\b\w+:\/\/[^\s]+/g, " ")
|
||||
|
||||
// 2) Match tokens that look like paths.
|
||||
// - Either contain at least one slash (e.g. src/index.ts)
|
||||
// - Or look like a simple filename with an extension (e.g. foo.md)
|
||||
// (no slashes; conservative to reduce false positives).
|
||||
const tokenRegex =
|
||||
/(?:^|[\s([{"'`])((?:[A-Za-z0-9_.-]+(?:\/[A-Za-z0-9_.-]+)+\/?|[A-Za-z0-9_.-]+\.[A-Za-z0-9]{1,10}))(?=$|[\s)\]}"'`,.;:!?])/g
|
||||
const matches: string[] = []
|
||||
let match: RegExpExecArray | null
|
||||
while ((match = tokenRegex.exec(withoutUrls))) {
|
||||
const candidate = match[1]
|
||||
if (!candidate) continue
|
||||
// Normalize away leading ./
|
||||
const normalized = candidate.startsWith("./") ? candidate.slice(2) : candidate
|
||||
// Avoid absurdly long tokens
|
||||
if (normalized.length > 300) continue
|
||||
matches.push(normalized)
|
||||
}
|
||||
|
||||
// De-dupe while preserving order
|
||||
const seen = new Set<string>()
|
||||
const result: string[] = []
|
||||
for (const m of matches) {
|
||||
const posix = m.replace(/\\/g, "/")
|
||||
if (posix === "/" || posix.startsWith("/") || posix.includes("..")) {
|
||||
// We only want repo/workspace-relative hints here.
|
||||
continue
|
||||
}
|
||||
if (!seen.has(posix)) {
|
||||
seen.add(posix)
|
||||
result.push(posix)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize an absolute filesystem path to a workspace-root-relative POSIX path.
|
||||
* Returns undefined if the absolute path is not within the given root.
|
||||
*/
|
||||
export function toWorkspaceRelativePosixPath(absPath: string, workspaceRoot: string): string | undefined {
|
||||
const rel = path.relative(workspaceRoot, absPath)
|
||||
// Outside the root
|
||||
if (rel.startsWith("..") || path.isAbsolute(rel)) return undefined
|
||||
return toPosix(rel)
|
||||
}
|
||||
@@ -5,6 +5,8 @@ import { fileExistsAtPath, isDirectory, readDirectory } from "@utils/fs"
|
||||
import fs from "fs/promises"
|
||||
import * as path from "path"
|
||||
import { Controller } from "@/core/controller"
|
||||
import { parseYamlFrontmatter } from "./frontmatter"
|
||||
import { evaluateRuleConditionals, RuleEvaluationContext } from "./rule-conditionals"
|
||||
|
||||
/**
|
||||
* Recursively traverses directory and finds all files, including checking for optional whitelisted file extension
|
||||
@@ -143,19 +145,114 @@ export function combineRuleToggles(toggles1: ClineRulesToggles, toggles2: ClineR
|
||||
* Read the content of rules files
|
||||
*/
|
||||
export const getRuleFilesTotalContent = async (rulesFilePaths: string[], basePath: string, toggles: ClineRulesToggles) => {
|
||||
const ruleFilesTotalContent = await Promise.all(
|
||||
return (await getRuleFilesTotalContentWithMetadata(rulesFilePaths, basePath, toggles)).content
|
||||
}
|
||||
|
||||
export type ActivatedConditionalRule = {
|
||||
name: string
|
||||
matchedConditions: Record<string, string[]>
|
||||
}
|
||||
|
||||
export type RuleLoadResult = {
|
||||
content: string
|
||||
activatedConditionalRules: ActivatedConditionalRule[]
|
||||
}
|
||||
|
||||
export const getRuleFilesTotalContentWithMetadata = async (
|
||||
rulesFilePaths: string[],
|
||||
basePath: string,
|
||||
toggles: ClineRulesToggles,
|
||||
opts?: { evaluationContext?: RuleEvaluationContext },
|
||||
): Promise<RuleLoadResult> => {
|
||||
const evaluationContext = opts?.evaluationContext ?? {}
|
||||
|
||||
type RuleLoadPart = {
|
||||
contentPart: string | null
|
||||
activatedRule: ActivatedConditionalRule | null
|
||||
}
|
||||
|
||||
const parts: RuleLoadPart[] = 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 { contentPart: null, activatedRule: null }
|
||||
}
|
||||
|
||||
return `${ruleFilePathRelative}\n` + (await fs.readFile(ruleFilePath, "utf8")).trim()
|
||||
const raw = (await fs.readFile(ruleFilePath, "utf8")).trim()
|
||||
if (!raw) {
|
||||
return { contentPart: null, activatedRule: null }
|
||||
}
|
||||
const { data, body, hadFrontmatter, parseError } = parseYamlFrontmatter(raw)
|
||||
// YAML parse errors are treated as fail-open.
|
||||
// NOTE: We intentionally preserve the raw frontmatter fence/content here so the LLM can still
|
||||
// see the author's intended scoping (e.g., `paths:`) and reason about it, even if it cannot be
|
||||
// evaluated reliably due to invalid YAML.
|
||||
if (hadFrontmatter && parseError) {
|
||||
return { contentPart: `${ruleFilePathRelative}\n${raw}`, activatedRule: null }
|
||||
}
|
||||
|
||||
const { passed, matchedConditions } = evaluateRuleConditionals(data, evaluationContext)
|
||||
if (!passed) {
|
||||
return { contentPart: null, activatedRule: null }
|
||||
}
|
||||
const activatedRule =
|
||||
hadFrontmatter && Object.keys(matchedConditions).length > 0
|
||||
? { name: ruleFilePathRelative, matchedConditions }
|
||||
: null
|
||||
|
||||
return { contentPart: `${ruleFilePathRelative}\n${body.trim()}`, activatedRule }
|
||||
}),
|
||||
).then((contents) => contents.filter(Boolean).join("\n\n"))
|
||||
return ruleFilesTotalContent
|
||||
)
|
||||
|
||||
return {
|
||||
content: parts
|
||||
.map((p) => p.contentPart)
|
||||
.filter(Boolean)
|
||||
.join("\n\n"),
|
||||
activatedConditionalRules: parts
|
||||
.map((p) => p.activatedRule)
|
||||
.filter((rule): rule is ActivatedConditionalRule => rule !== null),
|
||||
}
|
||||
}
|
||||
|
||||
export function getRemoteRulesTotalContentWithMetadata(
|
||||
remoteRules: GlobalInstructionsFile[],
|
||||
remoteToggles: ClineRulesToggles,
|
||||
opts?: { evaluationContext?: RuleEvaluationContext },
|
||||
): RuleLoadResult {
|
||||
const activatedConditionalRules: ActivatedConditionalRule[] = []
|
||||
const evaluationContext = opts?.evaluationContext ?? {}
|
||||
let combinedContent = ""
|
||||
|
||||
for (const rule of remoteRules) {
|
||||
const isEnabled = rule.alwaysEnabled || remoteToggles[rule.name] !== false
|
||||
if (!isEnabled) continue
|
||||
|
||||
const raw = (rule.contents || "").trim()
|
||||
if (!raw) continue
|
||||
|
||||
const { data, body, hadFrontmatter, parseError } = parseYamlFrontmatter(raw)
|
||||
if (hadFrontmatter && parseError) {
|
||||
// Fail open: include entire raw contents
|
||||
if (combinedContent) combinedContent += "\n\n"
|
||||
combinedContent += `${rule.name}\n${raw}`
|
||||
continue
|
||||
}
|
||||
|
||||
const { passed, matchedConditions } = evaluateRuleConditionals(data, evaluationContext)
|
||||
if (!passed) continue
|
||||
|
||||
if (hadFrontmatter && Object.keys(matchedConditions).length > 0) {
|
||||
activatedConditionalRules.push({ name: rule.name, matchedConditions })
|
||||
}
|
||||
|
||||
if (combinedContent) combinedContent += "\n\n"
|
||||
combinedContent += `${rule.name}\n${body.trim()}`
|
||||
}
|
||||
|
||||
return { content: combinedContent, activatedConditionalRules }
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -39,7 +39,6 @@ export const PatchClineSayMap = {
|
||||
|
||||
export class ApplyPatchHandler implements IFullyManagedTool {
|
||||
readonly name = ClineDefaultTool.APPLY_PATCH
|
||||
private appliedCommit?: Commit
|
||||
private config?: TaskConfig
|
||||
private pathResolver?: PathResolver
|
||||
private providerOps?: FileProviderOperations
|
||||
@@ -96,12 +95,14 @@ export class ApplyPatchHandler implements IFullyManagedTool {
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i]
|
||||
if (line.startsWith(PATCH_MARKERS.ADD)) {
|
||||
provider.editType = "modify"
|
||||
targetPath = line.substring(PATCH_MARKERS.ADD.length).trim()
|
||||
actionType = PatchActionType.ADD
|
||||
contentStartIndex = i + 1
|
||||
break
|
||||
}
|
||||
if (line.startsWith(PATCH_MARKERS.UPDATE)) {
|
||||
provider.editType = "modify"
|
||||
targetPath = line.substring(PATCH_MARKERS.UPDATE.length).trim()
|
||||
actionType = PatchActionType.UPDATE
|
||||
contentStartIndex = i + 1
|
||||
@@ -233,10 +234,8 @@ export class ApplyPatchHandler implements IFullyManagedTool {
|
||||
const { patch, fuzz } = parser.parse()
|
||||
|
||||
// Convert to commit
|
||||
const commit = this.patchToCommit(patch, currentFiles)
|
||||
const commit = await this.patchToCommit(patch, currentFiles)
|
||||
|
||||
// Store for potential revert
|
||||
this.appliedCommit = commit
|
||||
this.config = config
|
||||
|
||||
// Run PreToolUse hook before applying changes
|
||||
@@ -252,32 +251,88 @@ export class ApplyPatchHandler implements IFullyManagedTool {
|
||||
throw error
|
||||
}
|
||||
|
||||
// Apply the commit
|
||||
const applyResults = await this.applyCommit(commit)
|
||||
|
||||
// Generate summary
|
||||
const changedFiles = Object.keys(commit.changes)
|
||||
const messages = await this.generateChangeSummary(commit.changes)
|
||||
|
||||
const finalResponses = []
|
||||
const applyResults: Record<string, FileOpsResult> = {}
|
||||
|
||||
// Create a mapping from message path to original commit change key
|
||||
// (needed because for move operations, message.path is the new path, but commit.changes key is the old path)
|
||||
const pathToChangeKey = new Map<string, string>()
|
||||
for (const [originalPath, change] of Object.entries(commit.changes)) {
|
||||
if (change.type === PatchActionType.UPDATE && change.movePath) {
|
||||
pathToChangeKey.set(change.movePath, originalPath)
|
||||
} else {
|
||||
pathToChangeKey.set(originalPath, originalPath)
|
||||
}
|
||||
}
|
||||
|
||||
// For each file: prepare, get approval, then save
|
||||
for (const message of messages) {
|
||||
const messagePath = message.path
|
||||
if (!messagePath) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Get the original change key (for move operations, this is the old path)
|
||||
const originalPath = pathToChangeKey.get(messagePath)
|
||||
if (!originalPath) {
|
||||
continue
|
||||
}
|
||||
|
||||
const change = commit.changes[originalPath]
|
||||
if (!change) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Determine the actual file path to use for operations
|
||||
// For move operations, we prepare the new file, but the change is keyed by the old path
|
||||
const operationPath = change.type === PatchActionType.UPDATE && change.movePath ? change.movePath : originalPath
|
||||
|
||||
// Prepare the change for this file (open and update, but don't save)
|
||||
await this.prepareFileChange(change, operationPath)
|
||||
|
||||
// Get approval
|
||||
const approved = await this.handleApproval(config, block, message, rawInput)
|
||||
if (!approved) {
|
||||
await this.revertChanges()
|
||||
this.config = undefined
|
||||
config.taskState.didRejectTool = true
|
||||
await provider.revertChanges()
|
||||
await provider.reset()
|
||||
return "The user denied this patch operation."
|
||||
}
|
||||
|
||||
for (const filePath of changedFiles) {
|
||||
config.services.fileContextTracker.markFileAsEditedByCline(filePath)
|
||||
await config.services.fileContextTracker.trackFileContext(filePath, "cline_edited")
|
||||
// Save the changes for this file after approval
|
||||
const fileResult = await this.saveFileChange(change, operationPath)
|
||||
if (fileResult) {
|
||||
// For move operations, we need to handle both old and new paths
|
||||
if (change.type === PatchActionType.UPDATE && change.movePath) {
|
||||
applyResults[change.movePath] = fileResult
|
||||
// Delete the old file after saving the new one
|
||||
await this.providerOps!.deleteFile(originalPath)
|
||||
applyResults[originalPath] = { deleted: true }
|
||||
} else {
|
||||
applyResults[originalPath] = fileResult
|
||||
}
|
||||
}
|
||||
|
||||
config.taskState.didEditFile = true
|
||||
finalResponses.push(message.path)
|
||||
// Reset provider state to ensure clean state for the next file operation
|
||||
await provider.reset()
|
||||
|
||||
finalResponses.push(messagePath)
|
||||
}
|
||||
|
||||
// Track all changed files once after all operations are complete
|
||||
for (const changedFilePath of changedFiles) {
|
||||
const change = commit.changes[changedFilePath]
|
||||
// For move operations, track the new path instead
|
||||
const pathToTrack = change.type === PatchActionType.UPDATE && change.movePath ? change.movePath : changedFilePath
|
||||
config.services.fileContextTracker.markFileAsEditedByCline(pathToTrack)
|
||||
await config.services.fileContextTracker.trackFileContext(pathToTrack, "cline_edited")
|
||||
}
|
||||
|
||||
this.appliedCommit = undefined
|
||||
this.config = undefined
|
||||
|
||||
// Build response with file contents and diagnostics
|
||||
@@ -285,6 +340,7 @@ export class ApplyPatchHandler implements IFullyManagedTool {
|
||||
|
||||
for (const [path, result] of Object.entries(applyResults)) {
|
||||
if (result.deleted) {
|
||||
config.taskState.didEditFile = true
|
||||
responseLines.push(`\n${path}: [deleted]`)
|
||||
} else {
|
||||
// Format response similar to WriteToFileToolHandler
|
||||
@@ -321,9 +377,9 @@ export class ApplyPatchHandler implements IFullyManagedTool {
|
||||
return responseLines.join("\n")
|
||||
} catch (error) {
|
||||
await provider.revertChanges()
|
||||
await provider.reset()
|
||||
console.error("Reverted changes due to error in ApplyPatchHandler.", error)
|
||||
throw error
|
||||
} finally {
|
||||
await provider.reset()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -450,10 +506,15 @@ export class ApplyPatchHandler implements IFullyManagedTool {
|
||||
return files
|
||||
}
|
||||
|
||||
private patchToCommit(patch: Patch, originalFiles: Record<string, string>): Commit {
|
||||
private async patchToCommit(patch: Patch, originalFiles: Record<string, string>): Promise<Commit> {
|
||||
const changes: Record<string, FileChange> = {}
|
||||
|
||||
for (const [path, action] of Object.entries(patch.actions)) {
|
||||
const targetResolution = await this.pathResolver!.resolveAndValidate(path, "ApplyPatchHandler.previewPatch")
|
||||
if (!targetResolution) {
|
||||
continue
|
||||
}
|
||||
|
||||
switch (action.type) {
|
||||
case PatchActionType.DELETE:
|
||||
changes[path] = { type: PatchActionType.DELETE, oldContent: originalFiles[path] }
|
||||
@@ -468,7 +529,7 @@ export class ApplyPatchHandler implements IFullyManagedTool {
|
||||
changes[path] = {
|
||||
type: PatchActionType.UPDATE,
|
||||
oldContent: originalFiles[path],
|
||||
newContent: this.applyChunks(originalFiles[path]!, action.chunks, path).trimEnd(),
|
||||
newContent: this.applyChunks(originalFiles[path]!, action.chunks, path),
|
||||
movePath: action.movePath,
|
||||
}
|
||||
break
|
||||
@@ -531,93 +592,60 @@ export class ApplyPatchHandler implements IFullyManagedTool {
|
||||
return result.join("\n")
|
||||
}
|
||||
|
||||
private async applyCommit(commit: Commit): Promise<Record<string, FileOpsResult>> {
|
||||
/**
|
||||
* Prepares a single file change (opens file and updates content) without saving.
|
||||
* Call saveFileChange() after approval.
|
||||
*/
|
||||
private async prepareFileChange(change: FileChange, path: string): Promise<void> {
|
||||
const ops = this.providerOps!
|
||||
const results: Record<string, FileOpsResult> = {}
|
||||
|
||||
for (const [path, change] of Object.entries(commit.changes)) {
|
||||
switch (change.type) {
|
||||
case PatchActionType.DELETE:
|
||||
await ops.deleteFile(path)
|
||||
results[path] = { deleted: true }
|
||||
break
|
||||
case PatchActionType.ADD:
|
||||
if (!change.newContent) {
|
||||
throw new DiffError(`Cannot create ${path} with no content`)
|
||||
}
|
||||
const addResult = await ops.createFile(path, change.newContent)
|
||||
results[path] = {
|
||||
finalContent: addResult.finalContent,
|
||||
newProblemsMessage: addResult.newProblemsMessage,
|
||||
userEdits: addResult.userEdits,
|
||||
autoFormattingEdits: addResult.autoFormattingEdits,
|
||||
}
|
||||
break
|
||||
case PatchActionType.UPDATE:
|
||||
if (!change.newContent) {
|
||||
throw new DiffError(`UPDATE change for ${path} has no new content`)
|
||||
}
|
||||
if (change.movePath) {
|
||||
const moveResult = await ops.moveFile(path, change.movePath, change.newContent)
|
||||
results[change.movePath] = {
|
||||
finalContent: moveResult.finalContent,
|
||||
newProblemsMessage: moveResult.newProblemsMessage,
|
||||
userEdits: moveResult.userEdits,
|
||||
autoFormattingEdits: moveResult.autoFormattingEdits,
|
||||
}
|
||||
results[path] = { deleted: true }
|
||||
} else {
|
||||
const updateResult = await ops.modifyFile(path, change.newContent)
|
||||
results[path] = {
|
||||
finalContent: updateResult.finalContent,
|
||||
newProblemsMessage: updateResult.newProblemsMessage,
|
||||
userEdits: updateResult.userEdits,
|
||||
autoFormattingEdits: updateResult.autoFormattingEdits,
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
switch (change.type) {
|
||||
case PatchActionType.DELETE:
|
||||
await ops.deleteFile(path, false)
|
||||
break
|
||||
case PatchActionType.ADD:
|
||||
if (!change.newContent) {
|
||||
throw new DiffError(`Cannot create ${path} with no content`)
|
||||
}
|
||||
await ops.createFile(path, change.newContent, false)
|
||||
break
|
||||
case PatchActionType.UPDATE:
|
||||
if (!change.newContent) {
|
||||
throw new DiffError(`UPDATE change for ${path} has no new content`)
|
||||
}
|
||||
if (change.movePath) {
|
||||
// For move operations, prepare the new file (the old file will be handled separately)
|
||||
await ops.createFile(change.movePath, change.newContent, false)
|
||||
} else {
|
||||
await ops.modifyFile(path, change.newContent, false)
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
private async revertChanges(): Promise<void> {
|
||||
if (!this.appliedCommit || !this.providerOps) {
|
||||
return
|
||||
}
|
||||
/**
|
||||
* Saves the changes for a single file after approval.
|
||||
*/
|
||||
private async saveFileChange(change: FileChange, path: string): Promise<FileOpsResult | undefined> {
|
||||
const ops = this.providerOps!
|
||||
|
||||
const ops = this.providerOps
|
||||
|
||||
for (const [path, change] of Object.entries(this.appliedCommit.changes)) {
|
||||
try {
|
||||
switch (change.type) {
|
||||
case PatchActionType.DELETE:
|
||||
if (change.oldContent !== undefined) {
|
||||
await ops.createFile(path, change.oldContent)
|
||||
}
|
||||
break
|
||||
case PatchActionType.ADD:
|
||||
await ops.deleteFile(path)
|
||||
break
|
||||
case PatchActionType.UPDATE:
|
||||
if (change.movePath) {
|
||||
await ops.deleteFile(change.movePath)
|
||||
if (change.oldContent !== undefined) {
|
||||
await ops.createFile(path, change.oldContent)
|
||||
}
|
||||
} else if (change.oldContent !== undefined) {
|
||||
await ops.modifyFile(path, change.oldContent)
|
||||
}
|
||||
break
|
||||
switch (change.type) {
|
||||
case PatchActionType.DELETE:
|
||||
// For delete operations, actually delete the file now (after approval)
|
||||
await ops.deleteFile(path)
|
||||
return { deleted: true }
|
||||
case PatchActionType.ADD:
|
||||
if (!change.newContent) {
|
||||
throw new DiffError(`Cannot create ${path} with no content`)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to revert ${path}:`, error)
|
||||
}
|
||||
return await ops.saveChanges()
|
||||
case PatchActionType.UPDATE:
|
||||
if (!change.newContent) {
|
||||
throw new DiffError(`UPDATE change for ${path} has no new content`)
|
||||
}
|
||||
// For move operations, we're saving the new file (the old file deletion is handled in the calling code)
|
||||
return await ops.saveChanges()
|
||||
}
|
||||
|
||||
this.appliedCommit = undefined
|
||||
this.config = undefined
|
||||
}
|
||||
|
||||
private async generateChangeSummary(changes: Record<string, FileChange>): Promise<ClineSayTool[]> {
|
||||
@@ -703,6 +731,7 @@ export class ApplyPatchHandler implements IFullyManagedTool {
|
||||
undefined,
|
||||
block.isNativeToolCall,
|
||||
)
|
||||
|
||||
return approved
|
||||
}
|
||||
}
|
||||
|
||||
@@ -481,8 +481,6 @@ export class WriteToFileToolHandler implements IFullyManagedTool {
|
||||
return
|
||||
}
|
||||
|
||||
newContent = newContent.trimEnd() // remove any trailing newlines, since it's automatically inserted by the editor
|
||||
|
||||
return { relPath, absolutePath, fileExists, diff, content, newContent, workspaceContext }
|
||||
}
|
||||
|
||||
|
||||
@@ -14,34 +14,86 @@ export interface FileOpsResult {
|
||||
export class FileProviderOperations {
|
||||
constructor(private provider: DiffViewProvider) {}
|
||||
|
||||
async createFile(path: string, content: string): Promise<FileOpsResult> {
|
||||
async openFile(path: string): Promise<void> {
|
||||
await this.provider.open(path)
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves the current changes and returns the result.
|
||||
*/
|
||||
async saveChanges(): Promise<FileOpsResult> {
|
||||
const result = await this.provider.saveChanges()
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a file. If isFinal is false, prepares the creation without saving.
|
||||
* Call saveChanges() after approval when isFinal is false.
|
||||
*/
|
||||
async createFile(path: string, content: string, isFinal: boolean = true): Promise<FileOpsResult | undefined> {
|
||||
this.provider.editType = "create"
|
||||
await this.provider.open(path)
|
||||
await this.provider.update(content, true)
|
||||
const result = await this.provider.saveChanges()
|
||||
await this.provider.reset()
|
||||
return result
|
||||
await this.openFile(path)
|
||||
await this.provider.update(content, isFinal)
|
||||
|
||||
if (isFinal) {
|
||||
return await this.saveChanges()
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
async modifyFile(path: string, content: string): Promise<FileOpsResult> {
|
||||
/**
|
||||
* Modifies a file. If isFinal is false, prepares the modification without saving.
|
||||
* Call saveChanges() after approval when isFinal is false.
|
||||
*/
|
||||
async modifyFile(path: string, content: string, isFinal: boolean = true): Promise<FileOpsResult | undefined> {
|
||||
this.provider.editType = "modify"
|
||||
await this.provider.open(path)
|
||||
await this.provider.update(content, true)
|
||||
const result = await this.provider.saveChanges()
|
||||
await this.provider.reset()
|
||||
return result
|
||||
await this.openFile(path)
|
||||
await this.provider.update(content, isFinal)
|
||||
|
||||
if (isFinal) {
|
||||
return await this.saveChanges()
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
async deleteFile(path: string): Promise<void> {
|
||||
/**
|
||||
* Deletes a file. If isFinal is false, prepares the deletion without actually deleting.
|
||||
* Opens the file in the diff view to show it will be deleted.
|
||||
* Call deleteFile() with isFinal=true after approval when isFinal is false.
|
||||
*/
|
||||
async deleteFile(path: string, isFinal: boolean = true): Promise<FileOpsResult | undefined> {
|
||||
this.provider.editType = "delete"
|
||||
await this.provider.open(path)
|
||||
await this.provider.deleteFile(path)
|
||||
await this.openFile(path)
|
||||
|
||||
if (isFinal) {
|
||||
await this.provider.deleteFile(path)
|
||||
return undefined
|
||||
} else {
|
||||
// Update with empty content to show the file will be deleted
|
||||
await this.provider.update("", isFinal)
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
async moveFile(oldPath: string, newPath: string, content: string): Promise<FileOpsResult> {
|
||||
const result = await this.createFile(newPath, content)
|
||||
await this.deleteFile(oldPath)
|
||||
return result
|
||||
/**
|
||||
* Moves a file from oldPath to newPath. If isFinal is false, prepares the move without saving.
|
||||
* Call saveChanges() after approval when isFinal is false.
|
||||
*/
|
||||
async moveFile(
|
||||
oldPath: string,
|
||||
newPath: string,
|
||||
content: string,
|
||||
isFinal: boolean = true,
|
||||
): Promise<FileOpsResult | undefined> {
|
||||
if (isFinal) {
|
||||
const result = await this.createFile(newPath, content, isFinal)
|
||||
await this.deleteFile(oldPath, isFinal)
|
||||
return result
|
||||
} else {
|
||||
await this.createFile(newPath, content, isFinal)
|
||||
await this.deleteFile(oldPath, isFinal)
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
async getFileContent(): Promise<string | undefined> {
|
||||
|
||||
+12
@@ -42,6 +42,18 @@ export class ExternalDiffViewProvider extends DiffViewProvider {
|
||||
})
|
||||
}
|
||||
|
||||
protected override async getDocumentLineCount(): Promise<number> {
|
||||
const text = await this.getDocumentText()
|
||||
if (!text) {
|
||||
return 0
|
||||
}
|
||||
// Count lines: split by newline, but handle trailing newline correctly
|
||||
const lines = text.split("\n")
|
||||
// If text ends with newline, split creates an extra empty string at the end
|
||||
// which represents the "line" after the final newline - this is correct line count
|
||||
return lines.length
|
||||
}
|
||||
|
||||
protected async saveDocument(): Promise<Boolean> {
|
||||
if (!this.activeDiffEditorId) {
|
||||
return false
|
||||
|
||||
@@ -102,11 +102,31 @@ export class VscodeDiffViewProvider extends DiffViewProvider {
|
||||
|
||||
// Replace the text in the diff editor document.
|
||||
const document = this.activeDiffEditor?.document
|
||||
const replacingToEnd = rangeToReplace.endLine >= document.lineCount
|
||||
const edit = new vscode.WorkspaceEdit()
|
||||
const range = new vscode.Range(rangeToReplace.startLine, 0, rangeToReplace.endLine, 0)
|
||||
edit.replace(document.uri, range, content)
|
||||
await vscode.workspace.applyEdit(edit)
|
||||
|
||||
// VS Code can normalize trailing newlines on full-document replacements.
|
||||
// Only fix up when replacing to the end to avoid touching untouched content.
|
||||
if (replacingToEnd) {
|
||||
const desiredTrailingNewlines = countTrailingNewlines(content)
|
||||
const actualTrailingNewlines = countTrailingNewlines(document.getText())
|
||||
const newlineDelta = desiredTrailingNewlines - actualTrailingNewlines
|
||||
|
||||
if (newlineDelta > 0) {
|
||||
const fixEdit = new vscode.WorkspaceEdit()
|
||||
fixEdit.insert(document.uri, document.lineAt(document.lineCount - 1).range.end, "\n".repeat(newlineDelta))
|
||||
await vscode.workspace.applyEdit(fixEdit)
|
||||
} else if (newlineDelta < 0) {
|
||||
const fixEdit = new vscode.WorkspaceEdit()
|
||||
const startLine = Math.max(0, document.lineCount + newlineDelta)
|
||||
fixEdit.delete(document.uri, new vscode.Range(startLine, 0, document.lineCount, 0))
|
||||
await vscode.workspace.applyEdit(fixEdit)
|
||||
}
|
||||
}
|
||||
|
||||
if (currentLine !== undefined) {
|
||||
// Update decorations for the entire changed section
|
||||
this.activeLineController?.setActiveLine(currentLine)
|
||||
@@ -147,11 +167,18 @@ export class VscodeDiffViewProvider extends DiffViewProvider {
|
||||
edit.delete(document.uri, new vscode.Range(lineNumber, 0, document.lineCount, 0))
|
||||
await vscode.workspace.applyEdit(edit)
|
||||
}
|
||||
// Clear all decorations at the end (before applying final edit)
|
||||
}
|
||||
|
||||
protected override async onFinalUpdate(): Promise<void> {
|
||||
// Clear all decorations at the end of streaming
|
||||
this.fadedOverlayController?.clear()
|
||||
this.activeLineController?.clear()
|
||||
}
|
||||
|
||||
protected override async getDocumentLineCount(): Promise<number> {
|
||||
return this.activeDiffEditor?.document.lineCount ?? 0
|
||||
}
|
||||
|
||||
protected override async getDocumentText(): Promise<string | undefined> {
|
||||
if (!this.activeDiffEditor || !this.activeDiffEditor.document) {
|
||||
return undefined
|
||||
@@ -193,3 +220,11 @@ export class VscodeDiffViewProvider extends DiffViewProvider {
|
||||
this.activeLineController = undefined
|
||||
}
|
||||
}
|
||||
|
||||
function countTrailingNewlines(text: string): number {
|
||||
let count = 0
|
||||
for (let i = text.length - 1; i >= 0 && text[i] === "\n"; i -= 1) {
|
||||
count += 1
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
@@ -96,6 +96,24 @@ export abstract class DiffViewProvider {
|
||||
*/
|
||||
protected abstract truncateDocument(lineNumber: number): Promise<void>
|
||||
|
||||
/**
|
||||
* Returns the current line count of the document being edited.
|
||||
* Used for boundary validation before calling truncateDocument.
|
||||
*/
|
||||
protected abstract getDocumentLineCount(): Promise<number>
|
||||
|
||||
/**
|
||||
* Safely truncates the document, ensuring the line number is within bounds.
|
||||
* This prevents errors on hosts that strictly validate line numbers (e.g., JetBrains via gRPC).
|
||||
*/
|
||||
private async safelyTruncateDocument(lineNumber: number): Promise<void> {
|
||||
const lineCount = await this.getDocumentLineCount()
|
||||
// Only truncate if there's content beyond the specified line
|
||||
if (lineNumber < lineCount) {
|
||||
await this.truncateDocument(lineNumber)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the contents of the diff editor document.
|
||||
*
|
||||
@@ -182,8 +200,20 @@ export abstract class DiffViewProvider {
|
||||
// Replace all content up to the current line with accumulated lines
|
||||
// This is necessary (as compared to inserting one line at a time) to handle cases where html tags
|
||||
// on previous lines are auto closed for example
|
||||
const contentToReplace = accumulatedLines.slice(0, currentLine + 1).join("\n") + "\n"
|
||||
const rangeToReplace = { startLine: 0, endLine: currentLine + 1 }
|
||||
let contentToReplace = accumulatedLines.slice(0, currentLine + 1).join("\n")
|
||||
if (!isFinal) {
|
||||
// During streaming, add trailing newline for cursor positioning
|
||||
contentToReplace += "\n"
|
||||
}
|
||||
|
||||
// For the final update, replace the entire document to prevent concatenation
|
||||
// when content doesn't end with a newline. Without this, replacing lines 0-N
|
||||
// with content lacking a trailing newline causes line N+1's content to be
|
||||
// directly appended to our content (e.g., "Hello World" + "# Old Header" becomes
|
||||
// "Hello World# Old Header").
|
||||
const endLine = isFinal ? await this.getDocumentLineCount() : currentLine + 1
|
||||
|
||||
const rangeToReplace = { startLine: 0, endLine }
|
||||
await this.replaceText(contentToReplace, rangeToReplace, currentLine)
|
||||
|
||||
// Scroll to the actual change location if provided.
|
||||
@@ -211,19 +241,19 @@ export abstract class DiffViewProvider {
|
||||
this.streamedLines = accumulatedLines
|
||||
if (isFinal) {
|
||||
// Handle any remaining lines if the new content is shorter than the original
|
||||
await this.truncateDocument(this.streamedLines.length)
|
||||
|
||||
// Add empty last line if original content had one
|
||||
const hasEmptyLastLine = this.originalContent?.endsWith("\n")
|
||||
if (hasEmptyLastLine) {
|
||||
const accumulatedLines = accumulatedContent.split("\n")
|
||||
if (accumulatedLines[accumulatedLines.length - 1] !== "") {
|
||||
accumulatedContent += "\n"
|
||||
}
|
||||
}
|
||||
await this.safelyTruncateDocument(this.streamedLines.length)
|
||||
// Allow subclasses to perform cleanup (e.g., clearing decorations)
|
||||
await this.onFinalUpdate()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Called after the final update is complete. Subclasses can override to perform cleanup.
|
||||
*/
|
||||
protected async onFinalUpdate(): Promise<void> {
|
||||
// Default no-op
|
||||
}
|
||||
|
||||
async showFile(absolutePath: string): Promise<void> {
|
||||
await openFile(absolutePath, true)
|
||||
}
|
||||
|
||||
@@ -44,10 +44,16 @@ export class FileEditProvider extends DiffViewProvider {
|
||||
// Split the document into lines
|
||||
const lines = this.documentContent.split("\n")
|
||||
|
||||
// Check if we're replacing to the end of the document
|
||||
const replacingToEnd = rangeToReplace.endLine >= lines.length
|
||||
|
||||
// Replace the specified range with the new content
|
||||
const newContentLines = content.split("\n")
|
||||
// Remove trailing empty line if present in newContentLines for proper splicing
|
||||
if (newContentLines[newContentLines.length - 1] === "") {
|
||||
|
||||
// Remove trailing empty line for proper splicing, BUT only when NOT replacing
|
||||
// to the end of the document. When replacing to the end, keep the trailing
|
||||
// empty string to preserve trailing newlines from the content.
|
||||
if (!replacingToEnd && newContentLines[newContentLines.length - 1] === "") {
|
||||
newContentLines.pop()
|
||||
}
|
||||
|
||||
@@ -78,6 +84,13 @@ export class FileEditProvider extends DiffViewProvider {
|
||||
}
|
||||
}
|
||||
|
||||
protected async getDocumentLineCount(): Promise<number> {
|
||||
if (!this.documentContent) {
|
||||
return 0
|
||||
}
|
||||
return this.documentContent.split("\n").length
|
||||
}
|
||||
|
||||
protected async getDocumentText(): Promise<string | undefined> {
|
||||
return this.documentContent
|
||||
}
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
import * as assert from "assert"
|
||||
import { describe, it } from "mocha"
|
||||
import { DiffViewProvider } from "../DiffViewProvider"
|
||||
|
||||
class TestBoundaryDiffViewProvider extends DiffViewProvider {
|
||||
public documentText: string = ""
|
||||
public truncatedAt: number | undefined
|
||||
|
||||
async openDiffEditor(): Promise<void> {}
|
||||
async scrollEditorToLine(line: number): Promise<void> {}
|
||||
async scrollAnimation(startLine: number, endLine: number): Promise<void> {}
|
||||
|
||||
async truncateDocument(lineNumber: number): Promise<void> {
|
||||
this.truncatedAt = lineNumber
|
||||
const lines = this.documentText.split("\n")
|
||||
if (lineNumber < lines.length) {
|
||||
this.documentText = lines.slice(0, lineNumber).join("\n")
|
||||
}
|
||||
}
|
||||
|
||||
async getDocumentLineCount(): Promise<number> {
|
||||
return this.documentText.split("\n").length
|
||||
}
|
||||
|
||||
async getDocumentText(): Promise<string | undefined> {
|
||||
return this.documentText
|
||||
}
|
||||
|
||||
async saveDocument(): Promise<Boolean> {
|
||||
return true
|
||||
}
|
||||
async closeAllDiffViews(): Promise<void> {}
|
||||
async resetDiffView(): Promise<void> {}
|
||||
|
||||
async replaceText(
|
||||
content: string,
|
||||
rangeToReplace: { startLine: number; endLine: number },
|
||||
currentLine: number | undefined,
|
||||
): Promise<void> {
|
||||
// Minimal implementation for update() to work
|
||||
const lines = this.documentText.split("\n")
|
||||
|
||||
// Check if we're replacing to the end of the document
|
||||
const replacingToEnd = rangeToReplace.endLine >= lines.length
|
||||
|
||||
const newLines = content.split("\n")
|
||||
|
||||
// Remove trailing empty line for proper splicing, BUT only when NOT replacing
|
||||
// to the end of the document. When replacing to the end, keep the trailing
|
||||
// empty string to preserve trailing newlines from the content.
|
||||
if (!replacingToEnd && newLines[newLines.length - 1] === "") {
|
||||
newLines.pop()
|
||||
}
|
||||
|
||||
lines.splice(rangeToReplace.startLine, rangeToReplace.endLine - rangeToReplace.startLine, ...newLines)
|
||||
this.documentText = lines.join("\n")
|
||||
}
|
||||
|
||||
public setup(initialContent: string) {
|
||||
this.isEditing = true
|
||||
this.documentText = initialContent
|
||||
this.originalContent = initialContent
|
||||
this.truncatedAt = undefined
|
||||
}
|
||||
}
|
||||
|
||||
describe("DiffViewProvider Boundary Validation", () => {
|
||||
it("should replace entire document on final update to prevent concatenation", async () => {
|
||||
const provider = new TestBoundaryDiffViewProvider()
|
||||
// Start with multi-line content
|
||||
provider.setup("line1\nline2\nline3\n")
|
||||
|
||||
// Update with content that has no trailing newline
|
||||
// This previously caused "Hello World" + "line2" concatenation
|
||||
await provider.update("Hello World", true)
|
||||
|
||||
const result = await provider.getDocumentText()
|
||||
// Should be just "Hello World", not "Hello Worldline2\nline3\n"
|
||||
assert.strictEqual(result, "Hello World")
|
||||
})
|
||||
|
||||
it("safelyTruncateDocument should no-op when lineNumber >= lineCount", async () => {
|
||||
const provider = new TestBoundaryDiffViewProvider()
|
||||
provider.setup("line1\nline2\nline3")
|
||||
// lineCount is 3
|
||||
|
||||
// Access private method via any cast or just call update which calls it
|
||||
// But update calls it with streamedLines.length.
|
||||
// Let's use update to trigger it.
|
||||
|
||||
// If we update with same content, streamedLines.length will be 3.
|
||||
// safelyTruncateDocument(3) should be called.
|
||||
// 3 >= 3, so it should NOT call truncateDocument.
|
||||
|
||||
await provider.update("line1\nline2\nline3", true)
|
||||
|
||||
assert.strictEqual(provider.truncatedAt, undefined, "Should not have called truncateDocument")
|
||||
})
|
||||
|
||||
it("final update replaces entire document so truncation is no-op", async () => {
|
||||
const provider = new TestBoundaryDiffViewProvider()
|
||||
provider.setup("line1\nline2\nline3")
|
||||
|
||||
// Update with fewer lines
|
||||
await provider.update("line1\n", true)
|
||||
|
||||
// With the fix, the final update replaces the entire document (0 to lineCount).
|
||||
// So replaceText handles all the content, and truncation becomes unnecessary.
|
||||
// The document should contain just "line1\n" and truncation should NOT be called
|
||||
// because after replaceText, the document already has the correct content.
|
||||
|
||||
// Note: truncation might still be called but should be a no-op since document is already correct
|
||||
assert.strictEqual(provider.documentText, "line1\n")
|
||||
})
|
||||
|
||||
it("update() with shorter content replaces entire document", async () => {
|
||||
const provider = new TestBoundaryDiffViewProvider()
|
||||
provider.setup("line1\nline2\nline3\nline4")
|
||||
|
||||
// Update with 2 lines
|
||||
await provider.update("line1\nline2", true)
|
||||
|
||||
// With the fix, the final update replaces the entire document (0 to lineCount).
|
||||
// The document should contain just "line1\nline2".
|
||||
|
||||
assert.strictEqual(provider.documentText, "line1\nline2")
|
||||
})
|
||||
})
|
||||
|
||||
describe("DiffViewProvider Newline Preservation", () => {
|
||||
it("preserves trailing newline when content ends with newline", async () => {
|
||||
const provider = new TestBoundaryDiffViewProvider()
|
||||
// Original file has trailing newline
|
||||
provider.setup("line1\nline2\n")
|
||||
|
||||
// New content also has trailing newline
|
||||
await provider.update("new1\nnew2\n", true)
|
||||
|
||||
const result = await provider.getDocumentText()
|
||||
assert.strictEqual(result, "new1\nnew2\n", "Trailing newline should be preserved")
|
||||
assert.strictEqual(result?.endsWith("\n"), true)
|
||||
})
|
||||
|
||||
it("does not add trailing newline when content does not end with newline", async () => {
|
||||
const provider = new TestBoundaryDiffViewProvider()
|
||||
// Original file has trailing newline
|
||||
provider.setup("line1\nline2\n")
|
||||
|
||||
// New content does NOT have trailing newline
|
||||
await provider.update("new1\nnew2", true)
|
||||
|
||||
const result = await provider.getDocumentText()
|
||||
assert.strictEqual(result, "new1\nnew2", "Should not have trailing newline")
|
||||
assert.strictEqual(result?.endsWith("\n"), false)
|
||||
})
|
||||
|
||||
it("adds trailing newline when content ends with newline but original did not", async () => {
|
||||
const provider = new TestBoundaryDiffViewProvider()
|
||||
// Original file does NOT have trailing newline
|
||||
provider.setup("line1\nline2")
|
||||
|
||||
// New content has trailing newline
|
||||
await provider.update("new1\nnew2\n", true)
|
||||
|
||||
const result = await provider.getDocumentText()
|
||||
assert.strictEqual(result, "new1\nnew2\n", "Should add trailing newline")
|
||||
assert.strictEqual(result?.endsWith("\n"), true)
|
||||
})
|
||||
|
||||
it("preserves no trailing newline when neither original nor new content has one", async () => {
|
||||
const provider = new TestBoundaryDiffViewProvider()
|
||||
// Original file does NOT have trailing newline
|
||||
provider.setup("line1\nline2")
|
||||
|
||||
// New content also does NOT have trailing newline
|
||||
await provider.update("new1\nnew2", true)
|
||||
|
||||
const result = await provider.getDocumentText()
|
||||
assert.strictEqual(result, "new1\nnew2", "Should not have trailing newline")
|
||||
assert.strictEqual(result?.endsWith("\n"), false)
|
||||
})
|
||||
|
||||
it("handles shortening file while preserving trailing newline", async () => {
|
||||
const provider = new TestBoundaryDiffViewProvider()
|
||||
// Original: 10 lines with trailing newline
|
||||
provider.setup("line1\nline2\nline3\nline4\nline5\nline6\nline7\nline8\nline9\nline10\n")
|
||||
|
||||
// New: 3 lines with trailing newline
|
||||
await provider.update("line1\nline2\nline3\n", true)
|
||||
|
||||
const result = await provider.getDocumentText()
|
||||
assert.strictEqual(result, "line1\nline2\nline3\n", "Should shorten and preserve trailing newline")
|
||||
})
|
||||
|
||||
it("handles lengthening file while preserving trailing newline", async () => {
|
||||
const provider = new TestBoundaryDiffViewProvider()
|
||||
// Original: 3 lines with trailing newline
|
||||
provider.setup("line1\nline2\nline3\n")
|
||||
|
||||
// New: 5 lines with trailing newline
|
||||
await provider.update("line1\nline2\nline3\nline4\nline5\n", true)
|
||||
|
||||
const result = await provider.getDocumentText()
|
||||
assert.strictEqual(result, "line1\nline2\nline3\nline4\nline5\n", "Should lengthen and preserve trailing newline")
|
||||
})
|
||||
|
||||
it("handles single line content with trailing newline", async () => {
|
||||
const provider = new TestBoundaryDiffViewProvider()
|
||||
provider.setup("old content\n")
|
||||
|
||||
await provider.update("Hello World\n", true)
|
||||
|
||||
const result = await provider.getDocumentText()
|
||||
assert.strictEqual(result, "Hello World\n")
|
||||
})
|
||||
|
||||
it("handles single line content without trailing newline", async () => {
|
||||
const provider = new TestBoundaryDiffViewProvider()
|
||||
provider.setup("old content\nline2\n")
|
||||
|
||||
await provider.update("Hello World", true)
|
||||
|
||||
const result = await provider.getDocumentText()
|
||||
assert.strictEqual(result, "Hello World")
|
||||
})
|
||||
})
|
||||
@@ -25,6 +25,9 @@ import { LanguageModelChatSelector } from "vscode"
|
||||
// SINGLE SOURCE OF TRUTH FOR STORAGE KEYS
|
||||
//
|
||||
// Property definitions with types, default values, and metadata
|
||||
// NOTE: When adding a new field, the scripts/generate-state-proto.mjs will be
|
||||
// executed automatically to regenerate the proto/cline/state.proto file with the
|
||||
// new fields once the file is staged and committed.
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
@@ -83,7 +86,6 @@ const GLOBAL_STATE_FIELDS = {
|
||||
} satisfies FieldDefinitions
|
||||
|
||||
// Fields that map directly to ApiHandlerOptions in @shared/api.ts
|
||||
// NOTE: Keep these in sync with ApiHandlerOptions interface
|
||||
const API_HANDLER_SETTINGS_FIELDS = {
|
||||
// Global configuration (not mode-specific)
|
||||
liteLlmBaseUrl: { default: undefined as string | undefined },
|
||||
|
||||
Vendored
+15
@@ -0,0 +1,15 @@
|
||||
declare module "picomatch" {
|
||||
type PicomatchOptions = {
|
||||
dot?: boolean
|
||||
nocase?: boolean
|
||||
ignore?: string | string[]
|
||||
posix?: boolean
|
||||
windows?: boolean
|
||||
}
|
||||
|
||||
type PicomatchMatcher = (input: string) => boolean
|
||||
|
||||
function picomatch(pattern: string | string[], options?: PicomatchOptions): PicomatchMatcher
|
||||
|
||||
export default picomatch
|
||||
}
|
||||
@@ -17,6 +17,7 @@
|
||||
],
|
||||
"typeRoots": [
|
||||
"./node_modules/@types",
|
||||
"./src/types",
|
||||
"./src/test/types"
|
||||
],
|
||||
"outDir": "out",
|
||||
|
||||
@@ -2,7 +2,11 @@
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"moduleResolution": "node"
|
||||
"moduleResolution": "node",
|
||||
"typeRoots": [
|
||||
"./node_modules/@types",
|
||||
"./src/types"
|
||||
]
|
||||
},
|
||||
"ts-node": {
|
||||
"require": [
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { ChevronsDownUpIcon, FilePlus, FileText, FileX } from "lucide-react"
|
||||
import { StringRequest } from "@shared/proto/cline/common"
|
||||
import { ChevronsDownUpIcon, FilePlus, FileText, FileX, SquareArrowOutUpRightIcon } from "lucide-react"
|
||||
import { memo, useEffect, useMemo, useRef, useState } from "react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { FileServiceClient } from "@/services/grpc-client"
|
||||
|
||||
interface Patch {
|
||||
action: string
|
||||
@@ -94,6 +96,15 @@ const FileBlock = memo<{ file: Patch; isStreaming: boolean }>(
|
||||
shouldFollowRef.current = Math.abs(scrollHeight - clientHeight - scrollTop) < 10
|
||||
}
|
||||
|
||||
const handleOpenFile = (event: React.MouseEvent) => {
|
||||
event.stopPropagation()
|
||||
if (file.path) {
|
||||
FileServiceClient.openFileRelativePath(StringRequest.create({ value: file.path })).catch((err) =>
|
||||
console.error("Failed to open file:", err),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const actionStyle = ACTION_STYLES[file.action as keyof typeof ACTION_STYLES] ?? ACTION_STYLES.default
|
||||
const ActionIcon = actionStyle.icon
|
||||
|
||||
@@ -106,10 +117,23 @@ const FileBlock = memo<{ file: Patch; isStreaming: boolean }>(
|
||||
<div className="flex items-center gap-3 flex-1 w-full overflow-hidden">
|
||||
<div className={cn("flex items-center gap-2 w-full", actionStyle.borderClass)}>
|
||||
<ActionIcon className={cn("w-5 h-5", actionStyle.iconClass)} />
|
||||
<span className="font-medium truncate">{file.path}</span>
|
||||
<span
|
||||
className="font-medium truncate hover:underline hover:text-link"
|
||||
onClick={handleOpenFile}
|
||||
title="Open file in editor">
|
||||
{file.path}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<DiffStats additions={file.additions} deletions={file.deletions} />
|
||||
<div className="flex items-center gap-2">
|
||||
<DiffStats additions={file.additions} deletions={file.deletions} />
|
||||
<span
|
||||
className="p-1 hover:bg-description/20 rounded-xs transition-colors"
|
||||
onClick={handleOpenFile}
|
||||
title="Open file in editor">
|
||||
<SquareArrowOutUpRightIcon className="size-2 text-description hover:text-foreground" />
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{isExpanded && (
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import { BooleanRequest, EmptyRequest, StringArrayRequest, StringRequest } from "@shared/proto/cline/common"
|
||||
import { BooleanRequest, EmptyRequest, StringArrayRequest } from "@shared/proto/cline/common"
|
||||
import { GetTaskHistoryRequest, TaskFavoriteRequest } from "@shared/proto/cline/task"
|
||||
import { VSCodeCheckbox, VSCodeRadio, VSCodeRadioGroup, VSCodeTextField } from "@vscode/webview-ui-toolkit/react"
|
||||
import { VSCodeTextField } from "@vscode/webview-ui-toolkit/react"
|
||||
import Fuse, { FuseResult } from "fuse.js"
|
||||
import { FunnelIcon } from "lucide-react"
|
||||
import { memo, useCallback, useEffect, useMemo, useState } from "react"
|
||||
import { Virtuoso } from "react-virtuoso"
|
||||
import { GroupedVirtuoso } from "react-virtuoso"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger } from "@/components/ui/select"
|
||||
import { useExtensionState } from "@/context/ExtensionStateContext"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { TaskServiceClient } from "@/services/grpc-client"
|
||||
import { getEnvironmentColor } from "@/utils/environmentColors"
|
||||
import { formatLargeNumber, formatSize } from "@/utils/format"
|
||||
import { formatSize } from "@/utils/format"
|
||||
import HistoryViewItem from "./HistoryViewItem"
|
||||
|
||||
type HistoryViewProps = {
|
||||
onDone: () => void
|
||||
@@ -17,6 +19,22 @@ type HistoryViewProps = {
|
||||
|
||||
type SortOption = "newest" | "oldest" | "mostExpensive" | "mostTokens" | "mostRelevant"
|
||||
|
||||
const isToday = (timestamp: number): boolean => {
|
||||
const date = new Date(timestamp)
|
||||
const today = new Date()
|
||||
return today.toDateString() === date.toDateString()
|
||||
}
|
||||
|
||||
const HISTORY_FILTERS = {
|
||||
newest: "Newest",
|
||||
oldest: "Oldest",
|
||||
mostExpensive: "Most Expensive",
|
||||
mostTokens: "Most Tokens",
|
||||
mostRelevant: "Most Relevant",
|
||||
workspaceOnly: "Workspace Only",
|
||||
favoritesOnly: "Favorites Only",
|
||||
}
|
||||
|
||||
const HistoryView = ({ onDone }: HistoryViewProps) => {
|
||||
const extensionStateContext = useExtensionState()
|
||||
const { taskHistory, onRelinquishControl, environment } = extensionStateContext
|
||||
@@ -135,12 +153,6 @@ const HistoryView = ({ onDone }: HistoryViewProps) => {
|
||||
}
|
||||
}, [searchQuery, sortOption, lastNonRelevantSort])
|
||||
|
||||
const handleShowTaskWithId = useCallback((id: string) => {
|
||||
TaskServiceClient.showTaskWithId(StringRequest.create({ value: id })).catch((error) =>
|
||||
console.error("Error showing task:", error),
|
||||
)
|
||||
}, [])
|
||||
|
||||
const handleHistorySelect = useCallback((itemId: string, checked: boolean) => {
|
||||
setSelectedItems((prev) => {
|
||||
if (checked) {
|
||||
@@ -172,21 +184,6 @@ const HistoryView = ({ onDone }: HistoryViewProps) => {
|
||||
[fetchTotalTasksSize],
|
||||
)
|
||||
|
||||
const formatDate = useCallback((timestamp: number) => {
|
||||
const date = new Date(timestamp)
|
||||
return date
|
||||
?.toLocaleString("en-US", {
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
hour12: true,
|
||||
})
|
||||
.replace(", ", " ")
|
||||
.replace(" at", ",")
|
||||
.toUpperCase()
|
||||
}, [])
|
||||
|
||||
const fuse = useMemo(() => {
|
||||
return new Fuse(tasks, {
|
||||
keys: ["task"],
|
||||
@@ -228,6 +225,45 @@ const HistoryView = ({ onDone }: HistoryViewProps) => {
|
||||
return results
|
||||
}, [tasks, searchQuery, fuse, sortOption])
|
||||
|
||||
// Group tasks into "Today" and "Older" (only for date-based sorts)
|
||||
const { groupedTasks, groupCounts, groupLabels } = useMemo(() => {
|
||||
const isDateSort = sortOption === "newest" || sortOption === "oldest"
|
||||
|
||||
if (!isDateSort) {
|
||||
// No grouping for non-date sorts
|
||||
return {
|
||||
groupedTasks: taskHistorySearchResults,
|
||||
groupCounts: [taskHistorySearchResults.length],
|
||||
groupLabels: [] as string[],
|
||||
}
|
||||
}
|
||||
|
||||
const todayTasks: any[] = []
|
||||
const olderTasks: any[] = []
|
||||
|
||||
taskHistorySearchResults.forEach((task) => {
|
||||
if (isToday(task.ts)) {
|
||||
todayTasks.push(task)
|
||||
} else {
|
||||
olderTasks.push(task)
|
||||
}
|
||||
})
|
||||
|
||||
const groups: { tasks: any[]; label: string }[] = []
|
||||
if (todayTasks.length > 0) {
|
||||
groups.push({ tasks: todayTasks, label: "Today" })
|
||||
}
|
||||
if (olderTasks.length > 0) {
|
||||
groups.push({ tasks: olderTasks, label: "Older" })
|
||||
}
|
||||
|
||||
return {
|
||||
groupedTasks: groups.flatMap((g) => g.tasks),
|
||||
groupCounts: groups.map((g) => g.tasks.length),
|
||||
groupLabels: groups.map((g) => g.label),
|
||||
}
|
||||
}, [taskHistorySearchResults, sortOption])
|
||||
|
||||
// Calculate total size of selected items
|
||||
const selectedItemsSize = useMemo(() => {
|
||||
if (selectedItems.length === 0) {
|
||||
@@ -249,318 +285,184 @@ const HistoryView = ({ onDone }: HistoryViewProps) => {
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<style>
|
||||
{`
|
||||
.history-item:hover {
|
||||
background-color: var(--vscode-list-hoverBackground);
|
||||
}
|
||||
.delete-button, .export-button {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
.history-item:hover .delete-button,
|
||||
.history-item:hover .export-button {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
.history-item-highlight {
|
||||
background-color: var(--vscode-editor-findMatchHighlightBackground);
|
||||
color: inherit;
|
||||
}
|
||||
`}
|
||||
</style>
|
||||
<div className="fixed overflow-hidden inset-0 flex flex-col">
|
||||
<div className="flex justify-between items-center py-2.5 px-5">
|
||||
<h3
|
||||
className="m-0"
|
||||
style={{
|
||||
color: getEnvironmentColor(environment),
|
||||
}}>
|
||||
History
|
||||
</h3>
|
||||
<Button onClick={() => onDone()}>Done</Button>
|
||||
</div>
|
||||
<div className="py-1.5 px-4">
|
||||
<div className="flex flex-col gap-3">
|
||||
<VSCodeTextField
|
||||
className="w-full"
|
||||
onInput={(e) => {
|
||||
const newValue = (e.target as HTMLInputElement)?.value
|
||||
setSearchQuery(newValue)
|
||||
if (newValue && !searchQuery && sortOption !== "mostRelevant") {
|
||||
setLastNonRelevantSort(sortOption)
|
||||
setSortOption("mostRelevant")
|
||||
}
|
||||
}}
|
||||
placeholder="Fuzzy search history..."
|
||||
value={searchQuery}>
|
||||
<div className="fixed overflow-hidden inset-0 flex flex-col w-full">
|
||||
{/* HEADER */}
|
||||
<div className="flex justify-between items-center py-2.5 px-5">
|
||||
<h3
|
||||
className="m-0"
|
||||
style={{
|
||||
color: getEnvironmentColor(environment),
|
||||
}}>
|
||||
History
|
||||
</h3>
|
||||
<Button onClick={() => onDone()}>Done</Button>
|
||||
</div>
|
||||
|
||||
{/* FILTERS */}
|
||||
<div className="flex flex-col gap-3 px-3">
|
||||
{/* REPLACE VSCODE RADIO GROUP */}
|
||||
<div className="flex justify-between items-center">
|
||||
{/* SEARCH BOX */}
|
||||
<VSCodeTextField
|
||||
className="w-full"
|
||||
onInput={(e) => {
|
||||
const newValue = (e.target as HTMLInputElement)?.value
|
||||
setSearchQuery(newValue)
|
||||
if (newValue && !searchQuery && sortOption !== "mostRelevant") {
|
||||
setLastNonRelevantSort(sortOption)
|
||||
setSortOption("mostRelevant")
|
||||
}
|
||||
}}
|
||||
placeholder="Fuzzy search history..."
|
||||
value={searchQuery}>
|
||||
<div className="codicon codicon-search opacity-80 mt-0.5 !text-sm" slot="start" />
|
||||
{searchQuery && (
|
||||
<div
|
||||
className="codicon codicon-search"
|
||||
slot="start"
|
||||
style={{
|
||||
fontSize: 13,
|
||||
marginTop: 2.5,
|
||||
opacity: 0.8,
|
||||
}}
|
||||
aria-label="Clear search"
|
||||
className="input-icon-button codicon codicon-close flex justify-center items-center h-full"
|
||||
onClick={() => setSearchQuery("")}
|
||||
slot="end"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<div
|
||||
aria-label="Clear search"
|
||||
className="input-icon-button codicon codicon-close flex justify-center items-center h-full"
|
||||
onClick={() => setSearchQuery("")}
|
||||
slot="end"
|
||||
/>
|
||||
)}
|
||||
</VSCodeTextField>
|
||||
<VSCodeRadioGroup
|
||||
className="flex flex-wrap"
|
||||
onChange={(e) => setSortOption((e.target as HTMLInputElement).value as SortOption)}
|
||||
value={sortOption}>
|
||||
<VSCodeRadio value="newest">Newest</VSCodeRadio>
|
||||
<VSCodeRadio value="oldest">Oldest</VSCodeRadio>
|
||||
<VSCodeRadio value="mostExpensive">Most Expensive</VSCodeRadio>
|
||||
<VSCodeRadio value="mostTokens">Most Tokens</VSCodeRadio>
|
||||
<VSCodeRadio disabled={!searchQuery} style={{ opacity: searchQuery ? 1 : 0.5 }} value="mostRelevant">
|
||||
Most Relevant
|
||||
</VSCodeRadio>
|
||||
</VSCodeRadioGroup>
|
||||
<div className="flex flex-wrap -mt-2">
|
||||
<VSCodeRadio
|
||||
checked={showCurrentWorkspaceOnly}
|
||||
onClick={() => setShowCurrentWorkspaceOnly(!showCurrentWorkspaceOnly)}>
|
||||
<span className="flex items-center gap-[3px]">
|
||||
<span className="codicon codicon-folder text-button-background" />
|
||||
Workspace
|
||||
</span>
|
||||
</VSCodeRadio>
|
||||
<VSCodeRadio checked={showFavoritesOnly} onClick={() => setShowFavoritesOnly(!showFavoritesOnly)}>
|
||||
<span className="flex items-center gap-[3px]">
|
||||
<span className="codicon codicon-star-full text-button-background" />
|
||||
Favorites
|
||||
</span>
|
||||
</VSCodeRadio>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-grow overflow-y-auto m-0">
|
||||
<Virtuoso
|
||||
className="flex-grow overflow-y-scroll"
|
||||
data={taskHistorySearchResults}
|
||||
itemContent={(index, item) => (
|
||||
<div
|
||||
className="history-item"
|
||||
key={item.id}
|
||||
style={{
|
||||
cursor: "pointer",
|
||||
borderBottom:
|
||||
index < taskHistory.length - 1 ? "1px solid var(--vscode-panel-border)" : "none",
|
||||
display: "flex",
|
||||
}}>
|
||||
<VSCodeCheckbox
|
||||
checked={selectedItems.includes(item.id)}
|
||||
className="pl-3 pr-1 py-auto"
|
||||
onClick={(e) => {
|
||||
const checked = (e.target as HTMLInputElement).checked
|
||||
handleHistorySelect(item.id, checked)
|
||||
e.stopPropagation()
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="flex flex-col gap-2 py-3 px-5 pl-4 relative flex-grow"
|
||||
onClick={() => handleShowTaskWithId(item.id)}>
|
||||
<div className="flex justify-between items-center">
|
||||
<span
|
||||
style={{
|
||||
color: "var(--vscode-descriptionForeground)",
|
||||
fontWeight: 500,
|
||||
fontSize: "0.85em",
|
||||
textTransform: "uppercase",
|
||||
}}>
|
||||
{formatDate(item.ts)}
|
||||
</span>
|
||||
<div className="flex gap-1">
|
||||
{/* only show delete button if task not favorited */}
|
||||
{!(pendingFavoriteToggles[item.id] ?? item.isFavorited) && (
|
||||
<Button
|
||||
aria-label="Delete"
|
||||
className="delete-button p-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleDeleteHistoryItem(item.id)
|
||||
}}
|
||||
variant="icon">
|
||||
<div className="flex items-center gap-1 text-xs">
|
||||
<span className="codicon codicon-trash"></span>
|
||||
{formatSize(item.size)}
|
||||
</div>
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
aria-label={item.isFavorited ? "Remove from favorites" : "Add to favorites"}
|
||||
className="p-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
toggleFavorite(item.id, item.isFavorited || false)
|
||||
}}
|
||||
variant="icon">
|
||||
<div
|
||||
className={cn(
|
||||
`opacity-70 codicon ${
|
||||
pendingFavoriteToggles[item.id] !== undefined
|
||||
? pendingFavoriteToggles[item.id]
|
||||
? "codicon-star-full"
|
||||
: "codicon-star-empty"
|
||||
: item.isFavorited
|
||||
? "codicon-star-full"
|
||||
: "codicon-star-empty"
|
||||
}`,
|
||||
{
|
||||
"text-button-background opacity-100 block":
|
||||
pendingFavoriteToggles[item.id] ?? item.isFavorited,
|
||||
},
|
||||
)}
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-2 relative">
|
||||
<div className="line-clamp-3 overflow-hidden break-words whitespace-pre-wrap">
|
||||
<span
|
||||
className="ph-no-capture"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: item.task,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-1 flex-wrap">
|
||||
<span className="font-medium text-description">Tokens:</span>
|
||||
<span className="flex items-center gap-1 text-description">
|
||||
<i
|
||||
className="codicon codicon-arrow-up font-bold -mb-0.5"
|
||||
style={{
|
||||
fontSize: "12px",
|
||||
}}
|
||||
/>
|
||||
{formatLargeNumber(item.tokensIn || 0)}
|
||||
</span>
|
||||
<span className="flex items-center gap-1 text-description">
|
||||
<i
|
||||
className="codicon codicon-arrow-down font-bold -mb-0.5"
|
||||
style={{
|
||||
fontSize: "12px",
|
||||
}}
|
||||
/>
|
||||
{formatLargeNumber(item.tokensOut || 0)}
|
||||
</span>
|
||||
</div>
|
||||
{!item.totalCost && <ExportButton itemId={item.id} />}
|
||||
</div>
|
||||
|
||||
{!!(item.cacheWrites || item.cacheReads) && (
|
||||
<div className="flex items-center gap-1 flex-wrap">
|
||||
<span className="font-medium text-description">Cache:</span>
|
||||
{item.cacheWrites > 0 && (
|
||||
<span className="flex items-center gap-1 text-description">
|
||||
<i
|
||||
className="codicon codicon-arrow-right font-bold -mb-[1px]"
|
||||
style={{
|
||||
fontSize: "12px",
|
||||
}}
|
||||
/>
|
||||
{formatLargeNumber(item.cacheWrites)}
|
||||
</span>
|
||||
)}
|
||||
{item.cacheReads > 0 && (
|
||||
<span className="flex items-center gap-1 text-description">
|
||||
<i
|
||||
className="codicon codicon-arrow-left font-bold mb-0"
|
||||
style={{
|
||||
fontSize: "12px",
|
||||
}}
|
||||
/>
|
||||
{formatLargeNumber(item.cacheReads)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{item.modelId && <div className="text-description">Model: {item.modelId}</div>}
|
||||
{!!item.totalCost && (
|
||||
<div className="flex justify-between items-center -mt-0.5">
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="font-medium text-description">API Cost:</span>
|
||||
<span className="text-description">${item.totalCost?.toFixed(4)}</span>
|
||||
</div>
|
||||
<ExportButton itemId={item.id} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="p-2.5 border-t border-t-border-panel">
|
||||
<div className="flex gap-2.5 mb-2.5">
|
||||
<Button className="flex-1" onClick={() => handleBatchHistorySelect(true)} variant="secondary">
|
||||
Select All
|
||||
</Button>
|
||||
<Button className="flex-1" onClick={() => handleBatchHistorySelect(false)} variant="secondary">
|
||||
Select None
|
||||
</Button>
|
||||
</div>
|
||||
{selectedItems.length > 0 ? (
|
||||
<Button
|
||||
aria-label="Delete selected items"
|
||||
className="w-full"
|
||||
onClick={() => {
|
||||
handleDeleteSelectedHistoryItems(selectedItems)
|
||||
}}
|
||||
variant="danger">
|
||||
Delete {selectedItems.length > 1 ? selectedItems.length : ""} Selected
|
||||
{selectedItemsSize > 0 ? ` (${formatSize(selectedItemsSize)})` : ""}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
aria-label="Delete all history"
|
||||
className="w-full"
|
||||
disabled={deleteAllDisabled || taskHistory.length === 0}
|
||||
onClick={() => {
|
||||
setDeleteAllDisabled(true)
|
||||
TaskServiceClient.deleteAllTaskHistory(BooleanRequest.create({}))
|
||||
.then(() => fetchTotalTasksSize())
|
||||
.catch((error) => console.error("Error deleting task history:", error))
|
||||
.finally(() => setDeleteAllDisabled(false))
|
||||
}}
|
||||
variant="danger">
|
||||
Delete All History{totalTasksSize !== null ? ` (${formatSize(totalTasksSize)})` : ""}
|
||||
</Button>
|
||||
)}
|
||||
</VSCodeTextField>
|
||||
<Select
|
||||
onValueChange={(value) => {
|
||||
// Handle sort options
|
||||
if (
|
||||
value === "newest" ||
|
||||
value === "oldest" ||
|
||||
value === "mostExpensive" ||
|
||||
value === "mostTokens" ||
|
||||
value === "mostRelevant"
|
||||
) {
|
||||
if (value === "mostRelevant" && !searchQuery) {
|
||||
// Don't allow selecting mostRelevant without a search query
|
||||
return
|
||||
}
|
||||
setSortOption(value as SortOption)
|
||||
if (value !== "mostRelevant") {
|
||||
setLastNonRelevantSort(value as SortOption)
|
||||
}
|
||||
}
|
||||
// Handle filter toggles
|
||||
else if (value === "workspaceOnly") {
|
||||
setShowCurrentWorkspaceOnly(!showCurrentWorkspaceOnly)
|
||||
} else if (value === "favoritesOnly") {
|
||||
setShowFavoritesOnly(!showFavoritesOnly)
|
||||
}
|
||||
}}
|
||||
value={sortOption}>
|
||||
<SelectTrigger className="border-0 cursor-pointer" showIcon={false}>
|
||||
<FunnelIcon className="!size-2 text-foreground" />
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper">
|
||||
{Object.entries(HISTORY_FILTERS).map(([key, value]) => {
|
||||
const isSortOption = ["newest", "oldest", "mostExpensive", "mostTokens", "mostRelevant"].includes(
|
||||
key,
|
||||
)
|
||||
const isFilterOption = ["workspaceOnly", "favoritesOnly"].includes(key)
|
||||
const isSelected = isSortOption
|
||||
? sortOption === key
|
||||
: key === "workspaceOnly"
|
||||
? showCurrentWorkspaceOnly
|
||||
: key === "favoritesOnly"
|
||||
? showFavoritesOnly
|
||||
: false
|
||||
const isDisabled = key === "mostRelevant" && !searchQuery
|
||||
|
||||
return (
|
||||
<SelectItem
|
||||
className={isSelected ? "bg-button-background/30" : ""}
|
||||
disabled={isDisabled}
|
||||
key={key}
|
||||
value={key}>
|
||||
<span className="flex items-center gap-2">
|
||||
{isFilterOption && (
|
||||
<span
|
||||
className={`codicon ${
|
||||
key === "workspaceOnly" ? "codicon-folder" : "codicon-star-full"
|
||||
} ${isSelected ? "text-button-background" : ""}`}
|
||||
/>
|
||||
)}
|
||||
{value}
|
||||
</span>
|
||||
</SelectItem>
|
||||
)
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
|
||||
{/* HISTORY ITEMS */}
|
||||
<div className="flex-grow overflow-y-auto m-0 w-full py-2">
|
||||
<GroupedVirtuoso
|
||||
className="flex-grow overflow-y-scroll"
|
||||
groupContent={(index) => (
|
||||
<div className="px-4 py-2 text-xs font-bold uppercase tracking-wide sticky top-0 z-10 text-description bg-sidebar-background border-b-border-panel">
|
||||
{groupLabels[index]}
|
||||
</div>
|
||||
)}
|
||||
groupCounts={groupCounts}
|
||||
itemContent={(index) => {
|
||||
const item = groupedTasks[index]
|
||||
return (
|
||||
<HistoryViewItem
|
||||
handleDeleteHistoryItem={handleDeleteHistoryItem}
|
||||
handleHistorySelect={handleHistorySelect}
|
||||
index={index}
|
||||
item={item}
|
||||
pendingFavoriteToggles={pendingFavoriteToggles}
|
||||
selectedItems={selectedItems}
|
||||
toggleFavorite={toggleFavorite}
|
||||
/>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* FOOTER */}
|
||||
<div className="p-2.5 border-t border-t-border-panel">
|
||||
<div className="flex gap-2.5 mb-2.5">
|
||||
<Button className="flex-1" onClick={() => handleBatchHistorySelect(true)} variant="secondary">
|
||||
Select All
|
||||
</Button>
|
||||
<Button className="flex-1" onClick={() => handleBatchHistorySelect(false)} variant="secondary">
|
||||
Select None
|
||||
</Button>
|
||||
</div>
|
||||
{selectedItems.length > 0 ? (
|
||||
<Button
|
||||
aria-label="Delete selected items"
|
||||
className="w-full"
|
||||
onClick={() => {
|
||||
handleDeleteSelectedHistoryItems(selectedItems)
|
||||
}}
|
||||
variant="danger">
|
||||
Delete {selectedItems.length > 1 ? selectedItems.length : ""} Selected
|
||||
{selectedItemsSize > 0 ? ` (${formatSize(selectedItemsSize)})` : ""}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
aria-label="Delete all history"
|
||||
className="w-full"
|
||||
disabled={deleteAllDisabled || taskHistory.length === 0}
|
||||
onClick={() => {
|
||||
setDeleteAllDisabled(true)
|
||||
TaskServiceClient.deleteAllTaskHistory(BooleanRequest.create({}))
|
||||
.then(() => fetchTotalTasksSize())
|
||||
.catch((error) => console.error("Error deleting task history:", error))
|
||||
.finally(() => setDeleteAllDisabled(false))
|
||||
}}
|
||||
variant="danger">
|
||||
Delete All History{totalTasksSize !== null ? ` (${formatSize(totalTasksSize)})` : ""}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const ExportButton = ({ itemId }: { itemId: string }) => (
|
||||
<Button
|
||||
aria-label="Export"
|
||||
className="export-button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
TaskServiceClient.exportTaskWithId(StringRequest.create({ value: itemId })).catch((err) =>
|
||||
console.error("Failed to export task:", err),
|
||||
)
|
||||
}}
|
||||
variant="icon">
|
||||
<span className="opacity-100 text-sm font-medium">EXPORT</span>
|
||||
</Button>
|
||||
)
|
||||
|
||||
// https://gist.github.com/evenfrost/1ba123656ded32fb7a0cd4651efd4db0
|
||||
export const highlight = (fuseSearchResult: FuseResult<any>[], highlightClassName: string = "history-item-highlight") => {
|
||||
const set = (obj: Record<string, any>, path: string, value: any) => {
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
import { HistoryItem } from "@shared/HistoryItem"
|
||||
import { StringRequest } from "@shared/proto/cline/common"
|
||||
import { VSCodeCheckbox } from "@vscode/webview-ui-toolkit/react"
|
||||
import {
|
||||
ArrowDownIcon,
|
||||
ArrowLeftIcon,
|
||||
ArrowRightIcon,
|
||||
ArrowUpIcon,
|
||||
ChevronsDownUpIcon,
|
||||
ChevronsUpDownIcon,
|
||||
DownloadIcon,
|
||||
StarIcon,
|
||||
TrashIcon,
|
||||
} from "lucide-react"
|
||||
import { memo, useCallback, useState } from "react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { TaskServiceClient } from "@/services/grpc-client"
|
||||
import { formatLargeNumber, formatSize } from "@/utils/format"
|
||||
|
||||
type HistoryViewItemProps = {
|
||||
item: HistoryItem
|
||||
index: number
|
||||
selectedItems: string[]
|
||||
pendingFavoriteToggles: Record<string, boolean>
|
||||
handleDeleteHistoryItem: (id: string) => void
|
||||
toggleFavorite: (id: string, isCurrentlyFavorited: boolean) => void
|
||||
handleHistorySelect: (itemId: string, checked: boolean) => void
|
||||
}
|
||||
|
||||
const HistoryViewItem = ({
|
||||
item,
|
||||
pendingFavoriteToggles,
|
||||
handleDeleteHistoryItem,
|
||||
toggleFavorite,
|
||||
handleHistorySelect,
|
||||
selectedItems,
|
||||
}: HistoryViewItemProps) => {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
|
||||
const handleShowTaskWithId = useCallback((id: string) => {
|
||||
TaskServiceClient.showTaskWithId(StringRequest.create({ value: id })).catch((error) =>
|
||||
console.error("Error showing task:", error),
|
||||
)
|
||||
}, [])
|
||||
|
||||
const formatDate = useCallback((timestamp: number) => {
|
||||
const date = new Date(timestamp)
|
||||
const today = new Date()
|
||||
const isToday = today.toDateString() === date.toDateString()
|
||||
|
||||
return date
|
||||
.toLocaleString(
|
||||
"en-US",
|
||||
isToday
|
||||
? {
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
hour12: true,
|
||||
}
|
||||
: {
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
hour12: true,
|
||||
},
|
||||
)
|
||||
.replace(", ", " ")
|
||||
.replace(" at", ",")
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="history-item cursor-pointer flex group mb-1 hover:bg-list-hover" key={item.id}>
|
||||
<VSCodeCheckbox
|
||||
checked={selectedItems.includes(item.id)}
|
||||
className="pl-3 pr-1 py-auto"
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
const checked = (e.target as HTMLInputElement).checked
|
||||
handleHistorySelect(item.id, checked)
|
||||
}}
|
||||
/>
|
||||
|
||||
<div
|
||||
className="flex flex-col gap-2 py-2 pl-2 pr-3 relative flex-grow w-full"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleShowTaskWithId(item.id)
|
||||
}}>
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="line-clamp-1 overflow-hidden break-words whitespace-pre-wrap">
|
||||
<span className="ph-no-capture">{item.task}</span>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
aria-label="Delete"
|
||||
className="p-0 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
disabled={pendingFavoriteToggles[item.id] !== undefined}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleDeleteHistoryItem(item.id)
|
||||
}}
|
||||
variant="ghost">
|
||||
<span className="flex items-center gap-1 text-xs">
|
||||
<TrashIcon className="stroke-1" />
|
||||
</span>
|
||||
</Button>
|
||||
<Button
|
||||
aria-label={item.isFavorited ? "Remove from favorites" : "Add to favorites"}
|
||||
className="p-0"
|
||||
disabled={pendingFavoriteToggles[item.id] !== undefined}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
toggleFavorite(item.id, item.isFavorited || false)
|
||||
}}
|
||||
variant="icon">
|
||||
<StarIcon
|
||||
className={cn("opacity-70", {
|
||||
"text-button-background fill-button-background opacity-100":
|
||||
pendingFavoriteToggles[item.id] ?? item.isFavorited,
|
||||
})}
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
className="p-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setExpanded(!expanded)
|
||||
}}
|
||||
variant="icon">
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<div className="text-description text-xs uppercase">{formatDate(item.ts)}</div>
|
||||
<div className="self-end flex items-center text-xs">
|
||||
<span className="text-description">${item.totalCost?.toFixed(4) ?? 0}</span>
|
||||
{expanded ? (
|
||||
<ChevronsDownUpIcon className="text-description" />
|
||||
) : (
|
||||
<ChevronsUpDownIcon className="text-description" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Button>
|
||||
{expanded && (
|
||||
<Button
|
||||
className="m-0 text-xs cursor-pointer p-2 bg-accent/10 w-full rounded-xs"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setExpanded(!expanded)
|
||||
}}
|
||||
variant="text">
|
||||
<div className="flex flex-col gap-1 w-full text-xs">
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<div className="flex items-center gap-1 flex-wrap w-full">
|
||||
<div className="flex justify-between items-center w-full gap-1 text-xs">
|
||||
<span className="font-medium text-description">Tokens:</span>
|
||||
<div className="flex items-center gap-1 text-description text-xs">
|
||||
<span className="flex items-center gap-1 text-description">
|
||||
<ArrowUpIcon className="text-description !size-1" />
|
||||
{formatLargeNumber(item.tokensIn || 0)}
|
||||
</span>
|
||||
<span className="flex items-center gap-1 text-description">
|
||||
<ArrowDownIcon className="text-description !size-1" />
|
||||
{formatLargeNumber(item.tokensOut || 0)}
|
||||
</span>
|
||||
{item.cacheWrites
|
||||
? item.cacheWrites > 0 && (
|
||||
<span className="flex items-center gap-1 text-description">
|
||||
<ArrowRightIcon className="text-description !size-1" />
|
||||
{formatLargeNumber(item.cacheWrites)}
|
||||
</span>
|
||||
)
|
||||
: null}
|
||||
{item.cacheReads
|
||||
? item.cacheReads > 0 && (
|
||||
<span className="flex items-center gap-1 text-description">
|
||||
<ArrowLeftIcon className="text-description !size-1" />
|
||||
{formatLargeNumber(item.cacheReads)}
|
||||
</span>
|
||||
)
|
||||
: null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{item.modelId && (
|
||||
<div className="flex justify-between items-center w-full gap-1 text-xs">
|
||||
<span className="font-medium text-description">Model:</span>
|
||||
<span className="text-description">{item.modelId}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-between items-center w-full gap-1 text-xs">
|
||||
<span className="font-medium text-description">Size:</span>
|
||||
<span className="items-center gap-2 flex text-description">
|
||||
{formatSize(item.size)}
|
||||
<Button
|
||||
aria-label="Export"
|
||||
className="m-0 p-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
TaskServiceClient.exportTaskWithId(
|
||||
StringRequest.create({ value: item.id }),
|
||||
).catch((err) => console.error("Failed to export task:", err))
|
||||
}}
|
||||
variant="ghost">
|
||||
<DownloadIcon />
|
||||
</Button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(HistoryViewItem)
|
||||
@@ -21,10 +21,12 @@ function SelectValue({ ...props }: React.ComponentProps<typeof SelectPrimitive.V
|
||||
function SelectTrigger({
|
||||
className,
|
||||
size = "default",
|
||||
showIcon = true,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
|
||||
size?: "sm" | "default"
|
||||
showIcon?: boolean
|
||||
}) {
|
||||
return (
|
||||
<SelectPrimitive.Trigger
|
||||
@@ -36,9 +38,11 @@ function SelectTrigger({
|
||||
data-slot="select-trigger"
|
||||
{...props}>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDownIcon className="size-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
{showIcon && (
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDownIcon className="size-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
)}
|
||||
</SelectPrimitive.Trigger>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -55,6 +55,7 @@
|
||||
--color-banner-background: var(--vscode-banner-background);
|
||||
--color-banner-foreground: var(--vscode-banner-foreground);
|
||||
--color-banner-icon: var(--vscode-banner-iconForeground);
|
||||
--color-editor-match-highlight: var(--vscode-editor-findMatchHighlightBackground);
|
||||
--color-icon-foreground: var(--vscode-icon-foreground);
|
||||
--color-notification-foreground: var(--vscode-notificationsInfoIcon-foreground);
|
||||
--color-toolbar-default: var(--vscode-toolbar-background);
|
||||
@@ -182,7 +183,7 @@
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--size-1: calc(0.5 * var(--vscode-font-size));
|
||||
--size-1: calc(0.85 * var(--vscode-font-size));
|
||||
--size-2: calc(1 * var(--vscode-font-size));
|
||||
--size-3: calc(1.25 * var(--vscode-font-size));
|
||||
--size-4: calc(1.5 * var(--vscode-font-size));
|
||||
|
||||
Reference in New Issue
Block a user