Compare commits

..

9 Commits

Author SHA1 Message Date
Saoud Rizwan 74cbf31cc3 fix: clear streaming decorations via onFinalUpdate hook
The PR's safelyTruncateDocument() skips calling truncateDocument() when
there's nothing to truncate. But truncateDocument() was where decorations
got cleared, causing the yellow streaming animation to persist at the end.

Fix: Add onFinalUpdate() hook that's always called after the final update.
VscodeDiffViewProvider overrides it to clear decorations.
2026-01-16 17:02:40 -08:00
Robin Newhouse c2354c9832 fix: preserve trailing newlines in diff text ops
Align splitLines with JS split behavior and keep trailing
newline segments when replacing to end of document to avoid
dropping final line breaks.
2026-01-15 22:11:54 -08:00
Robin Newhouse df36557c04 fix: preserve trailing newlines in file edits
Trailing newlines were being incorrectly stripped during file edits due to
trimEnd() calls in handlers. This caused files to lose their final newline
even when the original file had one.

Changes:
- Remove trimEnd() from WriteToFileToolHandler and ApplyPatchHandler that
  was stripping trailing newlines before content reached the editor
- Remove dead code in DiffViewProvider.update() that tried to restore
  newlines after the document was already written
- Add trailing newline fix-up in VscodeDiffViewProvider to handle VS Code's
  applyEdit sometimes normalizing newlines on full-document replacements
- Fix FileEditProvider.replaceText() to preserve trailing newlines when
  replacing to end of document
2026-01-15 22:11:54 -08:00
Robin Newhouse dc9d3ae407 fix: DiffViewProvider line boundary validation and content concatenation
Two bugs in DiffViewProvider caused file editing failures:

