mirror of
https://github.com/cline/cline.git
synced 2026-09-04 11:44:01 +08:00
Compare commits
41 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8b499498c1 | |||
| 020ae3006e | |||
| c6d91be721 | |||
| 4d2fec787e | |||
| c8b05cdf9c | |||
| 9e69576fb0 | |||
| e6760ed6cc | |||
| 27a531c86a | |||
| 09c773bd5f | |||
| 037a781a6d | |||
| 968b46a179 | |||
| 5a777f28b6 | |||
| fe9e5bff74 | |||
| e65605535a | |||
| 3d100d835a | |||
| b9d3814355 | |||
| 12fbc48629 | |||
| d2f1e0cde0 | |||
| efe2388e4a | |||
| 247552fcb5 | |||
| 656e3276c6 | |||
| 0e29f05e28 | |||
| 985cb51c39 | |||
| abccde0e2a | |||
| ca984609ca | |||
| 6690d392cd | |||
| 40244f09fe | |||
| 9563a71c8a | |||
| c13e749eed | |||
| 8c3fd8ba55 | |||
| 8bfa7daa28 | |||
| 7cd4be7a68 | |||
| ab9f1a0785 | |||
| 68b84f3df4 | |||
| 16da0f1e06 | |||
| e898bd8825 | |||
| b7b0e96cc7 | |||
| f00c5f4ecc | |||
| 2709ccefcd | |||
| c8f0324536 | |||
| 937cebc7de |
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Refactor chat view into multiple modular files
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": minor
|
||||
---
|
||||
|
||||
Add Claude Sonnet 4 and Opus 4 model in SAP AI Core provider.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Refactor task class, moving auto approve
|
||||
@@ -1,5 +1,12 @@
|
||||
# Changelog
|
||||
|
||||
## [3.18.1]
|
||||
|
||||
- Add support for Claude 4 Sonnet in SAP AI Core provider (Thanks @GTxx!)
|
||||
- Fix ENAMETOOLONG error when using Claude Code provider with long conversation histories (Thanks @BarreiroT!)
|
||||
- Remove Gemini CLI provider because Google asked us to
|
||||
- Fix bug with "Delete All Tasks" functionality
|
||||
|
||||
## [3.18.0]
|
||||
|
||||
- Optimized Cline to work with the Claude 4 family of models, resulting in improved performance, reliability, and new capabilities
|
||||
|
||||
@@ -6,7 +6,7 @@ title: "Telemetry"
|
||||
|
||||
To help make Cline better for everyone, we collect anonymous usage data that helps us understand how developers are using our open-source AI coding agent. This feedback loop is crucial for improving Cline's capabilities and user experience.
|
||||
|
||||
We use PostHog, an open-source analytics platform, for data collection and analysis. Our telemetry implementation is fully transparent - you can review the [source code](https://github.com/cline/cline/blob/main/src/services/telemetry/TelemetryService.ts) to see exactly what we track.
|
||||
We use PostHog, an open-source analytics platform, for data collection and analysis. Our telemetry implementation is fully transparent - you can review the [source code](https://github.com/cline/cline/blob/main/src/services/posthog/telemetry/TelemetryService.ts) to see exactly what we track.
|
||||
|
||||
### Tracking Policy
|
||||
|
||||
@@ -22,7 +22,7 @@ We collect basic anonymous usage data including:
|
||||
**System Context:** OS type and VS Code environment details\
|
||||
**UI Activity:** Navigation patterns and feature usage
|
||||
|
||||
For complete transparency, you can inspect our [telemetry implementation](https://github.com/cline/cline/blob/main/src/services/telemetry/TelemetryService.ts) to see the exact events we track.
|
||||
For complete transparency, you can inspect our [telemetry implementation](https://github.com/cline/cline/blob/main/src/services/posthog/telemetry/TelemetryService.ts) to see the exact events we track.
|
||||
|
||||
### How to Opt Out
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ interface RunDiffEvalOptions {
|
||||
replay: boolean
|
||||
replayRunId?: string
|
||||
diffApplyFile?: string
|
||||
saveLocally: boolean
|
||||
maxCases?: number
|
||||
}
|
||||
|
||||
@@ -74,6 +75,10 @@ export async function runDiffEvalHandler(options: RunDiffEvalOptions) {
|
||||
args.push("--max-cases", String(options.maxCases))
|
||||
}
|
||||
|
||||
if (options.saveLocally) {
|
||||
args.push("--save-locally")
|
||||
}
|
||||
|
||||
try {
|
||||
console.log(chalk.gray(`Executing: npx tsx ${scriptPath} ${args.join(" ")}`))
|
||||
|
||||
|
||||
@@ -95,6 +95,7 @@ program
|
||||
.option("--replay", "Run evaluation from a pre-recorded LLM output, skipping the API call", false)
|
||||
.option("--replay-run-id <run_id>", "The ID of the run to replay from the database")
|
||||
.option("--diff-apply-file <filename>", "The name of the diff apply file to use for the replay")
|
||||
.option("--save-locally", "Save results to local JSON files in addition to database", false)
|
||||
.option("-v, --verbose", "Enable verbose logging", false)
|
||||
.action(async (options) => {
|
||||
try {
|
||||
|
||||
@@ -11,9 +11,10 @@ import {
|
||||
import { constructNewFileContent as constructNewFileContent_06_06_25 } from "./diff-apply/diff-06-06-25"
|
||||
import { constructNewFileContent as constructNewFileContent_06_23_25 } from "./diff-apply/diff-06-23-25"
|
||||
import { constructNewFileContent as constructNewFileContent_06_25_25 } from "./diff-apply/diff-06-25-25"
|
||||
import { constructNewFileContent as constructNewFileContent_06_26_25 } from "./diff-apply/diff-06-26-25"
|
||||
|
||||
type ParseAssistantMessageFn = (message: string) => AssistantMessageContent[]
|
||||
type ConstructNewFileContentFn = (diff: string, original: string, strict: boolean) => Promise<string>
|
||||
type ConstructNewFileContentFn = (diff: string, original: string, strict: boolean) => Promise<string | any>
|
||||
|
||||
const parsingFunctions: Record<string, ParseAssistantMessageFn> = {
|
||||
parseAssistantMessageV1: parseAssistantMessageV1,
|
||||
@@ -25,6 +26,7 @@ const diffEditingFunctions: Record<string, ConstructNewFileContentFn> = {
|
||||
"diff-06-06-25": constructNewFileContent_06_06_25,
|
||||
"diff-06-23-25": constructNewFileContent_06_23_25,
|
||||
"diff-06-25-25": constructNewFileContent_06_25_25,
|
||||
"diff-06-26-25": constructNewFileContent_06_26_25,
|
||||
}
|
||||
|
||||
import { TestInput, TestResult, ExtractedToolCall } from "./types"
|
||||
@@ -308,10 +310,18 @@ export async function runSingleEvaluation(input: TestInput): Promise<TestResult>
|
||||
|
||||
// checking if the diff edit succeeds, if it failed it will throw an error
|
||||
let diffSuccess = true
|
||||
let replacementData: any = undefined
|
||||
try {
|
||||
await constructNewFileContent(diffToolContent, originalFile, true)
|
||||
const result = await constructNewFileContent(diffToolContent, originalFile, true)
|
||||
|
||||
// Check if result is an object with replacements (new format)
|
||||
if (typeof result === 'object' && result !== null && 'replacements' in result) {
|
||||
replacementData = result.replacements
|
||||
}
|
||||
// If it's just a string, diffSuccess stays true and replacementData stays undefined
|
||||
} catch (error: any) {
|
||||
diffSuccess = false
|
||||
console.log("ERROR:",error)
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -320,6 +330,7 @@ export async function runSingleEvaluation(input: TestInput): Promise<TestResult>
|
||||
toolCalls: detectedToolCalls,
|
||||
diffEdit: diffToolContent,
|
||||
diffEditSuccess: diffSuccess,
|
||||
replacementData: replacementData,
|
||||
}
|
||||
} catch (error: any) {
|
||||
return {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { parseAssistantMessageV2, AssistantMessageContent } from "./parsing/pars
|
||||
import { constructNewFileContent as constructNewFileContent_06_06_25 } from "./diff-apply/diff-06-06-25"
|
||||
import { constructNewFileContent as constructNewFileContent_06_23_25 } from "./diff-apply/diff-06-23-25"
|
||||
import { constructNewFileContent as constructNewFileContent_06_25_25 } from "./diff-apply/diff-06-25-25"
|
||||
import { constructNewFileContent as constructNewFileContent_06_26_25 } from "./diff-apply/diff-06-26-25"
|
||||
import { constructNewFileContent as constructNewFileContentV3 } from "../../src/core/assistant-message/diff"
|
||||
import { basicSystemPrompt } from "./prompts/basicSystemPrompt-06-06-25"
|
||||
import { claude4SystemPrompt } from "./prompts/claude4SystemPrompt-06-06-25"
|
||||
@@ -484,6 +485,7 @@ class NodeTestRunner {
|
||||
"diff-06-06-25": constructNewFileContent_06_06_25,
|
||||
"diff-06-23-25": constructNewFileContent_06_23_25,
|
||||
"diff-06-25-25": constructNewFileContent_06_25_25,
|
||||
"diff-06-26-25": constructNewFileContent_06_26_25,
|
||||
constructNewFileContentV3: constructNewFileContentV3,
|
||||
}
|
||||
const constructNewFileContent = diffEditingFunctions[diffApplyFile]
|
||||
@@ -927,12 +929,13 @@ async function main() {
|
||||
.option("-n, --valid-attempts-per-case <number>", "Number of valid attempts per test case per model (will retry until this many valid attempts are collected)", "1")
|
||||
.option("--max-cases <number>", "Maximum number of test cases to run (limits total cases loaded)")
|
||||
.option("--parsing-function <name>", "The parsing function to use", "parseAssistantMessageV2")
|
||||
.option("--diff-edit-function <name>", "The diff editing function to use", "constructNewFileContentV2")
|
||||
.option("--diff-edit-function <name>", "The diff editing function to use", "diff-06-25-25")
|
||||
.option("--thinking-budget <tokens>", "Set the thinking tokens budget", "0")
|
||||
.option("--parallel", "Run tests in parallel", false)
|
||||
.option("--replay", "Run evaluation from a pre-recorded LLM output, skipping the API call", false)
|
||||
.option("--replay-run-id <run_id>", "The ID of the run to replay from the database")
|
||||
.option("--diff-apply-file <filename>", "The name of the diff apply file to use for the replay")
|
||||
.option("--save-locally", "Save results to local JSON files in addition to database", false)
|
||||
.option("-v, --verbose", "Enable verbose logging", false)
|
||||
.option("--max-concurrency <number>", "Maximum number of parallel requests", "80")
|
||||
|
||||
@@ -943,6 +946,7 @@ async function main() {
|
||||
const isVerbose = options.verbose
|
||||
const testPath = options.testPath
|
||||
const outputPath = options.outputPath
|
||||
const saveLocally = options.saveLocally
|
||||
const maxConcurrency = parseInt(options.maxConcurrency, 10);
|
||||
|
||||
// Parse model IDs from comma-separated string
|
||||
@@ -1150,6 +1154,12 @@ async function main() {
|
||||
const durationSeconds = ((endTime - startTime) / 1000).toFixed(2)
|
||||
log(isVerbose, `\n-Total execution time: ${durationSeconds} seconds`)
|
||||
|
||||
// Save results locally if requested
|
||||
if (saveLocally) {
|
||||
runner.saveTestResults(results, outputPath);
|
||||
log(isVerbose, `✓ Results also saved to JSON files in ${outputPath}`);
|
||||
}
|
||||
|
||||
log(isVerbose, `\n✓ All results stored in database. Use the dashboard to view results.`)
|
||||
} catch (error) {
|
||||
console.error("\nError running tests:", error)
|
||||
|
||||
@@ -0,0 +1,960 @@
|
||||
const SEARCH_BLOCK_START = "------- SEARCH"
|
||||
const SEARCH_BLOCK_END = "======="
|
||||
const REPLACE_BLOCK_END = "+++++++ REPLACE"
|
||||
|
||||
const SEARCH_BLOCK_CHAR = "-"
|
||||
const REPLACE_BLOCK_CHAR = "+"
|
||||
const LEGACY_SEARCH_BLOCK_CHAR = "<"
|
||||
const LEGACY_REPLACE_BLOCK_CHAR = ">"
|
||||
|
||||
// Replace the exact string constants with flexible regex patterns
|
||||
const SEARCH_BLOCK_START_REGEX = /^[-]{3,} SEARCH>?$/
|
||||
const LEGACY_SEARCH_BLOCK_START_REGEX = /^[<]{3,} SEARCH>?$/
|
||||
|
||||
const SEARCH_BLOCK_END_REGEX = /^[=]{3,}$/
|
||||
|
||||
const REPLACE_BLOCK_END_REGEX = /^[+]{3,} REPLACE>?$/
|
||||
const LEGACY_REPLACE_BLOCK_END_REGEX = /^[>]{3,} REPLACE>?$/
|
||||
|
||||
// Similarity thresholds for block anchor fallback matching
|
||||
const SINGLE_CANDIDATE_SIMILARITY_THRESHOLD = 0.0
|
||||
const MULTIPLE_CANDIDATES_SIMILARITY_THRESHOLD = 0.0
|
||||
|
||||
/**
|
||||
* Levenshtein distance algorithm implementation
|
||||
*/
|
||||
function levenshtein(a: string, b: string): number {
|
||||
// Handle empty strings
|
||||
if (a === "" || b === "") {
|
||||
return Math.max(a.length, b.length)
|
||||
}
|
||||
const matrix = Array.from({ length: a.length + 1 }, (_, i) =>
|
||||
Array.from({ length: b.length + 1 }, (_, j) => (i === 0 ? j : j === 0 ? i : 0)),
|
||||
)
|
||||
|
||||
for (let i = 1; i <= a.length; i++) {
|
||||
for (let j = 1; j <= b.length; j++) {
|
||||
const cost = a[i - 1] === b[j - 1] ? 0 : 1
|
||||
matrix[i][j] = Math.min(matrix[i - 1][j] + 1, matrix[i][j - 1] + 1, matrix[i - 1][j - 1] + cost)
|
||||
}
|
||||
}
|
||||
return matrix[a.length][b.length]
|
||||
}
|
||||
|
||||
// Helper functions to check if a line matches the flexible patterns
|
||||
function isSearchBlockStart(line: string): boolean {
|
||||
return SEARCH_BLOCK_START_REGEX.test(line) || LEGACY_SEARCH_BLOCK_START_REGEX.test(line)
|
||||
}
|
||||
|
||||
function isSearchBlockEnd(line: string): boolean {
|
||||
return SEARCH_BLOCK_END_REGEX.test(line)
|
||||
}
|
||||
|
||||
function isReplaceBlockEnd(line: string): boolean {
|
||||
return REPLACE_BLOCK_END_REGEX.test(line) || LEGACY_REPLACE_BLOCK_END_REGEX.test(line)
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts a line-trimmed fallback match for the given search content in the original content.
|
||||
* It tries to match `searchContent` lines against a block of lines in `originalContent` starting
|
||||
* from `lastProcessedIndex`. Lines are matched by trimming leading/trailing whitespace and ensuring
|
||||
* they are identical afterwards.
|
||||
*
|
||||
* Returns [matchIndexStart, matchIndexEnd] if found, or false if not found.
|
||||
*/
|
||||
function lineTrimmedFallbackMatch(originalContent: string, searchContent: string, startIndex: number): [number, number] | false {
|
||||
// Split both contents into lines
|
||||
const originalLines = originalContent.split("\n")
|
||||
const searchLines = searchContent.split("\n")
|
||||
|
||||
// Trim trailing empty line if exists (from the trailing \n in searchContent)
|
||||
if (searchLines[searchLines.length - 1] === "") {
|
||||
searchLines.pop()
|
||||
}
|
||||
|
||||
// Find the line number where startIndex falls
|
||||
let startLineNum = 0
|
||||
let currentIndex = 0
|
||||
while (currentIndex < startIndex && startLineNum < originalLines.length) {
|
||||
currentIndex += originalLines[startLineNum].length + 1 // +1 for \n
|
||||
startLineNum++
|
||||
}
|
||||
|
||||
// For each possible starting position in original content
|
||||
for (let i = startLineNum; i <= originalLines.length - searchLines.length; i++) {
|
||||
let matches = true
|
||||
|
||||
// Try to match all search lines from this position
|
||||
for (let j = 0; j < searchLines.length; j++) {
|
||||
const originalTrimmed = originalLines[i + j].trim()
|
||||
const searchTrimmed = searchLines[j].trim()
|
||||
|
||||
if (originalTrimmed !== searchTrimmed) {
|
||||
matches = false
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// If we found a match, calculate the exact character positions
|
||||
if (matches) {
|
||||
// Find start character index
|
||||
let matchStartIndex = 0
|
||||
for (let k = 0; k < i; k++) {
|
||||
matchStartIndex += originalLines[k].length + 1 // +1 for \n
|
||||
}
|
||||
|
||||
// Find end character index
|
||||
let matchEndIndex = matchStartIndex
|
||||
for (let k = 0; k < searchLines.length; k++) {
|
||||
matchEndIndex += originalLines[i + k].length + 1 // +1 for \n
|
||||
}
|
||||
|
||||
return [matchStartIndex, matchEndIndex]
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to match blocks of code by using the first and last lines as anchors,
|
||||
* with similarity checking to prevent false positives.
|
||||
* This is a third-tier fallback strategy that helps match blocks where we can identify
|
||||
* the correct location by matching the beginning and end, even if the exact content
|
||||
* differs slightly.
|
||||
*
|
||||
* The matching strategy:
|
||||
* 1. Only attempts to match blocks of 3 or more lines to avoid false positives
|
||||
* 2. Extracts from the search content:
|
||||
* - First line as the "start anchor"
|
||||
* - Last line as the "end anchor"
|
||||
* 3. Collects all candidate positions where both anchors match
|
||||
* 4. Uses levenshtein distance to calculate similarity of middle lines
|
||||
* 5. Returns match only if similarity meets threshold requirements
|
||||
*
|
||||
* This approach is particularly useful for matching blocks of code where:
|
||||
* - The exact content might have minor differences
|
||||
* - The beginning and end of the block are distinctive enough to serve as anchors
|
||||
* - The overall structure (number of lines) remains the same
|
||||
* - The middle content is reasonably similar (prevents false positives)
|
||||
*
|
||||
* @param originalContent - The full content of the original file
|
||||
* @param searchContent - The content we're trying to find in the original file
|
||||
* @param startIndex - The character index in originalContent where to start searching
|
||||
* @returns A tuple of [startIndex, endIndex] if a match is found, false otherwise
|
||||
*/
|
||||
function blockAnchorFallbackMatch(originalContent: string, searchContent: string, startIndex: number): [number, number, number] | false {
|
||||
const originalLines = originalContent.split("\n")
|
||||
const searchLines = searchContent.split("\n")
|
||||
|
||||
// Only use this approach for blocks of 3+ lines
|
||||
if (searchLines.length < 3) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Trim trailing empty line if exists
|
||||
if (searchLines[searchLines.length - 1] === "") {
|
||||
searchLines.pop()
|
||||
}
|
||||
|
||||
const firstLineSearch = searchLines[0].trim()
|
||||
const lastLineSearch = searchLines[searchLines.length - 1].trim()
|
||||
const searchBlockSize = searchLines.length
|
||||
|
||||
// Find the line number where startIndex falls
|
||||
let startLineNum = 0
|
||||
let currentIndex = 0
|
||||
while (currentIndex < startIndex && startLineNum < originalLines.length) {
|
||||
currentIndex += originalLines[startLineNum].length + 1
|
||||
startLineNum++
|
||||
}
|
||||
|
||||
// Collect all candidate positions
|
||||
const candidates: number[] = []
|
||||
for (let i = startLineNum; i <= originalLines.length - searchBlockSize; i++) {
|
||||
if (originalLines[i].trim() === firstLineSearch && originalLines[i + searchBlockSize - 1].trim() === lastLineSearch) {
|
||||
candidates.push(i)
|
||||
}
|
||||
}
|
||||
|
||||
// Return immediately if no candidates
|
||||
if (candidates.length === 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Handle single candidate scenario (using relaxed threshold)
|
||||
if (candidates.length === 1) {
|
||||
const i = candidates[0]
|
||||
let similarity = 0
|
||||
let linesToCheck = searchBlockSize - 2
|
||||
|
||||
for (let j = 1; j < searchBlockSize - 1; j++) {
|
||||
const originalLine = originalLines[i + j].trim()
|
||||
const searchLine = searchLines[j].trim()
|
||||
const maxLen = Math.max(originalLine.length, searchLine.length)
|
||||
if (maxLen === 0) {
|
||||
continue
|
||||
}
|
||||
const distance = levenshtein(originalLine, searchLine)
|
||||
similarity += (1 - distance / maxLen) / linesToCheck
|
||||
|
||||
// Exit early when threshold is reached
|
||||
if (similarity >= SINGLE_CANDIDATE_SIMILARITY_THRESHOLD) {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (similarity >= SINGLE_CANDIDATE_SIMILARITY_THRESHOLD) {
|
||||
let matchStartIndex = 0
|
||||
for (let k = 0; k < i; k++) {
|
||||
matchStartIndex += originalLines[k].length + 1
|
||||
}
|
||||
let matchEndIndex = matchStartIndex
|
||||
for (let k = 0; k < searchBlockSize; k++) {
|
||||
matchEndIndex += originalLines[i + k].length + 1
|
||||
}
|
||||
return [matchStartIndex, matchEndIndex, similarity]
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Calculate similarity for multiple candidates
|
||||
let bestMatchIndex = -1
|
||||
let maxSimilarity = -1
|
||||
|
||||
for (const i of candidates) {
|
||||
let similarity = 0
|
||||
for (let j = 1; j < searchBlockSize - 1; j++) {
|
||||
const originalLine = originalLines[i + j].trim()
|
||||
const searchLine = searchLines[j].trim()
|
||||
const maxLen = Math.max(originalLine.length, searchLine.length)
|
||||
if (maxLen === 0) {
|
||||
continue
|
||||
}
|
||||
const distance = levenshtein(originalLine, searchLine)
|
||||
similarity += 1 - distance / maxLen
|
||||
}
|
||||
similarity /= searchBlockSize - 2 // Average similarity
|
||||
|
||||
if (similarity > maxSimilarity) {
|
||||
maxSimilarity = similarity
|
||||
bestMatchIndex = i
|
||||
}
|
||||
}
|
||||
|
||||
// Threshold judgment
|
||||
if (maxSimilarity >= MULTIPLE_CANDIDATES_SIMILARITY_THRESHOLD) {
|
||||
const i = bestMatchIndex
|
||||
let matchStartIndex = 0
|
||||
for (let k = 0; k < i; k++) {
|
||||
matchStartIndex += originalLines[k].length + 1
|
||||
}
|
||||
let matchEndIndex = matchStartIndex
|
||||
for (let k = 0; k < searchBlockSize; k++) {
|
||||
matchEndIndex += originalLines[i + k].length + 1
|
||||
}
|
||||
return [matchStartIndex, matchEndIndex, maxSimilarity]
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* This function reconstructs the file content by applying a streamed diff (in a
|
||||
* specialized SEARCH/REPLACE block format) to the original file content. It is designed
|
||||
* to handle both incremental updates and the final resulting file after all chunks have
|
||||
* been processed.
|
||||
*
|
||||
* The diff format is a custom structure that uses three markers to define changes:
|
||||
*
|
||||
* ------- SEARCH
|
||||
* [Exact content to find in the original file]
|
||||
* =======
|
||||
* [Content to replace with]
|
||||
* +++++++ REPLACE
|
||||
*
|
||||
* Behavior and Assumptions:
|
||||
* 1. The file is processed chunk-by-chunk. Each chunk of `diffContent` may contain
|
||||
* partial or complete SEARCH/REPLACE blocks. By calling this function with each
|
||||
* incremental chunk (with `isFinal` indicating the last chunk), the final reconstructed
|
||||
* file content is produced.
|
||||
*
|
||||
* 2. Matching Strategy (in order of attempt):
|
||||
* a. Exact Match: First attempts to find the exact SEARCH block text in the original file
|
||||
* b. Line-Trimmed Match: Falls back to line-by-line comparison ignoring leading/trailing whitespace
|
||||
* c. Block Anchor Match: For blocks of 3+ lines, tries to match using first/last lines as anchors
|
||||
* If all matching strategies fail, an error is thrown.
|
||||
*
|
||||
* 3. Empty SEARCH Section:
|
||||
* - If SEARCH is empty and the original file is empty, this indicates creating a new file
|
||||
* (pure insertion).
|
||||
* - If SEARCH is empty and the original file is not empty, this indicates a complete
|
||||
* file replacement (the entire original content is considered matched and replaced).
|
||||
*
|
||||
* 4. Applying Changes:
|
||||
* - Before encountering the "=======" marker, lines are accumulated as search content.
|
||||
* - After "=======" and before ">>>>>>> REPLACE", lines are accumulated as replacement content.
|
||||
* - Once the block is complete (">>>>>>> REPLACE"), the matched section in the original
|
||||
* file is replaced with the accumulated replacement lines, and the position in the original
|
||||
* file is advanced.
|
||||
*
|
||||
* 5. Incremental Output:
|
||||
* - As soon as the match location is found and we are in the REPLACE section, each new
|
||||
* replacement line is appended to the result so that partial updates can be viewed
|
||||
* incrementally.
|
||||
*
|
||||
* 6. Partial Markers:
|
||||
* - If the final line of the chunk looks like it might be part of a marker but is not one
|
||||
* of the known markers, it is removed. This prevents incomplete or partial markers
|
||||
* from corrupting the output.
|
||||
*
|
||||
* 7. Finalization:
|
||||
* - Once all chunks have been processed (when `isFinal` is true), any remaining original
|
||||
* content after the last replaced section is appended to the result.
|
||||
* - Trailing newlines are not forcibly added. The code tries to output exactly what is specified.
|
||||
*
|
||||
* Errors:
|
||||
* - If the search block cannot be matched using any of the available matching strategies,
|
||||
* an error is thrown.
|
||||
*/
|
||||
export async function constructNewFileContent(
|
||||
diffContent: string,
|
||||
originalContent: string,
|
||||
isFinal: boolean,
|
||||
version: "v1" | "v2" = "v1",
|
||||
): Promise<any> {
|
||||
const constructor = constructNewFileContentVersionMapping[version]
|
||||
if (!constructor) {
|
||||
throw new Error(`Invalid version '${version}' for file content constructor`)
|
||||
}
|
||||
return constructor(diffContent, originalContent, isFinal)
|
||||
}
|
||||
|
||||
const constructNewFileContentVersionMapping: Record<
|
||||
string,
|
||||
(diffContent: string, originalContent: string, isFinal: boolean) => Promise<any>
|
||||
> = {
|
||||
v1: constructNewFileContentV1,
|
||||
v2: constructNewFileContentV2,
|
||||
} as const
|
||||
|
||||
async function constructNewFileContentV1(diffContent: string, originalContent: string, isFinal: boolean): Promise<{
|
||||
content: string;
|
||||
replacements: Array<{
|
||||
start: number;
|
||||
end: number;
|
||||
content: string;
|
||||
method: string;
|
||||
similarity: number;
|
||||
searchContent: string;
|
||||
matchedText: string;
|
||||
}>;
|
||||
}> {
|
||||
let result = ""
|
||||
let lastProcessedIndex = 0
|
||||
|
||||
let currentSearchContent = ""
|
||||
let currentReplaceContent = ""
|
||||
let inSearch = false
|
||||
let inReplace = false
|
||||
|
||||
let searchMatchIndex = -1
|
||||
let searchEndIndex = -1
|
||||
let matchMethod = ""
|
||||
let similarityScore = -1.0
|
||||
|
||||
// Track all replacements to handle out-of-order edits
|
||||
let replacements: Array<{
|
||||
start: number;
|
||||
end: number;
|
||||
content: string;
|
||||
method: string;
|
||||
similarity: number;
|
||||
searchContent: string;
|
||||
matchedText: string;
|
||||
}> = []
|
||||
let pendingOutOfOrderReplacement = false
|
||||
|
||||
let lines = diffContent.split("\n")
|
||||
|
||||
// If the last line looks like a partial marker but isn't recognized,
|
||||
// remove it because it might be incomplete.
|
||||
const lastLine = lines[lines.length - 1]
|
||||
if (
|
||||
lines.length > 0 &&
|
||||
(lastLine.startsWith(SEARCH_BLOCK_CHAR) ||
|
||||
lastLine.startsWith(LEGACY_SEARCH_BLOCK_CHAR) ||
|
||||
lastLine.startsWith("=") ||
|
||||
lastLine.startsWith(REPLACE_BLOCK_CHAR) ||
|
||||
lastLine.startsWith(LEGACY_REPLACE_BLOCK_CHAR)) &&
|
||||
!isSearchBlockStart(lastLine) &&
|
||||
!isSearchBlockEnd(lastLine) &&
|
||||
!isReplaceBlockEnd(lastLine)
|
||||
) {
|
||||
lines.pop()
|
||||
}
|
||||
|
||||
for (const line of lines) {
|
||||
if (isSearchBlockStart(line)) {
|
||||
inSearch = true
|
||||
currentSearchContent = ""
|
||||
currentReplaceContent = ""
|
||||
continue
|
||||
}
|
||||
|
||||
if (isSearchBlockEnd(line)) {
|
||||
inSearch = false
|
||||
inReplace = true
|
||||
|
||||
// Remove trailing linebreak for adding the === marker
|
||||
// if (currentSearchContent.endsWith("\r\n")) {
|
||||
// currentSearchContent = currentSearchContent.slice(0, -2)
|
||||
// } else if (currentSearchContent.endsWith("\n")) {
|
||||
// currentSearchContent = currentSearchContent.slice(0, -1)
|
||||
// }
|
||||
|
||||
if (!currentSearchContent) {
|
||||
// Empty search block
|
||||
if (originalContent.length === 0) {
|
||||
// New file scenario: nothing to match, just start inserting
|
||||
searchMatchIndex = 0
|
||||
searchEndIndex = 0
|
||||
matchMethod = "empty_new_file"
|
||||
} else {
|
||||
// ERROR: Empty search block with non-empty file indicates malformed SEARCH marker
|
||||
throw new Error(
|
||||
"Empty SEARCH block detected with non-empty file. This usually indicates a malformed SEARCH marker.\n" +
|
||||
"Please ensure your SEARCH marker follows the correct format:\n" +
|
||||
"- Use '------- SEARCH' (7+ dashes + space + SEARCH)\n",
|
||||
)
|
||||
}
|
||||
} else {
|
||||
// Add check for inefficient full-file search
|
||||
// if (currentSearchContent.trim() === originalContent.trim()) {
|
||||
// throw new Error(
|
||||
// "The SEARCH block contains the entire file content. Please either:\n" +
|
||||
// "1. Use an empty SEARCH block to replace the entire file, or\n" +
|
||||
// "2. Make focused changes to specific parts of the file that need modification.",
|
||||
// )
|
||||
// }
|
||||
|
||||
// Exact search match scenario
|
||||
const exactIndex = originalContent.indexOf(currentSearchContent, lastProcessedIndex)
|
||||
if (exactIndex !== -1) {
|
||||
searchMatchIndex = exactIndex
|
||||
searchEndIndex = exactIndex + currentSearchContent.length
|
||||
matchMethod = "exact_match"
|
||||
} else {
|
||||
// Attempt fallback line-trimmed matching
|
||||
const lineMatch = lineTrimmedFallbackMatch(originalContent, currentSearchContent, lastProcessedIndex)
|
||||
if (lineMatch) {
|
||||
;[searchMatchIndex, searchEndIndex] = lineMatch
|
||||
matchMethod = "line_trimmed_fallback"
|
||||
} else {
|
||||
// Try block anchor fallback for larger blocks
|
||||
const blockMatch = blockAnchorFallbackMatch(originalContent, currentSearchContent, lastProcessedIndex)
|
||||
if (blockMatch) {
|
||||
;[searchMatchIndex, searchEndIndex, similarityScore] = blockMatch
|
||||
matchMethod = "block_anchor_fallback"
|
||||
} else {
|
||||
// Last resort: search the entire file from the beginning
|
||||
const fullFileIndex = originalContent.indexOf(currentSearchContent, 0)
|
||||
if (fullFileIndex !== -1) {
|
||||
// Found in the file - could be out of order
|
||||
searchMatchIndex = fullFileIndex
|
||||
searchEndIndex = fullFileIndex + currentSearchContent.length
|
||||
matchMethod = "full_file_search"
|
||||
if (searchMatchIndex < lastProcessedIndex) {
|
||||
pendingOutOfOrderReplacement = true
|
||||
}
|
||||
} else {
|
||||
throw new Error(
|
||||
`The SEARCH block:\n${currentSearchContent.trimEnd()}\n...does not match anything in the file.`,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check if this is an out-of-order replacement
|
||||
if (searchMatchIndex < lastProcessedIndex) {
|
||||
pendingOutOfOrderReplacement = true
|
||||
}
|
||||
|
||||
// For in-order replacements, output everything up to the match location
|
||||
if (!pendingOutOfOrderReplacement) {
|
||||
result += originalContent.slice(lastProcessedIndex, searchMatchIndex)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (isReplaceBlockEnd(line)) {
|
||||
// Finished one replace block
|
||||
|
||||
if (searchMatchIndex === -1) {
|
||||
throw new Error(
|
||||
`The SEARCH block:\n${currentSearchContent.trimEnd()}\n...is malformatted.`,
|
||||
)
|
||||
}
|
||||
|
||||
// Store this replacement
|
||||
replacements.push({
|
||||
start: searchMatchIndex,
|
||||
end: searchEndIndex,
|
||||
content: currentReplaceContent,
|
||||
method: matchMethod,
|
||||
similarity: similarityScore,
|
||||
searchContent: currentSearchContent,
|
||||
matchedText: originalContent.slice(searchMatchIndex, searchEndIndex),
|
||||
})
|
||||
|
||||
// If this was an in-order replacement, advance lastProcessedIndex
|
||||
if (!pendingOutOfOrderReplacement) {
|
||||
lastProcessedIndex = searchEndIndex
|
||||
}
|
||||
|
||||
// Reset for next block
|
||||
inSearch = false
|
||||
inReplace = false
|
||||
currentSearchContent = ""
|
||||
currentReplaceContent = ""
|
||||
searchMatchIndex = -1
|
||||
searchEndIndex = -1
|
||||
similarityScore = -1.0
|
||||
pendingOutOfOrderReplacement = false
|
||||
continue
|
||||
}
|
||||
|
||||
// Accumulate content for search or replace
|
||||
// (currentReplaceContent is not being used for anything right now since we directly append to result.)
|
||||
// (We artificially add a linebreak since we split on \n at the beginning. In order to not include a trailing linebreak in the final search/result blocks we need to remove it before using them. This allows for partial line matches to be correctly identified.)
|
||||
// NOTE: search/replace blocks must be arranged in the order they appear in the file due to how we build the content using lastProcessedIndex. We also cannot strip the trailing newline since for non-partial lines it would remove the linebreak from the original content. (If we remove end linebreak from search, then we'd also have to remove it from replace but we can't know if it's a partial line or not since the model may be using the line break to indicate the end of the block rather than as part of the search content.) We require the model to output full lines in order for our fallbacks to work as well.
|
||||
if (inSearch) {
|
||||
currentSearchContent += line + "\n"
|
||||
} else if (inReplace) {
|
||||
currentReplaceContent += line + "\n"
|
||||
// Only output replacement lines immediately for in-order replacements
|
||||
if (searchMatchIndex !== -1 && !pendingOutOfOrderReplacement) {
|
||||
result += line + "\n"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If this is the final chunk, we need to apply all replacements and build the final result
|
||||
if (isFinal) {
|
||||
// Handle the case where we're still in replace mode when processing ends
|
||||
// and this is the final chunk - treat it as if we encountered the REPLACE marker
|
||||
if (inReplace && searchMatchIndex !== -1) {
|
||||
// Store this replacement
|
||||
replacements.push({
|
||||
start: searchMatchIndex,
|
||||
end: searchEndIndex,
|
||||
content: currentReplaceContent,
|
||||
method: matchMethod,
|
||||
similarity: similarityScore,
|
||||
searchContent: currentSearchContent,
|
||||
matchedText: originalContent.slice(searchMatchIndex, searchEndIndex),
|
||||
})
|
||||
|
||||
// If this was an in-order replacement, advance lastProcessedIndex
|
||||
if (!pendingOutOfOrderReplacement) {
|
||||
lastProcessedIndex = searchEndIndex
|
||||
}
|
||||
|
||||
// Reset state
|
||||
inSearch = false
|
||||
inReplace = false
|
||||
currentSearchContent = ""
|
||||
currentReplaceContent = ""
|
||||
searchMatchIndex = -1
|
||||
searchEndIndex = -1
|
||||
pendingOutOfOrderReplacement = false
|
||||
}
|
||||
// end of handling missing replace marker
|
||||
|
||||
// Sort replacements by start position
|
||||
replacements.sort((a, b) => a.start - b.start)
|
||||
|
||||
// Rebuild the entire result by applying all replacements
|
||||
result = ""
|
||||
let currentPos = 0
|
||||
|
||||
for (const replacement of replacements) {
|
||||
// Add original content up to this replacement
|
||||
result += originalContent.slice(currentPos, replacement.start)
|
||||
// Add the replacement content
|
||||
result += replacement.content
|
||||
// Move position to after the replaced section
|
||||
currentPos = replacement.end
|
||||
}
|
||||
|
||||
// Add any remaining original content
|
||||
result += originalContent.slice(currentPos)
|
||||
}
|
||||
|
||||
// For testing - return debug info
|
||||
return {
|
||||
content: result,
|
||||
replacements: replacements
|
||||
}
|
||||
}
|
||||
|
||||
enum ProcessingState {
|
||||
Idle = 0,
|
||||
StateSearch = 1 << 0,
|
||||
StateReplace = 1 << 1,
|
||||
}
|
||||
|
||||
class NewFileContentConstructor {
|
||||
private originalContent: string
|
||||
private isFinal: boolean
|
||||
private state: number
|
||||
private pendingNonStandardLines: string[]
|
||||
private result: string
|
||||
private lastProcessedIndex: number
|
||||
private currentSearchContent: string
|
||||
private currentReplaceContent: string
|
||||
private searchMatchIndex: number
|
||||
private searchEndIndex: number
|
||||
|
||||
constructor(originalContent: string, isFinal: boolean) {
|
||||
this.originalContent = originalContent
|
||||
this.isFinal = isFinal
|
||||
this.pendingNonStandardLines = []
|
||||
this.result = ""
|
||||
this.lastProcessedIndex = 0
|
||||
this.state = ProcessingState.Idle
|
||||
this.currentSearchContent = ""
|
||||
this.currentReplaceContent = ""
|
||||
this.searchMatchIndex = -1
|
||||
this.searchEndIndex = -1
|
||||
}
|
||||
|
||||
private resetForNextBlock() {
|
||||
// Reset for next block
|
||||
this.state = ProcessingState.Idle
|
||||
this.currentSearchContent = ""
|
||||
this.currentReplaceContent = ""
|
||||
this.searchMatchIndex = -1
|
||||
this.searchEndIndex = -1
|
||||
}
|
||||
|
||||
private findLastMatchingLineIndex(regx: RegExp, lineLimit: number) {
|
||||
for (let i = lineLimit; i > 0; ) {
|
||||
i--
|
||||
if (this.pendingNonStandardLines[i].match(regx)) {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
private updateProcessingState(newState: ProcessingState) {
|
||||
const isValidTransition =
|
||||
(this.state === ProcessingState.Idle && newState === ProcessingState.StateSearch) ||
|
||||
(this.state === ProcessingState.StateSearch && newState === ProcessingState.StateReplace)
|
||||
|
||||
if (!isValidTransition) {
|
||||
throw new Error(
|
||||
`Invalid state transition.\n` +
|
||||
"Valid transitions are:\n" +
|
||||
"- Idle → StateSearch\n" +
|
||||
"- StateSearch → StateReplace",
|
||||
)
|
||||
}
|
||||
|
||||
this.state |= newState
|
||||
}
|
||||
|
||||
private isStateActive(state: ProcessingState): boolean {
|
||||
return (this.state & state) === state
|
||||
}
|
||||
|
||||
private activateReplaceState() {
|
||||
this.updateProcessingState(ProcessingState.StateReplace)
|
||||
}
|
||||
|
||||
private activateSearchState() {
|
||||
this.updateProcessingState(ProcessingState.StateSearch)
|
||||
this.currentSearchContent = ""
|
||||
this.currentReplaceContent = ""
|
||||
}
|
||||
|
||||
private isSearchingActive(): boolean {
|
||||
return this.isStateActive(ProcessingState.StateSearch)
|
||||
}
|
||||
|
||||
private isReplacingActive(): boolean {
|
||||
return this.isStateActive(ProcessingState.StateReplace)
|
||||
}
|
||||
|
||||
private hasPendingNonStandardLines(pendingNonStandardLineLimit: number): boolean {
|
||||
return this.pendingNonStandardLines.length - pendingNonStandardLineLimit < this.pendingNonStandardLines.length
|
||||
}
|
||||
|
||||
public processLine(line: string) {
|
||||
this.internalProcessLine(line, true, this.pendingNonStandardLines.length)
|
||||
}
|
||||
|
||||
public getResult() {
|
||||
// If this is the final chunk, append any remaining original content
|
||||
if (this.isFinal && this.lastProcessedIndex < this.originalContent.length) {
|
||||
this.result += this.originalContent.slice(this.lastProcessedIndex)
|
||||
}
|
||||
if (this.isFinal && this.state !== ProcessingState.Idle) {
|
||||
throw new Error("File processing incomplete - SEARCH/REPLACE operations still active during finalization")
|
||||
}
|
||||
return this.result
|
||||
}
|
||||
|
||||
private internalProcessLine(
|
||||
line: string,
|
||||
canWritependingNonStandardLines: boolean,
|
||||
pendingNonStandardLineLimit: number,
|
||||
): number {
|
||||
let removeLineCount = 0
|
||||
if (isSearchBlockStart(line)) {
|
||||
removeLineCount = this.trimPendingNonStandardTrailingEmptyLines(pendingNonStandardLineLimit)
|
||||
if (removeLineCount > 0) {
|
||||
pendingNonStandardLineLimit = pendingNonStandardLineLimit - removeLineCount
|
||||
}
|
||||
if (this.hasPendingNonStandardLines(pendingNonStandardLineLimit)) {
|
||||
this.tryFixSearchReplaceBlock(pendingNonStandardLineLimit)
|
||||
canWritependingNonStandardLines && (this.pendingNonStandardLines.length = 0)
|
||||
}
|
||||
this.activateSearchState()
|
||||
} else if (isSearchBlockEnd(line)) {
|
||||
// 校验非标内容
|
||||
if (!this.isSearchingActive()) {
|
||||
this.tryFixSearchBlock(pendingNonStandardLineLimit)
|
||||
canWritependingNonStandardLines && (this.pendingNonStandardLines.length = 0)
|
||||
}
|
||||
this.activateReplaceState()
|
||||
this.beforeReplace()
|
||||
} else if (isReplaceBlockEnd(line)) {
|
||||
if (!this.isReplacingActive()) {
|
||||
this.tryFixReplaceBlock(pendingNonStandardLineLimit)
|
||||
canWritependingNonStandardLines && (this.pendingNonStandardLines.length = 0)
|
||||
}
|
||||
this.lastProcessedIndex = this.searchEndIndex
|
||||
this.resetForNextBlock()
|
||||
} else {
|
||||
// Accumulate content for search or replace
|
||||
// (currentReplaceContent is not being used for anything right now since we directly append to result.)
|
||||
// (We artificially add a linebreak since we split on \n at the beginning. In order to not include a trailing linebreak in the final search/result blocks we need to remove it before using them. This allows for partial line matches to be correctly identified.)
|
||||
// NOTE: search/replace blocks must be arranged in the order they appear in the file due to how we build the content using lastProcessedIndex. We also cannot strip the trailing newline since for non-partial lines it would remove the linebreak from the original content. (If we remove end linebreak from search, then we'd also have to remove it from replace but we can't know if it's a partial line or not since the model may be using the line break to indicate the end of the block rather than as part of the search content.) We require the model to output full lines in order for our fallbacks to work as well.
|
||||
if (this.isReplacingActive()) {
|
||||
this.currentReplaceContent += line + "\n"
|
||||
// Output replacement lines immediately if we know the insertion point
|
||||
if (this.searchMatchIndex !== -1) {
|
||||
this.result += line + "\n"
|
||||
}
|
||||
} else if (this.isSearchingActive()) {
|
||||
this.currentSearchContent += line + "\n"
|
||||
} else {
|
||||
let appendToPendingNonStandardLines = canWritependingNonStandardLines
|
||||
if (appendToPendingNonStandardLines) {
|
||||
// 处理非标内容
|
||||
this.pendingNonStandardLines.push(line)
|
||||
}
|
||||
}
|
||||
}
|
||||
return removeLineCount
|
||||
}
|
||||
|
||||
private beforeReplace() {
|
||||
// Remove trailing linebreak for adding the === marker
|
||||
// if (currentSearchContent.endsWith("\r\n")) {
|
||||
// currentSearchContent = currentSearchContent.slice(0, -2)
|
||||
// } else if (currentSearchContent.endsWith("\n")) {
|
||||
// currentSearchContent = currentSearchContent.slice(0, -1)
|
||||
// }
|
||||
|
||||
if (!this.currentSearchContent) {
|
||||
// Empty search block
|
||||
if (this.originalContent.length === 0) {
|
||||
// New file scenario: nothing to match, just start inserting
|
||||
this.searchMatchIndex = 0
|
||||
this.searchEndIndex = 0
|
||||
} else {
|
||||
// Complete file replacement scenario: treat the entire file as matched
|
||||
this.searchMatchIndex = 0
|
||||
this.searchEndIndex = this.originalContent.length
|
||||
}
|
||||
} else {
|
||||
// Add check for inefficient full-file search
|
||||
// if (currentSearchContent.trim() === originalContent.trim()) {
|
||||
// throw new Error(
|
||||
// "The SEARCH block contains the entire file content. Please either:\n" +
|
||||
// "1. Use an empty SEARCH block to replace the entire file, or\n" +
|
||||
// "2. Make focused changes to specific parts of the file that need modification.",
|
||||
// )
|
||||
// }
|
||||
// Exact search match scenario
|
||||
const exactIndex = this.originalContent.indexOf(this.currentSearchContent, this.lastProcessedIndex)
|
||||
if (exactIndex !== -1) {
|
||||
this.searchMatchIndex = exactIndex
|
||||
this.searchEndIndex = exactIndex + this.currentSearchContent.length
|
||||
} else {
|
||||
// Attempt fallback line-trimmed matching
|
||||
const lineMatch = lineTrimmedFallbackMatch(
|
||||
this.originalContent,
|
||||
this.currentSearchContent,
|
||||
this.lastProcessedIndex,
|
||||
)
|
||||
if (lineMatch) {
|
||||
;[this.searchMatchIndex, this.searchEndIndex] = lineMatch
|
||||
} else {
|
||||
// Try block anchor fallback for larger blocks
|
||||
const blockMatch = blockAnchorFallbackMatch(
|
||||
this.originalContent,
|
||||
this.currentSearchContent,
|
||||
this.lastProcessedIndex,
|
||||
)
|
||||
if (blockMatch) {
|
||||
;[this.searchMatchIndex, this.searchEndIndex, /* ignore similarity */] = blockMatch
|
||||
} else {
|
||||
throw new Error(
|
||||
`The SEARCH block:\n${this.currentSearchContent.trimEnd()}\n...does not match anything in the file.`,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (this.searchMatchIndex < this.lastProcessedIndex) {
|
||||
throw new Error(
|
||||
`The SEARCH block:\n${this.currentSearchContent.trimEnd()}\n...matched an incorrect content in the file.`,
|
||||
)
|
||||
}
|
||||
// Output everything up to the match location
|
||||
this.result += this.originalContent.slice(this.lastProcessedIndex, this.searchMatchIndex)
|
||||
}
|
||||
|
||||
private tryFixSearchBlock(lineLimit: number): number {
|
||||
let removeLineCount = 0
|
||||
if (lineLimit < 0) {
|
||||
lineLimit = this.pendingNonStandardLines.length
|
||||
}
|
||||
if (!lineLimit) {
|
||||
throw new Error("Invalid SEARCH/REPLACE block structure - no lines available to process")
|
||||
}
|
||||
let searchTagRegexp = /^([-]{3,}|[<]{3,}) SEARCH$/
|
||||
const searchTagIndex = this.findLastMatchingLineIndex(searchTagRegexp, lineLimit)
|
||||
if (searchTagIndex !== -1) {
|
||||
let fixLines = this.pendingNonStandardLines.slice(searchTagIndex, lineLimit)
|
||||
fixLines[0] = SEARCH_BLOCK_START
|
||||
for (const line of fixLines) {
|
||||
removeLineCount += this.internalProcessLine(line, false, searchTagIndex)
|
||||
}
|
||||
} else {
|
||||
throw new Error(
|
||||
`Invalid REPLACE marker detected - could not find matching SEARCH block starting from line ${searchTagIndex + 1}`,
|
||||
)
|
||||
}
|
||||
return removeLineCount
|
||||
}
|
||||
|
||||
private tryFixReplaceBlock(lineLimit: number): number {
|
||||
let removeLineCount = 0
|
||||
if (lineLimit < 0) {
|
||||
lineLimit = this.pendingNonStandardLines.length
|
||||
}
|
||||
if (!lineLimit) {
|
||||
throw new Error()
|
||||
}
|
||||
let replaceBeginTagRegexp = /^[=]{3,}$/
|
||||
const replaceBeginTagIndex = this.findLastMatchingLineIndex(replaceBeginTagRegexp, lineLimit)
|
||||
if (replaceBeginTagIndex !== -1) {
|
||||
// // 校验非标内容
|
||||
// if (!this.isSearchingActive()) {
|
||||
// removeLineCount += this.tryFixSearchBlock(replaceBeginTagIndex)
|
||||
// }
|
||||
let fixLines = this.pendingNonStandardLines.slice(replaceBeginTagIndex - removeLineCount, lineLimit - removeLineCount)
|
||||
fixLines[0] = SEARCH_BLOCK_END
|
||||
for (const line of fixLines) {
|
||||
removeLineCount += this.internalProcessLine(line, false, replaceBeginTagIndex - removeLineCount)
|
||||
}
|
||||
} else {
|
||||
throw new Error(`Malformed REPLACE block - missing valid separator after line ${replaceBeginTagIndex + 1}`)
|
||||
}
|
||||
return removeLineCount
|
||||
}
|
||||
|
||||
private tryFixSearchReplaceBlock(lineLimit: number): number {
|
||||
let removeLineCount = 0
|
||||
if (lineLimit < 0) {
|
||||
lineLimit = this.pendingNonStandardLines.length
|
||||
}
|
||||
if (!lineLimit) {
|
||||
throw new Error()
|
||||
}
|
||||
|
||||
let replaceEndTagRegexp = /^([+]{3,}|[>]{3,}) REPLACE$/
|
||||
const replaceEndTagIndex = this.findLastMatchingLineIndex(replaceEndTagRegexp, lineLimit)
|
||||
const likeReplaceEndTag = replaceEndTagIndex === lineLimit - 1
|
||||
if (likeReplaceEndTag) {
|
||||
// // 校验非标内容
|
||||
// if (!this.isReplacingActive()) {
|
||||
// removeLineCount += this.tryFixReplaceBlock(replaceEndTagIndex)
|
||||
// }
|
||||
let fixLines = this.pendingNonStandardLines.slice(replaceEndTagIndex - removeLineCount, lineLimit - removeLineCount)
|
||||
fixLines[fixLines.length - 1] = REPLACE_BLOCK_END
|
||||
for (const line of fixLines) {
|
||||
removeLineCount += this.internalProcessLine(line, false, replaceEndTagIndex - removeLineCount)
|
||||
}
|
||||
} else {
|
||||
throw new Error("Malformed SEARCH/REPLACE block structure: Missing valid closing REPLACE marker")
|
||||
}
|
||||
return removeLineCount
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes trailing empty lines from the pendingNonStandardLines array
|
||||
* @param lineLimit - The index to start checking from (exclusive).
|
||||
* Removes empty lines from lineLimit-1 backwards.
|
||||
* @returns The number of empty lines removed
|
||||
*/
|
||||
private trimPendingNonStandardTrailingEmptyLines(lineLimit: number): number {
|
||||
let removedCount = 0
|
||||
let i = Math.min(lineLimit, this.pendingNonStandardLines.length) - 1
|
||||
|
||||
while (i >= 0 && this.pendingNonStandardLines[i].trim() === "") {
|
||||
this.pendingNonStandardLines.pop()
|
||||
removedCount++
|
||||
i--
|
||||
}
|
||||
|
||||
return removedCount
|
||||
}
|
||||
}
|
||||
|
||||
export async function constructNewFileContentV2(diffContent: string, originalContent: string, isFinal: boolean): Promise<string> {
|
||||
let newFileContentConstructor = new NewFileContentConstructor(originalContent, isFinal)
|
||||
|
||||
let lines = diffContent.split("\n")
|
||||
|
||||
// If the last line looks like a partial marker but isn't recognized,
|
||||
// remove it because it might be incomplete.
|
||||
const lastLine = lines[lines.length - 1]
|
||||
if (
|
||||
lines.length > 0 &&
|
||||
(lastLine.startsWith(SEARCH_BLOCK_CHAR) ||
|
||||
lastLine.startsWith(LEGACY_SEARCH_BLOCK_CHAR) ||
|
||||
lastLine.startsWith("=") ||
|
||||
lastLine.startsWith(REPLACE_BLOCK_CHAR) ||
|
||||
lastLine.startsWith(LEGACY_REPLACE_BLOCK_CHAR)) &&
|
||||
lastLine !== SEARCH_BLOCK_START &&
|
||||
lastLine !== SEARCH_BLOCK_END &&
|
||||
lastLine !== REPLACE_BLOCK_END
|
||||
) {
|
||||
lines.pop()
|
||||
}
|
||||
|
||||
for (const line of lines) {
|
||||
newFileContentConstructor.processLine(line)
|
||||
}
|
||||
|
||||
let result = newFileContentConstructor.getResult()
|
||||
return result
|
||||
}
|
||||
@@ -81,6 +81,7 @@ export interface TestResult {
|
||||
diffEdit?: string
|
||||
toolCalls?: ExtractedToolCall[]
|
||||
diffEditSuccess?: boolean
|
||||
replacementData?: any
|
||||
error?: string
|
||||
errorString?: string
|
||||
}
|
||||
|
||||
Generated
+60
-552
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "claude-dev",
|
||||
"version": "3.18.0",
|
||||
"version": "3.18.1",
|
||||
"lockfileVersion": 2,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "claude-dev",
|
||||
"version": "3.18.0",
|
||||
"version": "3.18.1",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.37.0",
|
||||
@@ -45,7 +45,6 @@
|
||||
"fzf": "^0.5.2",
|
||||
"get-folder-size": "^5.0.0",
|
||||
"globby": "^14.0.2",
|
||||
"google-auth-library": "^10.1.0",
|
||||
"grpc-health-check": "^2.0.2",
|
||||
"iconv-lite": "^0.6.3",
|
||||
"ignore": "^7.0.3",
|
||||
@@ -202,75 +201,6 @@
|
||||
"undici-types": "~5.26.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@anthropic-ai/vertex-sdk/node_modules/gaxios": {
|
||||
"version": "6.7.1",
|
||||
"resolved": "https://registry.npmjs.org/gaxios/-/gaxios-6.7.1.tgz",
|
||||
"integrity": "sha512-LDODD4TMYx7XXdpwxAVRAIAuB0bzv0s+ywFonY46k126qzQHT9ygyoa9tncmOiQmmDrik65UYsEkv3lbfqQ3yQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"extend": "^3.0.2",
|
||||
"https-proxy-agent": "^7.0.1",
|
||||
"is-stream": "^2.0.0",
|
||||
"node-fetch": "^2.6.9",
|
||||
"uuid": "^9.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/@anthropic-ai/vertex-sdk/node_modules/gcp-metadata": {
|
||||
"version": "6.1.1",
|
||||
"resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-6.1.1.tgz",
|
||||
"integrity": "sha512-a4tiq7E0/5fTjxPAaH4jpjkSv/uCaU2p5KC6HVGrvl0cDjA8iBZv4vv1gyzlmK0ZUKqwpOyQMKzZQe3lTit77A==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"gaxios": "^6.1.1",
|
||||
"google-logging-utils": "^0.0.2",
|
||||
"json-bigint": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/@anthropic-ai/vertex-sdk/node_modules/google-auth-library": {
|
||||
"version": "9.15.1",
|
||||
"resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-9.15.1.tgz",
|
||||
"integrity": "sha512-Jb6Z0+nvECVz+2lzSMt9u98UsoakXxA2HGHMCxh+so3n90XgYWkq5dur19JAJV7ONiJY22yBTyJB1TSkvPq9Ng==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.3.0",
|
||||
"ecdsa-sig-formatter": "^1.0.11",
|
||||
"gaxios": "^6.1.1",
|
||||
"gcp-metadata": "^6.1.0",
|
||||
"gtoken": "^7.0.0",
|
||||
"jws": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/@anthropic-ai/vertex-sdk/node_modules/google-logging-utils": {
|
||||
"version": "0.0.2",
|
||||
"resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-0.0.2.tgz",
|
||||
"integrity": "sha512-NEgUnEcBiP5HrPzufUkBzJOD/Sxsco3rLNo1F1TNf7ieU8ryUzBhqba8r756CjLX7rn3fHl6iLEwPYuqpoKgQQ==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/@anthropic-ai/vertex-sdk/node_modules/gtoken": {
|
||||
"version": "7.1.0",
|
||||
"resolved": "https://registry.npmjs.org/gtoken/-/gtoken-7.1.0.tgz",
|
||||
"integrity": "sha512-pCcEwRi+TKpMlxAQObHDQ56KawURgyAf6jtIY046fJ5tIv3zDe/LEIubckAO8fj6JnAxLdmWkUfNyulQ2iKdEw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"gaxios": "^6.0.0",
|
||||
"jws": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@asyncapi/parser": {
|
||||
"version": "3.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@asyncapi/parser/-/parser-3.4.0.tgz",
|
||||
@@ -2938,75 +2868,6 @@
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@google-cloud/vertexai/node_modules/gaxios": {
|
||||
"version": "6.7.1",
|
||||
"resolved": "https://registry.npmjs.org/gaxios/-/gaxios-6.7.1.tgz",
|
||||
"integrity": "sha512-LDODD4TMYx7XXdpwxAVRAIAuB0bzv0s+ywFonY46k126qzQHT9ygyoa9tncmOiQmmDrik65UYsEkv3lbfqQ3yQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"extend": "^3.0.2",
|
||||
"https-proxy-agent": "^7.0.1",
|
||||
"is-stream": "^2.0.0",
|
||||
"node-fetch": "^2.6.9",
|
||||
"uuid": "^9.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/@google-cloud/vertexai/node_modules/gcp-metadata": {
|
||||
"version": "6.1.1",
|
||||
"resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-6.1.1.tgz",
|
||||
"integrity": "sha512-a4tiq7E0/5fTjxPAaH4jpjkSv/uCaU2p5KC6HVGrvl0cDjA8iBZv4vv1gyzlmK0ZUKqwpOyQMKzZQe3lTit77A==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"gaxios": "^6.1.1",
|
||||
"google-logging-utils": "^0.0.2",
|
||||
"json-bigint": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/@google-cloud/vertexai/node_modules/google-auth-library": {
|
||||
"version": "9.15.1",
|
||||
"resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-9.15.1.tgz",
|
||||
"integrity": "sha512-Jb6Z0+nvECVz+2lzSMt9u98UsoakXxA2HGHMCxh+so3n90XgYWkq5dur19JAJV7ONiJY22yBTyJB1TSkvPq9Ng==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.3.0",
|
||||
"ecdsa-sig-formatter": "^1.0.11",
|
||||
"gaxios": "^6.1.1",
|
||||
"gcp-metadata": "^6.1.0",
|
||||
"gtoken": "^7.0.0",
|
||||
"jws": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/@google-cloud/vertexai/node_modules/google-logging-utils": {
|
||||
"version": "0.0.2",
|
||||
"resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-0.0.2.tgz",
|
||||
"integrity": "sha512-NEgUnEcBiP5HrPzufUkBzJOD/Sxsco3rLNo1F1TNf7ieU8ryUzBhqba8r756CjLX7rn3fHl6iLEwPYuqpoKgQQ==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/@google-cloud/vertexai/node_modules/gtoken": {
|
||||
"version": "7.1.0",
|
||||
"resolved": "https://registry.npmjs.org/gtoken/-/gtoken-7.1.0.tgz",
|
||||
"integrity": "sha512-pCcEwRi+TKpMlxAQObHDQ56KawURgyAf6jtIY046fJ5tIv3zDe/LEIubckAO8fj6JnAxLdmWkUfNyulQ2iKdEw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"gaxios": "^6.0.0",
|
||||
"jws": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@google/genai": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@google/genai/-/genai-1.0.0.tgz",
|
||||
@@ -3025,75 +2886,6 @@
|
||||
"@modelcontextprotocol/sdk": "^1.11.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@google/genai/node_modules/gaxios": {
|
||||
"version": "6.7.1",
|
||||
"resolved": "https://registry.npmjs.org/gaxios/-/gaxios-6.7.1.tgz",
|
||||
"integrity": "sha512-LDODD4TMYx7XXdpwxAVRAIAuB0bzv0s+ywFonY46k126qzQHT9ygyoa9tncmOiQmmDrik65UYsEkv3lbfqQ3yQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"extend": "^3.0.2",
|
||||
"https-proxy-agent": "^7.0.1",
|
||||
"is-stream": "^2.0.0",
|
||||
"node-fetch": "^2.6.9",
|
||||
"uuid": "^9.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/@google/genai/node_modules/gcp-metadata": {
|
||||
"version": "6.1.1",
|
||||
"resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-6.1.1.tgz",
|
||||
"integrity": "sha512-a4tiq7E0/5fTjxPAaH4jpjkSv/uCaU2p5KC6HVGrvl0cDjA8iBZv4vv1gyzlmK0ZUKqwpOyQMKzZQe3lTit77A==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"gaxios": "^6.1.1",
|
||||
"google-logging-utils": "^0.0.2",
|
||||
"json-bigint": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/@google/genai/node_modules/google-auth-library": {
|
||||
"version": "9.15.1",
|
||||
"resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-9.15.1.tgz",
|
||||
"integrity": "sha512-Jb6Z0+nvECVz+2lzSMt9u98UsoakXxA2HGHMCxh+so3n90XgYWkq5dur19JAJV7ONiJY22yBTyJB1TSkvPq9Ng==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.3.0",
|
||||
"ecdsa-sig-formatter": "^1.0.11",
|
||||
"gaxios": "^6.1.1",
|
||||
"gcp-metadata": "^6.1.0",
|
||||
"gtoken": "^7.0.0",
|
||||
"jws": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/@google/genai/node_modules/google-logging-utils": {
|
||||
"version": "0.0.2",
|
||||
"resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-0.0.2.tgz",
|
||||
"integrity": "sha512-NEgUnEcBiP5HrPzufUkBzJOD/Sxsco3rLNo1F1TNf7ieU8ryUzBhqba8r756CjLX7rn3fHl6iLEwPYuqpoKgQQ==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/@google/genai/node_modules/gtoken": {
|
||||
"version": "7.1.0",
|
||||
"resolved": "https://registry.npmjs.org/gtoken/-/gtoken-7.1.0.tgz",
|
||||
"integrity": "sha512-pCcEwRi+TKpMlxAQObHDQ56KawURgyAf6jtIY046fJ5tIv3zDe/LEIubckAO8fj6JnAxLdmWkUfNyulQ2iKdEw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"gaxios": "^6.0.0",
|
||||
"jws": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@grpc/grpc-js": {
|
||||
"version": "1.9.15",
|
||||
"resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.9.15.tgz",
|
||||
@@ -9810,10 +9602,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/bignumber.js": {
|
||||
"version": "9.3.0",
|
||||
"resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.0.tgz",
|
||||
"integrity": "sha512-EM7aMFTXbptt/wZdMlBv2t8IViwQL+h6SLHosp8Yf0dqJMTnY6iL32opnAB6kAdL0SZPuvcAzFr31o0c/R3/RA==",
|
||||
"license": "MIT",
|
||||
"version": "9.1.2",
|
||||
"resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.1.2.tgz",
|
||||
"integrity": "sha512-2/mKyZH9K85bzOEfhXDBFZTGd1CTs+5IHpeFQo9luiBG7hghdC851Pj2WAhb6E3R6b9tZj/XKhbg4fum+Kepug==",
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
@@ -9999,8 +9790,7 @@
|
||||
"node_modules/buffer-equal-constant-time": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
|
||||
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
|
||||
"license": "BSD-3-Clause"
|
||||
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="
|
||||
},
|
||||
"node_modules/buffer-indexof-polyfill": {
|
||||
"version": "1.0.2",
|
||||
@@ -11658,7 +11448,6 @@
|
||||
"version": "1.0.11",
|
||||
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
|
||||
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
@@ -13297,29 +13086,6 @@
|
||||
"pend": "~1.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fetch-blob": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz",
|
||||
"integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/jimmywarting"
|
||||
},
|
||||
{
|
||||
"type": "paypal",
|
||||
"url": "https://paypal.me/jimmywarting"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"node-domexception": "^1.0.0",
|
||||
"web-streams-polyfill": "^3.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^12.20 || >= 14.13"
|
||||
}
|
||||
},
|
||||
"node_modules/figures": {
|
||||
"version": "6.1.0",
|
||||
"resolved": "https://registry.npmjs.org/figures/-/figures-6.1.0.tgz",
|
||||
@@ -13656,18 +13422,6 @@
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/formdata-polyfill": {
|
||||
"version": "4.0.10",
|
||||
"resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz",
|
||||
"integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fetch-blob": "^3.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/forwarded": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
|
||||
@@ -13934,44 +13688,18 @@
|
||||
}
|
||||
},
|
||||
"node_modules/gaxios": {
|
||||
"version": "7.1.1",
|
||||
"resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.1.tgz",
|
||||
"integrity": "sha512-Odju3uBUJyVCkW64nLD4wKLhbh93bh6vIg/ZIXkWiLPBrdgtc65+tls/qml+un3pr6JqYVFDZbbmLDQT68rTOQ==",
|
||||
"license": "Apache-2.0",
|
||||
"version": "6.7.1",
|
||||
"resolved": "https://registry.npmjs.org/gaxios/-/gaxios-6.7.1.tgz",
|
||||
"integrity": "sha512-LDODD4TMYx7XXdpwxAVRAIAuB0bzv0s+ywFonY46k126qzQHT9ygyoa9tncmOiQmmDrik65UYsEkv3lbfqQ3yQ==",
|
||||
"dependencies": {
|
||||
"extend": "^3.0.2",
|
||||
"https-proxy-agent": "^7.0.1",
|
||||
"node-fetch": "^3.3.2"
|
||||
"is-stream": "^2.0.0",
|
||||
"node-fetch": "^2.6.9",
|
||||
"uuid": "^9.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/gaxios/node_modules/data-uri-to-buffer": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz",
|
||||
"integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 12"
|
||||
}
|
||||
},
|
||||
"node_modules/gaxios/node_modules/node-fetch": {
|
||||
"version": "3.3.2",
|
||||
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz",
|
||||
"integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"data-uri-to-buffer": "^4.0.0",
|
||||
"fetch-blob": "^3.1.4",
|
||||
"formdata-polyfill": "^4.0.10"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/node-fetch"
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/gcd": {
|
||||
@@ -13981,17 +13709,15 @@
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/gcp-metadata": {
|
||||
"version": "7.0.1",
|
||||
"resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-7.0.1.tgz",
|
||||
"integrity": "sha512-UcO3kefx6dCcZkgcTGgVOTFb7b1LlQ02hY1omMjjrrBzkajRMCFgYOjs7J71WqnuG1k2b+9ppGL7FsOfhZMQKQ==",
|
||||
"license": "Apache-2.0",
|
||||
"version": "6.1.0",
|
||||
"resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-6.1.0.tgz",
|
||||
"integrity": "sha512-Jh/AIwwgaxan+7ZUUmRLCjtchyDiqh4KjBJ5tW3plBZb5iL/BPcso8A5DlzeD9qlw0duCamnNdpFjxwaT0KyKg==",
|
||||
"dependencies": {
|
||||
"gaxios": "^7.0.0",
|
||||
"google-logging-utils": "^1.0.0",
|
||||
"gaxios": "^6.0.0",
|
||||
"json-bigint": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/get-caller-file": {
|
||||
@@ -14264,28 +13990,18 @@
|
||||
}
|
||||
},
|
||||
"node_modules/google-auth-library": {
|
||||
"version": "10.1.0",
|
||||
"resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.1.0.tgz",
|
||||
"integrity": "sha512-GspVjZj1RbyRWpQ9FbAXMKjFGzZwDKnUHi66JJ+tcjcu5/xYAP1pdlWotCuIkMwjfVsxxDvsGZXGLzRt72D0sQ==",
|
||||
"version": "9.15.1",
|
||||
"resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-9.15.1.tgz",
|
||||
"integrity": "sha512-Jb6Z0+nvECVz+2lzSMt9u98UsoakXxA2HGHMCxh+so3n90XgYWkq5dur19JAJV7ONiJY22yBTyJB1TSkvPq9Ng==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.3.0",
|
||||
"ecdsa-sig-formatter": "^1.0.11",
|
||||
"gaxios": "^7.0.0",
|
||||
"gcp-metadata": "^7.0.0",
|
||||
"google-logging-utils": "^1.0.0",
|
||||
"gtoken": "^8.0.0",
|
||||
"gaxios": "^6.1.1",
|
||||
"gcp-metadata": "^6.1.0",
|
||||
"gtoken": "^7.0.0",
|
||||
"jws": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/google-logging-utils": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.1.tgz",
|
||||
"integrity": "sha512-rcX58I7nqpu4mbKztFeOAObbomBbHU2oIb/d3tJfF3dizGSApqtSwYJigGCooHdnMyQBIw8BrWyK96w3YXgr6A==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
}
|
||||
@@ -14422,16 +14138,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/gtoken": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/gtoken/-/gtoken-8.0.0.tgz",
|
||||
"integrity": "sha512-+CqsMbHPiSTdtSO14O51eMNlrp9N79gmeqmXeouJOhfucAedHw9noVe/n5uJk3tbKE6a+6ZCQg3RPhVhHByAIw==",
|
||||
"license": "MIT",
|
||||
"version": "7.1.0",
|
||||
"resolved": "https://registry.npmjs.org/gtoken/-/gtoken-7.1.0.tgz",
|
||||
"integrity": "sha512-pCcEwRi+TKpMlxAQObHDQ56KawURgyAf6jtIY046fJ5tIv3zDe/LEIubckAO8fj6JnAxLdmWkUfNyulQ2iKdEw==",
|
||||
"dependencies": {
|
||||
"gaxios": "^7.0.0",
|
||||
"gaxios": "^6.0.0",
|
||||
"jws": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/has-bigints": {
|
||||
@@ -16030,7 +15745,6 @@
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz",
|
||||
"integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bignumber.js": "^9.0.0"
|
||||
}
|
||||
@@ -16144,12 +15858,11 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/jwa": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
|
||||
"integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
|
||||
"license": "MIT",
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.0.tgz",
|
||||
"integrity": "sha512-jrZ2Qx916EA+fq9cEAeCROWPTfCwi1IVHqT2tapuqLEVVDKFDENFw1oL+MwrTvH6msKxsd1YTDVw6uKEcsrLEA==",
|
||||
"dependencies": {
|
||||
"buffer-equal-constant-time": "^1.0.1",
|
||||
"buffer-equal-constant-time": "1.0.1",
|
||||
"ecdsa-sig-formatter": "1.0.11",
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
@@ -16158,7 +15871,6 @@
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.0.tgz",
|
||||
"integrity": "sha512-KDncfTmOZoOMTFG4mBlG0qUIOlc03fmzH+ru6RgYVZhPkyiy/92Owlt/8UEN+a4TXR1FQetfIpJE8ApdvdVxTg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"jwa": "^2.0.0",
|
||||
"safe-buffer": "^5.0.1"
|
||||
@@ -23688,15 +23400,6 @@
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/web-streams-polyfill": {
|
||||
"version": "3.3.3",
|
||||
"resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz",
|
||||
"integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/web-tree-sitter": {
|
||||
"version": "0.22.6",
|
||||
"resolved": "https://registry.npmjs.org/web-tree-sitter/-/web-tree-sitter-0.22.6.tgz",
|
||||
@@ -24457,55 +24160,6 @@
|
||||
"requires": {
|
||||
"undici-types": "~5.26.4"
|
||||
}
|
||||
},
|
||||
"gaxios": {
|
||||
"version": "6.7.1",
|
||||
"resolved": "https://registry.npmjs.org/gaxios/-/gaxios-6.7.1.tgz",
|
||||
"integrity": "sha512-LDODD4TMYx7XXdpwxAVRAIAuB0bzv0s+ywFonY46k126qzQHT9ygyoa9tncmOiQmmDrik65UYsEkv3lbfqQ3yQ==",
|
||||
"requires": {
|
||||
"extend": "^3.0.2",
|
||||
"https-proxy-agent": "^7.0.1",
|
||||
"is-stream": "^2.0.0",
|
||||
"node-fetch": "^2.6.9",
|
||||
"uuid": "^9.0.1"
|
||||
}
|
||||
},
|
||||
"gcp-metadata": {
|
||||
"version": "6.1.1",
|
||||
"resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-6.1.1.tgz",
|
||||
"integrity": "sha512-a4tiq7E0/5fTjxPAaH4jpjkSv/uCaU2p5KC6HVGrvl0cDjA8iBZv4vv1gyzlmK0ZUKqwpOyQMKzZQe3lTit77A==",
|
||||
"requires": {
|
||||
"gaxios": "^6.1.1",
|
||||
"google-logging-utils": "^0.0.2",
|
||||
"json-bigint": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"google-auth-library": {
|
||||
"version": "9.15.1",
|
||||
"resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-9.15.1.tgz",
|
||||
"integrity": "sha512-Jb6Z0+nvECVz+2lzSMt9u98UsoakXxA2HGHMCxh+so3n90XgYWkq5dur19JAJV7ONiJY22yBTyJB1TSkvPq9Ng==",
|
||||
"requires": {
|
||||
"base64-js": "^1.3.0",
|
||||
"ecdsa-sig-formatter": "^1.0.11",
|
||||
"gaxios": "^6.1.1",
|
||||
"gcp-metadata": "^6.1.0",
|
||||
"gtoken": "^7.0.0",
|
||||
"jws": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"google-logging-utils": {
|
||||
"version": "0.0.2",
|
||||
"resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-0.0.2.tgz",
|
||||
"integrity": "sha512-NEgUnEcBiP5HrPzufUkBzJOD/Sxsco3rLNo1F1TNf7ieU8ryUzBhqba8r756CjLX7rn3fHl6iLEwPYuqpoKgQQ=="
|
||||
},
|
||||
"gtoken": {
|
||||
"version": "7.1.0",
|
||||
"resolved": "https://registry.npmjs.org/gtoken/-/gtoken-7.1.0.tgz",
|
||||
"integrity": "sha512-pCcEwRi+TKpMlxAQObHDQ56KawURgyAf6jtIY046fJ5tIv3zDe/LEIubckAO8fj6JnAxLdmWkUfNyulQ2iKdEw==",
|
||||
"requires": {
|
||||
"gaxios": "^6.0.0",
|
||||
"jws": "^4.0.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -26762,57 +26416,6 @@
|
||||
"integrity": "sha512-35o5tIEMLW3JeFJOaaMNR2e5sq+6rpnhrF97PuAxeOm0GlqVTESKhkGj7a5B5mmJSSSU3hUfIhcQCRRsw4Ipzg==",
|
||||
"requires": {
|
||||
"google-auth-library": "^9.1.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"gaxios": {
|
||||
"version": "6.7.1",
|
||||
"resolved": "https://registry.npmjs.org/gaxios/-/gaxios-6.7.1.tgz",
|
||||
"integrity": "sha512-LDODD4TMYx7XXdpwxAVRAIAuB0bzv0s+ywFonY46k126qzQHT9ygyoa9tncmOiQmmDrik65UYsEkv3lbfqQ3yQ==",
|
||||
"requires": {
|
||||
"extend": "^3.0.2",
|
||||
"https-proxy-agent": "^7.0.1",
|
||||
"is-stream": "^2.0.0",
|
||||
"node-fetch": "^2.6.9",
|
||||
"uuid": "^9.0.1"
|
||||
}
|
||||
},
|
||||
"gcp-metadata": {
|
||||
"version": "6.1.1",
|
||||
"resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-6.1.1.tgz",
|
||||
"integrity": "sha512-a4tiq7E0/5fTjxPAaH4jpjkSv/uCaU2p5KC6HVGrvl0cDjA8iBZv4vv1gyzlmK0ZUKqwpOyQMKzZQe3lTit77A==",
|
||||
"requires": {
|
||||
"gaxios": "^6.1.1",
|
||||
"google-logging-utils": "^0.0.2",
|
||||
"json-bigint": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"google-auth-library": {
|
||||
"version": "9.15.1",
|
||||
"resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-9.15.1.tgz",
|
||||
"integrity": "sha512-Jb6Z0+nvECVz+2lzSMt9u98UsoakXxA2HGHMCxh+so3n90XgYWkq5dur19JAJV7ONiJY22yBTyJB1TSkvPq9Ng==",
|
||||
"requires": {
|
||||
"base64-js": "^1.3.0",
|
||||
"ecdsa-sig-formatter": "^1.0.11",
|
||||
"gaxios": "^6.1.1",
|
||||
"gcp-metadata": "^6.1.0",
|
||||
"gtoken": "^7.0.0",
|
||||
"jws": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"google-logging-utils": {
|
||||
"version": "0.0.2",
|
||||
"resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-0.0.2.tgz",
|
||||
"integrity": "sha512-NEgUnEcBiP5HrPzufUkBzJOD/Sxsco3rLNo1F1TNf7ieU8ryUzBhqba8r756CjLX7rn3fHl6iLEwPYuqpoKgQQ=="
|
||||
},
|
||||
"gtoken": {
|
||||
"version": "7.1.0",
|
||||
"resolved": "https://registry.npmjs.org/gtoken/-/gtoken-7.1.0.tgz",
|
||||
"integrity": "sha512-pCcEwRi+TKpMlxAQObHDQ56KawURgyAf6jtIY046fJ5tIv3zDe/LEIubckAO8fj6JnAxLdmWkUfNyulQ2iKdEw==",
|
||||
"requires": {
|
||||
"gaxios": "^6.0.0",
|
||||
"jws": "^4.0.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"@google/genai": {
|
||||
@@ -26824,57 +26427,6 @@
|
||||
"ws": "^8.18.0",
|
||||
"zod": "^3.22.4",
|
||||
"zod-to-json-schema": "^3.22.4"
|
||||
},
|
||||
"dependencies": {
|
||||
"gaxios": {
|
||||
"version": "6.7.1",
|
||||
"resolved": "https://registry.npmjs.org/gaxios/-/gaxios-6.7.1.tgz",
|
||||
"integrity": "sha512-LDODD4TMYx7XXdpwxAVRAIAuB0bzv0s+ywFonY46k126qzQHT9ygyoa9tncmOiQmmDrik65UYsEkv3lbfqQ3yQ==",
|
||||
"requires": {
|
||||
"extend": "^3.0.2",
|
||||
"https-proxy-agent": "^7.0.1",
|
||||
"is-stream": "^2.0.0",
|
||||
"node-fetch": "^2.6.9",
|
||||
"uuid": "^9.0.1"
|
||||
}
|
||||
},
|
||||
"gcp-metadata": {
|
||||
"version": "6.1.1",
|
||||
"resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-6.1.1.tgz",
|
||||
"integrity": "sha512-a4tiq7E0/5fTjxPAaH4jpjkSv/uCaU2p5KC6HVGrvl0cDjA8iBZv4vv1gyzlmK0ZUKqwpOyQMKzZQe3lTit77A==",
|
||||
"requires": {
|
||||
"gaxios": "^6.1.1",
|
||||
"google-logging-utils": "^0.0.2",
|
||||
"json-bigint": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"google-auth-library": {
|
||||
"version": "9.15.1",
|
||||
"resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-9.15.1.tgz",
|
||||
"integrity": "sha512-Jb6Z0+nvECVz+2lzSMt9u98UsoakXxA2HGHMCxh+so3n90XgYWkq5dur19JAJV7ONiJY22yBTyJB1TSkvPq9Ng==",
|
||||
"requires": {
|
||||
"base64-js": "^1.3.0",
|
||||
"ecdsa-sig-formatter": "^1.0.11",
|
||||
"gaxios": "^6.1.1",
|
||||
"gcp-metadata": "^6.1.0",
|
||||
"gtoken": "^7.0.0",
|
||||
"jws": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"google-logging-utils": {
|
||||
"version": "0.0.2",
|
||||
"resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-0.0.2.tgz",
|
||||
"integrity": "sha512-NEgUnEcBiP5HrPzufUkBzJOD/Sxsco3rLNo1F1TNf7ieU8ryUzBhqba8r756CjLX7rn3fHl6iLEwPYuqpoKgQQ=="
|
||||
},
|
||||
"gtoken": {
|
||||
"version": "7.1.0",
|
||||
"resolved": "https://registry.npmjs.org/gtoken/-/gtoken-7.1.0.tgz",
|
||||
"integrity": "sha512-pCcEwRi+TKpMlxAQObHDQ56KawURgyAf6jtIY046fJ5tIv3zDe/LEIubckAO8fj6JnAxLdmWkUfNyulQ2iKdEw==",
|
||||
"requires": {
|
||||
"gaxios": "^6.0.0",
|
||||
"jws": "^4.0.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"@grpc/grpc-js": {
|
||||
@@ -31658,9 +31210,9 @@
|
||||
"integrity": "sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg=="
|
||||
},
|
||||
"bignumber.js": {
|
||||
"version": "9.3.0",
|
||||
"resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.0.tgz",
|
||||
"integrity": "sha512-EM7aMFTXbptt/wZdMlBv2t8IViwQL+h6SLHosp8Yf0dqJMTnY6iL32opnAB6kAdL0SZPuvcAzFr31o0c/R3/RA=="
|
||||
"version": "9.1.2",
|
||||
"resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.1.2.tgz",
|
||||
"integrity": "sha512-2/mKyZH9K85bzOEfhXDBFZTGd1CTs+5IHpeFQo9luiBG7hghdC851Pj2WAhb6E3R6b9tZj/XKhbg4fum+Kepug=="
|
||||
},
|
||||
"binary": {
|
||||
"version": "0.3.0",
|
||||
@@ -34012,15 +33564,6 @@
|
||||
"pend": "~1.2.0"
|
||||
}
|
||||
},
|
||||
"fetch-blob": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz",
|
||||
"integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==",
|
||||
"requires": {
|
||||
"node-domexception": "^1.0.0",
|
||||
"web-streams-polyfill": "^3.0.3"
|
||||
}
|
||||
},
|
||||
"figures": {
|
||||
"version": "6.1.0",
|
||||
"resolved": "https://registry.npmjs.org/figures/-/figures-6.1.0.tgz",
|
||||
@@ -34256,14 +33799,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"formdata-polyfill": {
|
||||
"version": "4.0.10",
|
||||
"resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz",
|
||||
"integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==",
|
||||
"requires": {
|
||||
"fetch-blob": "^3.1.2"
|
||||
}
|
||||
},
|
||||
"forwarded": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
|
||||
@@ -34449,30 +33984,15 @@
|
||||
}
|
||||
},
|
||||
"gaxios": {
|
||||
"version": "7.1.1",
|
||||
"resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.1.tgz",
|
||||
"integrity": "sha512-Odju3uBUJyVCkW64nLD4wKLhbh93bh6vIg/ZIXkWiLPBrdgtc65+tls/qml+un3pr6JqYVFDZbbmLDQT68rTOQ==",
|
||||
"version": "6.7.1",
|
||||
"resolved": "https://registry.npmjs.org/gaxios/-/gaxios-6.7.1.tgz",
|
||||
"integrity": "sha512-LDODD4TMYx7XXdpwxAVRAIAuB0bzv0s+ywFonY46k126qzQHT9ygyoa9tncmOiQmmDrik65UYsEkv3lbfqQ3yQ==",
|
||||
"requires": {
|
||||
"extend": "^3.0.2",
|
||||
"https-proxy-agent": "^7.0.1",
|
||||
"node-fetch": "^3.3.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"data-uri-to-buffer": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz",
|
||||
"integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="
|
||||
},
|
||||
"node-fetch": {
|
||||
"version": "3.3.2",
|
||||
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz",
|
||||
"integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==",
|
||||
"requires": {
|
||||
"data-uri-to-buffer": "^4.0.0",
|
||||
"fetch-blob": "^3.1.4",
|
||||
"formdata-polyfill": "^4.0.10"
|
||||
}
|
||||
}
|
||||
"is-stream": "^2.0.0",
|
||||
"node-fetch": "^2.6.9",
|
||||
"uuid": "^9.0.1"
|
||||
}
|
||||
},
|
||||
"gcd": {
|
||||
@@ -34482,12 +34002,11 @@
|
||||
"dev": true
|
||||
},
|
||||
"gcp-metadata": {
|
||||
"version": "7.0.1",
|
||||
"resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-7.0.1.tgz",
|
||||
"integrity": "sha512-UcO3kefx6dCcZkgcTGgVOTFb7b1LlQ02hY1omMjjrrBzkajRMCFgYOjs7J71WqnuG1k2b+9ppGL7FsOfhZMQKQ==",
|
||||
"version": "6.1.0",
|
||||
"resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-6.1.0.tgz",
|
||||
"integrity": "sha512-Jh/AIwwgaxan+7ZUUmRLCjtchyDiqh4KjBJ5tW3plBZb5iL/BPcso8A5DlzeD9qlw0duCamnNdpFjxwaT0KyKg==",
|
||||
"requires": {
|
||||
"gaxios": "^7.0.0",
|
||||
"google-logging-utils": "^1.0.0",
|
||||
"gaxios": "^6.0.0",
|
||||
"json-bigint": "^1.0.0"
|
||||
}
|
||||
},
|
||||
@@ -34661,24 +34180,18 @@
|
||||
}
|
||||
},
|
||||
"google-auth-library": {
|
||||
"version": "10.1.0",
|
||||
"resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.1.0.tgz",
|
||||
"integrity": "sha512-GspVjZj1RbyRWpQ9FbAXMKjFGzZwDKnUHi66JJ+tcjcu5/xYAP1pdlWotCuIkMwjfVsxxDvsGZXGLzRt72D0sQ==",
|
||||
"version": "9.15.1",
|
||||
"resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-9.15.1.tgz",
|
||||
"integrity": "sha512-Jb6Z0+nvECVz+2lzSMt9u98UsoakXxA2HGHMCxh+so3n90XgYWkq5dur19JAJV7ONiJY22yBTyJB1TSkvPq9Ng==",
|
||||
"requires": {
|
||||
"base64-js": "^1.3.0",
|
||||
"ecdsa-sig-formatter": "^1.0.11",
|
||||
"gaxios": "^7.0.0",
|
||||
"gcp-metadata": "^7.0.0",
|
||||
"google-logging-utils": "^1.0.0",
|
||||
"gtoken": "^8.0.0",
|
||||
"gaxios": "^6.1.1",
|
||||
"gcp-metadata": "^6.1.0",
|
||||
"gtoken": "^7.0.0",
|
||||
"jws": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"google-logging-utils": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.1.tgz",
|
||||
"integrity": "sha512-rcX58I7nqpu4mbKztFeOAObbomBbHU2oIb/d3tJfF3dizGSApqtSwYJigGCooHdnMyQBIw8BrWyK96w3YXgr6A=="
|
||||
},
|
||||
"gopd": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
||||
@@ -34779,11 +34292,11 @@
|
||||
}
|
||||
},
|
||||
"gtoken": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/gtoken/-/gtoken-8.0.0.tgz",
|
||||
"integrity": "sha512-+CqsMbHPiSTdtSO14O51eMNlrp9N79gmeqmXeouJOhfucAedHw9noVe/n5uJk3tbKE6a+6ZCQg3RPhVhHByAIw==",
|
||||
"version": "7.1.0",
|
||||
"resolved": "https://registry.npmjs.org/gtoken/-/gtoken-7.1.0.tgz",
|
||||
"integrity": "sha512-pCcEwRi+TKpMlxAQObHDQ56KawURgyAf6jtIY046fJ5tIv3zDe/LEIubckAO8fj6JnAxLdmWkUfNyulQ2iKdEw==",
|
||||
"requires": {
|
||||
"gaxios": "^7.0.0",
|
||||
"gaxios": "^6.0.0",
|
||||
"jws": "^4.0.0"
|
||||
}
|
||||
},
|
||||
@@ -35923,11 +35436,11 @@
|
||||
"dev": true
|
||||
},
|
||||
"jwa": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
|
||||
"integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.0.tgz",
|
||||
"integrity": "sha512-jrZ2Qx916EA+fq9cEAeCROWPTfCwi1IVHqT2tapuqLEVVDKFDENFw1oL+MwrTvH6msKxsd1YTDVw6uKEcsrLEA==",
|
||||
"requires": {
|
||||
"buffer-equal-constant-time": "^1.0.1",
|
||||
"buffer-equal-constant-time": "1.0.1",
|
||||
"ecdsa-sig-formatter": "1.0.11",
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
@@ -41118,11 +40631,6 @@
|
||||
"integrity": "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==",
|
||||
"dev": true
|
||||
},
|
||||
"web-streams-polyfill": {
|
||||
"version": "3.3.3",
|
||||
"resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz",
|
||||
"integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="
|
||||
},
|
||||
"web-tree-sitter": {
|
||||
"version": "0.22.6",
|
||||
"resolved": "https://registry.npmjs.org/web-tree-sitter/-/web-tree-sitter-0.22.6.tgz",
|
||||
|
||||
+3
-4
@@ -2,7 +2,7 @@
|
||||
"name": "claude-dev",
|
||||
"displayName": "Cline",
|
||||
"description": "Autonomous coding agent right in your IDE, capable of creating/editing files, running commands, using the browser, and more with your permission every step of the way.",
|
||||
"version": "3.18.0",
|
||||
"version": "3.18.1",
|
||||
"icon": "assets/icons/icon.png",
|
||||
"engines": {
|
||||
"vscode": "^1.84.0"
|
||||
@@ -332,14 +332,14 @@
|
||||
"package": "npm run check-types && npm run build:webview && npm run lint && node esbuild.js --production",
|
||||
"protos": "node proto/build-proto.js && node scripts/generate-server-setup.mjs && node scripts/generate-host-bridge-client.mjs",
|
||||
"postprotos": "prettier src/shared/proto src/core/controller src/hosts/ webview-ui/src/services src/generated --write --log-level warn",
|
||||
"clean": "rimraf dist dist-standalone webview-ui/build src/generated",
|
||||
"clean": "rimraf dist dist-standalone webview-ui/build src/generated out/",
|
||||
"compile-tests": "node ./scripts/build-tests.js",
|
||||
"watch-tests": "tsc -p . -w --outDir out",
|
||||
"pretest": "npm run compile-tests && npm run compile && npm run compile-standalone && npm run lint",
|
||||
"check-types": "npm run protos && tsc --noEmit",
|
||||
"lint": "eslint src --ext ts && eslint webview-ui/src --ext ts && buf lint && cd webview-ui && npm run lint",
|
||||
"format": "prettier . --check",
|
||||
"format:fix": "prettier . --write",
|
||||
"pretest": "npm run compile && npm run compile-tests && npm run compile-standalone && npm run lint",
|
||||
"test": "npm-run-all test:unit test:integration",
|
||||
"test:ci": "node scripts/test-ci.js",
|
||||
"test:integration": "vscode-test",
|
||||
@@ -442,7 +442,6 @@
|
||||
"fzf": "^0.5.2",
|
||||
"get-folder-size": "^5.0.0",
|
||||
"globby": "^14.0.2",
|
||||
"google-auth-library": "^10.1.0",
|
||||
"grpc-health-check": "^2.0.2",
|
||||
"iconv-lite": "^0.6.3",
|
||||
"ignore": "^7.0.3",
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
// Configuration file for protocol buffer build scripts
|
||||
// Contains service name mappings used by both build-proto.js and build-go-proto.js
|
||||
|
||||
// List of gRPC services
|
||||
// To add a new service, simply add it to this map and run the build scripts
|
||||
// The service handler will be automatically discovered and used by grpc-handler.ts
|
||||
export const serviceNameMap = {
|
||||
account: "cline.AccountService",
|
||||
browser: "cline.BrowserService",
|
||||
checkpoints: "cline.CheckpointsService",
|
||||
file: "cline.FileService",
|
||||
mcp: "cline.McpService",
|
||||
state: "cline.StateService",
|
||||
task: "cline.TaskService",
|
||||
web: "cline.WebService",
|
||||
models: "cline.ModelsService",
|
||||
slash: "cline.SlashService",
|
||||
ui: "cline.UiService",
|
||||
// Add new services here - no other code changes needed!
|
||||
}
|
||||
|
||||
// List of host gRPC services (IDE API bridge)
|
||||
// These services are implemented in the IDE extension and called by the standalone Cline Core
|
||||
export const hostServiceNameMap = {
|
||||
uri: "host.UriService",
|
||||
watch: "host.WatchService",
|
||||
workspace: "host.WorkspaceService",
|
||||
env: "host.EnvService",
|
||||
// Add new host services here
|
||||
}
|
||||
+18
-45
@@ -9,16 +9,18 @@ import chalk from "chalk"
|
||||
import os from "os"
|
||||
|
||||
import { createRequire } from "module"
|
||||
import { serviceNameMap, hostServiceNameMap } from "./build-proto-config.js"
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
const PROTOC = path.join(require.resolve("grpc-tools"), "../bin/protoc")
|
||||
|
||||
const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url))
|
||||
const ROOT_DIR = path.resolve(SCRIPT_DIR, "..")
|
||||
|
||||
const TS_OUT_DIR = path.join(ROOT_DIR, "src/shared/proto")
|
||||
const GRPC_JS_OUT_DIR = path.join(ROOT_DIR, "src/generated/grpc-js")
|
||||
const NICE_JS_OUT_DIR = path.join(ROOT_DIR, "src/generated/nice-grpc")
|
||||
const DESCRIPTOR_OUT_DIR = path.join(ROOT_DIR, "dist-standalone/proto")
|
||||
const TS_OUT_DIR = path.join(ROOT_DIR, "src", "shared", "proto")
|
||||
const GRPC_JS_OUT_DIR = path.join(ROOT_DIR, "src", "generated", "grpc-js")
|
||||
const NICE_JS_OUT_DIR = path.join(ROOT_DIR, "src", "generated", "nice-grpc")
|
||||
const DESCRIPTOR_OUT_DIR = path.join(ROOT_DIR, "dist-standalone", "proto")
|
||||
|
||||
const isWindows = process.platform === "win32"
|
||||
const TS_PROTO_PLUGIN = isWindows
|
||||
@@ -34,34 +36,13 @@ const TS_PROTO_OPTIONS = [
|
||||
"useDate=false", // Timestamp fields will not be automatically converted to Date.
|
||||
]
|
||||
|
||||
// List of gRPC services
|
||||
// To add a new service, simply add it to this map and run this script
|
||||
// The service handler will be automatically discovered and used by grpc-handler.ts
|
||||
const serviceNameMap = {
|
||||
account: "cline.AccountService",
|
||||
browser: "cline.BrowserService",
|
||||
checkpoints: "cline.CheckpointsService",
|
||||
file: "cline.FileService",
|
||||
mcp: "cline.McpService",
|
||||
state: "cline.StateService",
|
||||
task: "cline.TaskService",
|
||||
web: "cline.WebService",
|
||||
models: "cline.ModelsService",
|
||||
slash: "cline.SlashService",
|
||||
ui: "cline.UiService",
|
||||
// Add new services here - no other code changes needed!
|
||||
}
|
||||
const serviceDirs = Object.keys(serviceNameMap).map((serviceKey) => path.join(ROOT_DIR, "src/core/controller", serviceKey))
|
||||
// Service directories derived from imported serviceNameMap
|
||||
const serviceDirs = Object.keys(serviceNameMap).map((serviceKey) => path.join(ROOT_DIR, "src", "core", "controller", serviceKey))
|
||||
|
||||
// List of host gRPC services (IDE API bridge)
|
||||
// These services are implemented in the IDE extension and called by the standalone Cline Core
|
||||
const hostServiceNameMap = {
|
||||
uri: "host.UriService",
|
||||
watch: "host.WatchService",
|
||||
workspace: "host.WorkspaceService",
|
||||
// Add new host services here
|
||||
}
|
||||
const hostServiceDirs = Object.keys(hostServiceNameMap).map((serviceKey) => path.join(ROOT_DIR, "src/hosts/vscode", serviceKey))
|
||||
// Host service directories derived from imported hostServiceNameMap
|
||||
const hostServiceDirs = Object.keys(hostServiceNameMap).map((serviceKey) =>
|
||||
path.join(ROOT_DIR, "src", "hosts", "vscode", serviceKey),
|
||||
)
|
||||
|
||||
async function main() {
|
||||
console.log(chalk.bold.blue("Starting Protocol Buffer code generation..."))
|
||||
@@ -177,7 +158,7 @@ export {
|
||||
${serviceExports.join(",\n\t")}
|
||||
}`
|
||||
|
||||
const filePath = path.join(ROOT_DIR, "webview-ui/src/services/grpc-client.ts")
|
||||
const filePath = path.join(ROOT_DIR, "webview-ui", "src", "services", "grpc-client.ts")
|
||||
await writeFileWithMkdirs(filePath, content)
|
||||
log_verbose(chalk.green(`Generated gRPC client at ${filePath}`))
|
||||
}
|
||||
@@ -386,7 +367,7 @@ export interface ServiceHandlerConfig {
|
||||
export const serviceHandlers: Record<string, ServiceHandlerConfig> = {${serviceConfigs.join(",")}
|
||||
};`
|
||||
|
||||
const configPath = path.join(ROOT_DIR, "src/core/controller/grpc-service-config.ts")
|
||||
const configPath = path.join(ROOT_DIR, "src", "core", "controller", "grpc-service-config.ts")
|
||||
await writeFileWithMkdirs(configPath, content)
|
||||
log_verbose(chalk.green(`Generated service configuration at ${configPath}`))
|
||||
}
|
||||
@@ -602,13 +583,12 @@ async function cleanup() {
|
||||
for (const file of existingFiles) {
|
||||
await fs.unlink(path.join(TS_OUT_DIR, file))
|
||||
}
|
||||
await rmdir(path.join(ROOT_DIR, "src/generated"))
|
||||
await rmdir(path.join(ROOT_DIR, "src", "generated"))
|
||||
|
||||
// Clean up generated files that were moved.
|
||||
await fs.rm(path.join(ROOT_DIR, "src/standalone/services/host-grpc-client.ts"), { force: true })
|
||||
await rmdir(path.join(ROOT_DIR, "src/standalone/services"))
|
||||
|
||||
await fs.rm(path.join(ROOT_DIR, "hosts/vscode"), { force: true, recursive: true })
|
||||
await fs.rm(path.join(ROOT_DIR, "src", "standalone", "services", "host-grpc-client.ts"), { force: true })
|
||||
await rmdir(path.join(ROOT_DIR, "src", "standalone", "services"))
|
||||
await fs.rm(path.join(ROOT_DIR, "hosts", "vscode"), { force: true, recursive: true })
|
||||
await rmdir(path.join(ROOT_DIR, "hosts"))
|
||||
|
||||
await fs.rm(path.join(ROOT_DIR, "src/standalone/server-setup.ts"), { force: true })
|
||||
@@ -636,13 +616,6 @@ async function rmdir(path) {
|
||||
}
|
||||
}
|
||||
|
||||
function serviceNameWithoutPackage(fullServiceName) {
|
||||
return fullServiceName.replace(/.*\./, "")
|
||||
}
|
||||
function lowercaseFirstChar(str) {
|
||||
return str.charAt(0).toLowerCase() + str.slice(1)
|
||||
}
|
||||
|
||||
// Check for Apple Silicon compatibility
|
||||
function checkAppleSiliconCompatibility() {
|
||||
// Only run check on macOS
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package host;
|
||||
option java_package = "bot.cline.host.proto";
|
||||
option java_multiple_files = true;
|
||||
|
||||
import "common.proto";
|
||||
|
||||
// Provides methods for working with the user's environment.
|
||||
service EnvService {
|
||||
// Writes text to the system clipboard.
|
||||
rpc clipboardWriteText(cline.StringRequest) returns (cline.Empty);
|
||||
|
||||
// Reads text from the system clipboard.
|
||||
rpc clipboardReadText(cline.EmptyRequest) returns (cline.String);
|
||||
}
|
||||
+19
-22
@@ -105,25 +105,24 @@ enum ApiProvider {
|
||||
OLLAMA = 5;
|
||||
LMSTUDIO = 6;
|
||||
GEMINI = 7;
|
||||
GEMINI_CLI = 8;
|
||||
OPENAI_NATIVE = 9;
|
||||
REQUESTY = 10;
|
||||
TOGETHER = 11;
|
||||
DEEPSEEK = 12;
|
||||
QWEN = 13;
|
||||
DOUBAO = 14;
|
||||
MISTRAL = 15;
|
||||
VSCODE_LM = 16;
|
||||
CLINE = 17;
|
||||
LITELLM = 18;
|
||||
NEBIUS = 19;
|
||||
FIREWORKS = 20;
|
||||
ASKSAGE = 21;
|
||||
XAI = 22;
|
||||
SAMBANOVA = 23;
|
||||
CEREBRAS = 24;
|
||||
SAPAICORE = 25;
|
||||
CLAUDE_CODE = 26;
|
||||
OPENAI_NATIVE = 8;
|
||||
REQUESTY = 9;
|
||||
TOGETHER = 10;
|
||||
DEEPSEEK = 11;
|
||||
QWEN = 12;
|
||||
DOUBAO = 13;
|
||||
MISTRAL = 14;
|
||||
VSCODE_LM = 15;
|
||||
CLINE = 16;
|
||||
LITELLM = 17;
|
||||
NEBIUS = 18;
|
||||
FIREWORKS = 19;
|
||||
ASKSAGE = 20;
|
||||
XAI = 21;
|
||||
SAMBANOVA = 22;
|
||||
CEREBRAS = 23;
|
||||
SAPAICORE = 24;
|
||||
CLAUDE_CODE = 25;
|
||||
}
|
||||
|
||||
// Model info for OpenAI-compatible models
|
||||
@@ -237,6 +236,4 @@ message ModelsApiConfiguration {
|
||||
optional string sap_ai_core_token_url = 71;
|
||||
optional string sap_ai_core_base_url = 72;
|
||||
optional string claude_code_path = 73;
|
||||
optional string gemini_cli_oauth_path = 74;
|
||||
optional string gemini_cli_project_id = 75;
|
||||
}
|
||||
}
|
||||
+1
-9
@@ -23,8 +23,6 @@ service TaskService {
|
||||
rpc exportTaskWithId(StringRequest) returns (Empty);
|
||||
// Toggles the favorite status of a task
|
||||
rpc toggleTaskFavorite(TaskFavoriteRequest) returns (Empty);
|
||||
// Deletes all non-favorited tasks
|
||||
rpc deleteNonFavoritedTasks(EmptyRequest) returns (DeleteNonFavoritedTasksResults);
|
||||
// Gets filtered task history
|
||||
rpc getTaskHistory(GetTaskHistoryRequest) returns (TaskHistoryArray);
|
||||
// Sends a response to a previous ask operation
|
||||
@@ -36,7 +34,7 @@ service TaskService {
|
||||
// Executes a quick win task with command and title
|
||||
rpc executeQuickWin(ExecuteQuickWinRequest) returns (Empty);
|
||||
// Deletes all task history
|
||||
rpc deleteAllTaskHistory(BooleanRequest) returns (DeleteAllTaskHistoryCount);
|
||||
rpc deleteAllTaskHistory(EmptyRequest) returns (DeleteAllTaskHistoryCount);
|
||||
}
|
||||
|
||||
// Request message for creating a new task
|
||||
@@ -68,12 +66,6 @@ message TaskResponse {
|
||||
int32 cache_reads = 10;
|
||||
}
|
||||
|
||||
// Results returned when deleting non-favorited tasks
|
||||
message DeleteNonFavoritedTasksResults {
|
||||
int32 tasks_preserved = 1;
|
||||
int32 tasks_deleted = 2;
|
||||
}
|
||||
|
||||
// Request for getting task history with filtering
|
||||
message GetTaskHistoryRequest {
|
||||
Metadata metadata = 1;
|
||||
|
||||
@@ -8,7 +8,6 @@ import { OpenAiHandler } from "./providers/openai"
|
||||
import { OllamaHandler } from "./providers/ollama"
|
||||
import { LmStudioHandler } from "./providers/lmstudio"
|
||||
import { GeminiHandler } from "./providers/gemini"
|
||||
import { GeminiCliHandler } from "./providers/gemini-cli"
|
||||
import { OpenAiNativeHandler } from "./providers/openai-native"
|
||||
import { ApiStream, ApiStreamUsageChunk } from "./transform/stream"
|
||||
import { DeepSeekHandler } from "./providers/deepseek"
|
||||
@@ -57,8 +56,6 @@ function createHandlerForProvider(apiProvider: string | undefined, options: any)
|
||||
return new LmStudioHandler(options)
|
||||
case "gemini":
|
||||
return new GeminiHandler(options)
|
||||
case "gemini-cli":
|
||||
return new GeminiCliHandler(options)
|
||||
case "openai-native":
|
||||
return new OpenAiNativeHandler(options)
|
||||
case "deepseek":
|
||||
|
||||
@@ -1,418 +0,0 @@
|
||||
/**
|
||||
* Gemini CLI Provider - OAuth-based API Handler
|
||||
*
|
||||
* This implementation provides access to Google's Gemini models through OAuth authentication,
|
||||
* leveraging the same authentication mechanism as the official Gemini CLI tool.
|
||||
*
|
||||
* Attribution: This implementation is inspired by and uses concepts from the Google Gemini CLI,
|
||||
* which is licensed under the Apache License 2.0.
|
||||
* Original project: https://github.com/google-gemini/gemini-cli
|
||||
*
|
||||
* Copyright 2025 Google LLC
|
||||
* Licensed under the Apache License, Version 2.0
|
||||
*
|
||||
* Key features:
|
||||
* - OAuth2 authentication (no API keys required)
|
||||
* - Auto-discovery of Google Cloud project IDs
|
||||
* - Real-time streaming via Server-Sent Events
|
||||
* - Free tier access through Google's Code Assist API
|
||||
* - Compatible with personal Google accounts only
|
||||
*/
|
||||
|
||||
import type { Anthropic } from "@anthropic-ai/sdk"
|
||||
import { OAuth2Client } from "google-auth-library"
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import os from "os"
|
||||
import * as readline from "readline"
|
||||
import { Readable } from "stream"
|
||||
import { ApiHandler } from "../"
|
||||
import { ApiHandlerOptions, GeminiCliModelId, geminiCliModels, ModelInfo, geminiCliDefaultModelId } from "@shared/api"
|
||||
import { convertAnthropicMessageToGemini } from "../transform/gemini-format"
|
||||
import { ApiStream } from "../transform/stream"
|
||||
import { withRetry } from "../retry"
|
||||
|
||||
const CODE_ASSIST_ENDPOINT = "https://cloudcode-pa.googleapis.com"
|
||||
const CODE_ASSIST_API_VERSION = "v1internal"
|
||||
|
||||
// OAuth configuration
|
||||
const OAUTH_CLIENT_ID = "681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com"
|
||||
// Change this line in setup.js:
|
||||
const OAUTH_CLIENT_SECRET = "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"
|
||||
|
||||
const OAUTH_REDIRECT_URI = "http://localhost:45289"
|
||||
|
||||
interface OAuthCredentials {
|
||||
access_token: string
|
||||
refresh_token: string
|
||||
scope: string
|
||||
token_type: string
|
||||
expiry_date: number
|
||||
}
|
||||
|
||||
interface GeminiCliHandlerOptions extends ApiHandlerOptions {
|
||||
geminiCliOAuthPath?: string
|
||||
geminiCliProjectId?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Handler for Google's Gemini API via OAuth (Gemini CLI style).
|
||||
*
|
||||
* This provider uses OAuth authentication instead of API keys, making it suitable
|
||||
* for users who have already authenticated with the Gemini CLI tool.
|
||||
* It automatically discovers project IDs and works with the free tier.
|
||||
*/
|
||||
export class GeminiCliHandler implements ApiHandler {
|
||||
private options: GeminiCliHandlerOptions
|
||||
private authClient: OAuth2Client
|
||||
private projectId: string | null = null
|
||||
private authInitialized: boolean = false
|
||||
|
||||
constructor(options: GeminiCliHandlerOptions) {
|
||||
this.options = options
|
||||
this.authClient = new OAuth2Client(OAUTH_CLIENT_ID, OAUTH_CLIENT_SECRET, OAUTH_REDIRECT_URI)
|
||||
}
|
||||
|
||||
/**
|
||||
* Load OAuth credentials from the file system
|
||||
*/
|
||||
private async loadOAuthCredentials(): Promise<OAuthCredentials> {
|
||||
const credPath = this.options.geminiCliOAuthPath || path.join(os.homedir(), ".gemini", "oauth_creds.json")
|
||||
try {
|
||||
const data = await fs.readFile(credPath, "utf8")
|
||||
return JSON.parse(data)
|
||||
} catch (err) {
|
||||
throw new Error(`Failed to load OAuth credentials from ${credPath}. Please authenticate with 'gemini auth' first.`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Call a Code Assist API endpoint
|
||||
*/
|
||||
private async callEndpoint(method: string, body: any, retryAuth: boolean = true): Promise<any> {
|
||||
console.log(`[GeminiCLI] Calling endpoint: ${method}`)
|
||||
console.log(`[GeminiCLI] Request body:`, JSON.stringify(body, null, 2))
|
||||
|
||||
try {
|
||||
const res = await this.authClient.request({
|
||||
url: `${CODE_ASSIST_ENDPOINT}/${CODE_ASSIST_API_VERSION}:${method}`,
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
responseType: "json",
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
console.log(`[GeminiCLI] Response status:`, res.status)
|
||||
console.log(`[GeminiCLI] Response data:`, JSON.stringify(res.data, null, 2))
|
||||
return res.data
|
||||
} catch (error: any) {
|
||||
console.error(`[GeminiCLI] Error calling ${method}:`, error)
|
||||
console.error(`[GeminiCLI] Error response:`, error.response?.data)
|
||||
console.error(`[GeminiCLI] Error status:`, error.response?.status)
|
||||
console.error(`[GeminiCLI] Error message:`, error.message)
|
||||
|
||||
// If we get a 401 and haven't retried yet, try refreshing auth
|
||||
if (error.response?.status === 401 && retryAuth) {
|
||||
console.log(`[GeminiCLI] Got 401, attempting to refresh authentication...`)
|
||||
await this.initializeAuth(true) // Force refresh
|
||||
return this.callEndpoint(method, body, false) // Retry without further auth retries
|
||||
}
|
||||
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover or retrieve the project ID
|
||||
*/
|
||||
private async discoverProjectId(): Promise<string> {
|
||||
// If we already have a project ID, use it
|
||||
if (this.options.geminiCliProjectId) {
|
||||
return this.options.geminiCliProjectId
|
||||
}
|
||||
|
||||
// If we've already discovered it, return it
|
||||
if (this.projectId) {
|
||||
return this.projectId
|
||||
}
|
||||
|
||||
// Start with a default project ID (can be anything for personal OAuth)
|
||||
const initialProjectId = "default"
|
||||
|
||||
// Prepare client metadata
|
||||
const clientMetadata = {
|
||||
ideType: "IDE_UNSPECIFIED",
|
||||
platform: "PLATFORM_UNSPECIFIED",
|
||||
pluginType: "GEMINI",
|
||||
duetProject: initialProjectId,
|
||||
}
|
||||
|
||||
try {
|
||||
// Call loadCodeAssist to discover the actual project ID
|
||||
const loadRequest = {
|
||||
cloudaicompanionProject: initialProjectId,
|
||||
metadata: clientMetadata,
|
||||
}
|
||||
|
||||
const loadResponse = await this.callEndpoint("loadCodeAssist", loadRequest)
|
||||
|
||||
// Check if we already have a project ID from the response
|
||||
if (loadResponse.cloudaicompanionProject) {
|
||||
this.projectId = loadResponse.cloudaicompanionProject
|
||||
return this.projectId as string
|
||||
}
|
||||
|
||||
// If no existing project, we need to onboard
|
||||
const defaultTier = loadResponse.allowedTiers?.find((tier: any) => tier.isDefault)
|
||||
const tierId = defaultTier?.id || "free-tier"
|
||||
|
||||
const onboardRequest = {
|
||||
tierId: tierId,
|
||||
cloudaicompanionProject: initialProjectId,
|
||||
metadata: clientMetadata,
|
||||
}
|
||||
|
||||
let lroResponse = await this.callEndpoint("onboardUser", onboardRequest)
|
||||
|
||||
// Poll until operation is complete
|
||||
while (!lroResponse.done) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000))
|
||||
lroResponse = await this.callEndpoint("onboardUser", onboardRequest)
|
||||
}
|
||||
|
||||
const discoveredProjectId = lroResponse.response?.cloudaicompanionProject?.id || initialProjectId
|
||||
this.projectId = discoveredProjectId
|
||||
return this.projectId as string
|
||||
} catch (error: any) {
|
||||
console.error("Failed to discover project ID:", error.response?.data || error.message)
|
||||
throw new Error("Could not discover project ID. Make sure you're authenticated with 'gemini auth'.")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the OAuth client with credentials
|
||||
*/
|
||||
private async initializeAuth(forceRefresh: boolean = false): Promise<void> {
|
||||
// Check if we need to initialize or refresh
|
||||
if (this.authInitialized && !forceRefresh) {
|
||||
// Check if token is still valid
|
||||
const credentials = this.authClient.credentials
|
||||
if (credentials && credentials.expiry_date && Date.now() < credentials.expiry_date) {
|
||||
console.log(`[GeminiCLI] Auth already initialized and token still valid`)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[GeminiCLI] Initializing OAuth authentication...`)
|
||||
const credentials = await this.loadOAuthCredentials()
|
||||
const isExpired = credentials.expiry_date ? Date.now() > credentials.expiry_date : false
|
||||
|
||||
console.log(`[GeminiCLI] Loaded credentials:`, {
|
||||
hasAccessToken: !!credentials.access_token,
|
||||
hasRefreshToken: !!credentials.refresh_token,
|
||||
tokenType: credentials.token_type,
|
||||
expiryDate: credentials.expiry_date,
|
||||
isExpired: isExpired,
|
||||
})
|
||||
|
||||
this.authClient.setCredentials(credentials)
|
||||
|
||||
// If token is expired and we have a refresh token, try to refresh
|
||||
if (isExpired && credentials.refresh_token) {
|
||||
console.log(`[GeminiCLI] Token expired, attempting to refresh...`)
|
||||
try {
|
||||
const { credentials: newCredentials } = await this.authClient.refreshAccessToken()
|
||||
console.log(`[GeminiCLI] Token refreshed successfully`)
|
||||
// Note: In a real implementation, you'd want to save the new credentials back to the file
|
||||
// For now, we'll just use them in memory
|
||||
} catch (error) {
|
||||
console.error(`[GeminiCLI] Failed to refresh token:`, error)
|
||||
// Continue with the expired token - the API might still accept it
|
||||
}
|
||||
}
|
||||
|
||||
this.authInitialized = true
|
||||
console.log(`[GeminiCLI] OAuth client configured`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse Server-Sent Events from a stream
|
||||
*/
|
||||
private async *parseSSEStream(stream: Readable): AsyncGenerator<any> {
|
||||
const rl = readline.createInterface({
|
||||
input: stream,
|
||||
crlfDelay: Infinity,
|
||||
})
|
||||
|
||||
let bufferedLines: string[] = []
|
||||
|
||||
for await (const line of rl) {
|
||||
// Blank lines separate JSON objects in the stream
|
||||
if (line === "") {
|
||||
if (bufferedLines.length === 0) {
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
const jsonData = JSON.parse(bufferedLines.join("\n"))
|
||||
yield jsonData
|
||||
} catch (parseError) {
|
||||
console.error("Error parsing JSON chunk:", parseError)
|
||||
}
|
||||
|
||||
bufferedLines = []
|
||||
} else if (line.startsWith("data: ")) {
|
||||
bufferedLines.push(line.slice(6).trim())
|
||||
}
|
||||
}
|
||||
|
||||
// Process any remaining buffered content
|
||||
if (bufferedLines.length > 0) {
|
||||
try {
|
||||
const jsonData = JSON.parse(bufferedLines.join("\n"))
|
||||
yield jsonData
|
||||
} catch (parseError) {
|
||||
console.error("Error parsing final buffered content:", parseError)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a message using the Gemini CLI OAuth API
|
||||
*/
|
||||
@withRetry({
|
||||
maxRetries: 2,
|
||||
baseDelay: 2000,
|
||||
maxDelay: 10000,
|
||||
})
|
||||
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
|
||||
// Initialize auth if not already done
|
||||
await this.initializeAuth()
|
||||
// Discover project ID if needed
|
||||
const projectId = await this.discoverProjectId()
|
||||
|
||||
// Convert messages to Gemini format
|
||||
const contents = messages.map(convertAnthropicMessageToGemini)
|
||||
|
||||
// Get the selected model
|
||||
const { id: modelId, info: modelInfo } = this.getModel()
|
||||
|
||||
// Build the request
|
||||
const streamRequest = {
|
||||
model: modelId,
|
||||
project: projectId,
|
||||
request: {
|
||||
contents: [
|
||||
{
|
||||
role: "user",
|
||||
parts: [{ text: systemPrompt }],
|
||||
},
|
||||
...contents,
|
||||
],
|
||||
generationConfig: {
|
||||
temperature: 0.7,
|
||||
maxOutputTokens: modelInfo.maxTokens || 8192,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
let totalContent = ""
|
||||
let promptTokens = 0
|
||||
let outputTokens = 0
|
||||
let lastUsageMetadata: any = null
|
||||
|
||||
try {
|
||||
// Make the streaming request
|
||||
const response = await this.authClient.request({
|
||||
url: `${CODE_ASSIST_ENDPOINT}/${CODE_ASSIST_API_VERSION}:streamGenerateContent`,
|
||||
method: "POST",
|
||||
params: { alt: "sse" },
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
responseType: "stream",
|
||||
body: JSON.stringify(streamRequest),
|
||||
})
|
||||
|
||||
// Process the SSE stream
|
||||
for await (const jsonData of this.parseSSEStream(response.data as Readable)) {
|
||||
// Extract content from the response
|
||||
const candidate = jsonData.response?.candidates?.[0]
|
||||
if (candidate?.content?.parts?.[0]?.text) {
|
||||
const content = candidate.content.parts[0].text
|
||||
totalContent += content
|
||||
|
||||
// Yield text chunk
|
||||
yield {
|
||||
type: "text",
|
||||
text: content,
|
||||
}
|
||||
}
|
||||
|
||||
// Store usage metadata for final reporting
|
||||
if (jsonData.response?.usageMetadata) {
|
||||
lastUsageMetadata = jsonData.response.usageMetadata
|
||||
promptTokens = lastUsageMetadata.promptTokenCount || promptTokens
|
||||
outputTokens = lastUsageMetadata.candidatesTokenCount || outputTokens
|
||||
}
|
||||
|
||||
// Check if this is the final chunk
|
||||
if (candidate?.finishReason) {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Yield usage information
|
||||
if (lastUsageMetadata) {
|
||||
yield {
|
||||
type: "usage",
|
||||
inputTokens: promptTokens,
|
||||
outputTokens: outputTokens,
|
||||
totalCost: 0, // Free tier
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Handle rate limit errors similar to the Gemini provider
|
||||
if (error instanceof Error) {
|
||||
// Check for rate limit patterns in the error message
|
||||
const rateLimitPatterns = [
|
||||
/got status: 429/i,
|
||||
/429 Too Many Requests/i,
|
||||
/rate limit exceeded/i,
|
||||
/too many requests/i,
|
||||
/quota exceeded/i,
|
||||
/resource exhausted/i,
|
||||
/code 429/i,
|
||||
]
|
||||
|
||||
const isRateLimit = rateLimitPatterns.some((pattern) => pattern.test(error.message))
|
||||
|
||||
if (isRateLimit) {
|
||||
const rateLimitError = Object.assign(new Error(error.message), {
|
||||
...error,
|
||||
status: 429,
|
||||
})
|
||||
throw rateLimitError
|
||||
}
|
||||
}
|
||||
|
||||
// Re-throw the original error if it's not a rate limit error
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the model ID and info
|
||||
*/
|
||||
getModel(): { id: GeminiCliModelId; info: ModelInfo } {
|
||||
const modelId = this.options.apiModelId as GeminiCliModelId
|
||||
if (modelId && modelId in geminiCliModels) {
|
||||
return { id: modelId, info: geminiCliModels[modelId] }
|
||||
}
|
||||
return {
|
||||
id: geminiCliDefaultModelId,
|
||||
info: geminiCliModels[geminiCliDefaultModelId],
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -122,6 +122,8 @@ export class SapAiCoreHandler implements ApiHandler {
|
||||
const deploymentId = await this.getDeploymentForModel(model.id)
|
||||
|
||||
const anthropicModels = [
|
||||
"anthropic--claude-4-sonnet",
|
||||
"anthropic--claude-4-opus",
|
||||
"anthropic--claude-3.7-sonnet",
|
||||
"anthropic--claude-3.5-sonnet",
|
||||
"anthropic--claude-3-sonnet",
|
||||
@@ -136,7 +138,11 @@ export class SapAiCoreHandler implements ApiHandler {
|
||||
if (anthropicModels.includes(model.id)) {
|
||||
url = `${this.options.sapAiCoreBaseUrl}/v2/inference/deployments/${deploymentId}/invoke-with-response-stream`
|
||||
|
||||
if (model.id === "anthropic--claude-3.7-sonnet") {
|
||||
if (
|
||||
model.id === "anthropic--claude-4-sonnet" ||
|
||||
model.id === "anthropic--claude-4-opus" ||
|
||||
model.id === "anthropic--claude-3.7-sonnet"
|
||||
) {
|
||||
url = `${this.options.sapAiCoreBaseUrl}/v2/inference/deployments/${deploymentId}/converse-stream`
|
||||
payload = {
|
||||
inferenceConfig: {
|
||||
@@ -221,7 +227,11 @@ export class SapAiCoreHandler implements ApiHandler {
|
||||
}
|
||||
} else if (openAIModels.includes(model.id)) {
|
||||
yield* this.streamCompletionGPT(response.data, model)
|
||||
} else if (model.id === "anthropic--claude-3.7-sonnet") {
|
||||
} else if (
|
||||
model.id === "anthropic--claude-4-sonnet" ||
|
||||
model.id === "anthropic--claude-4-opus" ||
|
||||
model.id === "anthropic--claude-3.7-sonnet"
|
||||
) {
|
||||
yield* this.streamCompletionSonnet37(response.data, model)
|
||||
} else {
|
||||
yield* this.streamCompletion(response.data, model)
|
||||
|
||||
@@ -156,6 +156,45 @@ replaced
|
||||
expected: "line2\nreplaced\nline4",
|
||||
isFinal: true,
|
||||
},
|
||||
{
|
||||
name: "malformed diff - missing separator",
|
||||
original: "line1\nline2\nline3",
|
||||
diff: `------- SEARCH
|
||||
line2
|
||||
+++++++ REPLACE
|
||||
replaced`,
|
||||
shouldThrow: true,
|
||||
},
|
||||
{
|
||||
name: "malformed diff - trailing space on separator",
|
||||
original: "line1\nline2\nline3",
|
||||
diff: `------- SEARCH
|
||||
line2
|
||||
=======
|
||||
replaced
|
||||
+++++++ REPLACE`,
|
||||
shouldThrow: true,
|
||||
},
|
||||
{
|
||||
name: "malformed diff - double replace markers",
|
||||
original: "line1\nline2\nline3",
|
||||
diff: `------- SEARCH
|
||||
line2
|
||||
+++++++ REPLACE
|
||||
first replacement
|
||||
+++++++ REPLACE`,
|
||||
shouldThrow: true,
|
||||
},
|
||||
{
|
||||
name: "malformed diff - malformed separator with dashes",
|
||||
original: "line1\nline2\nline3",
|
||||
diff: `------- SEARCH
|
||||
line2
|
||||
------- =======
|
||||
replaced
|
||||
+++++++ REPLACE`,
|
||||
shouldThrow: true,
|
||||
},
|
||||
]
|
||||
//.filter(({name}) => name === "multiple ordered replacements")
|
||||
//.filter(({name}) => name === "delete then replace")
|
||||
|
||||
@@ -380,6 +380,10 @@ async function constructNewFileContentV1(diffContent: string, originalContent: s
|
||||
if (isReplaceBlockEnd(line)) {
|
||||
// Finished one replace block
|
||||
|
||||
if (searchMatchIndex === -1) {
|
||||
throw new Error(`The SEARCH block:\n${currentSearchContent.trimEnd()}\n...is malformatted.`)
|
||||
}
|
||||
|
||||
// Store this replacement
|
||||
replacements.push({
|
||||
start: searchMatchIndex,
|
||||
|
||||
@@ -6,6 +6,9 @@ import * as path from "path"
|
||||
import { FileContextTracker } from "./FileContextTracker"
|
||||
import * as diskModule from "@core/storage/disk"
|
||||
import type { TaskMetadata, FileMetadataEntry } from "./ContextTrackerTypes"
|
||||
import type { WebviewProviderCreator } from "@/hosts/host-providers"
|
||||
import * as hostProviders from "@hosts/host-providers"
|
||||
import { vscodeHostBridgeClient } from "@/hosts/vscode/client/host-grpc-client"
|
||||
|
||||
describe("FileContextTracker", () => {
|
||||
let sandbox: sinon.SinonSandbox
|
||||
@@ -37,7 +40,6 @@ describe("FileContextTracker", () => {
|
||||
}
|
||||
|
||||
// Use a function replacement instead of a direct stub
|
||||
const originalCreateFileSystemWatcher = vscode.workspace.createFileSystemWatcher
|
||||
vscode.workspace.createFileSystemWatcher = function () {
|
||||
return mockFileSystemWatcher
|
||||
}
|
||||
@@ -51,6 +53,7 @@ describe("FileContextTracker", () => {
|
||||
mockTaskMetadata = { files_in_context: [], model_usage: [] }
|
||||
getTaskMetadataStub = sandbox.stub(diskModule, "getTaskMetadata").resolves(mockTaskMetadata)
|
||||
saveTaskMetadataStub = sandbox.stub(diskModule, "saveTaskMetadata").resolves()
|
||||
hostProviders.initializeHostProviders(((_) => {}) as WebviewProviderCreator, vscodeHostBridgeClient)
|
||||
|
||||
// Create tracker instance
|
||||
taskId = "test-task-id"
|
||||
|
||||
@@ -5,6 +5,8 @@ import { getWorkspaceState, updateWorkspaceState } from "@core/storage/state"
|
||||
import { getGlobalState } from "@core/storage/state"
|
||||
import type { FileMetadataEntry } from "./ContextTrackerTypes"
|
||||
import type { ClineMessage } from "@shared/ExtensionMessage"
|
||||
import { getHostBridgeProvider } from "@/hosts/host-providers"
|
||||
import { getCwd } from "@/utils/path"
|
||||
|
||||
// This class is responsible for tracking file operations that may result in stale context.
|
||||
// If a user modifies a file outside of Cline, the context may become stale and need to be updated.
|
||||
@@ -37,8 +39,8 @@ export class FileContextTracker {
|
||||
/**
|
||||
* Gets the current working directory or returns undefined if it cannot be determined
|
||||
*/
|
||||
private getCwd(): string | undefined {
|
||||
const cwd = vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath).at(0)
|
||||
private async getCwd(): Promise<string | undefined> {
|
||||
const cwd = await getCwd(undefined)
|
||||
if (!cwd) {
|
||||
console.info("No workspace folder available - cannot determine current working directory")
|
||||
}
|
||||
@@ -54,7 +56,7 @@ export class FileContextTracker {
|
||||
return
|
||||
}
|
||||
|
||||
const cwd = this.getCwd()
|
||||
const cwd = await this.getCwd()
|
||||
if (!cwd) {
|
||||
return
|
||||
}
|
||||
@@ -85,7 +87,7 @@ export class FileContextTracker {
|
||||
*/
|
||||
async trackFileContext(filePath: string, operation: "read_tool" | "user_edited" | "cline_edited" | "file_mentioned") {
|
||||
try {
|
||||
const cwd = this.getCwd()
|
||||
const cwd = await this.getCwd()
|
||||
if (!cwd) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import * as vscode from "vscode"
|
||||
import { Controller } from ".."
|
||||
import { Empty, StringRequest } from "../../../shared/proto/common"
|
||||
import { writeTextToClipboard } from "@/utils/env"
|
||||
|
||||
/**
|
||||
* Copies text to the system clipboard
|
||||
@@ -11,7 +12,7 @@ import { Empty, StringRequest } from "../../../shared/proto/common"
|
||||
export async function copyToClipboard(controller: Controller, request: StringRequest): Promise<Empty> {
|
||||
try {
|
||||
if (request.value) {
|
||||
await vscode.env.clipboard.writeText(request.value)
|
||||
await writeTextToClipboard(request.value)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error copying to clipboard:", error)
|
||||
|
||||
@@ -8,7 +8,7 @@ import { openMention as coreOpenMention } from "../../mentions"
|
||||
* @param request The string request containing the mention text
|
||||
* @returns Empty response
|
||||
*/
|
||||
export async function openMention(controller: Controller, request: StringRequest): Promise<Empty> {
|
||||
export async function openMention(_controller: Controller, request: StringRequest): Promise<Empty> {
|
||||
coreOpenMention(request.value)
|
||||
return Empty.create()
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ import { convertGitCommitsToProtoGitCommits } from "@shared/proto-conversions/fi
|
||||
* @returns GitCommits containing the matching commits
|
||||
*/
|
||||
export const searchCommits: FileMethodHandler = async (controller: Controller, request: StringRequest): Promise<GitCommits> => {
|
||||
const cwd = getWorkspacePath()
|
||||
const cwd = await getWorkspacePath()
|
||||
if (!cwd) {
|
||||
return GitCommits.create({ commits: [] })
|
||||
}
|
||||
|
||||
@@ -12,10 +12,10 @@ import { convertSearchResultsToProtoFileInfos } from "@shared/proto-conversions/
|
||||
* @returns Results containing matching files/folders
|
||||
*/
|
||||
export const searchFiles: FileMethodHandler = async (
|
||||
controller: Controller,
|
||||
_controller: Controller,
|
||||
request: FileSearchRequest,
|
||||
): Promise<FileSearchResults> => {
|
||||
const workspacePath = getWorkspacePath()
|
||||
const workspacePath = await getWorkspacePath()
|
||||
|
||||
if (!workspacePath) {
|
||||
// Handle case where workspace path is not available
|
||||
|
||||
@@ -37,6 +37,7 @@ export class ServiceRegistry {
|
||||
* @param serviceName The name of the service (used for logging)
|
||||
*/
|
||||
constructor(serviceName: string) {
|
||||
console.log(`Registering Protobus service: ${serviceName}...`)
|
||||
this.serviceName = serviceName
|
||||
}
|
||||
|
||||
@@ -56,7 +57,6 @@ export class ServiceRegistry {
|
||||
}
|
||||
|
||||
this.methodMetadata[methodName] = { isStreaming, ...metadata }
|
||||
console.log(`Registered ${this.serviceName} method: ${methodName}${isStreaming ? " (streaming)" : ""}`)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,31 +1,31 @@
|
||||
import { telemetryService } from "@/services/posthog/telemetry/TelemetryService"
|
||||
import { getCwd } from "@/utils/path"
|
||||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
import { buildApiHandler } from "@api/index"
|
||||
import { cleanupLegacyCheckpoints } from "@integrations/checkpoints/CheckpointMigration"
|
||||
import { extractCommitMessage } from "@integrations/git/commit-message-generator"
|
||||
import { downloadTask } from "@integrations/misc/export-markdown"
|
||||
import WorkspaceTracker from "@integrations/workspace/WorkspaceTracker"
|
||||
import { ClineAccountService } from "@services/account/ClineAccountService"
|
||||
import { McpHub } from "@services/mcp/McpHub"
|
||||
import { ApiProvider, ModelInfo } from "@shared/api"
|
||||
import { ChatContent } from "@shared/ChatContent"
|
||||
import { ChatSettings, StoredChatSettings } from "@shared/ChatSettings"
|
||||
import { ClineRulesToggles } from "@shared/cline-rules"
|
||||
import { ExtensionMessage, ExtensionState, Platform } from "@shared/ExtensionMessage"
|
||||
import { HistoryItem } from "@shared/HistoryItem"
|
||||
import { McpMarketplaceCatalog } from "@shared/mcp"
|
||||
import { TelemetrySetting } from "@shared/TelemetrySetting"
|
||||
import { UserInfo } from "@shared/UserInfo"
|
||||
import { WebviewMessage } from "@shared/WebviewMessage"
|
||||
import { fileExistsAtPath } from "@utils/fs"
|
||||
import { getWorkingState } from "@utils/git"
|
||||
import axios from "axios"
|
||||
import { v4 as uuidv4 } from "uuid"
|
||||
import fs from "fs/promises"
|
||||
import { setTimeout as setTimeoutPromise } from "node:timers/promises"
|
||||
import pWaitFor from "p-wait-for"
|
||||
import * as path from "path"
|
||||
import * as vscode from "vscode"
|
||||
import { handleGrpcRequest, handleGrpcRequestCancel } from "./grpc-handler"
|
||||
import { buildApiHandler } from "@api/index"
|
||||
import { cleanupLegacyCheckpoints } from "@integrations/checkpoints/CheckpointMigration"
|
||||
import { downloadTask } from "@integrations/misc/export-markdown"
|
||||
import WorkspaceTracker from "@integrations/workspace/WorkspaceTracker"
|
||||
import { ClineAccountService } from "@services/account/ClineAccountService"
|
||||
import { McpHub } from "@services/mcp/McpHub"
|
||||
import { telemetryService } from "@/services/posthog/telemetry/TelemetryService"
|
||||
import { ApiProvider, ModelInfo } from "@shared/api"
|
||||
import { ChatContent } from "@shared/ChatContent"
|
||||
import { ChatSettings, StoredChatSettings } from "@shared/ChatSettings"
|
||||
import { ExtensionMessage, ExtensionState, Platform } from "@shared/ExtensionMessage"
|
||||
import { HistoryItem } from "@shared/HistoryItem"
|
||||
import { McpMarketplaceCatalog } from "@shared/mcp"
|
||||
import { UserInfo } from "@shared/UserInfo"
|
||||
import { TelemetrySetting } from "@shared/TelemetrySetting"
|
||||
import { WebviewMessage } from "@shared/WebviewMessage"
|
||||
import { fileExistsAtPath } from "@utils/fs"
|
||||
import { getWorkingState } from "@utils/git"
|
||||
import { extractCommitMessage } from "@integrations/git/commit-message-generator"
|
||||
import { ensureMcpServersDirectoryExists, ensureSettingsDirectoryExists, GlobalFileNames } from "../storage/disk"
|
||||
import {
|
||||
getAllExtensionState,
|
||||
@@ -33,19 +33,15 @@ import {
|
||||
getSecret,
|
||||
getWorkspaceState,
|
||||
storeSecret,
|
||||
updateApiConfiguration,
|
||||
updateGlobalState,
|
||||
updateWorkspaceState,
|
||||
} from "../storage/state"
|
||||
import { Task } from "../task"
|
||||
import { ClineRulesToggles } from "@shared/cline-rules"
|
||||
import { sendAuthCallbackEvent } from "./account/subscribeToAuthCallback"
|
||||
import { handleGrpcRequest, handleGrpcRequestCancel } from "./grpc-handler"
|
||||
import { sendMcpMarketplaceCatalogEvent } from "./mcp/subscribeToMcpMarketplaceCatalog"
|
||||
import { sendStateUpdate } from "./state/subscribeToState"
|
||||
import { sendAddToInputEvent } from "./ui/subscribeToAddToInput"
|
||||
import { sendAuthCallbackEvent } from "./account/subscribeToAuthCallback"
|
||||
import { sendMcpMarketplaceCatalogEvent } from "./mcp/subscribeToMcpMarketplaceCatalog"
|
||||
import { sendRelinquishControlEvent } from "./ui/subscribeToRelinquishControl"
|
||||
import { handleTaskServiceRequest } from "./task"
|
||||
import { BooleanRequest } from "@shared/proto/common"
|
||||
|
||||
/*
|
||||
https://github.com/microsoft/vscode-webview-ui-toolkit-samples/blob/main/default/weather-webview/src/providers/WeatherViewProvider.ts
|
||||
@@ -175,7 +171,6 @@ export class Controller {
|
||||
this.workspaceTracker,
|
||||
(historyItem) => this.updateTaskHistory(historyItem),
|
||||
() => this.postStateToWebview(),
|
||||
(message) => this.postMessageToWebview(message),
|
||||
(taskId) => this.reinitExistingTaskFromId(taskId),
|
||||
() => this.cancelTask(),
|
||||
apiConfiguration,
|
||||
@@ -683,8 +678,8 @@ export class Controller {
|
||||
|
||||
// Context menus and code actions
|
||||
|
||||
getFileMentionFromPath(filePath: string) {
|
||||
const cwd = vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath).at(0)
|
||||
async getFileMentionFromPath(filePath: string) {
|
||||
const cwd = await getCwd()
|
||||
if (!cwd) {
|
||||
return "@/" + filePath
|
||||
}
|
||||
@@ -699,7 +694,7 @@ export class Controller {
|
||||
await setTimeoutPromise(100)
|
||||
|
||||
// Post message to webview with the selected code
|
||||
const fileMention = this.getFileMentionFromPath(filePath)
|
||||
const fileMention = await this.getFileMentionFromPath(filePath)
|
||||
|
||||
let input = `${fileMention}\n\`\`\`\n${code}\n\`\`\``
|
||||
if (diagnostics) {
|
||||
@@ -736,7 +731,7 @@ export class Controller {
|
||||
await vscode.commands.executeCommand("claude-dev.SidebarProvider.focus")
|
||||
await setTimeoutPromise(100)
|
||||
|
||||
const fileMention = this.getFileMentionFromPath(filePath)
|
||||
const fileMention = await this.getFileMentionFromPath(filePath)
|
||||
const problemsString = this.convertDiagnosticsToProblemsString(diagnostics)
|
||||
await this.initTask(`Fix the following code in ${fileMention}\n\`\`\`\n${code}\n\`\`\`\n\nProblems:\n${problemsString}`)
|
||||
|
||||
@@ -915,7 +910,7 @@ export class Controller {
|
||||
if (this.task) {
|
||||
await telemetryService.sendCollectedEvents(this.task.taskId)
|
||||
}
|
||||
this.task?.abortTask()
|
||||
await this.task?.abortTask()
|
||||
this.task = undefined // removes reference to it, so once promises end it will be garbage collected
|
||||
}
|
||||
|
||||
@@ -988,7 +983,7 @@ export class Controller {
|
||||
async generateGitCommitMessage() {
|
||||
try {
|
||||
// Check if there's a workspace folder open
|
||||
const cwd = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath
|
||||
const cwd = await getCwd()
|
||||
if (!cwd) {
|
||||
vscode.window.showErrorMessage("No workspace folder open")
|
||||
return
|
||||
|
||||
@@ -2,7 +2,6 @@ import path from "path"
|
||||
import fs from "fs/promises"
|
||||
import { Controller } from ".."
|
||||
import { DeleteAllTaskHistoryCount } from "../../../shared/proto/task"
|
||||
import { BooleanRequest } from "../../../shared/proto/common"
|
||||
import { getGlobalState, updateGlobalState } from "../../storage/state"
|
||||
import { fileExistsAtPath } from "../../../utils/fs"
|
||||
import vscode from "vscode"
|
||||
@@ -13,7 +12,7 @@ import vscode from "vscode"
|
||||
* @param request Request with option to preserve favorites
|
||||
* @returns Results with count of deleted tasks
|
||||
*/
|
||||
export async function deleteAllTaskHistory(controller: Controller, request: BooleanRequest): Promise<DeleteAllTaskHistoryCount> {
|
||||
export async function deleteAllTaskHistory(controller: Controller): Promise<DeleteAllTaskHistoryCount> {
|
||||
try {
|
||||
// Clear current task first
|
||||
await controller.clearTask()
|
||||
@@ -22,8 +21,22 @@ export async function deleteAllTaskHistory(controller: Controller, request: Bool
|
||||
const taskHistory = ((await getGlobalState(controller.context, "taskHistory")) as any[]) || []
|
||||
const totalTasks = taskHistory.length
|
||||
|
||||
const userChoice = await vscode.window.showWarningMessage(
|
||||
"What would you like to delete?",
|
||||
{ modal: true },
|
||||
"Delete All Except Favorites",
|
||||
"Delete Everything",
|
||||
)
|
||||
|
||||
// Default VS Code Cancel button returns `undefined` - don't delete anything
|
||||
if (userChoice === undefined) {
|
||||
return DeleteAllTaskHistoryCount.create({
|
||||
tasksDeleted: 0,
|
||||
})
|
||||
}
|
||||
|
||||
// If preserving favorites, filter out non-favorites
|
||||
if (request.value) {
|
||||
if (userChoice === "Delete All Except Favorites") {
|
||||
const favoritedTasks = taskHistory.filter((task) => task.isFavorited === true)
|
||||
|
||||
// If there are favorited tasks, update state
|
||||
@@ -45,9 +58,20 @@ export async function deleteAllTaskHistory(controller: Controller, request: Bool
|
||||
tasksDeleted: totalTasks - favoritedTasks.length,
|
||||
})
|
||||
} else {
|
||||
return DeleteAllTaskHistoryCount.create({
|
||||
tasksDeleted: 0,
|
||||
})
|
||||
// No favorited tasks found - show warning and ask user what to do
|
||||
const answer = await vscode.window.showWarningMessage(
|
||||
"No favorited tasks found. Would you like to delete all tasks anyway?",
|
||||
{ modal: true },
|
||||
"Delete All Tasks",
|
||||
)
|
||||
|
||||
// User cancelled - don't delete anything
|
||||
if (answer === undefined) {
|
||||
return DeleteAllTaskHistoryCount.create({
|
||||
tasksDeleted: 0,
|
||||
})
|
||||
}
|
||||
// If user chose "Delete All Tasks", fall through to the `delete everything` section below
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,88 +0,0 @@
|
||||
import path from "path"
|
||||
import fs from "fs/promises"
|
||||
import { Controller } from ".."
|
||||
import { EmptyRequest } from "../../../shared/proto/common"
|
||||
import { DeleteNonFavoritedTasksResults } from "../../../shared/proto/task"
|
||||
import { getGlobalState, updateGlobalState } from "../../storage/state"
|
||||
import { fileExistsAtPath } from "../../../utils/fs"
|
||||
|
||||
/**
|
||||
* Deletes all non-favorited tasks, preserving only favorited ones
|
||||
* @param controller The controller instance
|
||||
* @param request Empty request
|
||||
* @returns DeleteNonFavoritedTasksResults with counts of preserved and deleted tasks
|
||||
*/
|
||||
export async function deleteNonFavoritedTasks(
|
||||
controller: Controller,
|
||||
_request: EmptyRequest,
|
||||
): Promise<DeleteNonFavoritedTasksResults> {
|
||||
try {
|
||||
// Clear current task first
|
||||
await controller.clearTask()
|
||||
|
||||
// Get existing task history
|
||||
const taskHistory = ((await getGlobalState(controller.context, "taskHistory")) as any[]) || []
|
||||
|
||||
// Filter out non-favorited tasks
|
||||
const favoritedTasks = taskHistory.filter((task) => task.isFavorited === true)
|
||||
const deletedCount = taskHistory.length - favoritedTasks.length
|
||||
|
||||
console.log(`[deleteNonFavoritedTasks] Found ${favoritedTasks.length} favorited tasks to preserve`)
|
||||
|
||||
// Update global state
|
||||
if (favoritedTasks.length > 0) {
|
||||
await updateGlobalState(controller.context, "taskHistory", favoritedTasks)
|
||||
} else {
|
||||
await updateGlobalState(controller.context, "taskHistory", undefined)
|
||||
}
|
||||
|
||||
// Handle file system cleanup for deleted tasks
|
||||
const preserveTaskIds = favoritedTasks.map((task) => task.id)
|
||||
await cleanupTaskFiles(controller, preserveTaskIds)
|
||||
|
||||
// Update webview
|
||||
try {
|
||||
await controller.postStateToWebview()
|
||||
} catch (webviewErr) {
|
||||
console.error("Error posting to webview:", webviewErr)
|
||||
}
|
||||
|
||||
return DeleteNonFavoritedTasksResults.create({
|
||||
tasksPreserved: favoritedTasks.length,
|
||||
tasksDeleted: deletedCount,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Error in deleteNonFavoritedTasks:", error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to cleanup task files while preserving specified tasks
|
||||
*/
|
||||
async function cleanupTaskFiles(controller: Controller, preserveTaskIds: string[]) {
|
||||
const taskDirPath = path.join(controller.context.globalStorageUri.fsPath, "tasks")
|
||||
|
||||
try {
|
||||
if (await fileExistsAtPath(taskDirPath)) {
|
||||
if (preserveTaskIds.length > 0) {
|
||||
const taskDirs = await fs.readdir(taskDirPath)
|
||||
console.debug(`[cleanupTaskFiles] Found ${taskDirs.length} task directories`)
|
||||
|
||||
// Delete only non-preserved task directories
|
||||
for (const dir of taskDirs) {
|
||||
if (!preserveTaskIds.includes(dir)) {
|
||||
await fs.rm(path.join(taskDirPath, dir), { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// No tasks to preserve, delete everything
|
||||
await fs.rm(taskDirPath, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error cleaning up task files:", error)
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
@@ -15,7 +15,7 @@ export async function getTaskHistory(controller: Controller, request: GetTaskHis
|
||||
|
||||
// Get task history from global state
|
||||
const taskHistory = ((await getGlobalState(controller.context, "taskHistory")) as any[]) || []
|
||||
const workspacePath = getWorkspacePath()
|
||||
const workspacePath = await getWorkspacePath()
|
||||
|
||||
// Apply filters
|
||||
let filteredTasks = taskHistory.filter((item) => {
|
||||
|
||||
@@ -11,13 +11,14 @@ import { getLatestTerminalOutput } from "@integrations/terminal/get-latest-outpu
|
||||
import { getCommitInfo } from "@utils/git"
|
||||
import { getWorkingState } from "@utils/git"
|
||||
import { FileContextTracker } from "../context/context-tracking/FileContextTracker"
|
||||
import { getCwd } from "@/utils/path"
|
||||
|
||||
export function openMention(mention?: string): void {
|
||||
export async function openMention(mention?: string): Promise<void> {
|
||||
if (!mention) {
|
||||
return
|
||||
}
|
||||
|
||||
const cwd = vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath).at(0)
|
||||
const cwd = await getCwd()
|
||||
if (!cwd) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -574,7 +574,7 @@ export class ToolExecutor {
|
||||
tool: fileExists ? "editedExistingFile" : "newFileCreated",
|
||||
path: getReadablePath(this.cwd, this.removeClosingTag(block, "path", relPath)),
|
||||
content: diff || content,
|
||||
operationIsLocatedInWorkspace: isLocatedInWorkspace(relPath),
|
||||
operationIsLocatedInWorkspace: await isLocatedInWorkspace(relPath),
|
||||
}
|
||||
|
||||
if (block.partial) {
|
||||
@@ -645,7 +645,7 @@ export class ToolExecutor {
|
||||
const completeMessage = JSON.stringify({
|
||||
...sharedMessageProps,
|
||||
content: diff || content,
|
||||
operationIsLocatedInWorkspace: isLocatedInWorkspace(relPath),
|
||||
operationIsLocatedInWorkspace: await isLocatedInWorkspace(relPath),
|
||||
// ? formatResponse.createPrettyPatch(
|
||||
// relPath,
|
||||
// this.diffViewProvider.originalContent,
|
||||
@@ -788,7 +788,7 @@ export class ToolExecutor {
|
||||
const partialMessage = JSON.stringify({
|
||||
...sharedMessageProps,
|
||||
content: undefined,
|
||||
operationIsLocatedInWorkspace: isLocatedInWorkspace(relPath),
|
||||
operationIsLocatedInWorkspace: await isLocatedInWorkspace(relPath),
|
||||
} satisfies ClineSayTool)
|
||||
if (this.shouldAutoApproveToolWithPath(block.name, block.params.path)) {
|
||||
this.removeLastPartialMessageIfExistsWithType("ask", "tool")
|
||||
@@ -819,7 +819,7 @@ export class ToolExecutor {
|
||||
const completeMessage = JSON.stringify({
|
||||
...sharedMessageProps,
|
||||
content: absolutePath,
|
||||
operationIsLocatedInWorkspace: isLocatedInWorkspace(relPath),
|
||||
operationIsLocatedInWorkspace: await isLocatedInWorkspace(relPath),
|
||||
} satisfies ClineSayTool)
|
||||
if (this.shouldAutoApproveToolWithPath(block.name, block.params.path)) {
|
||||
this.removeLastPartialMessageIfExistsWithType("ask", "tool")
|
||||
@@ -870,7 +870,7 @@ export class ToolExecutor {
|
||||
const partialMessage = JSON.stringify({
|
||||
...sharedMessageProps,
|
||||
content: "",
|
||||
operationIsLocatedInWorkspace: isLocatedInWorkspace(block.params.path),
|
||||
operationIsLocatedInWorkspace: await isLocatedInWorkspace(block.params.path),
|
||||
} satisfies ClineSayTool)
|
||||
if (this.shouldAutoApproveToolWithPath(block.name, block.params.path)) {
|
||||
this.removeLastPartialMessageIfExistsWithType("ask", "tool")
|
||||
@@ -902,7 +902,7 @@ export class ToolExecutor {
|
||||
const completeMessage = JSON.stringify({
|
||||
...sharedMessageProps,
|
||||
content: result,
|
||||
operationIsLocatedInWorkspace: isLocatedInWorkspace(block.params.path),
|
||||
operationIsLocatedInWorkspace: await isLocatedInWorkspace(block.params.path),
|
||||
} satisfies ClineSayTool)
|
||||
if (this.shouldAutoApproveToolWithPath(block.name, block.params.path)) {
|
||||
this.removeLastPartialMessageIfExistsWithType("ask", "tool")
|
||||
@@ -945,7 +945,7 @@ export class ToolExecutor {
|
||||
const partialMessage = JSON.stringify({
|
||||
...sharedMessageProps,
|
||||
content: "",
|
||||
operationIsLocatedInWorkspace: isLocatedInWorkspace(block.params.path),
|
||||
operationIsLocatedInWorkspace: await isLocatedInWorkspace(block.params.path),
|
||||
} satisfies ClineSayTool)
|
||||
if (this.shouldAutoApproveToolWithPath(block.name, block.params.path)) {
|
||||
this.removeLastPartialMessageIfExistsWithType("ask", "tool")
|
||||
@@ -974,7 +974,7 @@ export class ToolExecutor {
|
||||
const completeMessage = JSON.stringify({
|
||||
...sharedMessageProps,
|
||||
content: result,
|
||||
operationIsLocatedInWorkspace: isLocatedInWorkspace(block.params.path),
|
||||
operationIsLocatedInWorkspace: await isLocatedInWorkspace(block.params.path),
|
||||
} satisfies ClineSayTool)
|
||||
if (this.shouldAutoApproveToolWithPath(block.name, block.params.path)) {
|
||||
this.removeLastPartialMessageIfExistsWithType("ask", "tool")
|
||||
@@ -1021,7 +1021,7 @@ export class ToolExecutor {
|
||||
const partialMessage = JSON.stringify({
|
||||
...sharedMessageProps,
|
||||
content: "",
|
||||
operationIsLocatedInWorkspace: isLocatedInWorkspace(block.params.path),
|
||||
operationIsLocatedInWorkspace: await isLocatedInWorkspace(block.params.path),
|
||||
} satisfies ClineSayTool)
|
||||
if (this.shouldAutoApproveToolWithPath(block.name, block.params.path)) {
|
||||
this.removeLastPartialMessageIfExistsWithType("ask", "tool")
|
||||
@@ -1058,7 +1058,7 @@ export class ToolExecutor {
|
||||
const completeMessage = JSON.stringify({
|
||||
...sharedMessageProps,
|
||||
content: results,
|
||||
operationIsLocatedInWorkspace: isLocatedInWorkspace(block.params.path),
|
||||
operationIsLocatedInWorkspace: await isLocatedInWorkspace(block.params.path),
|
||||
} satisfies ClineSayTool)
|
||||
if (this.shouldAutoApproveToolWithPath(block.name, block.params.path)) {
|
||||
this.removeLastPartialMessageIfExistsWithType("ask", "tool")
|
||||
|
||||
+37
-53
@@ -1,13 +1,4 @@
|
||||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
import cloneDeep from "clone-deep"
|
||||
import { execa } from "execa"
|
||||
import { setTimeout as setTimeoutPromise } from "node:timers/promises"
|
||||
import os from "os"
|
||||
import pTimeout from "p-timeout"
|
||||
import pWaitFor from "p-wait-for"
|
||||
import * as path from "path"
|
||||
import * as vscode from "vscode"
|
||||
import { Logger } from "@services/logging/Logger"
|
||||
import { ApiHandler, buildApiHandler } from "@api/index"
|
||||
import { AnthropicHandler } from "@api/providers/anthropic"
|
||||
import { ClineHandler } from "@api/providers/cline"
|
||||
@@ -21,6 +12,7 @@ import { TerminalManager } from "@integrations/terminal/TerminalManager"
|
||||
import { BrowserSession } from "@services/browser/BrowserSession"
|
||||
import { UrlContentFetcher } from "@services/browser/UrlContentFetcher"
|
||||
import { listFiles } from "@services/glob/list-files"
|
||||
import { Logger } from "@services/logging/Logger"
|
||||
import { telemetryService } from "@services/posthog/telemetry/TelemetryService"
|
||||
import { ApiConfiguration } from "@shared/api"
|
||||
import { findLast, findLastIndex } from "@shared/array"
|
||||
@@ -29,42 +21,47 @@ import { BrowserSettings } from "@shared/BrowserSettings"
|
||||
import { ChatSettings } from "@shared/ChatSettings"
|
||||
import { combineApiRequests } from "@shared/combineApiRequests"
|
||||
import { combineCommandSequences } from "@shared/combineCommandSequences"
|
||||
import {
|
||||
ClineApiReqCancelReason,
|
||||
ClineApiReqInfo,
|
||||
ClineAsk,
|
||||
ClineMessage,
|
||||
ClineSay,
|
||||
ExtensionMessage,
|
||||
} from "@shared/ExtensionMessage"
|
||||
import { ClineApiReqCancelReason, ClineApiReqInfo, ClineAsk, ClineMessage, ClineSay } from "@shared/ExtensionMessage"
|
||||
import { getApiMetrics } from "@shared/getApiMetrics"
|
||||
import { HistoryItem } from "@shared/HistoryItem"
|
||||
import { DEFAULT_LANGUAGE_SETTINGS, getLanguageKey, LanguageDisplay } from "@shared/Languages"
|
||||
import { ClineAskResponse, ClineCheckpointRestore } from "@shared/WebviewMessage"
|
||||
import { arePathsEqual } from "@utils/path"
|
||||
import cloneDeep from "clone-deep"
|
||||
import { execa } from "execa"
|
||||
import { setTimeout as setTimeoutPromise } from "node:timers/promises"
|
||||
import os from "os"
|
||||
import pTimeout from "p-timeout"
|
||||
import pWaitFor from "p-wait-for"
|
||||
import * as path from "path"
|
||||
import * as vscode from "vscode"
|
||||
|
||||
import {
|
||||
AssistantMessageContent,
|
||||
parseAssistantMessageV2,
|
||||
parseAssistantMessageV3,
|
||||
ToolParamName,
|
||||
ToolUseName,
|
||||
} from "@core/assistant-message"
|
||||
import { ClineIgnoreController } from "@core/ignore/ClineIgnoreController"
|
||||
import { parseMentions } from "@core/mentions"
|
||||
import { formatResponse } from "@core/prompts/responses"
|
||||
import { addUserInstructions, SYSTEM_PROMPT } from "@core/prompts/system"
|
||||
import { sendPartialMessageEvent } from "@core/controller/ui/subscribeToPartialMessage"
|
||||
import { sendRelinquishControlEvent } from "@core/controller/ui/subscribeToRelinquishControl"
|
||||
import { convertClineMessageToProto } from "@shared/proto-conversions/cline-message"
|
||||
import { getContextWindowInfo } from "@core/context/context-management/context-window-utils"
|
||||
import { FileContextTracker } from "@core/context/context-tracking/FileContextTracker"
|
||||
import { ModelContextTracker } from "@core/context/context-tracking/ModelContextTracker"
|
||||
import { parseAssistantMessageV2, parseAssistantMessageV3, ToolUseName } from "@core/assistant-message"
|
||||
import {
|
||||
checkIsAnthropicContextWindowError,
|
||||
checkIsOpenRouterContextWindowError,
|
||||
} from "@core/context/context-management/context-error-handling"
|
||||
import { getContextWindowInfo } from "@core/context/context-management/context-window-utils"
|
||||
import { ContextManager } from "@core/context/context-management/ContextManager"
|
||||
import { FileContextTracker } from "@core/context/context-tracking/FileContextTracker"
|
||||
import { ModelContextTracker } from "@core/context/context-tracking/ModelContextTracker"
|
||||
import {
|
||||
getGlobalClineRules,
|
||||
getLocalClineRules,
|
||||
refreshClineRulesToggles,
|
||||
} from "@core/context/instructions/user-instructions/cline-rules"
|
||||
import {
|
||||
getLocalCursorRules,
|
||||
getLocalWindsurfRules,
|
||||
refreshExternalRulesToggles,
|
||||
} from "@core/context/instructions/user-instructions/external-rules"
|
||||
import { sendPartialMessageEvent } from "@core/controller/ui/subscribeToPartialMessage"
|
||||
import { sendRelinquishControlEvent } from "@core/controller/ui/subscribeToRelinquishControl"
|
||||
import { ClineIgnoreController } from "@core/ignore/ClineIgnoreController"
|
||||
import { parseMentions } from "@core/mentions"
|
||||
import { formatResponse } from "@core/prompts/responses"
|
||||
import { addUserInstructions, SYSTEM_PROMPT } from "@core/prompts/system"
|
||||
import { parseSlashCommands } from "@core/slash-commands"
|
||||
import {
|
||||
ensureRulesDirectoryExists,
|
||||
ensureTaskDirectoryExists,
|
||||
@@ -72,29 +69,19 @@ import {
|
||||
getSavedClineMessages,
|
||||
GlobalFileNames,
|
||||
} from "@core/storage/disk"
|
||||
import {
|
||||
getGlobalClineRules,
|
||||
getLocalClineRules,
|
||||
refreshClineRulesToggles,
|
||||
} from "@core/context/instructions/user-instructions/cline-rules"
|
||||
import { ensureLocalClineDirExists } from "../context/instructions/user-instructions/rule-helpers"
|
||||
import {
|
||||
refreshExternalRulesToggles,
|
||||
getLocalWindsurfRules,
|
||||
getLocalCursorRules,
|
||||
} from "@core/context/instructions/user-instructions/external-rules"
|
||||
import { refreshWorkflowToggles } from "../context/instructions/user-instructions/workflows"
|
||||
import { getWorkspaceState } from "@core/storage/state"
|
||||
import { parseSlashCommands } from "@core/slash-commands"
|
||||
import { processFilesIntoText } from "@integrations/misc/extract-text"
|
||||
import WorkspaceTracker from "@integrations/workspace/WorkspaceTracker"
|
||||
import { McpHub } from "@services/mcp/McpHub"
|
||||
import { isInTestMode } from "../../services/test/TestMode"
|
||||
import { processFilesIntoText } from "@integrations/misc/extract-text"
|
||||
import { convertClineMessageToProto } from "@shared/proto-conversions/cline-message"
|
||||
import { isClaude4ModelFamily, isGemini2dot5ModelFamily } from "@utils/model-utils"
|
||||
import { isInTestMode } from "../../services/test/TestMode"
|
||||
import { ensureLocalClineDirExists } from "../context/instructions/user-instructions/rule-helpers"
|
||||
import { refreshWorkflowToggles } from "../context/instructions/user-instructions/workflows"
|
||||
import { MessageStateHandler } from "./message-state"
|
||||
import { formatErrorWithStatusCode, updateApiReqMsg } from "./utils"
|
||||
import { TaskState } from "./TaskState"
|
||||
import { ToolExecutor } from "./ToolExecutor"
|
||||
import { formatErrorWithStatusCode, updateApiReqMsg } from "./utils"
|
||||
|
||||
export const USE_EXPERIMENTAL_CLAUDE4_FEATURES = false
|
||||
|
||||
@@ -137,7 +124,6 @@ export class Task {
|
||||
// Callbacks
|
||||
private updateTaskHistory: (historyItem: HistoryItem) => Promise<HistoryItem[]>
|
||||
private postStateToWebview: () => Promise<void>
|
||||
private postMessageToWebview: (message: ExtensionMessage) => Promise<void>
|
||||
private reinitExistingTaskFromId: (taskId: string) => Promise<void>
|
||||
private cancelTask: () => Promise<void>
|
||||
|
||||
@@ -154,7 +140,6 @@ export class Task {
|
||||
workspaceTracker: WorkspaceTracker,
|
||||
updateTaskHistory: (historyItem: HistoryItem) => Promise<HistoryItem[]>,
|
||||
postStateToWebview: () => Promise<void>,
|
||||
postMessageToWebview: (message: ExtensionMessage) => Promise<void>,
|
||||
reinitExistingTaskFromId: (taskId: string) => Promise<void>,
|
||||
cancelTask: () => Promise<void>,
|
||||
apiConfiguration: ApiConfiguration,
|
||||
@@ -177,7 +162,6 @@ export class Task {
|
||||
this.workspaceTracker = workspaceTracker
|
||||
this.updateTaskHistory = updateTaskHistory
|
||||
this.postStateToWebview = postStateToWebview
|
||||
this.postMessageToWebview = postMessageToWebview
|
||||
this.reinitExistingTaskFromId = reinitExistingTaskFromId
|
||||
this.cancelTask = cancelTask
|
||||
this.clineIgnoreController = new ClineIgnoreController(cwd)
|
||||
|
||||
@@ -12,6 +12,7 @@ import CheckpointTracker from "@integrations/checkpoints/CheckpointTracker"
|
||||
import { HistoryItem } from "@/shared/HistoryItem"
|
||||
import Anthropic from "@anthropic-ai/sdk"
|
||||
import { TaskState } from "./TaskState"
|
||||
import { getCwd } from "@/utils/path"
|
||||
|
||||
interface MessageStateHandlerParams {
|
||||
context: vscode.ExtensionContext
|
||||
@@ -21,8 +22,6 @@ interface MessageStateHandlerParams {
|
||||
taskState: TaskState
|
||||
}
|
||||
|
||||
const cwd = vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath).at(0) ?? path.join(os.homedir(), "Desktop") // may or may not exist but fs checking existence would immediately ask for permission which would be bad UX, need to come up with a better solution
|
||||
|
||||
export class MessageStateHandler {
|
||||
private apiConversationHistory: Anthropic.MessageParam[] = []
|
||||
private clineMessages: ClineMessage[] = []
|
||||
@@ -84,6 +83,7 @@ export class MessageStateHandler {
|
||||
} catch (error) {
|
||||
console.error("Failed to get task directory size:", taskDir, error)
|
||||
}
|
||||
const cwd = await getCwd(path.join(os.homedir(), "Desktop"))
|
||||
await this.updateTaskHistory({
|
||||
id: this.taskId,
|
||||
ts: lastRelevantMessage.ts,
|
||||
|
||||
+7
-5
@@ -34,6 +34,7 @@ import * as hostProviders from "@hosts/host-providers"
|
||||
import { vscodeHostBridgeClient } from "@/hosts/vscode/client/host-grpc-client"
|
||||
import { VscodeWebviewProvider } from "./core/webview/VscodeWebviewProvider"
|
||||
import { ExtensionContext } from "vscode"
|
||||
import { writeTextToClipboard, readTextFromClipboard } from "@/utils/env"
|
||||
|
||||
/*
|
||||
Built using https://github.com/microsoft/vscode-webview-ui-toolkit
|
||||
@@ -75,8 +76,9 @@ export async function activate(context: vscode.ExtensionContext) {
|
||||
const previousVersion = context.globalState.get<string>("clineVersion")
|
||||
const sidebarWebview = hostProviders.createWebviewProvider(WebviewProviderType.SIDEBAR)
|
||||
|
||||
const testModeWatchers = await initializeTestMode(sidebarWebview)
|
||||
// Initialize test mode and add disposables to context
|
||||
context.subscriptions.push(...initializeTestMode(context, sidebarWebview))
|
||||
context.subscriptions.push(...testModeWatchers)
|
||||
|
||||
vscode.commands.executeCommand("setContext", "cline.isDevMode", IS_DEV && IS_DEV === "true")
|
||||
|
||||
@@ -369,17 +371,17 @@ export async function activate(context: vscode.ExtensionContext) {
|
||||
}
|
||||
|
||||
// Save current clipboard content
|
||||
const tempCopyBuffer = await vscode.env.clipboard.readText()
|
||||
const tempCopyBuffer = await readTextFromClipboard()
|
||||
|
||||
try {
|
||||
// Copy the *existing* terminal selection (without selecting all)
|
||||
await vscode.commands.executeCommand("workbench.action.terminal.copySelection")
|
||||
|
||||
// Get copied content
|
||||
let terminalContents = (await vscode.env.clipboard.readText()).trim()
|
||||
let terminalContents = (await readTextFromClipboard()).trim()
|
||||
|
||||
// Restore original clipboard content
|
||||
await vscode.env.clipboard.writeText(tempCopyBuffer)
|
||||
await writeTextToClipboard(tempCopyBuffer)
|
||||
|
||||
if (!terminalContents) {
|
||||
// No terminal content was copied (either nothing selected or some error)
|
||||
@@ -405,7 +407,7 @@ export async function activate(context: vscode.ExtensionContext) {
|
||||
await visibleWebview?.controller.addSelectedTerminalOutputToChat(terminalContents, terminal.name)
|
||||
} catch (error) {
|
||||
// Ensure clipboard is restored even if an error occurs
|
||||
await vscode.env.clipboard.writeText(tempCopyBuffer)
|
||||
await writeTextToClipboard(tempCopyBuffer)
|
||||
console.error("Error getting terminal contents:", error)
|
||||
vscode.window.showErrorMessage("Failed to get terminal contents")
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
UriServiceClientInterface,
|
||||
WatchServiceClientInterface,
|
||||
WorkspaceServiceClientInterface,
|
||||
EnvServiceClientInterface,
|
||||
} from "@generated/hosts/host-bridge-client-types"
|
||||
|
||||
/**
|
||||
@@ -11,6 +12,7 @@ export interface HostBridgeClientProvider {
|
||||
uriServiceClient: UriServiceClientInterface
|
||||
watchServiceClient: WatchServiceClientInterface
|
||||
workspaceClient: WorkspaceServiceClientInterface
|
||||
envClient: EnvServiceClientInterface
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -6,4 +6,5 @@ export const vscodeHostBridgeClient: HostBridgeClientProvider = {
|
||||
uriServiceClient: createGrpcClient(host.UriServiceDefinition),
|
||||
watchServiceClient: createGrpcClient(host.WatchServiceDefinition),
|
||||
workspaceClient: createGrpcClient(host.WorkspaceServiceDefinition),
|
||||
envClient: createGrpcClient(host.EnvServiceDefinition),
|
||||
}
|
||||
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
import { EmptyRequest, String } from "@/shared/proto/common"
|
||||
import * as vscode from "vscode"
|
||||
|
||||
export async function clipboardReadText(_: EmptyRequest): Promise<String> {
|
||||
const text = await vscode.env.clipboard.readText()
|
||||
return String.create({ value: text })
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
import { StringRequest, Empty } from "@/shared/proto/common"
|
||||
import * as vscode from "vscode"
|
||||
|
||||
export async function clipboardWriteText(request: StringRequest): Promise<Empty> {
|
||||
await vscode.env.clipboard.writeText(request.value)
|
||||
return Empty.create({})
|
||||
}
|
||||
@@ -1,422 +0,0 @@
|
||||
import fs from "fs/promises"
|
||||
import os from "os"
|
||||
import * as path from "path"
|
||||
import simpleGit, { SimpleGit } from "simple-git"
|
||||
import * as vscode from "vscode"
|
||||
import { Controller as ClineProvider } from "@core/controller"
|
||||
import { fileExistsAtPath } from "@utils/fs"
|
||||
import { globby } from "globby"
|
||||
|
||||
class CheckpointTracker {
|
||||
private providerRef: WeakRef<ClineProvider>
|
||||
private taskId: string
|
||||
private disposables: vscode.Disposable[] = []
|
||||
private cwd: string
|
||||
private lastRetrievedShadowGitConfigWorkTree?: string
|
||||
lastCheckpointHash?: string
|
||||
|
||||
private constructor(provider: ClineProvider, taskId: string, cwd: string) {
|
||||
this.providerRef = new WeakRef(provider)
|
||||
this.taskId = taskId
|
||||
this.cwd = cwd
|
||||
}
|
||||
|
||||
public static async create(
|
||||
taskId: string,
|
||||
enableCheckpointsSetting: boolean,
|
||||
provider?: ClineProvider,
|
||||
): Promise<CheckpointTracker | undefined> {
|
||||
try {
|
||||
if (!provider) {
|
||||
throw new Error("Provider is required to create a checkpoint tracker")
|
||||
}
|
||||
|
||||
if (!enableCheckpointsSetting) {
|
||||
return undefined // Don't create tracker when disabled
|
||||
}
|
||||
|
||||
// Check if git is installed by attempting to get version
|
||||
try {
|
||||
await simpleGit().version()
|
||||
} catch (error) {
|
||||
throw new Error("Git must be installed to use checkpoints.") // FIXME: must match what we check for in TaskHeader to show link
|
||||
}
|
||||
|
||||
const cwd = await CheckpointTracker.getWorkingDirectory()
|
||||
const newTracker = new CheckpointTracker(provider, taskId, cwd)
|
||||
await newTracker.initShadowGit()
|
||||
return newTracker
|
||||
} catch (error) {
|
||||
console.error("Failed to create CheckpointTracker:", error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private static async getWorkingDirectory(): Promise<string> {
|
||||
const cwd = vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath).at(0)
|
||||
if (!cwd) {
|
||||
throw new Error("No workspace detected. Please open Cline in a workspace to use checkpoints.")
|
||||
}
|
||||
const homedir = os.homedir()
|
||||
const desktopPath = path.join(homedir, "Desktop")
|
||||
const documentsPath = path.join(homedir, "Documents")
|
||||
const downloadsPath = path.join(homedir, "Downloads")
|
||||
|
||||
switch (cwd) {
|
||||
case homedir:
|
||||
throw new Error("Cannot use checkpoints in home directory")
|
||||
case desktopPath:
|
||||
throw new Error("Cannot use checkpoints in Desktop directory")
|
||||
case documentsPath:
|
||||
throw new Error("Cannot use checkpoints in Documents directory")
|
||||
case downloadsPath:
|
||||
throw new Error("Cannot use checkpoints in Downloads directory")
|
||||
default:
|
||||
return cwd
|
||||
}
|
||||
}
|
||||
|
||||
private async getShadowGitPath(): Promise<string> {
|
||||
const globalStoragePath = this.providerRef.deref()?.context.globalStorageUri.fsPath
|
||||
if (!globalStoragePath) {
|
||||
throw new Error("Global storage uri is invalid")
|
||||
}
|
||||
const checkpointsDir = path.join(globalStoragePath, "tasks", this.taskId, "checkpoints")
|
||||
await fs.mkdir(checkpointsDir, { recursive: true })
|
||||
const gitPath = path.join(checkpointsDir, ".git")
|
||||
return gitPath
|
||||
}
|
||||
|
||||
public static async doesShadowGitExist(taskId: string, provider?: ClineProvider): Promise<boolean> {
|
||||
const globalStoragePath = provider?.context.globalStorageUri.fsPath
|
||||
if (!globalStoragePath) {
|
||||
return false
|
||||
}
|
||||
const gitPath = path.join(globalStoragePath, "tasks", taskId, "checkpoints", ".git")
|
||||
return await fileExistsAtPath(gitPath)
|
||||
}
|
||||
|
||||
public async initShadowGit(): Promise<string> {
|
||||
const gitPath = await this.getShadowGitPath()
|
||||
if (await fileExistsAtPath(gitPath)) {
|
||||
// Make sure it's the same cwd as the configured worktree
|
||||
const worktree = await this.getShadowGitConfigWorkTree()
|
||||
if (worktree !== this.cwd) {
|
||||
throw new Error("Checkpoints can only be used in the original workspace: " + worktree)
|
||||
}
|
||||
|
||||
return gitPath
|
||||
} else {
|
||||
const checkpointsDir = path.dirname(gitPath)
|
||||
const git = simpleGit(checkpointsDir)
|
||||
await git.init()
|
||||
|
||||
await git.addConfig("core.worktree", this.cwd) // sets the working tree to the current workspace
|
||||
|
||||
// Disable commit signing for shadow repo
|
||||
await git.addConfig("commit.gpgSign", "false")
|
||||
|
||||
// Get LFS patterns from workspace if they exist
|
||||
let lfsPatterns: string[] = []
|
||||
try {
|
||||
const attributesPath = path.join(this.cwd, ".gitattributes")
|
||||
if (await fileExistsAtPath(attributesPath)) {
|
||||
const attributesContent = await fs.readFile(attributesPath, "utf8")
|
||||
lfsPatterns = attributesContent
|
||||
.split("\n")
|
||||
.filter((line) => line.includes("filter=lfs"))
|
||||
.map((line) => line.split(" ")[0].trim())
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("Failed to read .gitattributes:", error)
|
||||
}
|
||||
|
||||
// Add basic excludes directly in git config, while respecting any .gitignore in the workspace
|
||||
// .git/info/exclude is local to the shadow git repo, so it's not shared with the main repo - and won't conflict with user's .gitignore
|
||||
// TODO: let user customize these
|
||||
const excludesPath = path.join(gitPath, "info", "exclude")
|
||||
await fs.mkdir(path.join(gitPath, "info"), { recursive: true })
|
||||
await fs.writeFile(
|
||||
excludesPath,
|
||||
[
|
||||
".git/", // ignore the user's .git
|
||||
`.git${GIT_DISABLED_SUFFIX}/`, // ignore the disabled nested git repos
|
||||
".DS_Store",
|
||||
"*.log",
|
||||
"node_modules/",
|
||||
"__pycache__/",
|
||||
"env/",
|
||||
"venv/",
|
||||
"target/dependency/",
|
||||
"build/dependencies/",
|
||||
"dist/",
|
||||
"out/",
|
||||
"bundle/",
|
||||
"vendor/",
|
||||
"tmp/",
|
||||
"temp/",
|
||||
"deps/",
|
||||
"pkg/",
|
||||
"Pods/",
|
||||
// Media files
|
||||
"*.jpg",
|
||||
"*.jpeg",
|
||||
"*.png",
|
||||
"*.gif",
|
||||
"*.bmp",
|
||||
"*.ico",
|
||||
// "*.svg",
|
||||
"*.mp3",
|
||||
"*.mp4",
|
||||
"*.wav",
|
||||
"*.avi",
|
||||
"*.mov",
|
||||
"*.wmv",
|
||||
"*.webm",
|
||||
"*.webp",
|
||||
"*.m4a",
|
||||
"*.flac",
|
||||
// Build and dependency directories
|
||||
"build/",
|
||||
"bin/",
|
||||
"obj/",
|
||||
".gradle/",
|
||||
".idea/",
|
||||
".vscode/",
|
||||
".vs/",
|
||||
"coverage/",
|
||||
".next/",
|
||||
".nuxt/",
|
||||
// Cache and temporary files
|
||||
"*.cache",
|
||||
"*.tmp",
|
||||
"*.temp",
|
||||
"*.swp",
|
||||
"*.swo",
|
||||
"*.pyc",
|
||||
"*.pyo",
|
||||
".pytest_cache/",
|
||||
".eslintcache",
|
||||
// Environment and config files
|
||||
".env*",
|
||||
"*.local",
|
||||
"*.development",
|
||||
"*.production",
|
||||
// Large data files
|
||||
"*.zip",
|
||||
"*.tar",
|
||||
"*.gz",
|
||||
"*.rar",
|
||||
"*.7z",
|
||||
"*.iso",
|
||||
"*.bin",
|
||||
"*.exe",
|
||||
"*.dll",
|
||||
"*.so",
|
||||
"*.dylib",
|
||||
// Database files
|
||||
"*.sqlite",
|
||||
"*.db",
|
||||
"*.sql",
|
||||
// Log files
|
||||
"*.logs",
|
||||
"*.error",
|
||||
"npm-debug.log*",
|
||||
"yarn-debug.log*",
|
||||
"yarn-error.log*",
|
||||
...lfsPatterns,
|
||||
].join("\n"),
|
||||
)
|
||||
|
||||
// Set up git identity (git throws an error if user.name or user.email is not set)
|
||||
await git.addConfig("user.name", "Cline Checkpoint")
|
||||
await git.addConfig("user.email", "noreply@example.com")
|
||||
|
||||
await this.addAllFiles(git)
|
||||
// Initial commit (--allow-empty ensures it works even with no files)
|
||||
await git.commit("initial commit", { "--allow-empty": null })
|
||||
|
||||
return gitPath
|
||||
}
|
||||
}
|
||||
|
||||
public async getShadowGitConfigWorkTree(): Promise<string | undefined> {
|
||||
if (this.lastRetrievedShadowGitConfigWorkTree) {
|
||||
return this.lastRetrievedShadowGitConfigWorkTree
|
||||
}
|
||||
try {
|
||||
const gitPath = await this.getShadowGitPath()
|
||||
const git = simpleGit(path.dirname(gitPath))
|
||||
const worktree = await git.getConfig("core.worktree")
|
||||
this.lastRetrievedShadowGitConfigWorkTree = worktree.value || undefined
|
||||
return this.lastRetrievedShadowGitConfigWorkTree
|
||||
} catch (error) {
|
||||
console.error("Failed to get shadow git config worktree:", error)
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
public async commit(): Promise<string | undefined> {
|
||||
try {
|
||||
const gitPath = await this.getShadowGitPath()
|
||||
const git = simpleGit(path.dirname(gitPath))
|
||||
await this.addAllFiles(git)
|
||||
const result = await git.commit("checkpoint", {
|
||||
"--allow-empty": null,
|
||||
})
|
||||
const commitHash = result.commit || ""
|
||||
this.lastCheckpointHash = commitHash
|
||||
return commitHash
|
||||
} catch (error) {
|
||||
console.error("Failed to create checkpoint:", error)
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
public async resetHead(commitHash: string): Promise<void> {
|
||||
const gitPath = await this.getShadowGitPath()
|
||||
const git = simpleGit(path.dirname(gitPath))
|
||||
|
||||
// Clean working directory and force reset
|
||||
// This ensures that the operation will succeed regardless of:
|
||||
// - Untracked files in the workspace
|
||||
// - Staged changes
|
||||
// - Unstaged changes
|
||||
// - Partial commits
|
||||
// - Merge conflicts
|
||||
await git.clean("f", ["-d", "-f"]) // Remove untracked files and directories
|
||||
await git.reset(["--hard", commitHash]) // Hard reset to target commit
|
||||
}
|
||||
|
||||
/**
|
||||
* Return an array describing changed files between one commit and either:
|
||||
* - another commit, or
|
||||
* - the current working directory (including uncommitted changes).
|
||||
*
|
||||
* If `rhsHash` is omitted, compares `lhsHash` to the working directory.
|
||||
* If you want truly untracked files to appear, `git add` them first.
|
||||
*
|
||||
* @param lhsHash - The commit to compare from (older commit)
|
||||
* @param rhsHash - The commit to compare to (newer commit).
|
||||
* If omitted, we compare to the working directory.
|
||||
* @returns Array of file changes with before/after content
|
||||
*/
|
||||
public async getDiffSet(
|
||||
lhsHash?: string,
|
||||
rhsHash?: string,
|
||||
): Promise<
|
||||
Array<{
|
||||
relativePath: string
|
||||
absolutePath: string
|
||||
before: string
|
||||
after: string
|
||||
}>
|
||||
> {
|
||||
const gitPath = await this.getShadowGitPath()
|
||||
const git = simpleGit(path.dirname(gitPath))
|
||||
|
||||
// If lhsHash is missing, use the initial commit of the repo
|
||||
let baseHash = lhsHash
|
||||
if (!baseHash) {
|
||||
const rootCommit = await git.raw(["rev-list", "--max-parents=0", "HEAD"])
|
||||
baseHash = rootCommit.trim()
|
||||
}
|
||||
|
||||
// Stage all changes so that untracked files appear in diff summary
|
||||
await this.addAllFiles(git)
|
||||
|
||||
const diffSummary = rhsHash ? await git.diffSummary([`${baseHash}..${rhsHash}`]) : await git.diffSummary([baseHash])
|
||||
|
||||
// For each changed file, gather before/after content
|
||||
const result = []
|
||||
const cwdPath = (await this.getShadowGitConfigWorkTree()) || this.cwd || ""
|
||||
|
||||
for (const file of diffSummary.files) {
|
||||
const filePath = file.file
|
||||
const absolutePath = path.join(cwdPath, filePath)
|
||||
|
||||
let beforeContent = ""
|
||||
try {
|
||||
beforeContent = await git.show([`${baseHash}:${filePath}`])
|
||||
} catch (_) {
|
||||
// file didn't exist in older commit => remains empty
|
||||
}
|
||||
|
||||
let afterContent = ""
|
||||
if (rhsHash) {
|
||||
// if user provided a newer commit, use git.show at that commit
|
||||
try {
|
||||
afterContent = await git.show([`${rhsHash}:${filePath}`])
|
||||
} catch (_) {
|
||||
// file didn't exist in newer commit => remains empty
|
||||
}
|
||||
} else {
|
||||
// otherwise, read from disk (includes uncommitted changes)
|
||||
try {
|
||||
afterContent = await fs.readFile(absolutePath, "utf8")
|
||||
} catch (_) {
|
||||
// file might be deleted => remains empty
|
||||
}
|
||||
}
|
||||
|
||||
result.push({
|
||||
relativePath: filePath,
|
||||
absolutePath,
|
||||
before: beforeContent,
|
||||
after: afterContent,
|
||||
})
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
private async addAllFiles(git: SimpleGit) {
|
||||
await this.renameNestedGitRepos(true)
|
||||
try {
|
||||
await git.add(".")
|
||||
} catch (error) {
|
||||
console.error("Failed to add files to git:", error)
|
||||
} finally {
|
||||
await this.renameNestedGitRepos(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Since we use git to track checkpoints, we need to temporarily disable nested git repos to work around git's requirement of using submodules for nested repos.
|
||||
private async renameNestedGitRepos(disable: boolean) {
|
||||
// Find all .git directories that are not at the root level
|
||||
const gitPaths = await globby("**/.git" + (disable ? "" : GIT_DISABLED_SUFFIX), {
|
||||
cwd: this.cwd,
|
||||
onlyDirectories: true,
|
||||
ignore: [".git"], // Ignore root level .git
|
||||
dot: true,
|
||||
markDirectories: false,
|
||||
})
|
||||
|
||||
// For each nested .git directory, rename it based on operation
|
||||
for (const gitPath of gitPaths) {
|
||||
const fullPath = path.join(this.cwd, gitPath)
|
||||
let newPath: string
|
||||
if (disable) {
|
||||
newPath = fullPath + GIT_DISABLED_SUFFIX
|
||||
} else {
|
||||
newPath = fullPath.endsWith(GIT_DISABLED_SUFFIX) ? fullPath.slice(0, -GIT_DISABLED_SUFFIX.length) : fullPath
|
||||
}
|
||||
|
||||
try {
|
||||
await fs.rename(fullPath, newPath)
|
||||
console.log(`CheckpointTracker ${disable ? "disabled" : "enabled"} nested git repo ${gitPath}`)
|
||||
} catch (error) {
|
||||
console.error(`CheckpointTracker failed to ${disable ? "disable" : "enable"} nested git repo ${gitPath}:`, error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public dispose() {
|
||||
this.disposables.forEach((d) => d.dispose())
|
||||
this.disposables = []
|
||||
}
|
||||
}
|
||||
|
||||
const GIT_DISABLED_SUFFIX = "_disabled"
|
||||
|
||||
export default CheckpointTracker
|
||||
@@ -2,6 +2,7 @@ import { mkdir, access, constants } from "fs/promises"
|
||||
import * as path from "path"
|
||||
import * as vscode from "vscode"
|
||||
import os from "os"
|
||||
import { getCwd } from "@/utils/path"
|
||||
|
||||
/**
|
||||
* Gets the path to the shadow Git repository in globalStorage.
|
||||
@@ -45,7 +46,7 @@ export async function getShadowGitPath(globalStoragePath: string, taskId: string
|
||||
* @throws Error if no workspace is detected, if in a protected directory, or if no read access
|
||||
*/
|
||||
export async function getWorkingDirectory(): Promise<string> {
|
||||
const cwd = vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath).at(0)
|
||||
const cwd = await getCwd()
|
||||
if (!cwd) {
|
||||
throw new Error("No workspace detected. Please open Cline in a workspace to use checkpoints.")
|
||||
}
|
||||
|
||||
@@ -112,7 +112,6 @@ function runProcess({ systemPrompt, messages, path, modelId }: ClaudeCodeOptions
|
||||
|
||||
const args = [
|
||||
"-p",
|
||||
JSON.stringify(messages),
|
||||
"--system-prompt",
|
||||
systemPrompt,
|
||||
"--verbose",
|
||||
@@ -129,8 +128,8 @@ function runProcess({ systemPrompt, messages, path, modelId }: ClaudeCodeOptions
|
||||
args.push("--model", modelId)
|
||||
}
|
||||
|
||||
return execa(claudePath, args, {
|
||||
stdin: "ignore",
|
||||
const claudeCodeProcess = execa(claudePath, args, {
|
||||
stdin: "pipe",
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
env: {
|
||||
@@ -142,6 +141,11 @@ function runProcess({ systemPrompt, messages, path, modelId }: ClaudeCodeOptions
|
||||
maxBuffer: 1024 * 1024 * 1000,
|
||||
timeout: CLAUDE_CODE_TIMEOUT,
|
||||
})
|
||||
|
||||
claudeCodeProcess.stdin.write(JSON.stringify(messages))
|
||||
claudeCodeProcess.stdin.end()
|
||||
|
||||
return claudeCodeProcess
|
||||
}
|
||||
|
||||
function parseChunk(data: string, processState: ProcessState) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import * as vscode from "vscode"
|
||||
import { getWorkingState } from "@utils/git"
|
||||
import { writeTextToClipboard } from "@utils/env"
|
||||
|
||||
/**
|
||||
* Formats the git diff into a prompt for the AI
|
||||
@@ -57,7 +58,7 @@ export function extractCommitMessage(aiResponse: string): string {
|
||||
* @param message The commit message to copy
|
||||
*/
|
||||
export async function copyCommitMessageToClipboard(message: string): Promise<void> {
|
||||
await vscode.env.clipboard.writeText(message)
|
||||
await writeTextToClipboard(message)
|
||||
vscode.window.showInformationMessage("Commit message copied to clipboard")
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import * as vscode from "vscode"
|
||||
import { readTextFromClipboard, writeTextToClipboard } from "@utils/env"
|
||||
|
||||
/**
|
||||
* Gets the contents of the active terminal
|
||||
@@ -6,7 +7,7 @@ import * as vscode from "vscode"
|
||||
*/
|
||||
export async function getLatestTerminalOutput(): Promise<string> {
|
||||
// Store original clipboard content to restore later
|
||||
const originalClipboard = await vscode.env.clipboard.readText()
|
||||
const originalClipboard = await readTextFromClipboard()
|
||||
|
||||
try {
|
||||
// Select terminal content
|
||||
@@ -19,7 +20,7 @@ export async function getLatestTerminalOutput(): Promise<string> {
|
||||
await vscode.commands.executeCommand("workbench.action.terminal.clearSelection")
|
||||
|
||||
// Get terminal contents from clipboard
|
||||
let terminalContents = (await vscode.env.clipboard.readText()).trim()
|
||||
let terminalContents = (await readTextFromClipboard()).trim()
|
||||
|
||||
// Check if there's actually a terminal open
|
||||
if (terminalContents === originalClipboard) {
|
||||
@@ -40,6 +41,6 @@ export async function getLatestTerminalOutput(): Promise<string> {
|
||||
return terminalContents
|
||||
} finally {
|
||||
// Restore original clipboard content
|
||||
await vscode.env.clipboard.writeText(originalClipboard)
|
||||
await writeTextToClipboard(originalClipboard)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,13 +2,22 @@ import * as vscode from "vscode"
|
||||
import * as path from "path"
|
||||
import { listFiles } from "@services/glob/list-files"
|
||||
import { sendWorkspaceUpdateEvent } from "@core/controller/file/subscribeToWorkspaceUpdates"
|
||||
|
||||
const cwd = vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath).at(0)
|
||||
import { getCwd } from "@/utils/path"
|
||||
|
||||
// Note: this is not a drop-in replacement for listFiles at the start of tasks, since that will be done for Desktops when there is no workspace selected
|
||||
class WorkspaceTracker {
|
||||
private disposables: vscode.Disposable[] = []
|
||||
private filePaths: Set<string> = new Set()
|
||||
private cwd: string = ""
|
||||
|
||||
constructor() {
|
||||
this.initializeCwd()
|
||||
this.registerListeners()
|
||||
}
|
||||
|
||||
private async initializeCwd() {
|
||||
this.cwd = await getCwd()
|
||||
}
|
||||
|
||||
private get activeFiles() {
|
||||
return new Set(
|
||||
@@ -18,16 +27,12 @@ class WorkspaceTracker {
|
||||
)
|
||||
}
|
||||
|
||||
constructor() {
|
||||
this.registerListeners()
|
||||
}
|
||||
|
||||
async populateFilePaths() {
|
||||
// should not auto get filepaths for desktop since it would immediately show permission popup before cline ever creates a file
|
||||
if (!cwd) {
|
||||
if (!this.cwd) {
|
||||
return
|
||||
}
|
||||
const [files, _] = await listFiles(cwd, true, 1_000)
|
||||
const [files, _] = await listFiles(this.cwd, true, 1_000)
|
||||
files.forEach((file) => this.filePaths.add(this.normalizeFilePath(file)))
|
||||
this.workspaceDidUpdate()
|
||||
}
|
||||
@@ -91,18 +96,18 @@ class WorkspaceTracker {
|
||||
}
|
||||
|
||||
private async workspaceDidUpdate() {
|
||||
if (!cwd) {
|
||||
if (!this.cwd) {
|
||||
return
|
||||
}
|
||||
const filePaths = Array.from(new Set([...this.activeFiles, ...this.filePaths])).map((file) => {
|
||||
const relativePath = path.relative(cwd, file).toPosix()
|
||||
const relativePath = path.relative(this.cwd, file).toPosix()
|
||||
return file.endsWith("/") ? relativePath + "/" : relativePath
|
||||
})
|
||||
await sendWorkspaceUpdateEvent(filePaths)
|
||||
}
|
||||
|
||||
private normalizeFilePath(filePath: string): string {
|
||||
const resolvedPath = cwd ? path.resolve(cwd, filePath) : path.resolve(filePath)
|
||||
const resolvedPath = this.cwd ? path.resolve(this.cwd, filePath) : path.resolve(filePath)
|
||||
return filePath.endsWith("/") ? resolvedPath + "/" : resolvedPath
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { getCwd } from "@/utils/path"
|
||||
import * as vscode from "vscode"
|
||||
|
||||
/*
|
||||
@@ -28,12 +29,12 @@ export async function getPythonEnvPath(): Promise<string | undefined> {
|
||||
// Access the Python extension API
|
||||
const pythonApi = pythonExtension.exports
|
||||
// Get the active environment path for the current workspace
|
||||
const workspaceFolder = vscode.workspace.workspaceFolders?.[0]
|
||||
const workspaceFolder = await getCwd()
|
||||
if (!workspaceFolder) {
|
||||
return undefined
|
||||
}
|
||||
// Get the active python environment path for the current workspace
|
||||
const pythonEnv = await pythonApi?.environments?.getActiveEnvironmentPath(workspaceFolder.uri)
|
||||
const pythonEnv = await pythonApi?.environments?.getActiveEnvironmentPath(workspaceFolder)
|
||||
if (pythonEnv && pythonEnv.path) {
|
||||
return pythonEnv.path
|
||||
} else {
|
||||
|
||||
@@ -1,33 +1,6 @@
|
||||
import * as path from "path"
|
||||
import * as vscode from "vscode"
|
||||
import { execa } from "execa"
|
||||
import { Logger } from "@services/logging/Logger"
|
||||
import { WebviewProvider } from "@core/webview"
|
||||
|
||||
/**
|
||||
* Gets a valid workspace path for Git operations
|
||||
* @param visibleWebview The visible webview instance
|
||||
* @returns A valid workspace path
|
||||
*/
|
||||
export function getWorkspacePath(visibleWebview: WebviewProvider): string {
|
||||
// First try to get the path from the controller's state
|
||||
let workspacePath = visibleWebview.controller.context.workspaceState.get<string>("cwd") || ""
|
||||
|
||||
// If workspace path is empty, try to get it from the active workspace folder
|
||||
if (!workspacePath) {
|
||||
const workspaceFolders = vscode.workspace.workspaceFolders
|
||||
if (workspaceFolders && workspaceFolders.length > 0) {
|
||||
workspacePath = workspaceFolders[0].uri.fsPath
|
||||
Logger.log(`Using workspace folder path: ${workspacePath}`)
|
||||
} else {
|
||||
// If no workspace folder is open, use the extension directory as a fallback
|
||||
workspacePath = path.join(__dirname, "..", "..", "..")
|
||||
Logger.log(`No workspace folder found, using extension directory: ${workspacePath}`)
|
||||
}
|
||||
}
|
||||
|
||||
return workspacePath
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that the workspace path is valid and writable for Git operations
|
||||
@@ -42,7 +15,7 @@ export async function validateWorkspacePath(workspacePath: string): Promise<void
|
||||
|
||||
// Check if the directory exists
|
||||
try {
|
||||
const { stdout } = await execa("test", ["-d", workspacePath])
|
||||
await execa("test", ["-d", workspacePath])
|
||||
} catch (error) {
|
||||
throw new Error(`Workspace path does not exist or is not a directory: ${workspacePath}`)
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@ import * as fs from "fs"
|
||||
import * as path from "path"
|
||||
import { Logger } from "../logging/Logger"
|
||||
import { createTestServer, shutdownTestServer } from "./TestServer"
|
||||
import { getHostBridgeProvider } from "@/hosts/host-providers"
|
||||
import { GetWorkspacePathsRequest } from "@/shared/proto/index.host"
|
||||
|
||||
// State variable
|
||||
let isTestMode = false
|
||||
@@ -31,13 +33,13 @@ export function isInTestMode(): boolean {
|
||||
/**
|
||||
* Check if we're in test mode by looking for evals.env file in workspace folders
|
||||
*/
|
||||
function checkForTestMode(): boolean {
|
||||
async function checkForTestMode(): Promise<boolean> {
|
||||
// Get all workspace folders
|
||||
const workspaceFolders = vscode.workspace.workspaceFolders || []
|
||||
const workspaceFolders = await getHostBridgeProvider().workspaceClient.getWorkspacePaths({})
|
||||
|
||||
// Check each workspace folder for an evals.env file
|
||||
for (const folder of workspaceFolders) {
|
||||
const evalsEnvPath = path.join(folder.uri.fsPath, "evals.env")
|
||||
for (const folder of workspaceFolders.paths) {
|
||||
const evalsEnvPath = path.join(folder, "evals.env")
|
||||
if (fs.existsSync(evalsEnvPath)) {
|
||||
Logger.log(`Found evals.env file at ${evalsEnvPath}, activating test mode`)
|
||||
return true
|
||||
@@ -49,14 +51,13 @@ function checkForTestMode(): boolean {
|
||||
|
||||
/**
|
||||
* Initialize test mode detection and setup file watchers
|
||||
* @param context VSCode extension context
|
||||
* @param webviewProvider The webview provider instance
|
||||
*/
|
||||
export function initializeTestMode(context: vscode.ExtensionContext, webviewProvider?: any): vscode.Disposable[] {
|
||||
export async function initializeTestMode(webviewProvider?: any): Promise<vscode.Disposable[]> {
|
||||
const disposables: vscode.Disposable[] = []
|
||||
|
||||
// Check if we're in test mode
|
||||
const IS_TEST = checkForTestMode()
|
||||
const IS_TEST = await checkForTestMode()
|
||||
|
||||
// Set test mode state for other parts of the code
|
||||
if (IS_TEST) {
|
||||
|
||||
@@ -6,13 +6,7 @@ import { Logger } from "@services/logging/Logger"
|
||||
import { WebviewProvider } from "@core/webview"
|
||||
import { AutoApprovalSettings } from "@shared/AutoApprovalSettings"
|
||||
import { TaskServiceClient } from "webview-ui/src/services/grpc-client"
|
||||
import {
|
||||
getWorkspacePath,
|
||||
validateWorkspacePath,
|
||||
initializeGitRepository,
|
||||
getFileChanges,
|
||||
calculateToolSuccessRate,
|
||||
} from "./GitHelper"
|
||||
import { validateWorkspacePath, initializeGitRepository, getFileChanges, calculateToolSuccessRate } from "./GitHelper"
|
||||
import {
|
||||
updateGlobalState,
|
||||
getAllExtensionState,
|
||||
@@ -25,6 +19,7 @@ import { ApiProvider } from "@shared/api"
|
||||
import { HistoryItem } from "@shared/HistoryItem"
|
||||
import { getSavedClineMessages, getSavedApiConversationHistory } from "@core/storage/disk"
|
||||
import { AskResponseRequest } from "@/shared/proto/task"
|
||||
import { getCwd } from "@/utils/path"
|
||||
|
||||
/**
|
||||
* Creates a tracker to monitor tool calls and failures during task execution
|
||||
@@ -215,7 +210,7 @@ export function createTestServer(webviewProvider?: WebviewProvider): http.Server
|
||||
|
||||
try {
|
||||
// Get and validate the workspace path
|
||||
const workspacePath = getWorkspacePath(visibleWebview)
|
||||
const workspacePath = await getCwd()
|
||||
Logger.log(`Using workspace path: ${workspacePath}`)
|
||||
|
||||
// Validate workspace path before proceeding with any operations
|
||||
@@ -375,7 +370,7 @@ export function createTestServer(webviewProvider?: WebviewProvider): http.Server
|
||||
let fileChanges
|
||||
try {
|
||||
// Get the workspace path using our helper function
|
||||
const workspacePath = getWorkspacePath(visibleWebview)
|
||||
const workspacePath = await getCwd()
|
||||
Logger.log(`Getting file changes from workspace path: ${workspacePath}`)
|
||||
|
||||
// Log directory contents for debugging
|
||||
|
||||
+16
-135
@@ -10,7 +10,6 @@ export type ApiProvider =
|
||||
| "ollama"
|
||||
| "lmstudio"
|
||||
| "gemini"
|
||||
| "gemini-cli"
|
||||
| "openai-native"
|
||||
| "requesty"
|
||||
| "together"
|
||||
@@ -70,8 +69,6 @@ export interface ApiHandlerOptions {
|
||||
lmStudioBaseUrl?: string
|
||||
geminiApiKey?: string
|
||||
geminiBaseUrl?: string
|
||||
geminiCliOAuthPath?: string
|
||||
geminiCliProjectId?: string
|
||||
openAiNativeApiKey?: string
|
||||
deepSeekApiKey?: string
|
||||
requestyApiKey?: string
|
||||
@@ -878,138 +875,6 @@ export const geminiModels = {
|
||||
},
|
||||
} as const satisfies Record<string, ModelInfo>
|
||||
|
||||
// Gemini CLI (OAuth-based)
|
||||
export type GeminiCliModelId = keyof typeof geminiCliModels
|
||||
export const geminiCliDefaultModelId: GeminiCliModelId = "gemini-2.5-flash"
|
||||
export const geminiCliModels = {
|
||||
"gemini-2.5-pro": {
|
||||
maxTokens: 65536,
|
||||
contextWindow: 1_048_576,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 0, // Free tier via OAuth
|
||||
outputPrice: 0, // Free tier via OAuth
|
||||
description: "Google's Gemini 2.5 Pro model via OAuth (free tier)",
|
||||
},
|
||||
"gemini-2.5-flash": {
|
||||
maxTokens: 65536,
|
||||
contextWindow: 1_048_576,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 0, // Free tier via OAuth
|
||||
outputPrice: 0, // Free tier via OAuth
|
||||
description: "Google's Gemini 2.5 Flash model via OAuth (free tier)",
|
||||
},
|
||||
"gemini-2.0-flash-001": {
|
||||
maxTokens: 8192,
|
||||
contextWindow: 1_048_576,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 0, // Free tier via OAuth
|
||||
outputPrice: 0, // Free tier via OAuth
|
||||
description: "Google's Gemini 2.0 Flash model via OAuth (free tier)",
|
||||
},
|
||||
"gemini-2.0-flash-lite-preview-02-05": {
|
||||
maxTokens: 8192,
|
||||
contextWindow: 1_048_576,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 0,
|
||||
outputPrice: 0,
|
||||
description: "Google's Gemini 2.0 Flash Lite Preview model via OAuth",
|
||||
},
|
||||
"gemini-2.0-pro-exp-02-05": {
|
||||
maxTokens: 8192,
|
||||
contextWindow: 2_097_152,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 0,
|
||||
outputPrice: 0,
|
||||
description: "Google's Gemini 2.0 Pro Experimental model via OAuth",
|
||||
},
|
||||
"gemini-2.0-flash-thinking-exp-01-21": {
|
||||
maxTokens: 65_536,
|
||||
contextWindow: 1_048_576,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 0,
|
||||
outputPrice: 0,
|
||||
description: "Google's Gemini 2.0 Flash Thinking Experimental model via OAuth",
|
||||
},
|
||||
"gemini-2.0-flash-thinking-exp-1219": {
|
||||
maxTokens: 8192,
|
||||
contextWindow: 32_767,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 0,
|
||||
outputPrice: 0,
|
||||
description: "Google's Gemini 2.0 Flash Thinking Experimental (1219) model via OAuth",
|
||||
},
|
||||
"gemini-2.0-flash-exp": {
|
||||
maxTokens: 8192,
|
||||
contextWindow: 1_048_576,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 0,
|
||||
outputPrice: 0,
|
||||
description: "Google's Gemini 2.0 Flash Experimental model via OAuth",
|
||||
},
|
||||
"gemini-1.5-flash-002": {
|
||||
maxTokens: 8192,
|
||||
contextWindow: 1_048_576,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 0, // Free tier via OAuth
|
||||
outputPrice: 0, // Free tier via OAuth
|
||||
description: "Google's Gemini 1.5 Flash 002 model via OAuth (free tier)",
|
||||
},
|
||||
"gemini-1.5-flash-exp-0827": {
|
||||
maxTokens: 8192,
|
||||
contextWindow: 1_048_576,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 0,
|
||||
outputPrice: 0,
|
||||
description: "Google's Gemini 1.5 Flash Experimental (0827) model via OAuth",
|
||||
},
|
||||
"gemini-1.5-flash-8b-exp-0827": {
|
||||
maxTokens: 8192,
|
||||
contextWindow: 1_048_576,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 0,
|
||||
outputPrice: 0,
|
||||
description: "Google's Gemini 1.5 Flash 8B Experimental model via OAuth",
|
||||
},
|
||||
"gemini-1.5-pro-002": {
|
||||
maxTokens: 8192,
|
||||
contextWindow: 2_097_152,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 0,
|
||||
outputPrice: 0,
|
||||
description: "Google's Gemini 1.5 Pro 002 model via OAuth",
|
||||
},
|
||||
"gemini-1.5-pro-exp-0827": {
|
||||
maxTokens: 8192,
|
||||
contextWindow: 2_097_152,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 0,
|
||||
outputPrice: 0,
|
||||
description: "Google's Gemini 1.5 Pro Experimental model via OAuth",
|
||||
},
|
||||
"gemini-exp-1206": {
|
||||
maxTokens: 8192,
|
||||
contextWindow: 2_097_152,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 0,
|
||||
outputPrice: 0,
|
||||
description: "Google's Gemini Experimental (1206) model via OAuth",
|
||||
},
|
||||
} as const satisfies Record<string, ModelInfo>
|
||||
|
||||
// OpenAI Native
|
||||
// https://openai.com/api/pricing/
|
||||
export type OpenAiNativeModelId = keyof typeof openAiNativeModels
|
||||
@@ -2509,6 +2374,22 @@ export const requestyDefaultModelInfo: ModelInfo = {
|
||||
export type SapAiCoreModelId = keyof typeof sapAiCoreModels
|
||||
export const sapAiCoreDefaultModelId: SapAiCoreModelId = "anthropic--claude-3.5-sonnet"
|
||||
export const sapAiCoreModels = {
|
||||
"anthropic--claude-4-sonnet": {
|
||||
maxTokens: 8192,
|
||||
contextWindow: 200_000,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 3.0,
|
||||
outputPrice: 15.0,
|
||||
},
|
||||
"anthropic--claude-4-opus": {
|
||||
maxTokens: 8192,
|
||||
contextWindow: 200_000,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 3.0,
|
||||
outputPrice: 15.0,
|
||||
},
|
||||
"anthropic--claude-3.7-sonnet": {
|
||||
maxTokens: 64_000,
|
||||
contextWindow: 200_000,
|
||||
|
||||
@@ -202,8 +202,6 @@ function convertApiProviderToProto(provider: string | undefined): ProtoApiProvid
|
||||
return ProtoApiProvider.LMSTUDIO
|
||||
case "gemini":
|
||||
return ProtoApiProvider.GEMINI
|
||||
case "gemini-cli":
|
||||
return ProtoApiProvider.GEMINI_CLI
|
||||
case "openai-native":
|
||||
return ProtoApiProvider.OPENAI_NATIVE
|
||||
case "requesty":
|
||||
@@ -264,8 +262,6 @@ function convertProtoToApiProvider(provider: ProtoApiProvider): ApiProvider {
|
||||
return "lmstudio"
|
||||
case ProtoApiProvider.GEMINI:
|
||||
return "gemini"
|
||||
case ProtoApiProvider.GEMINI_CLI:
|
||||
return "gemini-cli"
|
||||
case ProtoApiProvider.OPENAI_NATIVE:
|
||||
return "openai-native"
|
||||
case ProtoApiProvider.REQUESTY:
|
||||
@@ -383,8 +379,6 @@ export function convertApiConfigurationToProto(config: ApiConfiguration): ProtoA
|
||||
sapAiCoreTokenUrl: config.sapAiCoreTokenUrl,
|
||||
sapAiCoreBaseUrl: config.sapAiCoreBaseUrl,
|
||||
claudeCodePath: config.claudeCodePath,
|
||||
geminiCliOauthPath: config.geminiCliOAuthPath,
|
||||
geminiCliProjectId: config.geminiCliProjectId,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -464,7 +458,5 @@ export function convertProtoToApiConfiguration(protoConfig: ProtoApiConfiguratio
|
||||
sapAiCoreTokenUrl: protoConfig.sapAiCoreTokenUrl,
|
||||
sapAiCoreBaseUrl: protoConfig.sapAiCoreBaseUrl,
|
||||
claudeCodePath: protoConfig.claudeCodePath,
|
||||
geminiCliOAuthPath: protoConfig.geminiCliOauthPath,
|
||||
geminiCliProjectId: protoConfig.geminiCliProjectId,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,11 +3,13 @@ import {
|
||||
UriServiceClientImpl,
|
||||
WatchServiceClientImpl,
|
||||
WorkspaceServiceClientImpl,
|
||||
EnvServiceClientImpl,
|
||||
} from "@generated/standalone/host-bridge-clients"
|
||||
import {
|
||||
UriServiceClientInterface,
|
||||
WatchServiceClientInterface,
|
||||
WorkspaceServiceClientInterface,
|
||||
EnvServiceClientInterface,
|
||||
} from "@generated/hosts/host-bridge-client-types"
|
||||
import { HostBridgeClientProvider } from "@/hosts/host-provider-types"
|
||||
|
||||
@@ -20,6 +22,7 @@ export class ExternalHostBridgeClientManager implements HostBridgeClientProvider
|
||||
uriServiceClient: UriServiceClientInterface
|
||||
watchServiceClient: WatchServiceClientInterface
|
||||
workspaceClient: WorkspaceServiceClientInterface
|
||||
envClient: EnvServiceClientInterface
|
||||
|
||||
constructor() {
|
||||
const address = process.env.HOST_BRIDGE_ADDRESS || "localhost:50052"
|
||||
@@ -28,6 +31,7 @@ export class ExternalHostBridgeClientManager implements HostBridgeClientProvider
|
||||
this.uriServiceClient = new UriServiceClientImpl(this.channel)
|
||||
this.watchServiceClient = new WatchServiceClientImpl(this.channel)
|
||||
this.workspaceClient = new WorkspaceServiceClientImpl(this.channel)
|
||||
this.envClient = new EnvServiceClientImpl(this.channel)
|
||||
}
|
||||
|
||||
public close(): void {
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { getHostBridgeProvider } from "@/hosts/host-providers"
|
||||
import { StringRequest, EmptyRequest } from "@/shared/proto/common"
|
||||
|
||||
/**
|
||||
* Writes text to the system clipboard
|
||||
* @param text The text to write to the clipboard
|
||||
* @returns Promise that resolves when the operation is complete
|
||||
* @throws Error if the operation fails
|
||||
*/
|
||||
export async function writeTextToClipboard(text: string): Promise<void> {
|
||||
try {
|
||||
await getHostBridgeProvider().envClient.clipboardWriteText(StringRequest.create({ value: text }))
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
throw new Error(`Failed to write to clipboard: ${errorMessage}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads text from the system clipboard
|
||||
* @returns Promise that resolves to the clipboard text
|
||||
* @throws Error if the operation fails
|
||||
*/
|
||||
export async function readTextFromClipboard(): Promise<string> {
|
||||
try {
|
||||
const response = await getHostBridgeProvider().envClient.clipboardReadText(EmptyRequest.create({}))
|
||||
return response.value
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
throw new Error(`Failed to read from clipboard: ${errorMessage}`)
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import * as vscode from "vscode"
|
||||
import * as cp from "child_process"
|
||||
import * as os from "os"
|
||||
import * as util from "util"
|
||||
import { writeTextToClipboard } from "@/utils/env"
|
||||
|
||||
/**
|
||||
* Creates a properly encoded GitHub issue URL.
|
||||
@@ -81,7 +82,7 @@ export async function openUrlInBrowser(url: string): Promise<void> {
|
||||
|
||||
// Always copy to clipboard as a fallback
|
||||
try {
|
||||
await vscode.env.clipboard.writeText(url)
|
||||
await writeTextToClipboard(url)
|
||||
console.log("URL copied to clipboard as backup")
|
||||
} catch (error) {
|
||||
console.error(`Failed to copy URL to clipboard: ${error}`)
|
||||
@@ -159,7 +160,7 @@ export async function openUrlInBrowser(url: string): Promise<void> {
|
||||
)
|
||||
.then((selection) => {
|
||||
if (selection === "Copy URL Again") {
|
||||
vscode.env.clipboard.writeText(url)
|
||||
writeTextToClipboard(url)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { ApiHandler } from "@api/index"
|
||||
|
||||
export function isClaude4ModelFamily(api: ApiHandler): boolean {
|
||||
const model = api.getModel()
|
||||
const modelId = model.id
|
||||
const modelId = model.id.toLowerCase()
|
||||
return (
|
||||
modelId.includes("sonnet-4") || modelId.includes("opus-4") || modelId.includes("4-sonnet") || modelId.includes("4-opus")
|
||||
)
|
||||
|
||||
+13
-4
@@ -1,6 +1,7 @@
|
||||
import * as path from "path"
|
||||
import os from "os"
|
||||
import * as vscode from "vscode"
|
||||
import { getHostBridgeProvider } from "@/hosts/host-providers"
|
||||
|
||||
/*
|
||||
The Node.js 'path' module resolves and normalizes paths differently depending on the platform:
|
||||
@@ -101,9 +102,17 @@ export function getReadablePath(cwd: string, relPath?: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
export const getWorkspacePath = (defaultCwdPath = "") => {
|
||||
const cwdPath = vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath).at(0) || defaultCwdPath
|
||||
// Returns the path of the first workspace directory, or the defaultCwdPath if there is no workspace open.
|
||||
export const getCwd = async (defaultCwdPath = ""): Promise<string> => {
|
||||
const workspaceFolders = await getHostBridgeProvider().workspaceClient.getWorkspacePaths({})
|
||||
return workspaceFolders.paths.shift() || defaultCwdPath
|
||||
}
|
||||
|
||||
// Returns the workspace path of the file in the current editor.
|
||||
// If there is no path, it returns the top level workspace directory.
|
||||
export const getWorkspacePath = async (defaultCwdPath = "") => {
|
||||
const currentFileUri = vscode.window.activeTextEditor?.document.uri
|
||||
const cwdPath = await getCwd(defaultCwdPath)
|
||||
if (currentFileUri) {
|
||||
const workspaceFolder = vscode.workspace.getWorkspaceFolder(currentFileUri)
|
||||
return workspaceFolder?.uri.fsPath || cwdPath
|
||||
@@ -111,8 +120,8 @@ export const getWorkspacePath = (defaultCwdPath = "") => {
|
||||
return cwdPath
|
||||
}
|
||||
|
||||
export const isLocatedInWorkspace = (pathToCheck: string = ""): boolean => {
|
||||
const workspacePath = getWorkspacePath()
|
||||
export const isLocatedInWorkspace = async (pathToCheck: string = ""): Promise<boolean> => {
|
||||
const workspacePath = await getWorkspacePath()
|
||||
|
||||
// Handle long paths in Windows
|
||||
if (pathToCheck.startsWith("\\\\?\\") || workspacePath.startsWith("\\\\?\\")) {
|
||||
|
||||
@@ -0,0 +1,514 @@
|
||||
const { spawn } = require("child_process")
|
||||
const { EventEmitter } = require("events")
|
||||
const path = require("path")
|
||||
const os = require("os")
|
||||
|
||||
// Enhanced terminal management for standalone Cline
|
||||
// This replaces VSCode's terminal integration with real subprocess management
|
||||
|
||||
class StandaloneTerminalProcess extends EventEmitter {
|
||||
constructor() {
|
||||
super()
|
||||
this.waitForShellIntegration = false // We don't need to wait since we control the process
|
||||
this.isListening = true
|
||||
this.buffer = ""
|
||||
this.fullOutput = ""
|
||||
this.lastRetrievedIndex = 0
|
||||
this.isHot = false
|
||||
this.hotTimer = null
|
||||
this.childProcess = null
|
||||
this.exitCode = null
|
||||
this.isCompleted = false
|
||||
}
|
||||
|
||||
async run(terminal, command) {
|
||||
console.log(`[StandaloneTerminal] Running command: ${command}`)
|
||||
|
||||
// Get shell and working directory from terminal
|
||||
const shell = terminal._shellPath || this.getDefaultShell()
|
||||
const cwd = terminal._cwd || process.cwd()
|
||||
|
||||
// Prepare command for execution
|
||||
const shellArgs = this.getShellArgs(shell, command)
|
||||
|
||||
try {
|
||||
// Spawn the process
|
||||
this.childProcess = spawn(shell, shellArgs, {
|
||||
cwd: cwd,
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
env: { ...process.env, TERM: "xterm-256color" },
|
||||
})
|
||||
|
||||
// Track process state
|
||||
let didEmitEmptyLine = false
|
||||
|
||||
// Handle stdout
|
||||
this.childProcess.stdout.on("data", (data) => {
|
||||
const output = data.toString()
|
||||
this.handleOutput(output, didEmitEmptyLine)
|
||||
if (!didEmitEmptyLine && output) {
|
||||
this.emit("line", "") // Signal start of output
|
||||
didEmitEmptyLine = true
|
||||
}
|
||||
})
|
||||
|
||||
// Handle stderr
|
||||
this.childProcess.stderr.on("data", (data) => {
|
||||
const output = data.toString()
|
||||
this.handleOutput(output, didEmitEmptyLine)
|
||||
if (!didEmitEmptyLine && output) {
|
||||
this.emit("line", "")
|
||||
didEmitEmptyLine = true
|
||||
}
|
||||
})
|
||||
|
||||
// Handle process completion
|
||||
this.childProcess.on("close", (code, signal) => {
|
||||
console.log(`[StandaloneTerminal] Process closed with code ${code}, signal ${signal}`)
|
||||
this.exitCode = code
|
||||
this.isCompleted = true
|
||||
this.emitRemainingBuffer()
|
||||
|
||||
// Clear hot timer
|
||||
if (this.hotTimer) {
|
||||
clearTimeout(this.hotTimer)
|
||||
this.isHot = false
|
||||
}
|
||||
|
||||
this.emit("completed")
|
||||
this.emit("continue")
|
||||
})
|
||||
|
||||
// Handle process errors
|
||||
this.childProcess.on("error", (error) => {
|
||||
console.error(`[StandaloneTerminal] Process error:`, error)
|
||||
this.emit("error", error)
|
||||
})
|
||||
|
||||
// Update terminal's process reference
|
||||
terminal._process = this.childProcess
|
||||
terminal._processId = this.childProcess.pid
|
||||
} catch (error) {
|
||||
console.error(`[StandaloneTerminal] Failed to spawn process:`, error)
|
||||
this.emit("error", error)
|
||||
}
|
||||
}
|
||||
|
||||
handleOutput(data, didEmitEmptyLine) {
|
||||
// Set process as hot (actively outputting)
|
||||
this.isHot = true
|
||||
if (this.hotTimer) {
|
||||
clearTimeout(this.hotTimer)
|
||||
}
|
||||
|
||||
// Check for compilation markers to adjust hot timeout
|
||||
const compilingMarkers = ["compiling", "building", "bundling", "transpiling", "generating", "starting"]
|
||||
const markerNullifiers = [
|
||||
"compiled",
|
||||
"success",
|
||||
"finish",
|
||||
"complete",
|
||||
"succeed",
|
||||
"done",
|
||||
"end",
|
||||
"stop",
|
||||
"exit",
|
||||
"terminate",
|
||||
"error",
|
||||
"fail",
|
||||
]
|
||||
|
||||
const isCompiling =
|
||||
compilingMarkers.some((marker) => data.toLowerCase().includes(marker.toLowerCase())) &&
|
||||
!markerNullifiers.some((nullifier) => data.toLowerCase().includes(nullifier.toLowerCase()))
|
||||
|
||||
const hotTimeout = isCompiling ? 15000 : 2000
|
||||
this.hotTimer = setTimeout(() => {
|
||||
this.isHot = false
|
||||
}, hotTimeout)
|
||||
|
||||
// Store full output
|
||||
this.fullOutput += data
|
||||
|
||||
if (this.isListening) {
|
||||
this.emitLines(data)
|
||||
this.lastRetrievedIndex = this.fullOutput.length - this.buffer.length
|
||||
}
|
||||
}
|
||||
|
||||
emitLines(chunk) {
|
||||
this.buffer += chunk
|
||||
let lineEndIndex
|
||||
while ((lineEndIndex = this.buffer.indexOf("\n")) !== -1) {
|
||||
let line = this.buffer.slice(0, lineEndIndex).trimEnd()
|
||||
this.emit("line", line)
|
||||
this.buffer = this.buffer.slice(lineEndIndex + 1)
|
||||
}
|
||||
}
|
||||
|
||||
emitRemainingBuffer() {
|
||||
if (this.buffer && this.isListening) {
|
||||
const remainingBuffer = this.removeLastLineArtifacts(this.buffer)
|
||||
if (remainingBuffer) {
|
||||
this.emit("line", remainingBuffer)
|
||||
}
|
||||
this.buffer = ""
|
||||
this.lastRetrievedIndex = this.fullOutput.length
|
||||
}
|
||||
}
|
||||
|
||||
continue() {
|
||||
this.emitRemainingBuffer()
|
||||
this.isListening = false
|
||||
this.removeAllListeners("line")
|
||||
this.emit("continue")
|
||||
}
|
||||
|
||||
getUnretrievedOutput() {
|
||||
const unretrieved = this.fullOutput.slice(this.lastRetrievedIndex)
|
||||
this.lastRetrievedIndex = this.fullOutput.length
|
||||
return this.removeLastLineArtifacts(unretrieved)
|
||||
}
|
||||
|
||||
removeLastLineArtifacts(output) {
|
||||
const lines = output.trimEnd().split("\n")
|
||||
if (lines.length > 0) {
|
||||
const lastLine = lines[lines.length - 1]
|
||||
lines[lines.length - 1] = lastLine.replace(/[%$#>]\s*$/, "")
|
||||
}
|
||||
return lines.join("\n").trimEnd()
|
||||
}
|
||||
|
||||
getDefaultShell() {
|
||||
if (process.platform === "win32") {
|
||||
return process.env.COMSPEC || "cmd.exe"
|
||||
} else {
|
||||
return process.env.SHELL || "/bin/bash"
|
||||
}
|
||||
}
|
||||
|
||||
getShellArgs(shell, command) {
|
||||
if (process.platform === "win32") {
|
||||
if (shell.toLowerCase().includes("powershell") || shell.toLowerCase().includes("pwsh")) {
|
||||
return ["-Command", command]
|
||||
} else {
|
||||
return ["/c", command]
|
||||
}
|
||||
} else {
|
||||
return ["-c", command]
|
||||
}
|
||||
}
|
||||
|
||||
// Terminate the process if it's still running
|
||||
terminate() {
|
||||
if (this.childProcess && !this.isCompleted) {
|
||||
console.log(`[StandaloneTerminal] Terminating process ${this.childProcess.pid}`)
|
||||
this.childProcess.kill("SIGTERM")
|
||||
|
||||
// Force kill after timeout
|
||||
setTimeout(() => {
|
||||
if (!this.isCompleted) {
|
||||
console.log(`[StandaloneTerminal] Force killing process ${this.childProcess.pid}`)
|
||||
this.childProcess.kill("SIGKILL")
|
||||
}
|
||||
}, 5000)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class StandaloneTerminal {
|
||||
constructor(options = {}) {
|
||||
this.name = options.name || `Terminal ${Math.floor(Math.random() * 10000)}`
|
||||
this.processId = Promise.resolve(Math.floor(Math.random() * 100000))
|
||||
this.creationOptions = options
|
||||
this.exitStatus = undefined
|
||||
this.state = { isInteractedWith: false }
|
||||
this._cwd = options.cwd || process.cwd()
|
||||
this._shellPath = options.shellPath
|
||||
this._process = null
|
||||
this._processId = null
|
||||
|
||||
// Mock shell integration for compatibility
|
||||
this.shellIntegration = {
|
||||
cwd: { fsPath: this._cwd },
|
||||
executeCommand: (command) => {
|
||||
// Return a mock execution object that the TerminalProcess expects
|
||||
return {
|
||||
read: async function* () {
|
||||
// This will be handled by our StandaloneTerminalProcess
|
||||
yield ""
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
console.log(`[StandaloneTerminal] Created terminal: ${this.name} in ${this._cwd}`)
|
||||
}
|
||||
|
||||
sendText(text, addNewLine = true) {
|
||||
console.log(`[StandaloneTerminal] sendText: ${text}`)
|
||||
|
||||
// If we have an active process, send input to it
|
||||
if (this._process && !this._process.killed) {
|
||||
try {
|
||||
this._process.stdin.write(text + (addNewLine ? "\n" : ""))
|
||||
} catch (error) {
|
||||
console.error(`[StandaloneTerminal] Error sending text to process:`, error)
|
||||
}
|
||||
} else {
|
||||
// For compatibility with old behavior, we could spawn a new process
|
||||
console.log(`[StandaloneTerminal] No active process to send text to`)
|
||||
}
|
||||
}
|
||||
|
||||
show() {
|
||||
console.log(`[StandaloneTerminal] show: ${this.name}`)
|
||||
this.state.isInteractedWith = true
|
||||
}
|
||||
|
||||
hide() {
|
||||
console.log(`[StandaloneTerminal] hide: ${this.name}`)
|
||||
}
|
||||
|
||||
dispose() {
|
||||
console.log(`[StandaloneTerminal] dispose: ${this.name}`)
|
||||
if (this._process && !this._process.killed) {
|
||||
this._process.kill("SIGTERM")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Terminal registry for tracking terminals
|
||||
class StandaloneTerminalRegistry {
|
||||
constructor() {
|
||||
this.terminals = new Map()
|
||||
this.nextId = 1
|
||||
}
|
||||
|
||||
createTerminal(options = {}) {
|
||||
const terminal = new StandaloneTerminal(options)
|
||||
const id = this.nextId++
|
||||
|
||||
const terminalInfo = {
|
||||
id: id,
|
||||
terminal: terminal,
|
||||
busy: false,
|
||||
lastCommand: "",
|
||||
shellPath: options.shellPath,
|
||||
lastActive: Date.now(),
|
||||
pendingCwdChange: undefined,
|
||||
cwdResolved: undefined,
|
||||
}
|
||||
|
||||
this.terminals.set(id, terminalInfo)
|
||||
console.log(`[StandaloneTerminalRegistry] Created terminal ${id}`)
|
||||
return terminalInfo
|
||||
}
|
||||
|
||||
getTerminal(id) {
|
||||
return this.terminals.get(id)
|
||||
}
|
||||
|
||||
getAllTerminals() {
|
||||
return Array.from(this.terminals.values())
|
||||
}
|
||||
|
||||
removeTerminal(id) {
|
||||
const terminalInfo = this.terminals.get(id)
|
||||
if (terminalInfo) {
|
||||
terminalInfo.terminal.dispose()
|
||||
this.terminals.delete(id)
|
||||
console.log(`[StandaloneTerminalRegistry] Removed terminal ${id}`)
|
||||
}
|
||||
}
|
||||
|
||||
updateTerminal(id, updates) {
|
||||
const terminalInfo = this.terminals.get(id)
|
||||
if (terminalInfo) {
|
||||
Object.assign(terminalInfo, updates)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Enhanced terminal manager
|
||||
class StandaloneTerminalManager {
|
||||
constructor() {
|
||||
this.registry = new StandaloneTerminalRegistry()
|
||||
this.processes = new Map()
|
||||
this.terminalIds = new Set()
|
||||
this.shellIntegrationTimeout = 4000
|
||||
this.terminalReuseEnabled = true
|
||||
this.terminalOutputLineLimit = 500
|
||||
this.defaultTerminalProfile = "default"
|
||||
}
|
||||
|
||||
runCommand(terminalInfo, command) {
|
||||
console.log(`[StandaloneTerminalManager] Running command on terminal ${terminalInfo.id}: ${command}`)
|
||||
|
||||
terminalInfo.busy = true
|
||||
terminalInfo.lastCommand = command
|
||||
|
||||
const process = new StandaloneTerminalProcess()
|
||||
this.processes.set(terminalInfo.id, process)
|
||||
|
||||
process.once("completed", () => {
|
||||
terminalInfo.busy = false
|
||||
console.log(`[StandaloneTerminalManager] Command completed on terminal ${terminalInfo.id}`)
|
||||
})
|
||||
|
||||
process.once("error", (error) => {
|
||||
terminalInfo.busy = false
|
||||
console.error(`[StandaloneTerminalManager] Command error on terminal ${terminalInfo.id}:`, error)
|
||||
})
|
||||
|
||||
// Create promise for the process
|
||||
const promise = new Promise((resolve, reject) => {
|
||||
process.once("continue", () => resolve())
|
||||
process.once("error", (error) => reject(error))
|
||||
})
|
||||
|
||||
// Run the command immediately (no shell integration wait needed)
|
||||
process.run(terminalInfo.terminal, command)
|
||||
|
||||
// Return merged promise/process object
|
||||
return this.mergePromise(process, promise)
|
||||
}
|
||||
|
||||
async getOrCreateTerminal(cwd) {
|
||||
const terminals = this.registry.getAllTerminals()
|
||||
|
||||
// Find available terminal with matching CWD
|
||||
const matchingTerminal = terminals.find((t) => {
|
||||
if (t.busy) return false
|
||||
return t.terminal._cwd === cwd
|
||||
})
|
||||
|
||||
if (matchingTerminal) {
|
||||
this.terminalIds.add(matchingTerminal.id)
|
||||
console.log(`[StandaloneTerminalManager] Reusing terminal ${matchingTerminal.id}`)
|
||||
return matchingTerminal
|
||||
}
|
||||
|
||||
// Find any available terminal if reuse is enabled
|
||||
if (this.terminalReuseEnabled) {
|
||||
const availableTerminal = terminals.find((t) => !t.busy)
|
||||
if (availableTerminal) {
|
||||
// Change directory
|
||||
await this.runCommand(availableTerminal, `cd "${cwd}"`)
|
||||
availableTerminal.terminal._cwd = cwd
|
||||
availableTerminal.terminal.shellIntegration.cwd.fsPath = cwd
|
||||
this.terminalIds.add(availableTerminal.id)
|
||||
console.log(`[StandaloneTerminalManager] Reused terminal ${availableTerminal.id} with cd`)
|
||||
return availableTerminal
|
||||
}
|
||||
}
|
||||
|
||||
// Create new terminal
|
||||
const newTerminalInfo = this.registry.createTerminal({
|
||||
cwd: cwd,
|
||||
name: `Cline Terminal ${this.registry.nextId}`,
|
||||
})
|
||||
this.terminalIds.add(newTerminalInfo.id)
|
||||
console.log(`[StandaloneTerminalManager] Created new terminal ${newTerminalInfo.id}`)
|
||||
return newTerminalInfo
|
||||
}
|
||||
|
||||
getTerminals(busy) {
|
||||
return Array.from(this.terminalIds)
|
||||
.map((id) => this.registry.getTerminal(id))
|
||||
.filter((t) => t && t.busy === busy)
|
||||
.map((t) => ({ id: t.id, lastCommand: t.lastCommand }))
|
||||
}
|
||||
|
||||
getUnretrievedOutput(terminalId) {
|
||||
if (!this.terminalIds.has(terminalId)) {
|
||||
return ""
|
||||
}
|
||||
const process = this.processes.get(terminalId)
|
||||
return process ? process.getUnretrievedOutput() : ""
|
||||
}
|
||||
|
||||
isProcessHot(terminalId) {
|
||||
const process = this.processes.get(terminalId)
|
||||
return process ? process.isHot : false
|
||||
}
|
||||
|
||||
processOutput(outputLines) {
|
||||
if (outputLines.length > this.terminalOutputLineLimit) {
|
||||
const halfLimit = Math.floor(this.terminalOutputLineLimit / 2)
|
||||
const start = outputLines.slice(0, halfLimit)
|
||||
const end = outputLines.slice(outputLines.length - halfLimit)
|
||||
return `${start.join("\n")}\n... (output truncated) ...\n${end.join("\n")}`.trim()
|
||||
}
|
||||
return outputLines.join("\n").trim()
|
||||
}
|
||||
|
||||
disposeAll() {
|
||||
// Terminate all processes
|
||||
for (const [terminalId, process] of this.processes) {
|
||||
if (process && process.terminate) {
|
||||
process.terminate()
|
||||
}
|
||||
}
|
||||
|
||||
// Clear all tracking
|
||||
this.terminalIds.clear()
|
||||
this.processes.clear()
|
||||
|
||||
// Dispose all terminals
|
||||
for (const terminalInfo of this.registry.getAllTerminals()) {
|
||||
terminalInfo.terminal.dispose()
|
||||
}
|
||||
|
||||
console.log(`[StandaloneTerminalManager] Disposed all terminals`)
|
||||
}
|
||||
|
||||
// Set shell integration timeout (compatibility method)
|
||||
setShellIntegrationTimeout(timeout) {
|
||||
this.shellIntegrationTimeout = timeout
|
||||
console.log(`[StandaloneTerminalManager] Set shell integration timeout to ${timeout}ms`)
|
||||
}
|
||||
|
||||
// Set terminal reuse enabled (compatibility method)
|
||||
setTerminalReuseEnabled(enabled) {
|
||||
this.terminalReuseEnabled = enabled
|
||||
console.log(`[StandaloneTerminalManager] Set terminal reuse enabled to ${enabled}`)
|
||||
}
|
||||
|
||||
// Set terminal output line limit (compatibility method)
|
||||
setTerminalOutputLineLimit(limit) {
|
||||
this.terminalOutputLineLimit = limit
|
||||
console.log(`[StandaloneTerminalManager] Set terminal output line limit to ${limit}`)
|
||||
}
|
||||
|
||||
// Set default terminal profile (compatibility method)
|
||||
setDefaultTerminalProfile(profile) {
|
||||
this.defaultTerminalProfile = profile
|
||||
console.log(`[StandaloneTerminalManager] Set default terminal profile to ${profile}`)
|
||||
}
|
||||
|
||||
// Helper to merge process and promise (similar to execa)
|
||||
mergePromise(process, promise) {
|
||||
const nativePromisePrototype = (async () => {})().constructor.prototype
|
||||
const descriptors = ["then", "catch", "finally"].map((property) => [
|
||||
property,
|
||||
Reflect.getOwnPropertyDescriptor(nativePromisePrototype, property),
|
||||
])
|
||||
|
||||
for (const [property, descriptor] of descriptors) {
|
||||
if (descriptor) {
|
||||
const value = descriptor.value.bind(promise)
|
||||
Reflect.defineProperty(process, property, { ...descriptor, value })
|
||||
}
|
||||
}
|
||||
|
||||
return process
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
StandaloneTerminal,
|
||||
StandaloneTerminalProcess,
|
||||
StandaloneTerminalRegistry,
|
||||
StandaloneTerminalManager,
|
||||
}
|
||||
@@ -2,8 +2,19 @@ console.log("Loading stub impls...")
|
||||
|
||||
const { createStub } = require("./stub-utils")
|
||||
const open = require("open").default
|
||||
const fs = require("fs")
|
||||
const path = require("path")
|
||||
const { StandaloneTerminalManager } = require("./enhanced-terminal")
|
||||
|
||||
// Import the base vscode object from stubs
|
||||
const vscode = require("./vscode-stubs.js")
|
||||
|
||||
// Create global terminal manager instance
|
||||
const globalTerminalManager = new StandaloneTerminalManager()
|
||||
|
||||
// Extend the existing window object from stubs rather than overwriting it
|
||||
vscode.window = {
|
||||
...vscode.window, // Keep existing properties from stubs
|
||||
showInformationMessage: (...args) => {
|
||||
console.log("Stubbed showInformationMessage:", ...args)
|
||||
return Promise.resolve(undefined)
|
||||
@@ -28,9 +39,210 @@ vscode.window = {
|
||||
console.log("Stubbed showSaveDialog:", options)
|
||||
return undefined
|
||||
},
|
||||
showTextDocument: async (...args) => {
|
||||
console.log("Stubbed showTextDocument:", ...args)
|
||||
return {}
|
||||
showTextDocument: async (uri, options) => {
|
||||
console.log("Stubbed showTextDocument:", uri, options)
|
||||
|
||||
// Extract file path from URI
|
||||
let filePath = uri.path || uri.fsPath || uri
|
||||
if (typeof filePath !== "string") {
|
||||
filePath = uri.toString()
|
||||
}
|
||||
|
||||
// Remove file:// prefix if present
|
||||
if (filePath.startsWith("file://")) {
|
||||
filePath = filePath.substring(7)
|
||||
}
|
||||
|
||||
// Create a function that always reads the current file content
|
||||
const getCurrentFileContent = async () => {
|
||||
try {
|
||||
const content = await fs.promises.readFile(filePath, "utf8")
|
||||
console.log(`getCurrentFileContent: Read file ${filePath} (${content.length} chars)`)
|
||||
return content
|
||||
} catch (error) {
|
||||
console.log(`getCurrentFileContent: Could not read file ${filePath}:`, error.message)
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// Try to read the initial file content
|
||||
let fileContent = await getCurrentFileContent()
|
||||
let lineCount = fileContent.split("\n").length
|
||||
|
||||
// Check if we already have an active editor for this file path
|
||||
const existingEditor = vscode.window._documentEditors && vscode.window._documentEditors[filePath]
|
||||
if (existingEditor) {
|
||||
console.log(`showTextDocument: Updating existing editor for ${filePath}`)
|
||||
// Update the existing editor's content
|
||||
fileContent = await getCurrentFileContent()
|
||||
lineCount = fileContent.split("\n").length
|
||||
|
||||
// Update the document's getText method to return current content
|
||||
existingEditor.document.getText = (range) => {
|
||||
// Always read fresh content for getText calls
|
||||
const currentContent = require("fs").readFileSync(filePath, "utf8")
|
||||
if (!range) {
|
||||
return currentContent
|
||||
}
|
||||
// Handle range-based getText with current content
|
||||
const lines = currentContent.split("\n")
|
||||
const startLine = Math.max(0, range.start.line)
|
||||
const endLine = Math.min(lines.length - 1, range.end.line)
|
||||
|
||||
if (startLine === endLine) {
|
||||
// Single line
|
||||
const line = lines[startLine] || ""
|
||||
const startChar = Math.max(0, range.start.character)
|
||||
const endChar = Math.min(line.length, range.end.character)
|
||||
return line.substring(startChar, endChar)
|
||||
} else {
|
||||
// Multiple lines
|
||||
const result = []
|
||||
for (let i = startLine; i <= endLine; i++) {
|
||||
const line = lines[i] || ""
|
||||
if (i === startLine) {
|
||||
result.push(line.substring(range.start.character))
|
||||
} else if (i === endLine) {
|
||||
result.push(line.substring(0, range.end.character))
|
||||
} else {
|
||||
result.push(line)
|
||||
}
|
||||
}
|
||||
return result.join("\n")
|
||||
}
|
||||
}
|
||||
|
||||
// Update other properties
|
||||
existingEditor.document.lineCount = lineCount
|
||||
existingEditor.document.fileName = filePath
|
||||
|
||||
// Update the active text editor reference
|
||||
vscode.window.activeTextEditor = existingEditor
|
||||
|
||||
return existingEditor
|
||||
}
|
||||
|
||||
// Create a new mock text editor that always reads current file content
|
||||
const mockEditor = {
|
||||
document: {
|
||||
uri: uri,
|
||||
fileName: filePath,
|
||||
isDirty: false,
|
||||
lineCount: lineCount,
|
||||
getText: (range) => {
|
||||
// Always read fresh content for getText calls
|
||||
try {
|
||||
const currentContent = require("fs").readFileSync(filePath, "utf8")
|
||||
console.log(`document.getText: Read fresh content (${currentContent.length} chars)`)
|
||||
if (!range) {
|
||||
return currentContent
|
||||
}
|
||||
// Handle range-based getText with current content
|
||||
const lines = currentContent.split("\n")
|
||||
const startLine = Math.max(0, range.start.line)
|
||||
const endLine = Math.min(lines.length - 1, range.end.line)
|
||||
|
||||
if (startLine === endLine) {
|
||||
// Single line
|
||||
const line = lines[startLine] || ""
|
||||
const startChar = Math.max(0, range.start.character)
|
||||
const endChar = Math.min(line.length, range.end.character)
|
||||
return line.substring(startChar, endChar)
|
||||
} else {
|
||||
// Multiple lines
|
||||
const result = []
|
||||
for (let i = startLine; i <= endLine; i++) {
|
||||
const line = lines[i] || ""
|
||||
if (i === startLine) {
|
||||
result.push(line.substring(range.start.character))
|
||||
} else if (i === endLine) {
|
||||
result.push(line.substring(0, range.end.character))
|
||||
} else {
|
||||
result.push(line)
|
||||
}
|
||||
}
|
||||
return result.join("\n")
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error reading file in getText: ${error.message}`)
|
||||
return ""
|
||||
}
|
||||
},
|
||||
save: async () => {
|
||||
console.log("Called mock textDocument.save")
|
||||
return true
|
||||
},
|
||||
positionAt: (offset) => {
|
||||
try {
|
||||
const currentContent = require("fs").readFileSync(filePath, "utf8")
|
||||
const lines = currentContent.split("\n")
|
||||
let currentOffset = 0
|
||||
for (let line = 0; line < lines.length; line++) {
|
||||
const lineLength = lines[line].length + 1 // +1 for newline
|
||||
if (currentOffset + lineLength > offset) {
|
||||
return { line: line, character: offset - currentOffset }
|
||||
}
|
||||
currentOffset += lineLength
|
||||
}
|
||||
return { line: lines.length - 1, character: lines[lines.length - 1]?.length || 0 }
|
||||
} catch (error) {
|
||||
return { line: 0, character: 0 }
|
||||
}
|
||||
},
|
||||
offsetAt: (position) => {
|
||||
try {
|
||||
const currentContent = require("fs").readFileSync(filePath, "utf8")
|
||||
const lines = currentContent.split("\n")
|
||||
let offset = 0
|
||||
for (let i = 0; i < position.line && i < lines.length; i++) {
|
||||
offset += lines[i].length + 1 // +1 for newline
|
||||
}
|
||||
offset += Math.min(position.character, lines[position.line]?.length || 0)
|
||||
return offset
|
||||
} catch (error) {
|
||||
return 0
|
||||
}
|
||||
},
|
||||
},
|
||||
selection: { start: { line: 0, character: 0 }, end: { line: 0, character: 0 } },
|
||||
selections: [],
|
||||
visibleRanges: [{ start: { line: 0, character: 0 }, end: { line: 0, character: 0 } }],
|
||||
options: {},
|
||||
viewColumn: 1,
|
||||
edit: async (callback) => {
|
||||
console.log("Called mock textEditor.edit")
|
||||
return true
|
||||
},
|
||||
insertSnippet: async () => true,
|
||||
setDecorations: () => {},
|
||||
revealRange: () => {},
|
||||
show: () => {},
|
||||
hide: () => {},
|
||||
}
|
||||
|
||||
// Store the editor by file path for future reference
|
||||
if (!vscode.window._documentEditors) {
|
||||
vscode.window._documentEditors = {}
|
||||
}
|
||||
vscode.window._documentEditors[filePath] = mockEditor
|
||||
|
||||
// Update the active text editor
|
||||
vscode.window.activeTextEditor = mockEditor
|
||||
|
||||
// Trigger onDidChangeActiveTextEditor listeners
|
||||
if (vscode.window._activeTextEditorListeners) {
|
||||
setTimeout(() => {
|
||||
vscode.window._activeTextEditorListeners.forEach((listener) => {
|
||||
try {
|
||||
listener(mockEditor)
|
||||
} catch (error) {
|
||||
console.error("Error calling onDidChangeActiveTextEditor listener:", error)
|
||||
}
|
||||
})
|
||||
}, 10) // Small delay to simulate async behavior
|
||||
}
|
||||
|
||||
return mockEditor
|
||||
},
|
||||
createOutputChannel: (name) => {
|
||||
console.log("Stubbed createOutputChannel:", name)
|
||||
@@ -41,20 +253,60 @@ vscode.window = {
|
||||
}
|
||||
},
|
||||
createTerminal: (...args) => {
|
||||
console.log("Stubbed createTerminal:", ...args)
|
||||
return {
|
||||
sendText: console.log,
|
||||
show: () => {},
|
||||
dispose: () => {},
|
||||
console.log("Enhanced createTerminal:", ...args)
|
||||
|
||||
// Extract options from arguments
|
||||
let options = {}
|
||||
if (args.length > 0) {
|
||||
if (typeof args[0] === "string") {
|
||||
// Called with (name, shellPath, shellArgs)
|
||||
options = {
|
||||
name: args[0],
|
||||
shellPath: args[1],
|
||||
shellArgs: args[2],
|
||||
}
|
||||
} else if (typeof args[0] === "object") {
|
||||
// Called with options object
|
||||
options = args[0]
|
||||
}
|
||||
}
|
||||
|
||||
// Use our enhanced terminal manager to create a terminal
|
||||
const terminalInfo = globalTerminalManager.registry.createTerminal({
|
||||
name: options.name || `Terminal ${Date.now()}`,
|
||||
cwd: options.cwd || process.cwd(),
|
||||
shellPath: options.shellPath,
|
||||
})
|
||||
|
||||
// Store reference for tracking
|
||||
vscode.window.terminals.push(terminalInfo.terminal)
|
||||
if (!vscode.window.activeTerminal) {
|
||||
vscode.window.activeTerminal = terminalInfo.terminal
|
||||
}
|
||||
|
||||
console.log(`Enhanced terminal created: ${terminalInfo.id}`)
|
||||
return terminalInfo.terminal
|
||||
},
|
||||
activeTextEditor: undefined,
|
||||
visibleTextEditors: [],
|
||||
tabGroups: {
|
||||
all: [],
|
||||
close: async () => {},
|
||||
onDidChangeTabs: createStub("vscode.env.tabGroups.onDidChangeTabs"),
|
||||
activeTabGroup: { tabs: [] },
|
||||
all: [
|
||||
{
|
||||
tabs: [],
|
||||
isActive: true,
|
||||
viewColumn: 1,
|
||||
},
|
||||
],
|
||||
activeTabGroup: {
|
||||
tabs: [],
|
||||
isActive: true,
|
||||
viewColumn: 1,
|
||||
},
|
||||
close: async (tab) => {
|
||||
console.log("Stubbed tabGroups.close:", tab)
|
||||
return true
|
||||
},
|
||||
onDidChangeTabs: createStub("vscode.window.tabGroups.onDidChangeTabs"),
|
||||
},
|
||||
withProgress: async (_options, task) => {
|
||||
console.log("Stubbed withProgress")
|
||||
@@ -62,14 +314,56 @@ vscode.window = {
|
||||
},
|
||||
registerUriHandler: () => ({ dispose: () => {} }),
|
||||
registerWebviewViewProvider: () => ({ dispose: () => {} }),
|
||||
onDidChangeActiveTextEditor: () => ({ dispose: () => {} }),
|
||||
createTextEditorDecorationType: () => ({ dispose: () => {} }),
|
||||
createWebviewPanel: (..._args) => {
|
||||
throw new Error("WebviewPanel is not supported in standalone app.")
|
||||
onDidChangeActiveTextEditor: (listener) => {
|
||||
console.log("Called vscode.window.onDidChangeActiveTextEditor")
|
||||
// Store the listener so we can call it when showTextDocument is called
|
||||
vscode.window._activeTextEditorListeners = vscode.window._activeTextEditorListeners || []
|
||||
vscode.window._activeTextEditorListeners.push(listener)
|
||||
return {
|
||||
dispose: () => {
|
||||
console.log("Disposed onDidChangeActiveTextEditor listener")
|
||||
const index = vscode.window._activeTextEditorListeners.indexOf(listener)
|
||||
if (index > -1) {
|
||||
vscode.window._activeTextEditorListeners.splice(index, 1)
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
createTextEditorDecorationType: () => ({ dispose: () => {} }),
|
||||
createWebviewPanel: (...args) => {
|
||||
console.log("Stubbed createWebviewPanel:", ...args)
|
||||
return {
|
||||
webview: {},
|
||||
reveal: () => {},
|
||||
dispose: () => {},
|
||||
}
|
||||
},
|
||||
onDidChangeTerminalState: (listener) => {
|
||||
console.log("Called vscode.window.onDidChangeTerminalState")
|
||||
return {
|
||||
dispose: () => {
|
||||
console.log("Disposed onDidChangeTerminalState listener")
|
||||
},
|
||||
}
|
||||
},
|
||||
onDidChangeTextEditorVisibleRanges: (listener) => {
|
||||
console.log("Called vscode.window.onDidChangeTextEditorVisibleRanges")
|
||||
return {
|
||||
dispose: () => {
|
||||
console.log("Disposed onDidChangeTextEditorVisibleRanges listener")
|
||||
},
|
||||
}
|
||||
},
|
||||
terminals: [],
|
||||
activeTerminal: null,
|
||||
}
|
||||
|
||||
vscode.env = {
|
||||
// Initialize env object if it doesn't exist, then extend it
|
||||
if (!vscode.env) {
|
||||
vscode.env = {}
|
||||
}
|
||||
|
||||
Object.assign(vscode.env, {
|
||||
uriScheme: "vscode",
|
||||
appName: "Visual Studio Code",
|
||||
appRoot: "/tmp/vscode/appRoot",
|
||||
@@ -79,17 +373,26 @@ vscode.env = {
|
||||
sessionId: "stub-session-id",
|
||||
shell: "/bin/bash",
|
||||
|
||||
// Add the stub functions that were missing
|
||||
clipboard: createStub("vscode.env.clipboard"),
|
||||
openExternal: createStub("vscode.env.openExternal"),
|
||||
getQueryParameter: createStub("vscode.env.getQueryParameter"),
|
||||
onDidChangeTelemetryEnabled: createStub("vscode.env.onDidChangeTelemetryEnabled"),
|
||||
isTelemetryEnabled: createStub("vscode.env.isTelemetryEnabled"),
|
||||
telemetryConfiguration: createStub("vscode.env.telemetryConfiguration"),
|
||||
onDidChangeTelemetryConfiguration: createStub("vscode.env.onDidChangeTelemetryConfiguration"),
|
||||
createTelemetryLogger: createStub("vscode.env.createTelemetryLogger"),
|
||||
})
|
||||
|
||||
// Override the openExternal function with actual implementation
|
||||
vscode.env.openExternal = async (uri) => {
|
||||
const url = typeof uri === "string" ? uri : (uri.toString?.() ?? "")
|
||||
console.log("Opening browser:", url)
|
||||
await open(url)
|
||||
return true
|
||||
}
|
||||
|
||||
vscode.Uri = {
|
||||
// Extend Uri object with improved implementations
|
||||
Object.assign(vscode.Uri, {
|
||||
parse: (uriString) => {
|
||||
const url = new URL(uriString)
|
||||
return {
|
||||
@@ -134,13 +437,405 @@ vscode.Uri = {
|
||||
const joined = segments.map((s) => (typeof s === "string" ? s : s.path)).join("/")
|
||||
return vscode.Uri.file("/" + joined.replace(/\/+/g, "/"))
|
||||
},
|
||||
})
|
||||
|
||||
// Extend workspace object with file system operations
|
||||
Object.assign(vscode.workspace, {
|
||||
fs: {
|
||||
readFile: async function (uri) {
|
||||
console.log(`Called vscode.workspace.fs.readFile with uri:`, uri)
|
||||
try {
|
||||
// Extract file path from URI
|
||||
let filePath = uri.path || uri.fsPath || uri
|
||||
if (typeof filePath !== "string") {
|
||||
filePath = uri.toString()
|
||||
}
|
||||
|
||||
// Remove file:// prefix if present
|
||||
if (filePath.startsWith("file://")) {
|
||||
filePath = filePath.substring(7)
|
||||
}
|
||||
|
||||
console.log(`Reading file: ${filePath}`)
|
||||
const content = await fs.promises.readFile(filePath, "utf8")
|
||||
console.log(
|
||||
`File content read (${content.length} chars):`,
|
||||
content.substring(0, 100) + (content.length > 100 ? "..." : ""),
|
||||
)
|
||||
return new Uint8Array(Buffer.from(content, "utf8"))
|
||||
} catch (error) {
|
||||
console.error(`Error reading file:`, error)
|
||||
throw error
|
||||
}
|
||||
},
|
||||
|
||||
writeFile: async function (uri, content) {
|
||||
console.log(`Called vscode.workspace.fs.writeFile with uri:`, uri)
|
||||
try {
|
||||
// Extract file path from URI
|
||||
let filePath = uri.path || uri.fsPath || uri
|
||||
if (typeof filePath !== "string") {
|
||||
filePath = uri.toString()
|
||||
}
|
||||
|
||||
// Remove file:// prefix if present
|
||||
if (filePath.startsWith("file://")) {
|
||||
filePath = filePath.substring(7)
|
||||
}
|
||||
|
||||
console.log(`Writing file: ${filePath}`)
|
||||
|
||||
// Ensure directory exists
|
||||
const dir = path.dirname(filePath)
|
||||
await fs.promises.mkdir(dir, { recursive: true })
|
||||
|
||||
// Write the file
|
||||
await fs.promises.writeFile(filePath, content)
|
||||
} catch (error) {
|
||||
console.error(`Error writing file:`, error)
|
||||
throw error
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
// Add workspace folder configuration
|
||||
rootPath: process.cwd(),
|
||||
workspaceFolders: [
|
||||
{
|
||||
uri: vscode.Uri.file(process.cwd()),
|
||||
name: path.basename(process.cwd()),
|
||||
index: 0,
|
||||
},
|
||||
],
|
||||
name: path.basename(process.cwd()),
|
||||
workspaceFile: vscode.Uri.file(path.join(process.cwd(), ".vscode", "workspace.json")),
|
||||
|
||||
// Add other workspace methods as stubs
|
||||
getConfiguration: () => ({
|
||||
get: () => undefined,
|
||||
update: () => Promise.resolve(),
|
||||
has: () => false,
|
||||
}),
|
||||
getWorkspaceFolder: (uri) => {
|
||||
console.log("Called vscode.workspace.getWorkspaceFolder with:", uri)
|
||||
// Return the first workspace folder for any URI in standalone mode
|
||||
if (vscode.workspace.workspaceFolders && vscode.workspace.workspaceFolders.length > 0) {
|
||||
return vscode.workspace.workspaceFolders[0]
|
||||
}
|
||||
return undefined
|
||||
},
|
||||
createFileSystemWatcher: () => ({
|
||||
onDidChange: () => ({ dispose: () => {} }),
|
||||
onDidCreate: () => ({ dispose: () => {} }),
|
||||
onDidDelete: () => ({ dispose: () => {} }),
|
||||
dispose: () => {},
|
||||
}),
|
||||
onDidChangeConfiguration: () => ({ dispose: () => {} }),
|
||||
onDidChangeWorkspaceFolders: () => ({ dispose: () => {} }),
|
||||
onDidCreateFiles: createStub("vscode.workspace.onDidCreateFiles"),
|
||||
onDidDeleteFiles: createStub("vscode.workspace.onDidDeleteFiles"),
|
||||
onDidRenameFiles: createStub("vscode.workspace.onDidRenameFiles"),
|
||||
onWillCreateFiles: createStub("vscode.workspace.onWillCreateFiles"),
|
||||
onWillDeleteFiles: createStub("vscode.workspace.onWillDeleteFiles"),
|
||||
onWillRenameFiles: createStub("vscode.workspace.onWillRenameFiles"),
|
||||
textDocuments: {
|
||||
find: (predicate) => {
|
||||
console.log("Called vscode.workspace.textDocuments.find")
|
||||
// Return a mock text document that behaves like VSCode expects
|
||||
return {
|
||||
uri: { fsPath: "/tmp/mock-document" },
|
||||
fileName: "/tmp/mock-document",
|
||||
isDirty: false,
|
||||
save: async () => {
|
||||
console.log("Called mock textDocument.save")
|
||||
return true
|
||||
},
|
||||
getText: () => "",
|
||||
lineCount: 0,
|
||||
}
|
||||
},
|
||||
forEach: (callback) => {
|
||||
console.log("Called vscode.workspace.textDocuments.forEach")
|
||||
// No documents to iterate over in standalone mode
|
||||
},
|
||||
length: 0,
|
||||
[Symbol.iterator]: function* () {
|
||||
// Empty iterator for standalone mode
|
||||
},
|
||||
},
|
||||
|
||||
// Add the crucial applyEdit method
|
||||
applyEdit: async (workspaceEdit) => {
|
||||
console.log("Called vscode.workspace.applyEdit", workspaceEdit)
|
||||
|
||||
// For standalone mode, we'll simulate applying the edit by actually writing to files
|
||||
try {
|
||||
// WorkspaceEdit can contain multiple types of edits
|
||||
if (workspaceEdit._edits) {
|
||||
for (const edit of workspaceEdit._edits) {
|
||||
if (edit._type === 1) {
|
||||
// TextEdit
|
||||
const uri = edit._uri
|
||||
const edits = edit._edits
|
||||
|
||||
let filePath = uri.path || uri.fsPath
|
||||
if (filePath.startsWith("file://")) {
|
||||
filePath = filePath.substring(7)
|
||||
}
|
||||
|
||||
console.log(`Applying text edits to: ${filePath}`)
|
||||
|
||||
// Read current content if file exists
|
||||
let currentContent = ""
|
||||
try {
|
||||
currentContent = await fs.promises.readFile(filePath, "utf8")
|
||||
} catch (e) {
|
||||
// File doesn't exist, start with empty content
|
||||
console.log(`File ${filePath} doesn't exist, starting with empty content`)
|
||||
}
|
||||
|
||||
// Apply edits in reverse order (from end to beginning) to maintain positions
|
||||
const sortedEdits = edits.sort((a, b) => {
|
||||
const aStart = a.range.start.line * 1000000 + a.range.start.character
|
||||
const bStart = b.range.start.line * 1000000 + b.range.start.character
|
||||
return bStart - aStart
|
||||
})
|
||||
|
||||
let lines = currentContent.split("\n")
|
||||
|
||||
for (const edit of sortedEdits) {
|
||||
const startLine = edit.range.start.line
|
||||
const startChar = edit.range.start.character
|
||||
const endLine = edit.range.end.line
|
||||
const endChar = edit.range.end.character
|
||||
const newText = edit.newText
|
||||
|
||||
console.log(`Applying edit: ${startLine}:${startChar} - ${endLine}:${endChar} -> "${newText}"`)
|
||||
|
||||
// Handle the edit
|
||||
if (startLine === endLine) {
|
||||
// Single line edit
|
||||
const line = lines[startLine] || ""
|
||||
lines[startLine] = line.substring(0, startChar) + newText + line.substring(endChar)
|
||||
} else {
|
||||
// Multi-line edit
|
||||
const firstLine = lines[startLine] || ""
|
||||
const lastLine = lines[endLine] || ""
|
||||
const newFirstLine = firstLine.substring(0, startChar) + newText + lastLine.substring(endChar)
|
||||
|
||||
// Replace the range with the new content
|
||||
lines.splice(startLine, endLine - startLine + 1, newFirstLine)
|
||||
}
|
||||
}
|
||||
|
||||
const newContent = lines.join("\n")
|
||||
|
||||
// Ensure directory exists
|
||||
const dir = path.dirname(filePath)
|
||||
await fs.promises.mkdir(dir, { recursive: true })
|
||||
|
||||
// Write the updated content
|
||||
await fs.promises.writeFile(filePath, newContent, "utf8")
|
||||
console.log(`Successfully applied edits to: ${filePath}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error("Error applying workspace edit:", error)
|
||||
return false
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
// Fix CodeActionKind to have static properties instead of being a class
|
||||
vscode.CodeActionKind = {
|
||||
Empty: "",
|
||||
QuickFix: "quickfix",
|
||||
Refactor: "refactor",
|
||||
RefactorExtract: "refactor.extract",
|
||||
RefactorInline: "refactor.inline",
|
||||
RefactorRewrite: "refactor.rewrite",
|
||||
Source: "source",
|
||||
SourceOrganizeImports: "source.organizeImports",
|
||||
SourceFixAll: "source.fixAll",
|
||||
}
|
||||
|
||||
vscode.env.openExternal = async (uri) => {
|
||||
const url = typeof uri === "string" ? uri : (uri.toString?.() ?? "")
|
||||
console.log("Opening browser:", url)
|
||||
await open(url)
|
||||
return true
|
||||
// Add missing commands implementation
|
||||
if (!vscode.commands) {
|
||||
vscode.commands = {}
|
||||
}
|
||||
|
||||
Object.assign(vscode.commands, {
|
||||
executeCommand: async (command, ...args) => {
|
||||
console.log(`Called vscode.commands.executeCommand: ${command}`, args)
|
||||
|
||||
// Handle the vscode.diff command specifically
|
||||
if (command === "vscode.diff") {
|
||||
const [originalUri, modifiedUri, title, options] = args
|
||||
console.log("Opening diff view:", { originalUri, modifiedUri, title })
|
||||
|
||||
// For standalone mode, just open the modified file directly
|
||||
// since we can't show a proper diff view
|
||||
const editor = await vscode.window.showTextDocument(modifiedUri, {
|
||||
preserveFocus: options?.preserveFocus || false,
|
||||
preview: false,
|
||||
})
|
||||
|
||||
// Ensure the onDidChangeActiveTextEditor event fires with a slight delay
|
||||
// This is crucial for DiffViewProvider.openDiffEditor() to work properly
|
||||
setTimeout(() => {
|
||||
if (vscode.window._activeTextEditorListeners) {
|
||||
vscode.window._activeTextEditorListeners.forEach((listener) => {
|
||||
try {
|
||||
listener(editor)
|
||||
} catch (error) {
|
||||
console.error("Error calling onDidChangeActiveTextEditor listener in vscode.diff:", error)
|
||||
}
|
||||
})
|
||||
}
|
||||
}, 50) // Slightly longer delay to ensure proper event ordering
|
||||
|
||||
return editor
|
||||
}
|
||||
|
||||
// For other commands, just return a resolved promise
|
||||
return Promise.resolve()
|
||||
},
|
||||
registerCommand: (command, callback) => {
|
||||
console.log(`Registered command: ${command}`)
|
||||
return { dispose: () => {} }
|
||||
},
|
||||
getCommands: async () => {
|
||||
return []
|
||||
},
|
||||
})
|
||||
|
||||
// Add missing TabInput classes
|
||||
vscode.TabInputText = class TabInputText {
|
||||
constructor(uri) {
|
||||
this.uri = uri
|
||||
}
|
||||
}
|
||||
|
||||
vscode.TabInputTextDiff = class TabInputTextDiff {
|
||||
constructor(original, modified) {
|
||||
this.original = original
|
||||
this.modified = modified
|
||||
}
|
||||
}
|
||||
|
||||
// Add missing WorkspaceEdit and related classes
|
||||
vscode.WorkspaceEdit = class WorkspaceEdit {
|
||||
constructor() {
|
||||
this._edits = []
|
||||
}
|
||||
|
||||
replace(uri, range, newText) {
|
||||
console.log("WorkspaceEdit.replace:", uri, range, newText)
|
||||
this._edits.push({
|
||||
_type: 1, // TextEdit
|
||||
_uri: uri,
|
||||
_edits: [
|
||||
{
|
||||
range: range,
|
||||
newText: newText,
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
insert(uri, position, newText) {
|
||||
console.log("WorkspaceEdit.insert:", uri, position, newText)
|
||||
this.replace(uri, new vscode.Range(position, position), newText)
|
||||
}
|
||||
|
||||
delete(uri, range) {
|
||||
console.log("WorkspaceEdit.delete:", uri, range)
|
||||
this.replace(uri, range, "")
|
||||
}
|
||||
}
|
||||
|
||||
vscode.Range = class Range {
|
||||
constructor(startLine, startCharacter, endLine, endCharacter) {
|
||||
if (typeof startLine === "object") {
|
||||
// Called with Position objects
|
||||
this.start = startLine
|
||||
this.end = startCharacter
|
||||
} else {
|
||||
// Called with line/character numbers
|
||||
this.start = new vscode.Position(startLine, startCharacter)
|
||||
this.end = new vscode.Position(endLine, endCharacter)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
vscode.Position = class Position {
|
||||
constructor(line, character) {
|
||||
this.line = line
|
||||
this.character = character
|
||||
}
|
||||
}
|
||||
|
||||
vscode.Selection = class Selection extends vscode.Range {
|
||||
constructor(anchorLine, anchorCharacter, activeLine, activeCharacter) {
|
||||
if (typeof anchorLine === "object") {
|
||||
// Called with Position objects
|
||||
super(anchorLine, anchorCharacter)
|
||||
this.anchor = anchorLine
|
||||
this.active = anchorCharacter
|
||||
} else {
|
||||
// Called with line/character numbers
|
||||
super(anchorLine, anchorCharacter, activeLine, activeCharacter)
|
||||
this.anchor = new vscode.Position(anchorLine, anchorCharacter)
|
||||
this.active = new vscode.Position(activeLine, activeCharacter)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add TextEditorRevealType enum
|
||||
vscode.TextEditorRevealType = {
|
||||
Default: 0,
|
||||
InCenter: 1,
|
||||
InCenterIfOutsideViewport: 2,
|
||||
AtTop: 3,
|
||||
}
|
||||
|
||||
// Add missing languages API
|
||||
if (!vscode.languages) {
|
||||
vscode.languages = {}
|
||||
}
|
||||
|
||||
Object.assign(vscode.languages, {
|
||||
getDiagnostics: (uri) => {
|
||||
console.log("Called vscode.languages.getDiagnostics")
|
||||
// Return empty diagnostics for standalone mode
|
||||
if (uri) {
|
||||
return []
|
||||
} else {
|
||||
// Return all diagnostics as empty array
|
||||
return []
|
||||
}
|
||||
},
|
||||
registerCodeActionsProvider: () => ({ dispose: () => {} }),
|
||||
createDiagnosticCollection: () => ({
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
clear: () => {},
|
||||
dispose: () => {},
|
||||
}),
|
||||
})
|
||||
|
||||
console.log("Finished loading stub impls...")
|
||||
|
||||
// Export the terminal manager globally for Cline core to use
|
||||
global.standaloneTerminalManager = globalTerminalManager
|
||||
|
||||
// Override the TerminalManager to use our standalone implementation
|
||||
if (typeof global !== "undefined") {
|
||||
// Replace the TerminalManager class with our standalone implementation
|
||||
global.StandaloneTerminalManagerClass = require("./enhanced-terminal").StandaloneTerminalManager
|
||||
}
|
||||
|
||||
module.exports = vscode
|
||||
|
||||
+1
-1
@@ -16,7 +16,7 @@ const tsConfig = JSON.parse(fs.readFileSync(path.join(baseUrl, "tsconfig.json"),
|
||||
const outPaths = {}
|
||||
Object.keys(tsConfig.compilerOptions.paths).forEach((key) => {
|
||||
const value = tsConfig.compilerOptions.paths[key]
|
||||
outPaths[key] = value.map((path) => path.replace("src", "out"))
|
||||
outPaths[key] = value.map((path) => path.replace("src", "out/src"))
|
||||
})
|
||||
|
||||
tsConfigPaths.register({
|
||||
|
||||
+1
-1
@@ -11,7 +11,7 @@
|
||||
"types": ["node", "mocha", "should", "vscode", "chai"],
|
||||
"typeRoots": ["./node_modules/@types", "./src/test/types"],
|
||||
"outDir": "out",
|
||||
"rootDir": "src"
|
||||
"rootDir": "."
|
||||
},
|
||||
"include": ["src/**/*.test.ts"],
|
||||
"exclude": ["src/test/**/*.js", "src/**/__tests__/*"]
|
||||
|
||||
@@ -9,13 +9,13 @@ import { BrowserAction, BrowserActionResult, ClineMessage, ClineSayBrowserAction
|
||||
import { StringRequest } from "@shared/proto/common"
|
||||
import { VSCodeButton } from "@vscode/webview-ui-toolkit/react"
|
||||
import deepEqual from "fast-deep-equal"
|
||||
import React, { CSSProperties, memo, useEffect, useMemo, useRef, useState } from "react"
|
||||
import React, { CSSProperties, memo, useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||
import { useSize } from "react-use"
|
||||
import styled from "styled-components"
|
||||
|
||||
interface BrowserSessionRowProps {
|
||||
messages: ClineMessage[]
|
||||
isExpanded: (messageTs: number) => boolean
|
||||
expandedRows: Record<number, boolean>
|
||||
onToggleExpand: (messageTs: number) => void
|
||||
lastModifiedMessage?: ClineMessage
|
||||
isLast: boolean
|
||||
@@ -294,10 +294,13 @@ const BrowserSessionRow = memo((props: BrowserSessionRowProps) => {
|
||||
{currentPage?.nextAction?.messages.map((message) => (
|
||||
<BrowserSessionRowContent
|
||||
key={message.ts}
|
||||
{...props}
|
||||
message={message}
|
||||
expandedRows={props.expandedRows}
|
||||
onToggleExpand={props.onToggleExpand}
|
||||
lastModifiedMessage={props.lastModifiedMessage}
|
||||
isLast={props.isLast}
|
||||
onSetQuote={props.onSetQuote}
|
||||
setMaxActionHeight={setMaxActionHeight}
|
||||
onSetQuote={onSetQuote}
|
||||
/>
|
||||
))}
|
||||
{!isBrowsing && messages.some((m) => m.say === "browser_action_result") && currentPageIndex === 0 && (
|
||||
@@ -498,73 +501,78 @@ interface BrowserSessionRowContentProps extends Omit<BrowserSessionRowProps, "me
|
||||
onSetQuote: (text: string) => void
|
||||
}
|
||||
|
||||
const BrowserSessionRowContent = ({
|
||||
message,
|
||||
isExpanded,
|
||||
onToggleExpand,
|
||||
lastModifiedMessage,
|
||||
isLast,
|
||||
setMaxActionHeight,
|
||||
onSetQuote,
|
||||
}: BrowserSessionRowContentProps) => {
|
||||
if (message.ask === "browser_action_launch" || message.say === "browser_action_launch") {
|
||||
return (
|
||||
<>
|
||||
<div style={headerStyle}>
|
||||
<span style={browserSessionStartedTextStyle}>Browser Session Started</span>
|
||||
</div>
|
||||
<div style={codeBlockContainerStyle}>
|
||||
<CodeBlock source={`${"```"}shell\n${message.text}\n${"```"}`} forceWrap={true} />
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
const BrowserSessionRowContent = memo(
|
||||
({
|
||||
message,
|
||||
expandedRows,
|
||||
onToggleExpand,
|
||||
lastModifiedMessage,
|
||||
isLast,
|
||||
setMaxActionHeight,
|
||||
onSetQuote,
|
||||
}: BrowserSessionRowContentProps) => {
|
||||
const handleToggle = useCallback(() => {
|
||||
if (message.say === "api_req_started") {
|
||||
setMaxActionHeight(0)
|
||||
}
|
||||
onToggleExpand(message.ts)
|
||||
}, [onToggleExpand, message.ts, setMaxActionHeight])
|
||||
|
||||
switch (message.type) {
|
||||
case "say":
|
||||
switch (message.say) {
|
||||
case "api_req_started":
|
||||
case "text":
|
||||
case "reasoning":
|
||||
return (
|
||||
<div style={chatRowContentContainerStyle}>
|
||||
<ChatRowContent
|
||||
message={message}
|
||||
isExpanded={isExpanded(message.ts)}
|
||||
onToggleExpand={() => {
|
||||
if (message.say === "api_req_started") {
|
||||
setMaxActionHeight(0)
|
||||
}
|
||||
onToggleExpand(message.ts)
|
||||
}}
|
||||
lastModifiedMessage={lastModifiedMessage}
|
||||
isLast={isLast}
|
||||
onSetQuote={onSetQuote}
|
||||
if (message.ask === "browser_action_launch" || message.say === "browser_action_launch") {
|
||||
return (
|
||||
<>
|
||||
<div style={headerStyle}>
|
||||
<span style={browserSessionStartedTextStyle}>Browser Session Started</span>
|
||||
</div>
|
||||
<div style={codeBlockContainerStyle}>
|
||||
<CodeBlock source={`${"```"}shell\n${message.text}\n${"```"}`} forceWrap={true} />
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
switch (message.type) {
|
||||
case "say":
|
||||
switch (message.say) {
|
||||
case "api_req_started":
|
||||
case "text":
|
||||
case "reasoning":
|
||||
return (
|
||||
<div style={chatRowContentContainerStyle}>
|
||||
<ChatRowContent
|
||||
message={message}
|
||||
isExpanded={expandedRows[message.ts] ?? false}
|
||||
onToggleExpand={handleToggle}
|
||||
lastModifiedMessage={lastModifiedMessage}
|
||||
isLast={isLast}
|
||||
onSetQuote={onSetQuote}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
case "browser_action":
|
||||
const browserAction = JSON.parse(message.text || "{}") as ClineSayBrowserAction
|
||||
return (
|
||||
<BrowserActionBox
|
||||
action={browserAction.action}
|
||||
coordinate={browserAction.coordinate}
|
||||
text={browserAction.text}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
)
|
||||
|
||||
case "browser_action":
|
||||
const browserAction = JSON.parse(message.text || "{}") as ClineSayBrowserAction
|
||||
return (
|
||||
<BrowserActionBox
|
||||
action={browserAction.action}
|
||||
coordinate={browserAction.coordinate}
|
||||
text={browserAction.text}
|
||||
/>
|
||||
)
|
||||
default:
|
||||
return null
|
||||
}
|
||||
|
||||
default:
|
||||
return null
|
||||
}
|
||||
|
||||
case "ask":
|
||||
switch (message.ask) {
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
case "ask":
|
||||
switch (message.ask) {
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
},
|
||||
deepEqual,
|
||||
)
|
||||
|
||||
const BrowserActionBox = ({ action, coordinate, text }: { action: BrowserAction; coordinate?: string; text?: string }) => {
|
||||
const getBrowserActionText = (action: BrowserAction, coordinate?: string, text?: string) => {
|
||||
|
||||
+1327
-1365
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,6 @@
|
||||
import { MAX_IMAGES_AND_FILES_PER_MESSAGE } from "@/components/chat/ChatView"
|
||||
import { CHAT_CONSTANTS } from "@/components/chat/chat-view/constants"
|
||||
|
||||
const { MAX_IMAGES_AND_FILES_PER_MESSAGE } = CHAT_CONSTANTS
|
||||
import ContextMenu from "@/components/chat/ContextMenu"
|
||||
import SlashCommandMenu from "@/components/chat/SlashCommandMenu"
|
||||
import { CODE_BLOCK_BG_COLOR } from "@/components/common/CodeBlock"
|
||||
@@ -29,10 +31,8 @@ import {
|
||||
validateSlashCommand,
|
||||
} from "@/utils/slash-commands"
|
||||
import { validateApiConfiguration, validateModelId } from "@/utils/validate"
|
||||
import { vscode } from "@/utils/vscode"
|
||||
import { ChatSettings } from "@shared/ChatSettings"
|
||||
import { mentionRegex, mentionRegexGlobal } from "@shared/context-mentions"
|
||||
import { ExtensionMessage } from "@shared/ExtensionMessage"
|
||||
import { EmptyRequest, StringRequest } from "@shared/proto/common"
|
||||
import { FileSearchRequest, RelativePathsRequest } from "@shared/proto/file"
|
||||
import { UpdateApiConfigurationRequest } from "@shared/proto/models"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,100 @@
|
||||
import React from "react"
|
||||
import { VSCodeButton } from "@vscode/webview-ui-toolkit/react"
|
||||
import styled from "styled-components"
|
||||
import { ChatState, MessageHandlers } from "../../types/chatTypes"
|
||||
|
||||
interface ActionButtonsProps {
|
||||
chatState: ChatState
|
||||
messageHandlers: MessageHandlers
|
||||
isStreaming: boolean
|
||||
scrollBehavior: {
|
||||
scrollToBottomSmooth: () => void
|
||||
disableAutoScrollRef: React.MutableRefObject<boolean>
|
||||
showScrollToBottom: boolean
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Action buttons area including scroll-to-bottom and approve/reject buttons
|
||||
*/
|
||||
export const ActionButtons: React.FC<ActionButtonsProps> = ({ chatState, messageHandlers, isStreaming, scrollBehavior }) => {
|
||||
const { primaryButtonText, secondaryButtonText, enableButtons, didClickCancel, inputValue, selectedImages, selectedFiles } =
|
||||
chatState
|
||||
|
||||
const { showScrollToBottom, scrollToBottomSmooth, disableAutoScrollRef } = scrollBehavior
|
||||
|
||||
if (showScrollToBottom) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
padding: "10px 15px 0px 15px",
|
||||
}}>
|
||||
<ScrollToBottomButton
|
||||
onClick={() => {
|
||||
scrollToBottomSmooth()
|
||||
disableAutoScrollRef.current = false
|
||||
}}>
|
||||
<span className="codicon codicon-chevron-down" style={{ fontSize: "18px" }}></span>
|
||||
</ScrollToBottomButton>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const shouldShowButtons = primaryButtonText || secondaryButtonText || isStreaming
|
||||
const opacity = shouldShowButtons ? (enableButtons || (isStreaming && !didClickCancel) ? 1 : 0.5) : 0
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
opacity,
|
||||
display: "flex",
|
||||
padding: `${shouldShowButtons ? "10" : "0"}px 15px 0px 15px`,
|
||||
}}>
|
||||
{primaryButtonText && !isStreaming && (
|
||||
<VSCodeButton
|
||||
appearance="primary"
|
||||
disabled={!enableButtons}
|
||||
style={{
|
||||
flex: secondaryButtonText ? 1 : 2,
|
||||
marginRight: secondaryButtonText ? "6px" : "0",
|
||||
}}
|
||||
onClick={() => messageHandlers.handlePrimaryButtonClick(inputValue, selectedImages, selectedFiles)}>
|
||||
{primaryButtonText}
|
||||
</VSCodeButton>
|
||||
)}
|
||||
{(secondaryButtonText || isStreaming) && (
|
||||
<VSCodeButton
|
||||
appearance="secondary"
|
||||
disabled={!enableButtons && !(isStreaming && !didClickCancel)}
|
||||
style={{
|
||||
flex: isStreaming ? 2 : 1,
|
||||
marginLeft: isStreaming ? 0 : "6px",
|
||||
}}
|
||||
onClick={() => messageHandlers.handleSecondaryButtonClick(inputValue, selectedImages, selectedFiles)}>
|
||||
{isStreaming ? "Cancel" : secondaryButtonText}
|
||||
</VSCodeButton>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const ScrollToBottomButton = styled.div`
|
||||
background-color: color-mix(in srgb, var(--vscode-toolbar-hoverBackground) 55%, transparent);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
height: 25px;
|
||||
|
||||
&:hover {
|
||||
background-color: color-mix(in srgb, var(--vscode-toolbar-hoverBackground) 90%, transparent);
|
||||
}
|
||||
|
||||
&:active {
|
||||
background-color: color-mix(in srgb, var(--vscode-toolbar-hoverBackground) 70%, transparent);
|
||||
}
|
||||
`
|
||||
@@ -0,0 +1,26 @@
|
||||
import React from "react"
|
||||
import styled from "styled-components"
|
||||
|
||||
interface ChatLayoutProps {
|
||||
isHidden: boolean
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
* Main layout container for the chat view
|
||||
* Provides the fixed positioning and flex layout structure
|
||||
*/
|
||||
export const ChatLayout: React.FC<ChatLayoutProps> = ({ isHidden, children }) => {
|
||||
return <ChatLayoutContainer isHidden={isHidden}>{children}</ChatLayoutContainer>
|
||||
}
|
||||
|
||||
const ChatLayoutContainer = styled.div<{ isHidden: boolean }>`
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
display: ${(props) => (props.isHidden ? "none" : "flex")};
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
`
|
||||
@@ -0,0 +1,78 @@
|
||||
import React from "react"
|
||||
import QuotedMessagePreview from "@/components/chat/QuotedMessagePreview"
|
||||
import ChatTextArea from "@/components/chat/ChatTextArea"
|
||||
import { ChatState, MessageHandlers, ScrollBehavior } from "../../types/chatTypes"
|
||||
|
||||
interface InputSectionProps {
|
||||
chatState: ChatState
|
||||
messageHandlers: MessageHandlers
|
||||
scrollBehavior: ScrollBehavior
|
||||
placeholderText: string
|
||||
shouldDisableFilesAndImages: boolean
|
||||
selectFilesAndImages: () => Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* Input section including quoted message preview and chat text area
|
||||
*/
|
||||
export const InputSection: React.FC<InputSectionProps> = ({
|
||||
chatState,
|
||||
messageHandlers,
|
||||
scrollBehavior,
|
||||
placeholderText,
|
||||
shouldDisableFilesAndImages,
|
||||
selectFilesAndImages,
|
||||
}) => {
|
||||
const {
|
||||
activeQuote,
|
||||
setActiveQuote,
|
||||
isTextAreaFocused,
|
||||
inputValue,
|
||||
setInputValue,
|
||||
sendingDisabled,
|
||||
selectedImages,
|
||||
setSelectedImages,
|
||||
selectedFiles,
|
||||
setSelectedFiles,
|
||||
textAreaRef,
|
||||
handleFocusChange,
|
||||
} = chatState
|
||||
|
||||
const { isAtBottom, scrollToBottomAuto } = scrollBehavior
|
||||
|
||||
return (
|
||||
<>
|
||||
{activeQuote && (
|
||||
<div style={{ marginBottom: "-12px", marginTop: "10px" }}>
|
||||
<QuotedMessagePreview
|
||||
text={activeQuote}
|
||||
onDismiss={() => setActiveQuote(null)}
|
||||
isFocused={isTextAreaFocused}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ChatTextArea
|
||||
ref={textAreaRef}
|
||||
onFocusChange={handleFocusChange}
|
||||
activeQuote={activeQuote}
|
||||
inputValue={inputValue}
|
||||
setInputValue={setInputValue}
|
||||
sendingDisabled={sendingDisabled}
|
||||
placeholderText={placeholderText}
|
||||
selectedImages={selectedImages}
|
||||
setSelectedImages={setSelectedImages}
|
||||
setSelectedFiles={setSelectedFiles}
|
||||
selectedFiles={selectedFiles}
|
||||
onSend={() => messageHandlers.handleSendMessage(inputValue, selectedImages, selectedFiles)}
|
||||
onSelectFilesAndImages={selectFilesAndImages}
|
||||
shouldDisableFilesAndImages={shouldDisableFilesAndImages}
|
||||
onHeightChange={() => {
|
||||
if (isAtBottom) {
|
||||
scrollToBottomAuto()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import React, { useCallback } from "react"
|
||||
import { Virtuoso } from "react-virtuoso"
|
||||
import AutoApproveBar from "@/components/chat/auto-approve-menu/AutoApproveBar"
|
||||
import { ClineMessage } from "@shared/ExtensionMessage"
|
||||
import { ScrollBehavior, ChatState, MessageHandlers } from "../../types/chatTypes"
|
||||
import { createMessageRenderer } from "../messages/MessageRenderer"
|
||||
|
||||
interface MessagesAreaProps {
|
||||
task: ClineMessage
|
||||
groupedMessages: (ClineMessage | ClineMessage[])[]
|
||||
modifiedMessages: ClineMessage[]
|
||||
scrollBehavior: ScrollBehavior
|
||||
chatState: ChatState
|
||||
messageHandlers: MessageHandlers
|
||||
}
|
||||
|
||||
/**
|
||||
* The scrollable messages area with virtualized list
|
||||
* Handles rendering of chat rows and browser sessions
|
||||
*/
|
||||
export const MessagesArea: React.FC<MessagesAreaProps> = ({
|
||||
task,
|
||||
groupedMessages,
|
||||
modifiedMessages,
|
||||
scrollBehavior,
|
||||
chatState,
|
||||
messageHandlers,
|
||||
}) => {
|
||||
const {
|
||||
virtuosoRef,
|
||||
scrollContainerRef,
|
||||
toggleRowExpansion,
|
||||
handleRowHeightChange,
|
||||
setIsAtBottom,
|
||||
setShowScrollToBottom,
|
||||
disableAutoScrollRef,
|
||||
} = scrollBehavior
|
||||
|
||||
const { expandedRows, inputValue, setActiveQuote } = chatState
|
||||
|
||||
const itemContent = useCallback(
|
||||
createMessageRenderer(
|
||||
groupedMessages,
|
||||
modifiedMessages,
|
||||
expandedRows,
|
||||
toggleRowExpansion,
|
||||
handleRowHeightChange,
|
||||
setActiveQuote,
|
||||
inputValue,
|
||||
messageHandlers,
|
||||
),
|
||||
[
|
||||
groupedMessages,
|
||||
modifiedMessages,
|
||||
expandedRows,
|
||||
toggleRowExpansion,
|
||||
handleRowHeightChange,
|
||||
setActiveQuote,
|
||||
inputValue,
|
||||
messageHandlers,
|
||||
],
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div style={{ flexGrow: 1, display: "flex" }} ref={scrollContainerRef}>
|
||||
<Virtuoso
|
||||
ref={virtuosoRef}
|
||||
key={task.ts} // trick to make sure virtuoso re-renders when task changes
|
||||
className="scrollable"
|
||||
style={{
|
||||
flexGrow: 1,
|
||||
overflowY: "scroll", // always show scrollbar
|
||||
}}
|
||||
components={{
|
||||
Footer: () => <div style={{ height: 5 }} />, // Add empty padding at the bottom
|
||||
}}
|
||||
increaseViewportBy={{
|
||||
top: 3_000,
|
||||
bottom: Number.MAX_SAFE_INTEGER,
|
||||
}}
|
||||
data={groupedMessages}
|
||||
itemContent={itemContent}
|
||||
atBottomStateChange={(isAtBottom) => {
|
||||
setIsAtBottom(isAtBottom)
|
||||
if (isAtBottom) {
|
||||
disableAutoScrollRef.current = false
|
||||
}
|
||||
setShowScrollToBottom(disableAutoScrollRef.current && !isAtBottom)
|
||||
}}
|
||||
atBottomThreshold={10}
|
||||
initialTopMostItemIndex={groupedMessages.length - 1}
|
||||
/>
|
||||
</div>
|
||||
<AutoApproveBar />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import React from "react"
|
||||
import TaskHeader from "@/components/chat/task-header/TaskHeader"
|
||||
import { ClineMessage } from "@shared/ExtensionMessage"
|
||||
import { MessageHandlers, ScrollBehavior } from "../../types/chatTypes"
|
||||
|
||||
interface TaskSectionProps {
|
||||
task: ClineMessage
|
||||
apiMetrics: {
|
||||
totalTokensIn: number
|
||||
totalTokensOut: number
|
||||
totalCacheWrites?: number
|
||||
totalCacheReads?: number
|
||||
totalCost: number
|
||||
}
|
||||
lastApiReqTotalTokens?: number
|
||||
selectedModelInfo: {
|
||||
supportsPromptCache: boolean
|
||||
supportsImages: boolean
|
||||
}
|
||||
messageHandlers: MessageHandlers
|
||||
scrollBehavior: ScrollBehavior
|
||||
}
|
||||
|
||||
/**
|
||||
* Task section shown when there's an active task
|
||||
* Includes the task header and manages task-specific UI
|
||||
*/
|
||||
export const TaskSection: React.FC<TaskSectionProps> = ({
|
||||
task,
|
||||
apiMetrics,
|
||||
lastApiReqTotalTokens,
|
||||
selectedModelInfo,
|
||||
messageHandlers,
|
||||
scrollBehavior,
|
||||
}) => {
|
||||
return (
|
||||
<TaskHeader
|
||||
task={task}
|
||||
tokensIn={apiMetrics.totalTokensIn}
|
||||
tokensOut={apiMetrics.totalTokensOut}
|
||||
doesModelSupportPromptCache={selectedModelInfo.supportsPromptCache}
|
||||
cacheWrites={apiMetrics.totalCacheWrites}
|
||||
cacheReads={apiMetrics.totalCacheReads}
|
||||
totalCost={apiMetrics.totalCost}
|
||||
lastApiReqTotalTokens={lastApiReqTotalTokens}
|
||||
onClose={messageHandlers.handleTaskCloseButtonClick}
|
||||
onScrollToMessage={scrollBehavior.scrollToMessage}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import React from "react"
|
||||
import TelemetryBanner from "@/components/common/TelemetryBanner"
|
||||
import Announcement from "@/components/chat/Announcement"
|
||||
import HomeHeader from "@/components/welcome/HomeHeader"
|
||||
import HistoryPreview from "@/components/history/HistoryPreview"
|
||||
import { SuggestedTasks } from "@/components/welcome/SuggestedTasks"
|
||||
import AutoApproveBar from "@/components/chat/auto-approve-menu/AutoApproveBar"
|
||||
import { WelcomeSectionProps } from "../../types/chatTypes"
|
||||
|
||||
/**
|
||||
* Welcome section shown when there's no active task
|
||||
* Includes telemetry banner, announcements, home header, and history preview
|
||||
*/
|
||||
export const WelcomeSection: React.FC<WelcomeSectionProps> = ({
|
||||
showAnnouncement,
|
||||
hideAnnouncement,
|
||||
showHistoryView,
|
||||
telemetrySetting,
|
||||
version,
|
||||
taskHistory,
|
||||
shouldShowQuickWins,
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
style={{
|
||||
flex: "1 1 0",
|
||||
minHeight: 0,
|
||||
overflowY: "auto",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
paddingBottom: "10px",
|
||||
}}>
|
||||
{telemetrySetting === "unset" && <TelemetryBanner />}
|
||||
{showAnnouncement && <Announcement version={version} hideAnnouncement={hideAnnouncement} />}
|
||||
<HomeHeader />
|
||||
{!shouldShowQuickWins && taskHistory.length > 0 && <HistoryPreview showHistoryView={showHistoryView} />}
|
||||
</div>
|
||||
<SuggestedTasks shouldShowQuickWins={shouldShowQuickWins} />
|
||||
<AutoApproveBar />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Export all layout components
|
||||
*/
|
||||
|
||||
export { ChatLayout } from "./ChatLayout"
|
||||
export { WelcomeSection } from "./WelcomeSection"
|
||||
export { TaskSection } from "./TaskSection"
|
||||
export { MessagesArea } from "./MessagesArea"
|
||||
export { ActionButtons } from "./ActionButtons"
|
||||
export { InputSection } from "./InputSection"
|
||||
@@ -0,0 +1,103 @@
|
||||
import React, { useCallback } from "react"
|
||||
import BrowserSessionRow from "@/components/chat/BrowserSessionRow"
|
||||
import ChatRow from "@/components/chat/ChatRow"
|
||||
import { ClineMessage } from "@shared/ExtensionMessage"
|
||||
import { MessageHandlers } from "../../types/chatTypes"
|
||||
|
||||
interface MessageRendererProps {
|
||||
index: number
|
||||
messageOrGroup: ClineMessage | ClineMessage[]
|
||||
groupedMessages: (ClineMessage | ClineMessage[])[]
|
||||
modifiedMessages: ClineMessage[]
|
||||
expandedRows: Record<number, boolean>
|
||||
onToggleExpand: (ts: number) => void
|
||||
onHeightChange: (isTaller: boolean) => void
|
||||
onSetQuote: (quote: string | null) => void
|
||||
inputValue: string
|
||||
messageHandlers: MessageHandlers
|
||||
}
|
||||
|
||||
/**
|
||||
* Specialized component for rendering different message types
|
||||
* Handles browser sessions, regular messages, and checkpoint logic
|
||||
*/
|
||||
export const MessageRenderer: React.FC<MessageRendererProps> = ({
|
||||
index,
|
||||
messageOrGroup,
|
||||
groupedMessages,
|
||||
modifiedMessages,
|
||||
expandedRows,
|
||||
onToggleExpand,
|
||||
onHeightChange,
|
||||
onSetQuote,
|
||||
inputValue,
|
||||
messageHandlers,
|
||||
}) => {
|
||||
// Browser session group
|
||||
if (Array.isArray(messageOrGroup)) {
|
||||
return (
|
||||
<BrowserSessionRow
|
||||
key={messageOrGroup[0]?.ts}
|
||||
messages={messageOrGroup}
|
||||
isLast={index === groupedMessages.length - 1}
|
||||
lastModifiedMessage={modifiedMessages.at(-1)}
|
||||
onHeightChange={onHeightChange}
|
||||
expandedRows={expandedRows}
|
||||
onToggleExpand={onToggleExpand}
|
||||
onSetQuote={onSetQuote}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// Determine if this is the last message for status display purposes
|
||||
const nextMessage = index < groupedMessages.length - 1 && groupedMessages[index + 1]
|
||||
const isNextCheckpoint = !Array.isArray(nextMessage) && nextMessage && nextMessage?.say === "checkpoint_created"
|
||||
const isLastMessageGroup = isNextCheckpoint && index === groupedMessages.length - 2
|
||||
const isLast = index === groupedMessages.length - 1 || isLastMessageGroup
|
||||
|
||||
// Regular message
|
||||
return (
|
||||
<ChatRow
|
||||
key={messageOrGroup.ts}
|
||||
message={messageOrGroup}
|
||||
isExpanded={expandedRows[messageOrGroup.ts] || false}
|
||||
onToggleExpand={onToggleExpand}
|
||||
lastModifiedMessage={modifiedMessages.at(-1)}
|
||||
isLast={isLast}
|
||||
onHeightChange={onHeightChange}
|
||||
inputValue={inputValue}
|
||||
sendMessageFromChatRow={messageHandlers.handleSendMessage}
|
||||
onSetQuote={onSetQuote}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory function to create the itemContent callback for Virtuoso
|
||||
* This allows us to encapsulate the rendering logic while maintaining performance
|
||||
*/
|
||||
export const createMessageRenderer = (
|
||||
groupedMessages: (ClineMessage | ClineMessage[])[],
|
||||
modifiedMessages: ClineMessage[],
|
||||
expandedRows: Record<number, boolean>,
|
||||
onToggleExpand: (ts: number) => void,
|
||||
onHeightChange: (isTaller: boolean) => void,
|
||||
onSetQuote: (quote: string | null) => void,
|
||||
inputValue: string,
|
||||
messageHandlers: MessageHandlers,
|
||||
) => {
|
||||
return (index: number, messageOrGroup: ClineMessage | ClineMessage[]) => (
|
||||
<MessageRenderer
|
||||
index={index}
|
||||
messageOrGroup={messageOrGroup}
|
||||
groupedMessages={groupedMessages}
|
||||
modifiedMessages={modifiedMessages}
|
||||
expandedRows={expandedRows}
|
||||
onToggleExpand={onToggleExpand}
|
||||
onHeightChange={onHeightChange}
|
||||
onSetQuote={onSetQuote}
|
||||
inputValue={inputValue}
|
||||
messageHandlers={messageHandlers}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import React, { useMemo } from "react"
|
||||
import { ClineMessage } from "@shared/ExtensionMessage"
|
||||
import { findLast } from "@shared/array"
|
||||
|
||||
/**
|
||||
* Hook to determine if the chat is currently streaming
|
||||
* Encapsulates the complex streaming detection logic
|
||||
*/
|
||||
export const useIsStreaming = (
|
||||
modifiedMessages: ClineMessage[],
|
||||
clineAsk?: string,
|
||||
enableButtons?: boolean,
|
||||
primaryButtonText?: string,
|
||||
): boolean => {
|
||||
return useMemo(() => {
|
||||
// Check if the last message is an ask (tool is waiting for user input)
|
||||
const isLastAsk = !!modifiedMessages.at(-1)?.ask
|
||||
const isToolCurrentlyAsking = isLastAsk && clineAsk !== undefined && enableButtons && primaryButtonText !== undefined
|
||||
if (isToolCurrentlyAsking) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check if the last message is partial (still being streamed)
|
||||
const isLastMessagePartial = modifiedMessages.at(-1)?.partial === true
|
||||
if (isLastMessagePartial) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check if there's an ongoing API request
|
||||
const lastApiReqStarted = findLast(modifiedMessages, (message) => message.say === "api_req_started")
|
||||
if (lastApiReqStarted && lastApiReqStarted.text != null && lastApiReqStarted.say === "api_req_started") {
|
||||
const cost = JSON.parse(lastApiReqStarted.text).cost
|
||||
if (cost === undefined) {
|
||||
// API request has not finished yet
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}, [modifiedMessages, clineAsk, enableButtons, primaryButtonText])
|
||||
}
|
||||
|
||||
/**
|
||||
* Component that shows a visual streaming indicator
|
||||
* Can be used to show loading states, typing indicators, etc.
|
||||
*/
|
||||
export const StreamingVisualIndicator: React.FC<{ isStreaming: boolean }> = ({ isStreaming }) => {
|
||||
if (!isStreaming) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
padding: "8px 16px",
|
||||
color: "var(--vscode-descriptionForeground)",
|
||||
fontSize: "12px",
|
||||
}}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: "4px",
|
||||
marginRight: "8px",
|
||||
}}>
|
||||
{[0, 1, 2].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
width: "4px",
|
||||
height: "4px",
|
||||
borderRadius: "50%",
|
||||
backgroundColor: "var(--vscode-progressBar-background)",
|
||||
animation: `pulse 1.4s infinite ease-in-out ${i * 0.16}s`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<span>Cline is thinking...</span>
|
||||
<style>{`
|
||||
@keyframes pulse {
|
||||
0%, 80%, 100% {
|
||||
opacity: 0.3;
|
||||
transform: scale(0.8);
|
||||
}
|
||||
40% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* Export all message-related components
|
||||
*/
|
||||
|
||||
export { MessageRenderer, createMessageRenderer } from "./MessageRenderer"
|
||||
export { useIsStreaming, StreamingVisualIndicator } from "./StreamingIndicator"
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Constants used across the chat view components
|
||||
*/
|
||||
export const CHAT_CONSTANTS = {
|
||||
MAX_IMAGES_AND_FILES_PER_MESSAGE: 20,
|
||||
QUICK_WINS_HISTORY_THRESHOLD: 300,
|
||||
} as const
|
||||
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Export all custom hooks for the chat view
|
||||
*/
|
||||
|
||||
export { useChatState } from "./useChatState"
|
||||
export { useButtonState } from "./useButtonState"
|
||||
export { useScrollBehavior } from "./useScrollBehavior"
|
||||
export { useMessageHandlers } from "./useMessageHandlers"
|
||||
@@ -0,0 +1,160 @@
|
||||
import { useEffect } from "react"
|
||||
import { useDeepCompareEffect } from "react-use"
|
||||
import { ClineMessage, ClineSayTool } from "@shared/ExtensionMessage"
|
||||
import { ChatState } from "../types/chatTypes"
|
||||
|
||||
/**
|
||||
* Custom hook for managing button state based on messages
|
||||
* Handles button text and enable/disable states based on the current ask type
|
||||
*/
|
||||
export function useButtonState(messages: ClineMessage[], chatState: ChatState) {
|
||||
const {
|
||||
setSendingDisabled,
|
||||
setEnableButtons,
|
||||
setPrimaryButtonText,
|
||||
setSecondaryButtonText,
|
||||
setDidClickCancel,
|
||||
lastMessage,
|
||||
secondLastMessage,
|
||||
} = chatState
|
||||
|
||||
// Update button state based on last message
|
||||
useDeepCompareEffect(() => {
|
||||
if (lastMessage) {
|
||||
switch (lastMessage.type) {
|
||||
case "ask":
|
||||
const isPartial = lastMessage.partial === true
|
||||
switch (lastMessage.ask) {
|
||||
case "api_req_failed":
|
||||
setSendingDisabled(true)
|
||||
setEnableButtons(true)
|
||||
setPrimaryButtonText("Retry")
|
||||
setSecondaryButtonText("Start New Task")
|
||||
break
|
||||
case "mistake_limit_reached":
|
||||
setSendingDisabled(false)
|
||||
setEnableButtons(true)
|
||||
setPrimaryButtonText("Proceed Anyways")
|
||||
setSecondaryButtonText("Start New Task")
|
||||
break
|
||||
case "auto_approval_max_req_reached":
|
||||
setSendingDisabled(true)
|
||||
setEnableButtons(true)
|
||||
setPrimaryButtonText("Proceed")
|
||||
setSecondaryButtonText("Start New Task")
|
||||
break
|
||||
case "followup":
|
||||
setSendingDisabled(isPartial)
|
||||
setEnableButtons(false)
|
||||
break
|
||||
case "plan_mode_respond":
|
||||
setSendingDisabled(isPartial)
|
||||
setEnableButtons(false)
|
||||
break
|
||||
case "tool":
|
||||
setSendingDisabled(isPartial)
|
||||
setEnableButtons(!isPartial)
|
||||
const tool = JSON.parse(lastMessage.text || "{}") as ClineSayTool
|
||||
switch (tool.tool) {
|
||||
case "editedExistingFile":
|
||||
case "newFileCreated":
|
||||
setPrimaryButtonText("Save")
|
||||
setSecondaryButtonText("Reject")
|
||||
break
|
||||
default:
|
||||
setPrimaryButtonText("Approve")
|
||||
setSecondaryButtonText("Reject")
|
||||
break
|
||||
}
|
||||
break
|
||||
case "browser_action_launch":
|
||||
setSendingDisabled(isPartial)
|
||||
setEnableButtons(!isPartial)
|
||||
setPrimaryButtonText("Approve")
|
||||
setSecondaryButtonText("Reject")
|
||||
break
|
||||
case "command":
|
||||
setSendingDisabled(isPartial)
|
||||
setEnableButtons(!isPartial)
|
||||
setPrimaryButtonText("Run Command")
|
||||
setSecondaryButtonText("Reject")
|
||||
break
|
||||
case "command_output":
|
||||
setSendingDisabled(false)
|
||||
setEnableButtons(true)
|
||||
setPrimaryButtonText("Proceed While Running")
|
||||
setSecondaryButtonText(undefined)
|
||||
break
|
||||
case "use_mcp_server":
|
||||
setSendingDisabled(isPartial)
|
||||
setEnableButtons(!isPartial)
|
||||
setPrimaryButtonText("Approve")
|
||||
setSecondaryButtonText("Reject")
|
||||
break
|
||||
case "completion_result":
|
||||
setSendingDisabled(isPartial)
|
||||
setEnableButtons(!isPartial)
|
||||
setPrimaryButtonText("Start New Task")
|
||||
setSecondaryButtonText(undefined)
|
||||
break
|
||||
case "resume_task":
|
||||
setSendingDisabled(false)
|
||||
setEnableButtons(true)
|
||||
setPrimaryButtonText("Resume Task")
|
||||
setSecondaryButtonText(undefined)
|
||||
setDidClickCancel(false)
|
||||
break
|
||||
case "resume_completed_task":
|
||||
setSendingDisabled(false)
|
||||
setEnableButtons(true)
|
||||
setPrimaryButtonText("Start New Task")
|
||||
setSecondaryButtonText(undefined)
|
||||
setDidClickCancel(false)
|
||||
break
|
||||
case "new_task":
|
||||
setSendingDisabled(isPartial)
|
||||
setEnableButtons(!isPartial)
|
||||
setPrimaryButtonText("Start New Task with Context")
|
||||
setSecondaryButtonText(undefined)
|
||||
break
|
||||
case "condense":
|
||||
setSendingDisabled(isPartial)
|
||||
setEnableButtons(!isPartial)
|
||||
setPrimaryButtonText("Condense Conversation")
|
||||
setSecondaryButtonText(undefined)
|
||||
break
|
||||
case "report_bug":
|
||||
setSendingDisabled(isPartial)
|
||||
setEnableButtons(!isPartial)
|
||||
setPrimaryButtonText("Report GitHub issue")
|
||||
setSecondaryButtonText(undefined)
|
||||
break
|
||||
}
|
||||
break
|
||||
case "say":
|
||||
switch (lastMessage.say) {
|
||||
case "api_req_started":
|
||||
if (secondLastMessage?.ask === "command_output") {
|
||||
chatState.setInputValue("")
|
||||
setSendingDisabled(true)
|
||||
chatState.setSelectedImages([])
|
||||
chatState.setSelectedFiles([])
|
||||
setEnableButtons(false)
|
||||
}
|
||||
break
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}, [lastMessage, secondLastMessage])
|
||||
|
||||
// Reset button state when no messages
|
||||
useEffect(() => {
|
||||
if (messages.length === 0) {
|
||||
setSendingDisabled(false)
|
||||
setEnableButtons(false)
|
||||
setPrimaryButtonText("Approve")
|
||||
setSecondaryButtonText("Reject")
|
||||
}
|
||||
}, [messages.length, setSendingDisabled, setEnableButtons, setPrimaryButtonText, setSecondaryButtonText])
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { useState, useMemo, useRef, useCallback } from "react"
|
||||
import { ClineMessage, ClineAsk } from "@shared/ExtensionMessage"
|
||||
import { ChatState, MessageHandlers } from "../types/chatTypes"
|
||||
|
||||
/**
|
||||
* Custom hook for managing chat state
|
||||
* Handles input values, selection states, and UI state
|
||||
*/
|
||||
export function useChatState(messages: ClineMessage[]): ChatState {
|
||||
// Input and selection state
|
||||
const [inputValue, setInputValue] = useState("")
|
||||
const [activeQuote, setActiveQuote] = useState<string | null>(null)
|
||||
const [isTextAreaFocused, setIsTextAreaFocused] = useState(false)
|
||||
const [selectedImages, setSelectedImages] = useState<string[]>([])
|
||||
const [selectedFiles, setSelectedFiles] = useState<string[]>([])
|
||||
|
||||
// UI state
|
||||
const [sendingDisabled, setSendingDisabled] = useState(false)
|
||||
const [enableButtons, setEnableButtons] = useState<boolean>(false)
|
||||
const [primaryButtonText, setPrimaryButtonText] = useState<string | undefined>("Approve")
|
||||
const [secondaryButtonText, setSecondaryButtonText] = useState<string | undefined>("Reject")
|
||||
const [didClickCancel, setDidClickCancel] = useState(false)
|
||||
const [expandedRows, setExpandedRows] = useState<Record<number, boolean>>({})
|
||||
|
||||
// Refs
|
||||
const textAreaRef = useRef<HTMLTextAreaElement>(null)
|
||||
|
||||
// Derived state
|
||||
const lastMessage = useMemo(() => messages.at(-1), [messages])
|
||||
const secondLastMessage = useMemo(() => messages.at(-2), [messages])
|
||||
const clineAsk = useMemo(() => (lastMessage?.type === "ask" ? lastMessage.ask : undefined), [lastMessage])
|
||||
|
||||
// Clear expanded rows when task changes
|
||||
const task = useMemo(() => messages.at(0), [messages])
|
||||
const clearExpandedRows = useCallback(() => {
|
||||
setExpandedRows({})
|
||||
}, [])
|
||||
|
||||
// Reset state when starting new conversation
|
||||
const resetState = useCallback(() => {
|
||||
setInputValue("")
|
||||
setActiveQuote(null)
|
||||
setSendingDisabled(false)
|
||||
setSelectedImages([])
|
||||
setSelectedFiles([])
|
||||
setEnableButtons(false)
|
||||
setPrimaryButtonText("Approve")
|
||||
setSecondaryButtonText("Reject")
|
||||
setDidClickCancel(false)
|
||||
}, [])
|
||||
|
||||
// Handle focus change
|
||||
const handleFocusChange = useCallback((isFocused: boolean) => {
|
||||
setIsTextAreaFocused(isFocused)
|
||||
}, [])
|
||||
|
||||
return {
|
||||
// State values
|
||||
inputValue,
|
||||
setInputValue,
|
||||
activeQuote,
|
||||
setActiveQuote,
|
||||
isTextAreaFocused,
|
||||
setIsTextAreaFocused,
|
||||
selectedImages,
|
||||
setSelectedImages,
|
||||
selectedFiles,
|
||||
setSelectedFiles,
|
||||
sendingDisabled,
|
||||
setSendingDisabled,
|
||||
enableButtons,
|
||||
setEnableButtons,
|
||||
primaryButtonText,
|
||||
setPrimaryButtonText,
|
||||
secondaryButtonText,
|
||||
setSecondaryButtonText,
|
||||
didClickCancel,
|
||||
setDidClickCancel,
|
||||
expandedRows,
|
||||
setExpandedRows,
|
||||
|
||||
// Refs
|
||||
textAreaRef,
|
||||
|
||||
// Derived values
|
||||
lastMessage,
|
||||
secondLastMessage,
|
||||
clineAsk,
|
||||
task,
|
||||
|
||||
// Handlers
|
||||
handleFocusChange,
|
||||
clearExpandedRows,
|
||||
resetState,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
import { useCallback } from "react"
|
||||
import { ClineMessage, ClineAsk } from "@shared/ExtensionMessage"
|
||||
import { TaskServiceClient, SlashServiceClient } from "@/services/grpc-client"
|
||||
import { EmptyRequest, StringRequest } from "@shared/proto/common"
|
||||
import { AskResponseRequest, NewTaskRequest } from "@shared/proto/task"
|
||||
import { MessageHandlers, ChatState } from "../types/chatTypes"
|
||||
|
||||
/**
|
||||
* Custom hook for managing message handlers
|
||||
* Handles sending messages, button clicks, and task management
|
||||
*/
|
||||
export function useMessageHandlers(messages: ClineMessage[], chatState: ChatState, isStreaming: boolean): MessageHandlers {
|
||||
const {
|
||||
inputValue,
|
||||
setInputValue,
|
||||
activeQuote,
|
||||
setActiveQuote,
|
||||
selectedImages,
|
||||
setSelectedImages,
|
||||
selectedFiles,
|
||||
setSelectedFiles,
|
||||
setSendingDisabled,
|
||||
setEnableButtons,
|
||||
setDidClickCancel,
|
||||
clineAsk,
|
||||
lastMessage,
|
||||
} = chatState
|
||||
|
||||
// Handle sending a message
|
||||
const handleSendMessage = useCallback(
|
||||
async (text: string, images: string[], files: string[]) => {
|
||||
let messageToSend = text.trim()
|
||||
const hasContent = messageToSend || images.length > 0 || files.length > 0
|
||||
|
||||
// Prepend the active quote if it exists
|
||||
if (activeQuote && hasContent) {
|
||||
const prefix = "[context] \n> "
|
||||
const formattedQuote = activeQuote
|
||||
const suffix = "\n[/context] \n\n"
|
||||
messageToSend = `${prefix} ${formattedQuote} ${suffix} ${messageToSend}`
|
||||
}
|
||||
|
||||
if (hasContent) {
|
||||
console.log("[ChatView] handleSendMessage - Sending message:", messageToSend)
|
||||
if (messages.length === 0) {
|
||||
await TaskServiceClient.newTask(NewTaskRequest.create({ text: messageToSend, images, files }))
|
||||
} else if (clineAsk) {
|
||||
switch (clineAsk) {
|
||||
case "followup":
|
||||
case "plan_mode_respond":
|
||||
case "tool":
|
||||
case "browser_action_launch":
|
||||
case "command":
|
||||
case "command_output":
|
||||
case "use_mcp_server":
|
||||
case "completion_result":
|
||||
case "resume_task":
|
||||
case "resume_completed_task":
|
||||
case "mistake_limit_reached":
|
||||
case "new_task":
|
||||
case "condense":
|
||||
case "report_bug":
|
||||
await TaskServiceClient.askResponse(
|
||||
AskResponseRequest.create({
|
||||
responseType: "messageResponse",
|
||||
text: messageToSend,
|
||||
images,
|
||||
files,
|
||||
}),
|
||||
)
|
||||
break
|
||||
}
|
||||
}
|
||||
setInputValue("")
|
||||
setActiveQuote(null)
|
||||
setSendingDisabled(true)
|
||||
setSelectedImages([])
|
||||
setSelectedFiles([])
|
||||
setEnableButtons(false)
|
||||
|
||||
// Reset auto-scroll
|
||||
if ("disableAutoScrollRef" in chatState) {
|
||||
;(chatState as any).disableAutoScrollRef.current = false
|
||||
}
|
||||
}
|
||||
},
|
||||
[
|
||||
messages.length,
|
||||
clineAsk,
|
||||
activeQuote,
|
||||
setInputValue,
|
||||
setActiveQuote,
|
||||
setSendingDisabled,
|
||||
setSelectedImages,
|
||||
setSelectedFiles,
|
||||
setEnableButtons,
|
||||
chatState,
|
||||
],
|
||||
)
|
||||
|
||||
// Start a new task
|
||||
const startNewTask = useCallback(async () => {
|
||||
setActiveQuote(null)
|
||||
await TaskServiceClient.clearTask(EmptyRequest.create({}))
|
||||
}, [setActiveQuote])
|
||||
|
||||
// Handle primary button click
|
||||
const handlePrimaryButtonClick = useCallback(
|
||||
async (text?: string, images?: string[], files?: string[]) => {
|
||||
const trimmedInput = text?.trim()
|
||||
switch (clineAsk) {
|
||||
case "api_req_failed":
|
||||
case "command":
|
||||
case "command_output":
|
||||
case "tool":
|
||||
case "browser_action_launch":
|
||||
case "use_mcp_server":
|
||||
case "resume_task":
|
||||
case "mistake_limit_reached":
|
||||
case "auto_approval_max_req_reached":
|
||||
if (trimmedInput || (images && images.length > 0) || (files && files.length > 0)) {
|
||||
await TaskServiceClient.askResponse(
|
||||
AskResponseRequest.create({
|
||||
responseType: "yesButtonClicked",
|
||||
text: trimmedInput,
|
||||
images: images,
|
||||
files: files,
|
||||
}),
|
||||
)
|
||||
} else {
|
||||
await TaskServiceClient.askResponse(
|
||||
AskResponseRequest.create({
|
||||
responseType: "yesButtonClicked",
|
||||
}),
|
||||
)
|
||||
}
|
||||
// Clear input state after sending
|
||||
setInputValue("")
|
||||
setActiveQuote(null)
|
||||
setSelectedImages([])
|
||||
setSelectedFiles([])
|
||||
break
|
||||
case "completion_result":
|
||||
case "resume_completed_task":
|
||||
startNewTask()
|
||||
break
|
||||
case "new_task":
|
||||
console.info("new task button clicked!", { lastMessage, messages, clineAsk, text })
|
||||
await TaskServiceClient.newTask(
|
||||
NewTaskRequest.create({
|
||||
text: lastMessage?.text,
|
||||
images: [],
|
||||
files: [],
|
||||
}),
|
||||
)
|
||||
break
|
||||
case "condense":
|
||||
await SlashServiceClient.condense(StringRequest.create({ value: lastMessage?.text })).catch((err) =>
|
||||
console.error(err),
|
||||
)
|
||||
break
|
||||
case "report_bug":
|
||||
await SlashServiceClient.reportBug(StringRequest.create({ value: lastMessage?.text })).catch((err) =>
|
||||
console.error(err),
|
||||
)
|
||||
break
|
||||
}
|
||||
setSendingDisabled(true)
|
||||
setEnableButtons(false)
|
||||
|
||||
// Reset auto-scroll
|
||||
if ("disableAutoScrollRef" in chatState) {
|
||||
;(chatState as any).disableAutoScrollRef.current = false
|
||||
}
|
||||
},
|
||||
[
|
||||
clineAsk,
|
||||
startNewTask,
|
||||
lastMessage,
|
||||
messages,
|
||||
setInputValue,
|
||||
setActiveQuote,
|
||||
setSelectedImages,
|
||||
setSelectedFiles,
|
||||
setSendingDisabled,
|
||||
setEnableButtons,
|
||||
chatState,
|
||||
],
|
||||
)
|
||||
|
||||
// Handle secondary button click
|
||||
const handleSecondaryButtonClick = useCallback(
|
||||
async (text?: string, images?: string[], files?: string[]) => {
|
||||
const trimmedInput = text?.trim()
|
||||
|
||||
if (isStreaming) {
|
||||
await TaskServiceClient.cancelTask(EmptyRequest.create({}))
|
||||
setDidClickCancel(true)
|
||||
return
|
||||
}
|
||||
|
||||
switch (clineAsk) {
|
||||
case "api_req_failed":
|
||||
case "mistake_limit_reached":
|
||||
case "auto_approval_max_req_reached":
|
||||
startNewTask()
|
||||
break
|
||||
case "command":
|
||||
case "tool":
|
||||
case "browser_action_launch":
|
||||
case "use_mcp_server":
|
||||
if (trimmedInput || (images && images.length > 0) || (files && files.length > 0)) {
|
||||
await TaskServiceClient.askResponse(
|
||||
AskResponseRequest.create({
|
||||
responseType: "noButtonClicked",
|
||||
text: trimmedInput,
|
||||
images: images,
|
||||
files: files,
|
||||
}),
|
||||
)
|
||||
} else {
|
||||
await TaskServiceClient.askResponse(
|
||||
AskResponseRequest.create({
|
||||
responseType: "noButtonClicked",
|
||||
}),
|
||||
)
|
||||
}
|
||||
// Clear input state after sending
|
||||
setInputValue("")
|
||||
setActiveQuote(null)
|
||||
setSelectedImages([])
|
||||
setSelectedFiles([])
|
||||
break
|
||||
}
|
||||
setSendingDisabled(true)
|
||||
setEnableButtons(false)
|
||||
|
||||
// Reset auto-scroll
|
||||
if ("disableAutoScrollRef" in chatState) {
|
||||
;(chatState as any).disableAutoScrollRef.current = false
|
||||
}
|
||||
},
|
||||
[
|
||||
isStreaming,
|
||||
clineAsk,
|
||||
startNewTask,
|
||||
setInputValue,
|
||||
setActiveQuote,
|
||||
setSelectedImages,
|
||||
setSelectedFiles,
|
||||
setSendingDisabled,
|
||||
setEnableButtons,
|
||||
setDidClickCancel,
|
||||
chatState,
|
||||
],
|
||||
)
|
||||
|
||||
// Handle task close button click
|
||||
const handleTaskCloseButtonClick = useCallback(() => {
|
||||
startNewTask()
|
||||
}, [startNewTask])
|
||||
|
||||
return {
|
||||
handleSendMessage,
|
||||
handlePrimaryButtonClick,
|
||||
handleSecondaryButtonClick,
|
||||
handleTaskCloseButtonClick,
|
||||
startNewTask,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
import { useRef, useState, useCallback, useEffect, useMemo } from "react"
|
||||
import { useEvent } from "react-use"
|
||||
import debounce from "debounce"
|
||||
import { VirtuosoHandle } from "react-virtuoso"
|
||||
import { ClineMessage } from "@shared/ExtensionMessage"
|
||||
import { ScrollBehavior } from "../types/chatTypes"
|
||||
|
||||
/**
|
||||
* Custom hook for managing scroll behavior
|
||||
* Handles auto-scrolling, manual scrolling, and scroll-to-message functionality
|
||||
*/
|
||||
export function useScrollBehavior(
|
||||
messages: ClineMessage[],
|
||||
visibleMessages: ClineMessage[],
|
||||
groupedMessages: (ClineMessage | ClineMessage[])[],
|
||||
expandedRows: Record<number, boolean>,
|
||||
setExpandedRows: React.Dispatch<React.SetStateAction<Record<number, boolean>>>,
|
||||
): ScrollBehavior & {
|
||||
showScrollToBottom: boolean
|
||||
setShowScrollToBottom: React.Dispatch<React.SetStateAction<boolean>>
|
||||
isAtBottom: boolean
|
||||
setIsAtBottom: React.Dispatch<React.SetStateAction<boolean>>
|
||||
pendingScrollToMessage: number | null
|
||||
setPendingScrollToMessage: React.Dispatch<React.SetStateAction<number | null>>
|
||||
} {
|
||||
// Refs
|
||||
const virtuosoRef = useRef<VirtuosoHandle>(null)
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null)
|
||||
const disableAutoScrollRef = useRef(false)
|
||||
|
||||
// State
|
||||
const [showScrollToBottom, setShowScrollToBottom] = useState(false)
|
||||
const [isAtBottom, setIsAtBottom] = useState(false)
|
||||
const [pendingScrollToMessage, setPendingScrollToMessage] = useState<number | null>(null)
|
||||
|
||||
// Smooth scroll to bottom with debounce
|
||||
const scrollToBottomSmooth = useMemo(
|
||||
() =>
|
||||
debounce(
|
||||
() => {
|
||||
virtuosoRef.current?.scrollTo({
|
||||
top: Number.MAX_SAFE_INTEGER,
|
||||
behavior: "smooth",
|
||||
})
|
||||
},
|
||||
10,
|
||||
{ immediate: true },
|
||||
),
|
||||
[],
|
||||
)
|
||||
|
||||
// Instant scroll to bottom
|
||||
const scrollToBottomAuto = useCallback(() => {
|
||||
virtuosoRef.current?.scrollTo({
|
||||
top: Number.MAX_SAFE_INTEGER,
|
||||
behavior: "auto", // instant causes crash
|
||||
})
|
||||
}, [])
|
||||
|
||||
// Scroll to specific message
|
||||
const scrollToMessage = useCallback(
|
||||
(messageIndex: number) => {
|
||||
setPendingScrollToMessage(messageIndex)
|
||||
|
||||
const targetMessage = messages[messageIndex]
|
||||
if (!targetMessage) {
|
||||
setPendingScrollToMessage(null)
|
||||
return
|
||||
}
|
||||
|
||||
const visibleIndex = visibleMessages.findIndex((msg) => msg.ts === targetMessage.ts)
|
||||
if (visibleIndex === -1) {
|
||||
setPendingScrollToMessage(null)
|
||||
return
|
||||
}
|
||||
|
||||
let groupIndex = -1
|
||||
let currentVisibleIndex = 0
|
||||
|
||||
for (let i = 0; i < groupedMessages.length; i++) {
|
||||
const group = groupedMessages[i]
|
||||
if (Array.isArray(group)) {
|
||||
const groupSize = group.length
|
||||
const messageInGroup = group.some((msg) => msg.ts === targetMessage.ts)
|
||||
if (messageInGroup) {
|
||||
groupIndex = i
|
||||
break
|
||||
}
|
||||
currentVisibleIndex += groupSize
|
||||
} else {
|
||||
if (group.ts === targetMessage.ts) {
|
||||
groupIndex = i
|
||||
break
|
||||
}
|
||||
currentVisibleIndex++
|
||||
}
|
||||
}
|
||||
|
||||
if (groupIndex !== -1) {
|
||||
setPendingScrollToMessage(null)
|
||||
disableAutoScrollRef.current = true
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
virtuosoRef.current?.scrollToIndex({
|
||||
index: groupIndex,
|
||||
align: "start",
|
||||
behavior: "smooth",
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
},
|
||||
[messages, visibleMessages, groupedMessages],
|
||||
)
|
||||
|
||||
// Toggle row expansion with scroll handling
|
||||
const toggleRowExpansion = useCallback(
|
||||
(ts: number) => {
|
||||
const isCollapsing = expandedRows[ts] ?? false
|
||||
const lastGroup = groupedMessages.at(-1)
|
||||
const isLast = Array.isArray(lastGroup) ? lastGroup[0].ts === ts : lastGroup?.ts === ts
|
||||
const secondToLastGroup = groupedMessages.at(-2)
|
||||
const isSecondToLast = Array.isArray(secondToLastGroup)
|
||||
? secondToLastGroup[0].ts === ts
|
||||
: secondToLastGroup?.ts === ts
|
||||
|
||||
const isLastCollapsedApiReq =
|
||||
isLast &&
|
||||
!Array.isArray(lastGroup) && // Make sure it's not a browser session group
|
||||
lastGroup?.say === "api_req_started" &&
|
||||
!expandedRows[lastGroup.ts]
|
||||
|
||||
setExpandedRows((prev) => ({
|
||||
...prev,
|
||||
[ts]: !prev[ts],
|
||||
}))
|
||||
|
||||
// disable auto scroll when user expands row
|
||||
if (!isCollapsing) {
|
||||
disableAutoScrollRef.current = true
|
||||
}
|
||||
|
||||
if (isCollapsing && isAtBottom) {
|
||||
const timer = setTimeout(() => {
|
||||
scrollToBottomAuto()
|
||||
}, 0)
|
||||
return () => clearTimeout(timer)
|
||||
} else if (isLast || isSecondToLast) {
|
||||
if (isCollapsing) {
|
||||
if (isSecondToLast && !isLastCollapsedApiReq) {
|
||||
return
|
||||
}
|
||||
const timer = setTimeout(() => {
|
||||
scrollToBottomAuto()
|
||||
}, 0)
|
||||
return () => clearTimeout(timer)
|
||||
} else {
|
||||
const timer = setTimeout(() => {
|
||||
virtuosoRef.current?.scrollToIndex({
|
||||
index: groupedMessages.length - (isLast ? 1 : 2),
|
||||
align: "start",
|
||||
})
|
||||
}, 0)
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
},
|
||||
[groupedMessages, expandedRows, scrollToBottomAuto, isAtBottom, setExpandedRows],
|
||||
)
|
||||
|
||||
// Handle row height changes
|
||||
const handleRowHeightChange = useCallback(
|
||||
(isTaller: boolean) => {
|
||||
if (!disableAutoScrollRef.current) {
|
||||
if (isTaller) {
|
||||
scrollToBottomSmooth()
|
||||
} else {
|
||||
setTimeout(() => {
|
||||
scrollToBottomAuto()
|
||||
}, 0)
|
||||
}
|
||||
}
|
||||
},
|
||||
[scrollToBottomSmooth, scrollToBottomAuto],
|
||||
)
|
||||
|
||||
// Auto-scroll when new messages arrive
|
||||
useEffect(() => {
|
||||
if (!disableAutoScrollRef.current) {
|
||||
setTimeout(() => {
|
||||
scrollToBottomSmooth()
|
||||
}, 50)
|
||||
}
|
||||
}, [groupedMessages.length, scrollToBottomSmooth])
|
||||
|
||||
// Handle pending scroll to message
|
||||
useEffect(() => {
|
||||
if (pendingScrollToMessage !== null) {
|
||||
scrollToMessage(pendingScrollToMessage)
|
||||
}
|
||||
}, [pendingScrollToMessage, groupedMessages, scrollToMessage])
|
||||
|
||||
// Handle wheel events to detect manual scrolling
|
||||
const handleWheel = useCallback((event: Event) => {
|
||||
const wheelEvent = event as WheelEvent
|
||||
if (wheelEvent.deltaY && wheelEvent.deltaY < 0) {
|
||||
if (scrollContainerRef.current?.contains(wheelEvent.target as Node)) {
|
||||
// user scrolled up
|
||||
disableAutoScrollRef.current = true
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEvent("wheel", handleWheel, window, { passive: true })
|
||||
|
||||
return {
|
||||
virtuosoRef,
|
||||
scrollContainerRef,
|
||||
disableAutoScrollRef,
|
||||
scrollToBottomSmooth,
|
||||
scrollToBottomAuto,
|
||||
scrollToMessage,
|
||||
toggleRowExpansion,
|
||||
handleRowHeightChange,
|
||||
showScrollToBottom,
|
||||
setShowScrollToBottom,
|
||||
isAtBottom,
|
||||
setIsAtBottom,
|
||||
pendingScrollToMessage,
|
||||
setPendingScrollToMessage,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* Barrel export for chat-view utilities, hooks, components, and types
|
||||
*/
|
||||
|
||||
// Export utilities
|
||||
export * from "./utils/markdownUtils"
|
||||
export * from "./utils/messageUtils"
|
||||
export * from "./utils/scrollUtils"
|
||||
|
||||
// Export hooks
|
||||
export * from "./hooks"
|
||||
|
||||
// Export layout components
|
||||
export * from "./components/layout"
|
||||
|
||||
// Export message components
|
||||
export * from "./components/messages"
|
||||
|
||||
// Export types and constants
|
||||
export * from "./types/chatTypes"
|
||||
export * from "./constants"
|
||||
@@ -0,0 +1,172 @@
|
||||
/**
|
||||
* Shared types and interfaces for the chat view components
|
||||
*/
|
||||
|
||||
import { ClineMessage, ClineAsk } from "@shared/ExtensionMessage"
|
||||
import { VirtuosoHandle } from "react-virtuoso"
|
||||
|
||||
/**
|
||||
* Main ChatView component props
|
||||
*/
|
||||
export interface ChatViewProps {
|
||||
isHidden: boolean
|
||||
showAnnouncement: boolean
|
||||
hideAnnouncement: () => void
|
||||
showHistoryView: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Chat state interface
|
||||
*/
|
||||
export interface ChatState {
|
||||
// State values
|
||||
inputValue: string
|
||||
setInputValue: React.Dispatch<React.SetStateAction<string>>
|
||||
activeQuote: string | null
|
||||
setActiveQuote: React.Dispatch<React.SetStateAction<string | null>>
|
||||
isTextAreaFocused: boolean
|
||||
setIsTextAreaFocused: React.Dispatch<React.SetStateAction<boolean>>
|
||||
selectedImages: string[]
|
||||
setSelectedImages: React.Dispatch<React.SetStateAction<string[]>>
|
||||
selectedFiles: string[]
|
||||
setSelectedFiles: React.Dispatch<React.SetStateAction<string[]>>
|
||||
sendingDisabled: boolean
|
||||
setSendingDisabled: React.Dispatch<React.SetStateAction<boolean>>
|
||||
enableButtons: boolean
|
||||
setEnableButtons: React.Dispatch<React.SetStateAction<boolean>>
|
||||
primaryButtonText: string | undefined
|
||||
setPrimaryButtonText: React.Dispatch<React.SetStateAction<string | undefined>>
|
||||
secondaryButtonText: string | undefined
|
||||
setSecondaryButtonText: React.Dispatch<React.SetStateAction<string | undefined>>
|
||||
didClickCancel: boolean
|
||||
setDidClickCancel: React.Dispatch<React.SetStateAction<boolean>>
|
||||
expandedRows: Record<number, boolean>
|
||||
setExpandedRows: React.Dispatch<React.SetStateAction<Record<number, boolean>>>
|
||||
|
||||
// Refs
|
||||
textAreaRef: React.RefObject<HTMLTextAreaElement>
|
||||
|
||||
// Derived values
|
||||
lastMessage: ClineMessage | undefined
|
||||
secondLastMessage: ClineMessage | undefined
|
||||
clineAsk: ClineAsk | undefined
|
||||
task: ClineMessage | undefined
|
||||
|
||||
// Handlers
|
||||
handleFocusChange: (isFocused: boolean) => void
|
||||
clearExpandedRows: () => void
|
||||
resetState: () => void
|
||||
|
||||
// Scroll-related state (will be moved to scroll hook)
|
||||
showScrollToBottom?: boolean
|
||||
isAtBottom?: boolean
|
||||
pendingScrollToMessage?: number | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Message handlers interface
|
||||
*/
|
||||
export interface MessageHandlers {
|
||||
handleSendMessage: (text: string, images: string[], files: string[]) => Promise<void>
|
||||
handlePrimaryButtonClick: (text?: string, images?: string[], files?: string[]) => Promise<void>
|
||||
handleSecondaryButtonClick: (text?: string, images?: string[], files?: string[]) => Promise<void>
|
||||
handleTaskCloseButtonClick: () => void
|
||||
startNewTask: () => Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* Scroll behavior interface
|
||||
*/
|
||||
export interface ScrollBehavior {
|
||||
virtuosoRef: React.RefObject<VirtuosoHandle>
|
||||
scrollContainerRef: React.RefObject<HTMLDivElement>
|
||||
disableAutoScrollRef: React.MutableRefObject<boolean>
|
||||
scrollToBottomSmooth: () => void
|
||||
scrollToBottomAuto: () => void
|
||||
scrollToMessage: (messageIndex: number) => void
|
||||
toggleRowExpansion: (ts: number) => void
|
||||
handleRowHeightChange: (isTaller: boolean) => void
|
||||
showScrollToBottom: boolean
|
||||
setShowScrollToBottom: React.Dispatch<React.SetStateAction<boolean>>
|
||||
isAtBottom: boolean
|
||||
setIsAtBottom: React.Dispatch<React.SetStateAction<boolean>>
|
||||
pendingScrollToMessage: number | null
|
||||
setPendingScrollToMessage: React.Dispatch<React.SetStateAction<number | null>>
|
||||
}
|
||||
|
||||
/**
|
||||
* Button state interface
|
||||
*/
|
||||
export interface ButtonState {
|
||||
enableButtons: boolean
|
||||
primaryButtonText: string | undefined
|
||||
secondaryButtonText: string | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Input state interface
|
||||
*/
|
||||
export interface InputState {
|
||||
inputValue: string
|
||||
selectedImages: string[]
|
||||
selectedFiles: string[]
|
||||
activeQuote: string | null
|
||||
isTextAreaFocused: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Task section props
|
||||
*/
|
||||
export interface TaskSectionProps {
|
||||
task: ClineMessage
|
||||
messages: ClineMessage[]
|
||||
scrollBehavior: ScrollBehavior
|
||||
buttonState: ButtonState
|
||||
messageHandlers: MessageHandlers
|
||||
chatState: ChatState
|
||||
apiMetrics: {
|
||||
totalTokensIn: number
|
||||
totalTokensOut: number
|
||||
totalCacheWrites?: number
|
||||
totalCacheReads?: number
|
||||
totalCost: number
|
||||
}
|
||||
lastApiReqTotalTokens?: number
|
||||
selectedModelInfo: {
|
||||
supportsPromptCache: boolean
|
||||
supportsImages: boolean
|
||||
}
|
||||
isStreaming: boolean
|
||||
clineAsk?: ClineAsk
|
||||
modifiedMessages: ClineMessage[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Welcome section props
|
||||
*/
|
||||
export interface WelcomeSectionProps {
|
||||
showAnnouncement: boolean
|
||||
hideAnnouncement: () => void
|
||||
showHistoryView: () => void
|
||||
telemetrySetting: string
|
||||
version: string
|
||||
taskHistory: any[]
|
||||
shouldShowQuickWins: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Input section props
|
||||
*/
|
||||
export interface InputSectionProps {
|
||||
chatState: ChatState
|
||||
messageHandlers: MessageHandlers
|
||||
textAreaRef: React.RefObject<HTMLTextAreaElement>
|
||||
onFocusChange: (isFocused: boolean) => void
|
||||
onInputChange: (value: string) => void
|
||||
onQuoteChange: (quote: string | null) => void
|
||||
onImagesChange: (images: string[]) => void
|
||||
onFilesChange: (files: string[]) => void
|
||||
placeholderText: string
|
||||
shouldDisableFilesAndImages: boolean
|
||||
selectFilesAndImages: () => Promise<void>
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* Utility functions for handling markdown conversions and cleanup
|
||||
*/
|
||||
|
||||
import { unified } from "unified"
|
||||
import remarkStringify from "remark-stringify"
|
||||
import rehypeRemark from "rehype-remark"
|
||||
import rehypeParse from "rehype-parse"
|
||||
|
||||
/**
|
||||
* Clean up markdown escape characters
|
||||
*/
|
||||
export function cleanupMarkdownEscapes(markdown: string): string {
|
||||
return (
|
||||
markdown
|
||||
// Handle underscores and asterisks (single or multiple)
|
||||
.replace(/\\([_*]+)/g, "$1")
|
||||
|
||||
// Handle angle brackets (for generics and XML)
|
||||
.replace(/\\([<>])/g, "$1")
|
||||
|
||||
// Handle backticks (for code)
|
||||
.replace(/\\(`)/g, "$1")
|
||||
|
||||
// Handle other common markdown special characters
|
||||
.replace(/\\([[\]()#.!])/g, "$1")
|
||||
|
||||
// Fix multiple consecutive backslashes
|
||||
.replace(/\\{2,}([_*`<>[\]()#.!])/g, "$1")
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert HTML to Markdown
|
||||
*/
|
||||
export async function convertHtmlToMarkdown(html: string): Promise<string> {
|
||||
// Process the HTML to Markdown
|
||||
const result = await unified()
|
||||
.use(rehypeParse as any, { fragment: true }) // Parse HTML fragments
|
||||
.use(rehypeRemark as any) // Convert HTML to Markdown AST
|
||||
.use(remarkStringify as any, {
|
||||
// Convert Markdown AST to text
|
||||
bullet: "-", // Use - for unordered lists
|
||||
emphasis: "*", // Use * for emphasis
|
||||
strong: "_", // Use _ for strong
|
||||
listItemIndent: "one", // Use one space for list indentation
|
||||
rule: "-", // Use - for horizontal rules
|
||||
ruleSpaces: false, // No spaces in horizontal rules
|
||||
fences: true,
|
||||
escape: false,
|
||||
entities: false,
|
||||
})
|
||||
.process(html)
|
||||
|
||||
const md = String(result)
|
||||
// Apply comprehensive cleanup of escape characters
|
||||
return cleanupMarkdownEscapes(md)
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* Utility functions for message filtering, grouping, and manipulation
|
||||
*/
|
||||
|
||||
import { ClineMessage, ClineSayBrowserAction } from "@shared/ExtensionMessage"
|
||||
import { combineApiRequests } from "@shared/combineApiRequests"
|
||||
import { combineCommandSequences } from "@shared/combineCommandSequences"
|
||||
|
||||
/**
|
||||
* Combine API requests and command sequences in messages
|
||||
*/
|
||||
export function processMessages(messages: ClineMessage[]): ClineMessage[] {
|
||||
return combineApiRequests(combineCommandSequences(messages))
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter messages that should be visible in the chat
|
||||
*/
|
||||
export function filterVisibleMessages(messages: ClineMessage[]): ClineMessage[] {
|
||||
return messages.filter((message) => {
|
||||
switch (message.ask) {
|
||||
case "completion_result":
|
||||
// don't show a chat row for a completion_result ask without text
|
||||
if (message.text === "") {
|
||||
return false
|
||||
}
|
||||
break
|
||||
case "api_req_failed":
|
||||
case "resume_task":
|
||||
case "resume_completed_task":
|
||||
return false
|
||||
}
|
||||
switch (message.say) {
|
||||
case "api_req_finished":
|
||||
case "api_req_retried":
|
||||
case "deleted_api_reqs":
|
||||
return false
|
||||
case "text":
|
||||
// Sometimes cline returns an empty text message, we don't want to render these
|
||||
if ((message.text ?? "") === "" && (message.images?.length ?? 0) === 0) {
|
||||
return false
|
||||
}
|
||||
break
|
||||
case "mcp_server_request_started":
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a message is part of a browser session
|
||||
*/
|
||||
export function isBrowserSessionMessage(message: ClineMessage): boolean {
|
||||
if (message.type === "ask") {
|
||||
return ["browser_action_launch"].includes(message.ask!)
|
||||
}
|
||||
if (message.type === "say") {
|
||||
return [
|
||||
"browser_action_launch",
|
||||
"api_req_started",
|
||||
"text",
|
||||
"browser_action",
|
||||
"browser_action_result",
|
||||
"checkpoint_created",
|
||||
"reasoning",
|
||||
].includes(message.say!)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Group messages, combining browser session messages into arrays
|
||||
*/
|
||||
export function groupMessages(visibleMessages: ClineMessage[]): (ClineMessage | ClineMessage[])[] {
|
||||
const result: (ClineMessage | ClineMessage[])[] = []
|
||||
let currentGroup: ClineMessage[] = []
|
||||
let isInBrowserSession = false
|
||||
|
||||
const endBrowserSession = () => {
|
||||
if (currentGroup.length > 0) {
|
||||
result.push([...currentGroup])
|
||||
currentGroup = []
|
||||
isInBrowserSession = false
|
||||
}
|
||||
}
|
||||
|
||||
visibleMessages.forEach((message) => {
|
||||
if (message.ask === "browser_action_launch" || message.say === "browser_action_launch") {
|
||||
// complete existing browser session if any
|
||||
endBrowserSession()
|
||||
// start new
|
||||
isInBrowserSession = true
|
||||
currentGroup.push(message)
|
||||
} else if (isInBrowserSession) {
|
||||
// end session if api_req_started is cancelled
|
||||
if (message.say === "api_req_started") {
|
||||
// get last api_req_started in currentGroup to check if it's cancelled
|
||||
const lastApiReqStarted = [...currentGroup].reverse().find((m) => m.say === "api_req_started")
|
||||
if (lastApiReqStarted?.text != null) {
|
||||
const info = JSON.parse(lastApiReqStarted.text)
|
||||
const isCancelled = info.cancelReason != null
|
||||
if (isCancelled) {
|
||||
endBrowserSession()
|
||||
result.push(message)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isBrowserSessionMessage(message)) {
|
||||
currentGroup.push(message)
|
||||
|
||||
// Check if this is a close action
|
||||
if (message.say === "browser_action") {
|
||||
const browserAction = JSON.parse(message.text || "{}") as ClineSayBrowserAction
|
||||
if (browserAction.action === "close") {
|
||||
endBrowserSession()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// complete existing browser session if any
|
||||
endBrowserSession()
|
||||
result.push(message)
|
||||
}
|
||||
} else {
|
||||
result.push(message)
|
||||
}
|
||||
})
|
||||
|
||||
// Handle case where browser session is the last group
|
||||
if (currentGroup.length > 0) {
|
||||
result.push([...currentGroup])
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the task message from the messages array
|
||||
*/
|
||||
export function getTaskMessage(messages: ClineMessage[]): ClineMessage | undefined {
|
||||
return messages.at(0)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if we should show the scroll to bottom button
|
||||
*/
|
||||
export function shouldShowScrollButton(disableAutoScroll: boolean, isAtBottom: boolean): boolean {
|
||||
return disableAutoScroll && !isAtBottom
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Utility functions for scroll behavior and management
|
||||
*/
|
||||
|
||||
import debounce from "debounce"
|
||||
import { VirtuosoHandle } from "react-virtuoso"
|
||||
|
||||
/**
|
||||
* Create a debounced smooth scroll function
|
||||
*/
|
||||
export function createSmoothScrollToBottom(virtuosoRef: React.RefObject<VirtuosoHandle>) {
|
||||
return debounce(
|
||||
() => {
|
||||
virtuosoRef.current?.scrollTo({
|
||||
top: Number.MAX_SAFE_INTEGER,
|
||||
behavior: "smooth",
|
||||
})
|
||||
},
|
||||
10,
|
||||
{ immediate: true },
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Scroll to bottom with auto behavior
|
||||
*/
|
||||
export function scrollToBottomAuto(virtuosoRef: React.RefObject<VirtuosoHandle>) {
|
||||
virtuosoRef.current?.scrollTo({
|
||||
top: Number.MAX_SAFE_INTEGER,
|
||||
behavior: "auto", // instant causes crash
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle wheel events to detect user scroll
|
||||
*/
|
||||
export function createWheelHandler(
|
||||
scrollContainerRef: React.RefObject<HTMLDivElement>,
|
||||
disableAutoScrollRef: React.MutableRefObject<boolean>,
|
||||
) {
|
||||
return (event: Event) => {
|
||||
const wheelEvent = event as WheelEvent
|
||||
if (wheelEvent.deltaY && wheelEvent.deltaY < 0) {
|
||||
if (scrollContainerRef.current?.contains(wheelEvent.target as Node)) {
|
||||
// user scrolled up
|
||||
disableAutoScrollRef.current = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Constants for scroll behavior
|
||||
*/
|
||||
export const SCROLL_CONSTANTS = {
|
||||
AT_BOTTOM_THRESHOLD: 10,
|
||||
VIEWPORT_INCREASE_TOP: 3_000,
|
||||
VIEWPORT_INCREASE_BOTTOM: Number.MAX_SAFE_INTEGER,
|
||||
FOOTER_HEIGHT: 5,
|
||||
} as const
|
||||
@@ -727,17 +727,9 @@ const HistoryView = ({ onDone }: HistoryViewProps) => {
|
||||
disabled={deleteAllDisabled || taskHistory.length === 0}
|
||||
onClick={() => {
|
||||
setDeleteAllDisabled(true)
|
||||
const confirmDelete = window.confirm("Are you sure you want to delete all task history?")
|
||||
if (confirmDelete) {
|
||||
const preserveFavorites = window.confirm(
|
||||
"Would you like to preserve favorited tasks?\n\nClick 'OK' to preserve favorites, or 'Cancel' to delete everything.",
|
||||
)
|
||||
TaskServiceClient.deleteAllTaskHistory(BooleanRequest.create({ value: preserveFavorites }))
|
||||
.catch((error) => console.error("Error deleting task history:", error))
|
||||
.finally(() => setDeleteAllDisabled(false))
|
||||
} else {
|
||||
setDeleteAllDisabled(false)
|
||||
}
|
||||
TaskServiceClient.deleteAllTaskHistory(BooleanRequest.create({}))
|
||||
.catch((error) => console.error("Error deleting task history:", error))
|
||||
.finally(() => setDeleteAllDisabled(false))
|
||||
}}>
|
||||
Delete All History{totalTasksSize !== null ? ` (${formatSize(totalTasksSize)})` : ""}
|
||||
</DangerButton>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,104 +0,0 @@
|
||||
import { VSCodeCheckbox, VSCodeDropdown, VSCodeOption } from "@vscode/webview-ui-toolkit/react"
|
||||
import { useExtensionState } from "@/context/ExtensionStateContext"
|
||||
import { memo } from "react"
|
||||
import { OpenAIReasoningEffort } from "@shared/ChatSettings"
|
||||
|
||||
const FeatureSettingsSection = () => {
|
||||
const {
|
||||
enableCheckpointsSetting,
|
||||
setEnableCheckpointsSetting,
|
||||
mcpMarketplaceEnabled,
|
||||
setMcpMarketplaceEnabled,
|
||||
mcpRichDisplayEnabled,
|
||||
setMcpRichDisplayEnabled,
|
||||
mcpResponsesCollapsed,
|
||||
setMcpResponsesCollapsed,
|
||||
chatSettings,
|
||||
setChatSettings,
|
||||
} = useExtensionState()
|
||||
|
||||
return (
|
||||
<div style={{ marginBottom: 20 }}>
|
||||
<div>
|
||||
<VSCodeCheckbox
|
||||
checked={enableCheckpointsSetting}
|
||||
onChange={(e: any) => {
|
||||
const checked = e.target.checked === true
|
||||
setEnableCheckpointsSetting(checked)
|
||||
}}>
|
||||
Enable Checkpoints
|
||||
</VSCodeCheckbox>
|
||||
<p className="text-xs text-[var(--vscode-descriptionForeground)]">
|
||||
Enables extension to save checkpoints of workspace throughout the task. Uses git under the hood which may not
|
||||
work well with large workspaces.
|
||||
</p>
|
||||
</div>
|
||||
<div style={{ marginTop: 10 }}>
|
||||
<VSCodeCheckbox
|
||||
checked={mcpMarketplaceEnabled}
|
||||
onChange={(e: any) => {
|
||||
const checked = e.target.checked === true
|
||||
setMcpMarketplaceEnabled(checked)
|
||||
}}>
|
||||
Enable MCP Marketplace
|
||||
</VSCodeCheckbox>
|
||||
<p className="text-xs text-[var(--vscode-descriptionForeground)]">
|
||||
Enables the MCP Marketplace tab for discovering and installing MCP servers.
|
||||
</p>
|
||||
</div>
|
||||
<div style={{ marginTop: 10 }}>
|
||||
<VSCodeCheckbox
|
||||
checked={mcpRichDisplayEnabled}
|
||||
onChange={(e: any) => {
|
||||
const checked = e.target.checked === true
|
||||
setMcpRichDisplayEnabled(checked)
|
||||
}}>
|
||||
Enable Rich MCP Display
|
||||
</VSCodeCheckbox>
|
||||
<p className="text-xs text-[var(--vscode-descriptionForeground)]">
|
||||
Enables rich formatting for MCP responses. When disabled, responses will be shown in plain text.
|
||||
</p>
|
||||
</div>
|
||||
<div style={{ marginTop: 10 }}>
|
||||
<VSCodeCheckbox
|
||||
checked={mcpResponsesCollapsed}
|
||||
onChange={(e: any) => {
|
||||
const checked = e.target.checked === true
|
||||
setMcpResponsesCollapsed(checked)
|
||||
}}>
|
||||
Collapse MCP Responses
|
||||
</VSCodeCheckbox>
|
||||
<p className="text-xs text-[var(--vscode-descriptionForeground)]">
|
||||
Sets the default display mode for MCP response panels
|
||||
</p>
|
||||
</div>
|
||||
<div style={{ marginTop: 10 }}>
|
||||
<label
|
||||
htmlFor="openai-reasoning-effort-dropdown"
|
||||
className="block text-sm font-medium text-[var(--vscode-foreground)] mb-1">
|
||||
OpenAI Reasoning Effort
|
||||
</label>
|
||||
<VSCodeDropdown
|
||||
id="openai-reasoning-effort-dropdown"
|
||||
currentValue={chatSettings.openAIReasoningEffort || "medium"}
|
||||
onChange={(e: any) => {
|
||||
const newValue = e.target.currentValue as OpenAIReasoningEffort
|
||||
setChatSettings({
|
||||
...chatSettings,
|
||||
openAIReasoningEffort: newValue,
|
||||
})
|
||||
}}
|
||||
className="w-full">
|
||||
<VSCodeOption value="low">Low</VSCodeOption>
|
||||
<VSCodeOption value="medium">Medium</VSCodeOption>
|
||||
<VSCodeOption value="high">High</VSCodeOption>
|
||||
</VSCodeDropdown>
|
||||
<p className="text-xs mt-[5px] text-[var(--vscode-descriptionForeground)]">
|
||||
Reasoning effort for the OpenAI family of models(applies to all OpenAI model providers)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(FeatureSettingsSection)
|
||||
@@ -10,7 +10,7 @@ import { useRemark } from "react-remark"
|
||||
import { useMount } from "react-use"
|
||||
import styled from "styled-components"
|
||||
import { highlight } from "../history/HistoryView"
|
||||
import { ModelInfoView } from "./ApiOptions"
|
||||
import { ModelInfoView } from "./common/ModelInfoView"
|
||||
import { normalizeApiConfiguration } from "./utils/providerUtils"
|
||||
import FeaturedModelCard from "./FeaturedModelCard"
|
||||
import ThinkingBudgetSlider from "./ThinkingBudgetSlider"
|
||||
@@ -43,13 +43,13 @@ export interface OpenRouterModelPickerProps {
|
||||
// Featured models for Cline provider
|
||||
const featuredModels = [
|
||||
{
|
||||
id: "anthropic/claude-sonnet-4",
|
||||
description: "Recommended for agentic coding in Cline",
|
||||
id: "google/gemini-2.5-pro",
|
||||
description: "Large 1M context window, great value",
|
||||
label: "Best",
|
||||
},
|
||||
{
|
||||
id: "google/gemini-2.5-pro",
|
||||
description: "Large 1M context window, great value",
|
||||
id: "anthropic/claude-sonnet-4",
|
||||
description: "Recommended for agentic coding in Cline",
|
||||
label: "Trending",
|
||||
},
|
||||
{
|
||||
@@ -66,7 +66,6 @@ const OpenRouterModelPicker: React.FC<OpenRouterModelPickerProps> = ({ isPopup }
|
||||
const [selectedIndex, setSelectedIndex] = useState(-1)
|
||||
const dropdownRef = useRef<HTMLDivElement>(null)
|
||||
const itemRefs = useRef<(HTMLDivElement | null)[]>([])
|
||||
const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false)
|
||||
const dropdownListRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const handleModelChange = (newModelId: string) => {
|
||||
@@ -311,13 +310,7 @@ const OpenRouterModelPicker: React.FC<OpenRouterModelPickerProps> = ({ isPopup }
|
||||
<ThinkingBudgetSlider apiConfiguration={apiConfiguration} setApiConfiguration={setApiConfiguration} />
|
||||
)}
|
||||
|
||||
<ModelInfoView
|
||||
selectedModelId={selectedModelId}
|
||||
modelInfo={selectedModelInfo}
|
||||
isDescriptionExpanded={isDescriptionExpanded}
|
||||
setIsDescriptionExpanded={setIsDescriptionExpanded}
|
||||
isPopup={isPopup}
|
||||
/>
|
||||
<ModelInfoView selectedModelId={selectedModelId} modelInfo={selectedModelInfo} isPopup={isPopup} />
|
||||
</>
|
||||
) : (
|
||||
<p
|
||||
@@ -334,8 +327,8 @@ const OpenRouterModelPicker: React.FC<OpenRouterModelPickerProps> = ({ isPopup }
|
||||
If you're unsure which model to choose, Cline works best with{" "}
|
||||
<VSCodeLink
|
||||
style={{ display: "inline", fontSize: "inherit" }}
|
||||
onClick={() => handleModelChange("anthropic/claude-sonnet-4")}>
|
||||
anthropic/claude-sonnet-4.
|
||||
onClick={() => handleModelChange("google/gemini-2.5-pro")}>
|
||||
google/gemini-2.5-pro.
|
||||
</VSCodeLink>
|
||||
You can also try searching "free" for no-cost options currently available.
|
||||
</>
|
||||
|
||||
@@ -10,7 +10,7 @@ import { useExtensionState } from "../../context/ExtensionStateContext"
|
||||
import { ModelsServiceClient } from "../../services/grpc-client"
|
||||
import { CODE_BLOCK_BG_COLOR } from "../common/CodeBlock"
|
||||
import { highlight } from "../history/HistoryView"
|
||||
import { ModelInfoView } from "./ApiOptions"
|
||||
import { ModelInfoView } from "./common/ModelInfoView"
|
||||
import { normalizeApiConfiguration } from "./utils/providerUtils"
|
||||
import ThinkingBudgetSlider from "./ThinkingBudgetSlider"
|
||||
|
||||
@@ -25,7 +25,6 @@ const RequestyModelPicker: React.FC<RequestyModelPickerProps> = ({ isPopup }) =>
|
||||
const [selectedIndex, setSelectedIndex] = useState(-1)
|
||||
const dropdownRef = useRef<HTMLDivElement>(null)
|
||||
const itemRefs = useRef<(HTMLDivElement | null)[]>([])
|
||||
const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false)
|
||||
const dropdownListRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const handleModelChange = (newModelId: string) => {
|
||||
@@ -230,13 +229,7 @@ const RequestyModelPicker: React.FC<RequestyModelPickerProps> = ({ isPopup }) =>
|
||||
{showBudgetSlider && (
|
||||
<ThinkingBudgetSlider apiConfiguration={apiConfiguration} setApiConfiguration={setApiConfiguration} />
|
||||
)}
|
||||
<ModelInfoView
|
||||
selectedModelId={selectedModelId}
|
||||
modelInfo={selectedModelInfo}
|
||||
isDescriptionExpanded={isDescriptionExpanded}
|
||||
setIsDescriptionExpanded={setIsDescriptionExpanded}
|
||||
isPopup={isPopup}
|
||||
/>
|
||||
<ModelInfoView selectedModelId={selectedModelId} modelInfo={selectedModelInfo} isPopup={isPopup} />
|
||||
</>
|
||||
) : (
|
||||
<p
|
||||
|
||||
@@ -3,26 +3,27 @@ import HeroTooltip from "@/components/common/HeroTooltip"
|
||||
import { useExtensionState } from "@/context/ExtensionStateContext"
|
||||
import { StateServiceClient } from "@/services/grpc-client"
|
||||
import { validateApiConfiguration, validateModelId } from "@/utils/validate"
|
||||
import { vscode } from "@/utils/vscode"
|
||||
import { ExtensionMessage } from "@shared/ExtensionMessage"
|
||||
import { EmptyRequest, StringRequest } from "@shared/proto/common"
|
||||
import { StringRequest } from "@shared/proto/common"
|
||||
import { PlanActMode, ResetStateRequest, TogglePlanActModeRequest, UpdateSettingsRequest } from "@shared/proto/state"
|
||||
import { VSCodeButton, VSCodeCheckbox, VSCodeLink, VSCodeTextArea } from "@vscode/webview-ui-toolkit/react"
|
||||
import { VSCodeButton, VSCodeLink } from "@vscode/webview-ui-toolkit/react"
|
||||
import { CheckCheck, FlaskConical, Info, LucideIcon, Settings, SquareMousePointer, SquareTerminal, Webhook } from "lucide-react"
|
||||
import { memo, useCallback, useEffect, useRef, useState } from "react"
|
||||
import { useEvent } from "react-use"
|
||||
import { Tab, TabContent, TabHeader, TabList, TabTrigger } from "../common/Tab"
|
||||
import { TabButton } from "../mcp/configuration/McpConfigurationView"
|
||||
import ApiOptions from "./ApiOptions"
|
||||
import BrowserSettingsSection from "./BrowserSettingsSection"
|
||||
import { BrowserSettings } from "@shared/BrowserSettings"
|
||||
import FeatureSettingsSection from "./FeatureSettingsSection"
|
||||
import PreferredLanguageSetting from "./PreferredLanguageSetting" // Added import
|
||||
import FeatureSettingsSection from "./sections/FeatureSettingsSection"
|
||||
import Section from "./Section"
|
||||
import SectionHeader from "./SectionHeader"
|
||||
import TerminalSettingsSection from "./TerminalSettingsSection"
|
||||
import TerminalSettingsSection from "./sections/TerminalSettingsSection"
|
||||
import ApiConfigurationSection from "./sections/ApiConfigurationSection"
|
||||
import GeneralSettingsSection from "./sections/GeneralSettingsSection"
|
||||
import BrowserSettingsSection from "./sections/BrowserSettingsSection"
|
||||
import DebugSection from "./sections/DebugSection"
|
||||
import AboutSection from "./sections/AboutSection"
|
||||
import { convertApiConfigurationToProtoApiConfiguration } from "@shared/proto-conversions/state/settings-conversion"
|
||||
import { convertChatSettingsToProtoChatSettings } from "@shared/proto-conversions/state/chat-settings-conversion"
|
||||
|
||||
const IS_DEV = process.env.IS_DEV
|
||||
|
||||
// Styles for the tab system
|
||||
@@ -761,192 +762,52 @@ const SettingsView = ({ onDone, targetSection }: SettingsViewProps) => {
|
||||
<TabContent className="flex-1 overflow-auto">
|
||||
{/* API Configuration Tab */}
|
||||
{activeTab === "api-config" && (
|
||||
<div>
|
||||
{renderSectionHeader("api-config")}
|
||||
<Section>
|
||||
{/* Tabs container */}
|
||||
{planActSeparateModelsSetting ? (
|
||||
<div className="rounded-md mb-5 bg-[var(--vscode-panel-background)]">
|
||||
<div className="flex gap-[1px] mb-[10px] -mt-2 border-0 border-b border-solid border-[var(--vscode-panel-border)]">
|
||||
<TabButton
|
||||
isActive={chatSettings.mode === "plan"}
|
||||
onClick={() => handlePlanActModeChange("plan")}
|
||||
disabled={isSwitchingMode}
|
||||
style={{
|
||||
opacity: isSwitchingMode ? 0.6 : 1,
|
||||
cursor: isSwitchingMode ? "not-allowed" : "pointer",
|
||||
}}>
|
||||
{isSwitchingMode && chatSettings.mode === "act"
|
||||
? "Switching..."
|
||||
: "Plan Mode"}
|
||||
</TabButton>
|
||||
<TabButton
|
||||
isActive={chatSettings.mode === "act"}
|
||||
onClick={() => handlePlanActModeChange("act")}
|
||||
disabled={isSwitchingMode}
|
||||
style={{
|
||||
opacity: isSwitchingMode ? 0.6 : 1,
|
||||
cursor: isSwitchingMode ? "not-allowed" : "pointer",
|
||||
}}>
|
||||
{isSwitchingMode && chatSettings.mode === "plan"
|
||||
? "Switching..."
|
||||
: "Act Mode"}
|
||||
</TabButton>
|
||||
</div>
|
||||
|
||||
{/* Content container */}
|
||||
<div className="-mb-3">
|
||||
<ApiOptions
|
||||
key={chatSettings.mode}
|
||||
showModelOptions={true}
|
||||
apiErrorMessage={apiErrorMessage}
|
||||
modelIdErrorMessage={modelIdErrorMessage}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<ApiOptions
|
||||
key={"single"}
|
||||
showModelOptions={true}
|
||||
apiErrorMessage={apiErrorMessage}
|
||||
modelIdErrorMessage={modelIdErrorMessage}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="mb-[5px]">
|
||||
<VSCodeCheckbox
|
||||
className="mb-[5px]"
|
||||
checked={planActSeparateModelsSetting}
|
||||
onChange={(e: any) => {
|
||||
const checked = e.target.checked === true
|
||||
setPlanActSeparateModelsSetting(checked)
|
||||
}}>
|
||||
Use different models for Plan and Act modes
|
||||
</VSCodeCheckbox>
|
||||
<p className="text-xs mt-[5px] text-[var(--vscode-descriptionForeground)]">
|
||||
Switching between Plan and Act mode will persist the API and model used in the
|
||||
previous mode. This may be helpful e.g. when using a strong reasoning model to
|
||||
architect a plan for a cheaper coding model to act on.
|
||||
</p>
|
||||
</div>
|
||||
</Section>
|
||||
</div>
|
||||
<ApiConfigurationSection
|
||||
planActSeparateModelsSetting={planActSeparateModelsSetting}
|
||||
chatSettings={chatSettings}
|
||||
isSwitchingMode={isSwitchingMode}
|
||||
apiErrorMessage={apiErrorMessage}
|
||||
modelIdErrorMessage={modelIdErrorMessage}
|
||||
handlePlanActModeChange={handlePlanActModeChange}
|
||||
setPlanActSeparateModelsSetting={setPlanActSeparateModelsSetting}
|
||||
renderSectionHeader={renderSectionHeader}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* General Settings Tab */}
|
||||
{activeTab === "general" && (
|
||||
<div>
|
||||
{renderSectionHeader("general")}
|
||||
<Section>
|
||||
{chatSettings && (
|
||||
<PreferredLanguageSetting
|
||||
chatSettings={chatSettings}
|
||||
setChatSettings={setChatSettings}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="mb-[5px]">
|
||||
<VSCodeCheckbox
|
||||
className="mb-[5px]"
|
||||
checked={telemetrySetting !== "disabled"}
|
||||
onChange={(e: any) => {
|
||||
const checked = e.target.checked === true
|
||||
setTelemetrySetting(checked ? "enabled" : "disabled")
|
||||
}}>
|
||||
Allow anonymous error and usage reporting
|
||||
</VSCodeCheckbox>
|
||||
<p className="text-xs mt-[5px] text-[var(--vscode-descriptionForeground)]">
|
||||
Help improve Cline by sending anonymous usage data and error reports. No code,
|
||||
prompts, or personal information are ever sent. See our{" "}
|
||||
<VSCodeLink
|
||||
href="https://docs.cline.bot/more-info/telemetry"
|
||||
className="text-inherit">
|
||||
telemetry overview
|
||||
</VSCodeLink>{" "}
|
||||
and{" "}
|
||||
<VSCodeLink href="https://cline.bot/privacy" className="text-inherit">
|
||||
privacy policy
|
||||
</VSCodeLink>{" "}
|
||||
for more details.
|
||||
</p>
|
||||
</div>
|
||||
</Section>
|
||||
</div>
|
||||
<GeneralSettingsSection
|
||||
chatSettings={chatSettings}
|
||||
setChatSettings={setChatSettings}
|
||||
telemetrySetting={telemetrySetting}
|
||||
setTelemetrySetting={setTelemetrySetting}
|
||||
renderSectionHeader={renderSectionHeader}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Feature Settings Tab */}
|
||||
{activeTab === "features" && (
|
||||
<div>
|
||||
{renderSectionHeader("features")}
|
||||
<Section>
|
||||
<FeatureSettingsSection />
|
||||
</Section>
|
||||
</div>
|
||||
)}
|
||||
{activeTab === "features" && <FeatureSettingsSection renderSectionHeader={renderSectionHeader} />}
|
||||
|
||||
{/* Browser Settings Tab */}
|
||||
{activeTab === "browser" && (
|
||||
<div>
|
||||
{renderSectionHeader("browser")}
|
||||
<Section>
|
||||
<BrowserSettingsSection
|
||||
localBrowserSettings={localBrowserSettings}
|
||||
onBrowserSettingsChange={setLocalBrowserSettings}
|
||||
/>
|
||||
</Section>
|
||||
</div>
|
||||
<BrowserSettingsSection
|
||||
localBrowserSettings={localBrowserSettings}
|
||||
onBrowserSettingsChange={setLocalBrowserSettings}
|
||||
renderSectionHeader={renderSectionHeader}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Terminal Settings Tab */}
|
||||
{activeTab === "terminal" && (
|
||||
<div>
|
||||
{renderSectionHeader("terminal")}
|
||||
<Section>
|
||||
<TerminalSettingsSection />
|
||||
</Section>
|
||||
</div>
|
||||
)}
|
||||
{activeTab === "terminal" && <TerminalSettingsSection renderSectionHeader={renderSectionHeader} />}
|
||||
|
||||
{/* Debug Tab (only in dev mode) */}
|
||||
{IS_DEV && activeTab === "debug" && (
|
||||
<div>
|
||||
{renderSectionHeader("debug")}
|
||||
<Section>
|
||||
<VSCodeButton
|
||||
onClick={() => handleResetState()}
|
||||
className="mt-[5px] w-auto"
|
||||
style={{ backgroundColor: "var(--vscode-errorForeground)", color: "black" }}>
|
||||
Reset Workspace State
|
||||
</VSCodeButton>
|
||||
<VSCodeButton
|
||||
onClick={() => handleResetState(true)}
|
||||
className="mt-[5px] w-auto"
|
||||
style={{ backgroundColor: "var(--vscode-errorForeground)", color: "black" }}>
|
||||
Reset Global State
|
||||
</VSCodeButton>
|
||||
<p className="text-xs mt-[5px] text-[var(--vscode-descriptionForeground)]">
|
||||
This will reset all global state and secret storage in the extension.
|
||||
</p>
|
||||
</Section>
|
||||
</div>
|
||||
<DebugSection onResetState={handleResetState} renderSectionHeader={renderSectionHeader} />
|
||||
)}
|
||||
|
||||
{/* About Tab */}
|
||||
{activeTab === "about" && (
|
||||
<div>
|
||||
{renderSectionHeader("about")}
|
||||
<Section>
|
||||
<div className="text-center text-[var(--vscode-descriptionForeground)] text-xs leading-[1.2] px-0 py-0 pr-2 pb-[15px] mt-auto">
|
||||
<p className="break-words m-0 p-0">
|
||||
If you have any questions or feedback, feel free to open an issue at{" "}
|
||||
<VSCodeLink href="https://github.com/cline/cline" className="inline">
|
||||
https://github.com/cline/cline
|
||||
</VSCodeLink>
|
||||
</p>
|
||||
<p className="italic mt-[10px] mb-0 p-0">v{version}</p>
|
||||
</div>
|
||||
</Section>
|
||||
</div>
|
||||
<AboutSection version={version} renderSectionHeader={renderSectionHeader} />
|
||||
)}
|
||||
</TabContent>
|
||||
)
|
||||
|
||||
@@ -1,142 +0,0 @@
|
||||
import React, { useState, useEffect } from "react"
|
||||
import { VSCodeTextField, VSCodeCheckbox, VSCodeDropdown, VSCodeOption } from "@vscode/webview-ui-toolkit/react"
|
||||
import { useExtensionState } from "@/context/ExtensionStateContext"
|
||||
import TerminalOutputLineLimitSlider from "./TerminalOutputLineLimitSlider"
|
||||
import { StateServiceClient } from "../../services/grpc-client"
|
||||
import { Int64, Int64Request } from "@shared/proto/common"
|
||||
|
||||
export const TerminalSettingsSection: React.FC = () => {
|
||||
const {
|
||||
shellIntegrationTimeout,
|
||||
setShellIntegrationTimeout,
|
||||
terminalReuseEnabled,
|
||||
setTerminalReuseEnabled,
|
||||
defaultTerminalProfile,
|
||||
setDefaultTerminalProfile,
|
||||
availableTerminalProfiles,
|
||||
platform,
|
||||
} = useExtensionState()
|
||||
|
||||
const [inputValue, setInputValue] = useState((shellIntegrationTimeout / 1000).toString())
|
||||
const [inputError, setInputError] = useState<string | null>(null)
|
||||
|
||||
const handleTimeoutChange = (event: Event) => {
|
||||
const target = event.target as HTMLInputElement
|
||||
const value = target.value
|
||||
|
||||
setInputValue(value)
|
||||
|
||||
const seconds = parseFloat(value)
|
||||
if (isNaN(seconds) || seconds <= 0) {
|
||||
setInputError("Please enter a positive number")
|
||||
return
|
||||
}
|
||||
|
||||
setInputError(null)
|
||||
const timeout = Math.round(seconds * 1000)
|
||||
|
||||
setShellIntegrationTimeout(timeout)
|
||||
|
||||
StateServiceClient.updateTerminalConnectionTimeout({
|
||||
value: timeout,
|
||||
} as Int64Request)
|
||||
.then((response: Int64) => {
|
||||
setShellIntegrationTimeout(response.value)
|
||||
setInputValue((response.value / 1000).toString())
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Failed to update terminal connection timeout:", error)
|
||||
})
|
||||
}
|
||||
|
||||
const handleInputBlur = () => {
|
||||
if (inputError) {
|
||||
setInputValue((shellIntegrationTimeout / 1000).toString())
|
||||
setInputError(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleTerminalReuseChange = (event: Event) => {
|
||||
const target = event.target as HTMLInputElement
|
||||
const checked = target.checked
|
||||
setTerminalReuseEnabled(checked)
|
||||
StateServiceClient.updateTerminalReuseEnabled({ value: checked } as any).catch((error) => {
|
||||
console.error("Failed to update terminal reuse enabled:", error)
|
||||
})
|
||||
}
|
||||
|
||||
// Use any to avoid type conflicts between Event and FormEvent
|
||||
const handleDefaultTerminalProfileChange = (event: any) => {
|
||||
const target = event.target as HTMLSelectElement
|
||||
const profileId = target.value
|
||||
// Only update the local state, let the Save button handle the backend update
|
||||
setDefaultTerminalProfile(profileId)
|
||||
}
|
||||
|
||||
const profilesToShow = availableTerminalProfiles
|
||||
|
||||
return (
|
||||
<div id="terminal-settings-section" style={{ marginBottom: 20 }}>
|
||||
<div style={{ marginBottom: 15 }}>
|
||||
<label htmlFor="default-terminal-profile" style={{ fontWeight: "500", display: "block", marginBottom: 5 }}>
|
||||
Default Terminal Profile
|
||||
</label>
|
||||
<VSCodeDropdown
|
||||
id="default-terminal-profile"
|
||||
value={defaultTerminalProfile || "default"}
|
||||
onChange={handleDefaultTerminalProfileChange}
|
||||
style={{ width: "100%" }}>
|
||||
{profilesToShow.map((profile) => (
|
||||
<VSCodeOption key={profile.id} value={profile.id} title={profile.description}>
|
||||
{profile.name}
|
||||
</VSCodeOption>
|
||||
))}
|
||||
</VSCodeDropdown>
|
||||
<p style={{ fontSize: "12px", color: "var(--vscode-descriptionForeground)", margin: "5px 0 0 0" }}>
|
||||
Select the default terminal Cline will use. 'Default' uses your VSCode global setting.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: 15 }}>
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<label style={{ fontWeight: "500", display: "block", marginBottom: 5 }}>
|
||||
Shell integration timeout (seconds)
|
||||
</label>
|
||||
<div style={{ display: "flex", alignItems: "center" }}>
|
||||
<VSCodeTextField
|
||||
style={{ width: "100%" }}
|
||||
value={inputValue}
|
||||
placeholder="Enter timeout in seconds"
|
||||
onChange={(event) => handleTimeoutChange(event as Event)}
|
||||
onBlur={handleInputBlur}
|
||||
/>
|
||||
</div>
|
||||
{inputError && (
|
||||
<div style={{ color: "var(--vscode-errorForeground)", fontSize: "12px", marginTop: 5 }}>{inputError}</div>
|
||||
)}
|
||||
</div>
|
||||
<p style={{ fontSize: "12px", color: "var(--vscode-descriptionForeground)", margin: 0 }}>
|
||||
Set how long Cline waits for shell integration to activate before executing commands. Increase this value if
|
||||
you experience terminal connection timeouts.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: 15 }}>
|
||||
<div style={{ display: "flex", alignItems: "center", marginBottom: 8 }}>
|
||||
<VSCodeCheckbox
|
||||
checked={terminalReuseEnabled ?? true}
|
||||
onChange={(event) => handleTerminalReuseChange(event as Event)}>
|
||||
Enable aggressive terminal reuse
|
||||
</VSCodeCheckbox>
|
||||
</div>
|
||||
<p style={{ fontSize: "12px", color: "var(--vscode-descriptionForeground)", margin: 0 }}>
|
||||
When enabled, Cline will reuse existing terminal windows that aren't in the current working directory. Disable
|
||||
this if you experience issues with task lockout after a terminal command.
|
||||
</p>
|
||||
</div>
|
||||
<TerminalOutputLineLimitSlider />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default TerminalSettingsSection
|
||||
@@ -99,7 +99,7 @@ export const ModelInfoView = ({ selectedModelId, modelInfo, isPopup }: ModelInfo
|
||||
// Internal state management for description expansion
|
||||
const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false)
|
||||
|
||||
const isGeminiProvider = Object.keys(geminiModels).includes(selectedModelId)
|
||||
const isGemini = Object.keys(geminiModels).includes(selectedModelId)
|
||||
const hasThinkingConfig = hasThinkingBudget(modelInfo)
|
||||
const hasTiers = !!modelInfo.tiers && modelInfo.tiers.length > 0
|
||||
|
||||
@@ -170,7 +170,7 @@ export const ModelInfoView = ({ selectedModelId, modelInfo, isPopup }: ModelInfo
|
||||
supportsLabel="Supports browser use"
|
||||
doesNotSupportLabel="Does not support browser use"
|
||||
/>,
|
||||
!isGeminiProvider && (
|
||||
!isGemini && (
|
||||
<ModelInfoSupportsItem
|
||||
key="supportsPromptCache"
|
||||
isSupported={supportsPromptCache(modelInfo)}
|
||||
@@ -195,15 +195,6 @@ export const ModelInfoView = ({ selectedModelId, modelInfo, isPopup }: ModelInfo
|
||||
</span>
|
||||
),
|
||||
outputPriceElement, // Add the generated output price block
|
||||
isGeminiProvider && (
|
||||
<span key="geminiInfo" style={{ fontStyle: "italic" }}>
|
||||
* Free up to {selectedModelId && selectedModelId.includes("flash") ? "15" : "2"} requests per minute. After that,
|
||||
billing depends on prompt size.{" "}
|
||||
<VSCodeLink href="https://ai.google.dev/pricing" style={{ display: "inline", fontSize: "inherit" }}>
|
||||
For more info, see pricing details.
|
||||
</VSCodeLink>
|
||||
</span>
|
||||
),
|
||||
].filter(Boolean)
|
||||
|
||||
return (
|
||||
|
||||
@@ -32,6 +32,17 @@ interface ModelSelectorProps {
|
||||
label?: string
|
||||
}
|
||||
|
||||
/*
|
||||
OG Saoud Note:
|
||||
|
||||
VSCodeDropdown has an open bug where dynamically rendered options don't auto select the provided value prop. You can see this for yourself by comparing it with normal select/option elements, which work as expected.
|
||||
https://github.com/microsoft/vscode-webview-ui-toolkit/issues/433
|
||||
|
||||
In our case, when the user switches between providers, we recalculate the selectedModelId depending on the provider, the default model for that provider, and a modelId that the user may have selected. Unfortunately, the VSCodeDropdown component wouldn't select this calculated value, and would default to the first "Select a model..." option instead, which makes it seem like the model was cleared out when it wasn't.
|
||||
|
||||
As a workaround, we create separate instances of the dropdown for each provider, and then conditionally render the one that matches the current provider.
|
||||
*/
|
||||
|
||||
/**
|
||||
* A reusable component for selecting models from a dropdown
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,311 @@
|
||||
import { ApiConfiguration, bedrockDefaultModelId, bedrockModels } from "@shared/api"
|
||||
import {
|
||||
VSCodeCheckbox,
|
||||
VSCodeDropdown,
|
||||
VSCodeOption,
|
||||
VSCodeRadio,
|
||||
VSCodeRadioGroup,
|
||||
VSCodeTextField,
|
||||
} from "@vscode/webview-ui-toolkit/react"
|
||||
import { useState } from "react"
|
||||
import { ModelInfoView } from "../common/ModelInfoView"
|
||||
import { DropdownContainer } from "../common/ModelSelector"
|
||||
import ThinkingBudgetSlider from "../ThinkingBudgetSlider"
|
||||
import { normalizeApiConfiguration } from "../utils/providerUtils"
|
||||
|
||||
// Z-index constants for proper dropdown layering
|
||||
const DROPDOWN_Z_INDEX = 1000
|
||||
|
||||
interface BedrockProviderProps {
|
||||
apiConfiguration: ApiConfiguration
|
||||
handleInputChange: (field: keyof ApiConfiguration) => (event: any) => void
|
||||
showModelOptions: boolean
|
||||
isPopup?: boolean
|
||||
setApiConfiguration: (config: ApiConfiguration) => void
|
||||
}
|
||||
|
||||
export const BedrockProvider = ({
|
||||
apiConfiguration,
|
||||
handleInputChange,
|
||||
showModelOptions,
|
||||
isPopup,
|
||||
setApiConfiguration,
|
||||
}: BedrockProviderProps) => {
|
||||
const { selectedModelId, selectedModelInfo } = normalizeApiConfiguration(apiConfiguration)
|
||||
const [awsEndpointSelected, setAwsEndpointSelected] = useState(!!apiConfiguration?.awsBedrockEndpoint)
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 5,
|
||||
}}>
|
||||
<VSCodeRadioGroup
|
||||
value={apiConfiguration?.awsUseProfile ? "profile" : "credentials"}
|
||||
onChange={(e) => {
|
||||
const value = (e.target as HTMLInputElement)?.value
|
||||
const useProfile = value === "profile"
|
||||
setApiConfiguration({
|
||||
...apiConfiguration,
|
||||
awsUseProfile: useProfile,
|
||||
})
|
||||
}}>
|
||||
<VSCodeRadio value="credentials">AWS Credentials</VSCodeRadio>
|
||||
<VSCodeRadio value="profile">AWS Profile</VSCodeRadio>
|
||||
</VSCodeRadioGroup>
|
||||
|
||||
{apiConfiguration?.awsUseProfile ? (
|
||||
<VSCodeTextField
|
||||
value={apiConfiguration?.awsProfile || ""}
|
||||
style={{ width: "100%" }}
|
||||
onInput={handleInputChange("awsProfile")}
|
||||
placeholder="Enter profile name (default if empty)">
|
||||
<span style={{ fontWeight: 500 }}>AWS Profile Name</span>
|
||||
</VSCodeTextField>
|
||||
) : (
|
||||
<>
|
||||
<VSCodeTextField
|
||||
value={apiConfiguration?.awsAccessKey || ""}
|
||||
style={{ width: "100%" }}
|
||||
type="password"
|
||||
onInput={handleInputChange("awsAccessKey")}
|
||||
placeholder="Enter Access Key...">
|
||||
<span style={{ fontWeight: 500 }}>AWS Access Key</span>
|
||||
</VSCodeTextField>
|
||||
<VSCodeTextField
|
||||
value={apiConfiguration?.awsSecretKey || ""}
|
||||
style={{ width: "100%" }}
|
||||
type="password"
|
||||
onInput={handleInputChange("awsSecretKey")}
|
||||
placeholder="Enter Secret Key...">
|
||||
<span style={{ fontWeight: 500 }}>AWS Secret Key</span>
|
||||
</VSCodeTextField>
|
||||
<VSCodeTextField
|
||||
value={apiConfiguration?.awsSessionToken || ""}
|
||||
style={{ width: "100%" }}
|
||||
type="password"
|
||||
onInput={handleInputChange("awsSessionToken")}
|
||||
placeholder="Enter Session Token...">
|
||||
<span style={{ fontWeight: 500 }}>AWS Session Token</span>
|
||||
</VSCodeTextField>
|
||||
</>
|
||||
)}
|
||||
|
||||
<DropdownContainer zIndex={DROPDOWN_Z_INDEX - 1} className="dropdown-container">
|
||||
<label htmlFor="aws-region-dropdown">
|
||||
<span style={{ fontWeight: 500 }}>AWS Region</span>
|
||||
</label>
|
||||
<VSCodeDropdown
|
||||
id="aws-region-dropdown"
|
||||
value={apiConfiguration?.awsRegion || ""}
|
||||
style={{ width: "100%" }}
|
||||
onChange={handleInputChange("awsRegion")}>
|
||||
<VSCodeOption value="">Select a region...</VSCodeOption>
|
||||
{/* The user will have to choose a region that supports the model they use, but this shouldn't be a problem since they'd have to request access for it in that region in the first place. */}
|
||||
<VSCodeOption value="us-east-1">us-east-1</VSCodeOption>
|
||||
<VSCodeOption value="us-east-2">us-east-2</VSCodeOption>
|
||||
{/* <VSCodeOption value="us-west-1">us-west-1</VSCodeOption> */}
|
||||
<VSCodeOption value="us-west-2">us-west-2</VSCodeOption>
|
||||
{/* <VSCodeOption value="af-south-1">af-south-1</VSCodeOption> */}
|
||||
{/* <VSCodeOption value="ap-east-1">ap-east-1</VSCodeOption> */}
|
||||
<VSCodeOption value="ap-south-1">ap-south-1</VSCodeOption>
|
||||
<VSCodeOption value="ap-northeast-1">ap-northeast-1</VSCodeOption>
|
||||
<VSCodeOption value="ap-northeast-2">ap-northeast-2</VSCodeOption>
|
||||
<VSCodeOption value="ap-northeast-3">ap-northeast-3</VSCodeOption>
|
||||
<VSCodeOption value="ap-southeast-1">ap-southeast-1</VSCodeOption>
|
||||
<VSCodeOption value="ap-southeast-2">ap-southeast-2</VSCodeOption>
|
||||
<VSCodeOption value="ca-central-1">ca-central-1</VSCodeOption>
|
||||
<VSCodeOption value="eu-central-1">eu-central-1</VSCodeOption>
|
||||
<VSCodeOption value="eu-central-2">eu-central-2</VSCodeOption>
|
||||
<VSCodeOption value="eu-west-1">eu-west-1</VSCodeOption>
|
||||
<VSCodeOption value="eu-west-2">eu-west-2</VSCodeOption>
|
||||
<VSCodeOption value="eu-west-3">eu-west-3</VSCodeOption>
|
||||
<VSCodeOption value="eu-north-1">eu-north-1</VSCodeOption>
|
||||
<VSCodeOption value="eu-south-1">eu-south-1</VSCodeOption>
|
||||
<VSCodeOption value="eu-south-2">eu-south-2</VSCodeOption>
|
||||
{/* <VSCodeOption value="me-south-1">me-south-1</VSCodeOption> */}
|
||||
<VSCodeOption value="sa-east-1">sa-east-1</VSCodeOption>
|
||||
<VSCodeOption value="us-gov-east-1">us-gov-east-1</VSCodeOption>
|
||||
<VSCodeOption value="us-gov-west-1">us-gov-west-1</VSCodeOption>
|
||||
{/* <VSCodeOption value="us-gov-east-1">us-gov-east-1</VSCodeOption> */}
|
||||
</VSCodeDropdown>
|
||||
</DropdownContainer>
|
||||
|
||||
<div style={{ display: "flex", flexDirection: "column" }}>
|
||||
<VSCodeCheckbox
|
||||
checked={awsEndpointSelected}
|
||||
onChange={(e: any) => {
|
||||
const isChecked = e.target.checked === true
|
||||
setAwsEndpointSelected(isChecked)
|
||||
if (!isChecked) {
|
||||
setApiConfiguration({
|
||||
...apiConfiguration,
|
||||
awsBedrockEndpoint: "",
|
||||
})
|
||||
}
|
||||
}}>
|
||||
Use custom VPC endpoint
|
||||
</VSCodeCheckbox>
|
||||
|
||||
{awsEndpointSelected && (
|
||||
<VSCodeTextField
|
||||
value={apiConfiguration?.awsBedrockEndpoint || ""}
|
||||
style={{ width: "100%", marginTop: 3, marginBottom: 5 }}
|
||||
type="url"
|
||||
onInput={handleInputChange("awsBedrockEndpoint")}
|
||||
placeholder="Enter VPC Endpoint URL (optional)"
|
||||
/>
|
||||
)}
|
||||
|
||||
<VSCodeCheckbox
|
||||
checked={apiConfiguration?.awsUseCrossRegionInference || false}
|
||||
onChange={(e: any) => {
|
||||
const isChecked = e.target.checked === true
|
||||
setApiConfiguration({
|
||||
...apiConfiguration,
|
||||
awsUseCrossRegionInference: isChecked,
|
||||
})
|
||||
}}>
|
||||
Use cross-region inference
|
||||
</VSCodeCheckbox>
|
||||
|
||||
{selectedModelInfo.supportsPromptCache && (
|
||||
<>
|
||||
<VSCodeCheckbox
|
||||
checked={apiConfiguration?.awsBedrockUsePromptCache || false}
|
||||
onChange={(e: any) => {
|
||||
const isChecked = e.target.checked === true
|
||||
setApiConfiguration({
|
||||
...apiConfiguration,
|
||||
awsBedrockUsePromptCache: isChecked,
|
||||
})
|
||||
}}>
|
||||
Use prompt caching
|
||||
</VSCodeCheckbox>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p
|
||||
style={{
|
||||
fontSize: "12px",
|
||||
marginTop: "5px",
|
||||
color: "var(--vscode-descriptionForeground)",
|
||||
}}>
|
||||
{apiConfiguration?.awsUseProfile ? (
|
||||
<>
|
||||
Using AWS Profile credentials from ~/.aws/credentials. Leave profile name empty to use the default
|
||||
profile. These credentials are only used locally to make API requests from this extension.
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Authenticate by either providing the keys above or use the default AWS credential providers, i.e.
|
||||
~/.aws/credentials or environment variables. These credentials are only used locally to make API requests
|
||||
from this extension.
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
|
||||
{showModelOptions && (
|
||||
<>
|
||||
<label htmlFor="bedrock-model-dropdown">
|
||||
<span style={{ fontWeight: 500 }}>Model</span>
|
||||
</label>
|
||||
<DropdownContainer zIndex={DROPDOWN_Z_INDEX - 2} className="dropdown-container">
|
||||
<VSCodeDropdown
|
||||
id="bedrock-model-dropdown"
|
||||
value={apiConfiguration?.awsBedrockCustomSelected ? "custom" : selectedModelId}
|
||||
onChange={(e: any) => {
|
||||
const isCustom = e.target.value === "custom"
|
||||
setApiConfiguration({
|
||||
...apiConfiguration,
|
||||
apiModelId: isCustom ? "" : e.target.value,
|
||||
awsBedrockCustomSelected: isCustom,
|
||||
awsBedrockCustomModelBaseId: bedrockDefaultModelId,
|
||||
})
|
||||
}}
|
||||
style={{ width: "100%" }}>
|
||||
<VSCodeOption value="">Select a model...</VSCodeOption>
|
||||
{Object.keys(bedrockModels).map((modelId) => (
|
||||
<VSCodeOption
|
||||
key={modelId}
|
||||
value={modelId}
|
||||
style={{
|
||||
whiteSpace: "normal",
|
||||
wordWrap: "break-word",
|
||||
maxWidth: "100%",
|
||||
}}>
|
||||
{modelId}
|
||||
</VSCodeOption>
|
||||
))}
|
||||
<VSCodeOption value="custom">Custom</VSCodeOption>
|
||||
</VSCodeDropdown>
|
||||
</DropdownContainer>
|
||||
|
||||
{apiConfiguration?.awsBedrockCustomSelected && (
|
||||
<div>
|
||||
<p
|
||||
style={{
|
||||
fontSize: "12px",
|
||||
marginTop: "5px",
|
||||
color: "var(--vscode-descriptionForeground)",
|
||||
}}>
|
||||
Select "Custom" when using the Application Inference Profile in Bedrock. Enter the Application
|
||||
Inference Profile ARN in the Model ID field.
|
||||
</p>
|
||||
<label htmlFor="bedrock-model-input">
|
||||
<span style={{ fontWeight: 500 }}>Model ID</span>
|
||||
</label>
|
||||
<VSCodeTextField
|
||||
id="bedrock-model-input"
|
||||
value={apiConfiguration?.apiModelId || ""}
|
||||
style={{ width: "100%", marginTop: 3 }}
|
||||
onInput={handleInputChange("apiModelId")}
|
||||
placeholder="Enter custom model ID..."
|
||||
/>
|
||||
<label htmlFor="bedrock-base-model-dropdown">
|
||||
<span style={{ fontWeight: 500 }}>Base Inference Model</span>
|
||||
</label>
|
||||
<DropdownContainer zIndex={DROPDOWN_Z_INDEX - 3} className="dropdown-container">
|
||||
<VSCodeDropdown
|
||||
id="bedrock-base-model-dropdown"
|
||||
value={apiConfiguration?.awsBedrockCustomModelBaseId || bedrockDefaultModelId}
|
||||
onChange={handleInputChange("awsBedrockCustomModelBaseId")}
|
||||
style={{ width: "100%" }}>
|
||||
<VSCodeOption value="">Select a model...</VSCodeOption>
|
||||
{Object.keys(bedrockModels).map((modelId) => (
|
||||
<VSCodeOption
|
||||
key={modelId}
|
||||
value={modelId}
|
||||
style={{
|
||||
whiteSpace: "normal",
|
||||
wordWrap: "break-word",
|
||||
maxWidth: "100%",
|
||||
}}>
|
||||
{modelId}
|
||||
</VSCodeOption>
|
||||
))}
|
||||
</VSCodeDropdown>
|
||||
</DropdownContainer>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(selectedModelId === "anthropic.claude-3-7-sonnet-20250219-v1:0" ||
|
||||
selectedModelId === "anthropic.claude-sonnet-4-20250514-v1:0" ||
|
||||
selectedModelId === "anthropic.claude-opus-4-20250514-v1:0" ||
|
||||
(apiConfiguration?.awsBedrockCustomSelected &&
|
||||
apiConfiguration?.awsBedrockCustomModelBaseId === "anthropic.claude-3-7-sonnet-20250219-v1:0") ||
|
||||
(apiConfiguration?.awsBedrockCustomSelected &&
|
||||
apiConfiguration?.awsBedrockCustomModelBaseId === "anthropic.claude-sonnet-4-20250514-v1:0") ||
|
||||
(apiConfiguration?.awsBedrockCustomSelected &&
|
||||
apiConfiguration?.awsBedrockCustomModelBaseId === "anthropic.claude-opus-4-20250514-v1:0")) && (
|
||||
<ThinkingBudgetSlider apiConfiguration={apiConfiguration} setApiConfiguration={setApiConfiguration} />
|
||||
)}
|
||||
|
||||
<ModelInfoView selectedModelId={selectedModelId} modelInfo={selectedModelInfo} isPopup={isPopup} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { ApiConfiguration, cerebrasModels } from "@shared/api"
|
||||
import { ApiKeyField } from "../common/ApiKeyField"
|
||||
import { ModelSelector } from "../common/ModelSelector"
|
||||
import { ModelInfoView } from "../common/ModelInfoView"
|
||||
import { normalizeApiConfiguration } from "../utils/providerUtils"
|
||||
|
||||
/**
|
||||
* Props for the CerebrasProvider component
|
||||
*/
|
||||
interface CerebrasProviderProps {
|
||||
apiConfiguration: ApiConfiguration
|
||||
handleInputChange: (field: keyof ApiConfiguration) => (event: any) => void
|
||||
showModelOptions: boolean
|
||||
isPopup?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* The Cerebras provider configuration component
|
||||
*/
|
||||
export const CerebrasProvider = ({ apiConfiguration, handleInputChange, showModelOptions, isPopup }: CerebrasProviderProps) => {
|
||||
// Get the normalized configuration
|
||||
const { selectedModelId, selectedModelInfo } = normalizeApiConfiguration(apiConfiguration)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<ApiKeyField
|
||||
value={apiConfiguration?.cerebrasApiKey || ""}
|
||||
onChange={handleInputChange("cerebrasApiKey")}
|
||||
providerName="Cerebras"
|
||||
signupUrl="https://cloud.cerebras.ai/"
|
||||
/>
|
||||
|
||||
{showModelOptions && (
|
||||
<>
|
||||
<ModelSelector
|
||||
models={cerebrasModels}
|
||||
selectedModelId={selectedModelId}
|
||||
onChange={handleInputChange("apiModelId")}
|
||||
label="Model"
|
||||
/>
|
||||
|
||||
<ModelInfoView selectedModelId={selectedModelId} modelInfo={selectedModelInfo} isPopup={isPopup} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user