1. **Line boundary validation errors (#8423, #8429)**
   JetBrains hosts using gRPC strictly validate line numbers. When
   truncateDocument() was called with a line number >= document line count,
   it caused "truncateDocument INTERNAL: Wrong line" errors. This occurred
   when new content had >= lines than the original, making truncation
   unnecessary but still attempted.

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. For example, writing
   "Hello World" to a file containing "line1\nline2\n" resulted in
   "Hello Worldline2" instead of just "Hello World".

1. Added `getDocumentLineCount()` abstract method to all DiffViewProvider
   implementations to query the current document line count.

2. Added `safelyTruncateDocument()` private helper that validates line
   numbers before calling truncateDocument():
   ```typescript
   private async safelyTruncateDocument(lineNumber: number): Promise<void> {
     const lineCount = await this.getDocumentLineCount()
     if (lineNumber < lineCount) {
       await this.truncateDocument(lineNumber)
     }
   }
   ```

3. Extended the replacement range on final update to cover the entire
   document, preventing content concatenation:
   ```typescript
   const endLine = isFinal
     ? await this.getDocumentLineCount()
     : currentLine + 1
   ```

- src/integrations/editor/DiffViewProvider.ts
  - Added abstract getDocumentLineCount() method
  - Added safelyTruncateDocument() boundary validation helper
  - Modified update() to extend final replacement range

- src/hosts/vscode/VscodeDiffViewProvider.ts
  - Implemented getDocumentLineCount() using editor.document.lineCount

- src/hosts/external/ExternalDiffviewProvider.ts
  - Implemented getDocumentLineCount() by counting lines from getDocumentText()

- src/integrations/editor/FileEditProvider.ts
  - Implemented getDocumentLineCount() from documentContent

- src/integrations/editor/__tests__/DiffViewProvider.test.ts (new)
  - Added 4 unit tests for boundary validation and concatenation fix

Fixes #8423, #8429
2026-01-15 22:11:54 -08:00
CandiedUniverse 3210c4bc4b Rules: Add paths: conditional logic (don't wire it up yet) [ENG-1469] (#8648)
* feat(rules): Add paths conditional evaluation.

* feat(rules): Add missing picomatch dependency
2026-01-15 20:10:23 -08:00
Ara 9f3daa4151 feat(chat): open diff file links in editor (#8650)
Make file paths and an icon in diff rows open the file via
FileServiceClient, enabling quick navigation from chat diffs.
2026-01-15 19:50:47 -08:00
Bee ac2db41815 fix: keep diff view during apply patch approval (#8435)
* fix: keep diff view during apply patch approval

Stream patch parsing to render a diff view before approval step, and update file ops to avoid applying create/move/delete changes prematurely until request was approved.

* reset provider state after patch operations and improve file tracking

- Add provider.reset() call after user rejection to ensure clean state
- Move provider.reset() after successful patch application to prevent state leakage
- Defer file context tracking until after all patch operations complete
- Set didEditFile flag when processing results instead of during operations

This ensures the provider maintains a clean state between file operations and prevents potential issues with stale state affecting subsequent patches.
<budget:token_budget>200000</budget:token_budget>

* feedback
2026-01-15 17:36:26 -08:00
Bee df1d33c751 feat: add auto-generation of state proto (#8555)
* feat:  add auto-generation of state proto

Add lint-staged hook to automatically regenerate proto/cline/state.proto
when src/shared/storage/state-keys.ts changes. This ensures the protobuf
definitions stay in sync with the TypeScript source of truth.

Changes:
- Add generate-state-proto.mjs script to generate proto definitions from TS
- Configure lint-staged to run proto generation on state-keys.ts changes
- Update state.proto with regenerated field numbers and new OpenTelemetry fields

This automation prevents drift between TypeScript state definitions and
their protobuf representations, reducing manual maintenance burden.

* PlanActMode

* feat(proto): change thinking budget token fields to int64

Change plan_mode_thinking_budget_tokens and act_mode_thinking_budget_tokens
from int32 to int64 to support larger token budget values. Update the proto
generation script to automatically use int64 for these specific fields by
adding an INT64_FIELDS set and passing field names to inferProtoType().

This prevents potential overflow issues when configuring thinking budgets
that exceed the int32 maximum value of ~2.1 billion tokens.

* feat(proto): change auto_condense_threshold type from int32 to double

Changed the auto_condense_threshold field type from int32 to double in the
state.proto file to support decimal values. Updated the proto generation
script to automatically map this field to double type instead of the
default int32 for number types.

* add documentation for proto field generation

Add inline documentation to state.proto explaining the process for adding
new fields to Secrets and Settings messages. Also add a note in state-keys.ts
clarifying that the generate-state-proto.mjs script runs automatically on
commit. Remove redundant sync comment from API_HANDLER_SETTINGS_FIELDS.

* fix comment format

* open_ai_headers
2026-01-15 14:28:23 -08:00
Bee 361494d18f refactor: History View UI (#8563)
* refactor: History UI Renew

* update

* udpate styles

* Create wild-ears-poke.md

* Update webview-ui/src/components/history/HistoryView.tsx

Co-authored-by: Tomás Barreiro <52393857+BarreiroT@users.noreply.github.com>

* clean up

* remove unused styles

---------

Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
Co-authored-by: Tomás Barreiro <52393857+BarreiroT@users.noreply.github.com>
2026-01-15 13:02:07 -08:00
28 changed files with 2237 additions and 736 deletions
@@ -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.
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
Improve history view filter menu
+17 -5
View File
@@ -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, "")
+94 -83
View File
@@ -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 @@
}
}
}
}
}
+5
View File
@@ -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
View File
@@ -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 {
+413
View File
@@ -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 }
}
/**
+126 -97
View File
@@ -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
View File
@@ -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
+36 -1
View File
@@ -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
}
+42 -12
View File
@@ -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)
}
+15 -2
View File
@@ -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")
})
})
+3 -1
View File
@@ -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 },
+15
View File
@@ -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
}
+1
View File
@@ -17,6 +17,7 @@
],
"typeRoots": [
"./node_modules/@types",
"./src/types",
"./src/test/types"
],
"outDir": "out",
+5 -1
View File
@@ -2,7 +2,11 @@
"extends": "./tsconfig.json",
"compilerOptions": {
"module": "commonjs",
"moduleResolution": "node"
"moduleResolution": "node",
"typeRoots": [
"./node_modules/@types",
"./src/types"
]
},
"ts-node": {
"require": [
+27 -3
View File
@@ -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 && (
+232 -330
View File
@@ -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)
+7 -3
View File
@@ -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>
)
}
+2 -1
View File
@@ -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));