Compare commits

..
146 changed files with 28993 additions and 3978 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
Fix input box positioning issue in chat view.
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
Support sending context to active webview when editor panels are opened.
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
remove unused parseAssistantmessageV1
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
Fix LiteLLM Proxy Provider Cost Tracking
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
Add voice dictation feature for Cline account users
-31
View File
@@ -1,36 +1,5 @@
# Changelog
## [3.21.0]
- Add support for GPT-5 model family including GPT-5, GPT-5 Mini, and GPT-5 Nano with prompt caching support and set GPT-5 as the new default model
- Add "Take a Tour" button for new users to easily access the VSCode walkthrough and improve onboarding experience
- Enhance plan mode response handling with better exploration parameter support
## [3.20.13]
- Fix prompt caching support for Opus 4.1 on OpenRouter/Cline
## [3.20.12]
- Add Claude Opus 4.1 model support to AWS Bedrock provider (Thanks @omercelik!)
- Fix prompt caching and extended thinking support for Claude Opus 4.1 in Anthropic provider
## [3.20.11]
Add gpt-oss-120b as a Cerebras model
Add Opus 4.1 through Claude Code
## [3.20.10]
- Add OpenAI's new open-source models (GPT-OSS-120B and GPT-OSS-20B) to Hugging Face and Groq providers
## [3.20.9]
- Add support for Claude Opus 4.1 model in Anthropic provider
- Add Baseten as a new API provider with support for DeepSeek, Llama, and Kimi K2 models (Thanks @AlexKer!)
- Fix error messages not clearing from UI when retrying failed tasks
- Fix chat input box positioning issues
## [3.20.8]
- Add navbar tooltips on hover
+1
View File
@@ -79,6 +79,7 @@
"features/drag-and-drop",
"features/plan-and-act",
"features/slash-commands/workflows",
"features/voice-recording",
"features/editing-messages",
{
"group": "@ Mentions",
+62
View File
@@ -0,0 +1,62 @@
---
title: Voice Recording
description:
---
Cline lets you record audio messages in chat, which are transcribed using Cline's transcription service.
## How It Works
1. **Enable dictation** in Feature Settings (it's on by default).
2. **Click the microphone** in the chat input.
3. **Speak** - the button turns red while recording.
4. **Click stop** when done.
5. **Wait for transcription** - "[Transcribing...]" will appear and then the finished transcription will appear.
## Settings
Enable or disable voice recording in Feature Settings by toggling "Enable Dictation." You can also change the transcription language to one of the supported languages.
## Requirements
### Audio Recording Tools
Cline uses FFmpeg to capture your voice across all platforms:
- **macOS**: FFmpeg (via Homebrew: `brew install ffmpeg`)
- **Linux**: FFmpeg (via apt: `sudo apt-get install ffmpeg`)
- **Windows**: FFmpeg (via winget: `winget install Gyan.FFmpeg`)
If you don't have FFmpeg installed, Cline will automatically detect this and prompt you to install it with a single click.
## Technical Details
### Independent from Chat Provider
The voice transcription feature works completely independently from whatever chat provider you're using. You can use Claude, GPT-4, or any other model for your conversations, and voice transcription will always use Cline's own transcription service. As long as you have a valid Cline account with enough credits, voice mode will work regardless of your chat model choice.
### Audio Format
Voice recordings are captured in WebM format using the Opus codec for optimal compression. The system records in mono (single channel) at a 16kHz sample rate, which is specifically optimized for voice recognition. The bitrate is set to 32kbps to keep file sizes efficient while maintaining good audio quality.
### Privacy & Security
Your audio is recorded locally on your machine and only the audio file itself is sent to Cline's transcription service for processing. No audio is stored anywhere after transcription is complete, and all temporary files are automatically cleaned up to protect your privacy.
## Troubleshooting
`Failed to start recording` - Audio recording tools aren't installed. Cline will prompt you to install FFmpeg - just follow the chat instructions.
`Invalid audio format or request data` - Usually an audio recording issue. Make sure FFmpeg is properly installed and working.
`Authentication failed` - You need to reauthenticate your Cline account. Sign out and back in, then check your internet connection.
`Insufficient credits for transcription service` - Your Cline account doesn't have enough credits. Check your balance and purchase more if needed.
`Cannot connect to transcription service` - Connection issue. Check your internet connection and firewall settings aren't blocking Cline's servers.
## API Usage
Voice transcription uses Cline's transcription service, which requires credits from your Cline account. Currently, voice transcription is billed at $0.006 per minute of audio.
**Note:** We are still experimenting with this feature and pricing may change in the future.
+8770
View File
File diff suppressed because it is too large Load Diff
-1
View File
@@ -16,7 +16,6 @@ description: "Learn how to configure and use Anthropic Claude models with Cline.
Cline supports the following Anthropic Claude models:
- `claude-opus-4-1-20250805`
- `claude-opus-4-20250514`
- `claude-opus-4-20250514:thinking` (Extended Thinking variant)
- `claude-sonnet-4-20250514` (Recommended)
-1
View File
@@ -52,7 +52,6 @@ If you're not sure where Claude Code is installed:
The Claude Code provider supports these models:
- `claude-sonnet-4-20250514` (Recommended)
- `claude-opus-4-1-20250805`
- `claude-opus-4-20250514`
- `claude-3-7-sonnet-20250219`
- `claude-3-5-sonnet-20241022`
-3
View File
@@ -10,7 +10,6 @@ interface RunDiffEvalOptions {
parsingFunction: string
diffEditFunction: string
thinkingBudget: number
provider: string
parallel: boolean
verbose: boolean
testPath: string
@@ -40,8 +39,6 @@ export async function runDiffEvalHandler(options: RunDiffEvalOptions) {
options.parsingFunction,
"--diff-edit-function",
options.diffEditFunction,
"--provider",
options.provider,
]
// Conditionally add the optional arguments
-1
View File
@@ -92,7 +92,6 @@ program
.option("--parsing-function <name>", "The parsing function to use", "parseAssistantMessageV2")
.option("--diff-edit-function <name>", "The diff editing function to use", "constructNewFileContentV2")
.option("--thinking-budget <tokens>", "Set the thinking tokens budget", "0")
.option("--provider <provider>", "API provider to use (openrouter, openai)", "openrouter")
.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")
+19 -30
View File
@@ -1,9 +1,9 @@
import { OpenRouterHandler } from "../../src/api/providers/openrouter"
import { OpenAiNativeHandler } from "../../src/api/providers/openai-native"
import { ApiHandlerOptions } from "../../src/shared/api"
import { Anthropic } from "@anthropic-ai/sdk"
import {
parseAssistantMessageV1,
parseAssistantMessageV2,
parseAssistantMessageV3,
AssistantMessageContent,
@@ -17,6 +17,7 @@ type ParseAssistantMessageFn = (message: string) => AssistantMessageContent[]
type ConstructNewFileContentFn = (diff: string, original: string, strict: boolean) => Promise<string | any>
const parsingFunctions: Record<string, ParseAssistantMessageFn> = {
parseAssistantMessageV1: parseAssistantMessageV1,
parseAssistantMessageV2: parseAssistantMessageV2,
parseAssistantMessageV3: parseAssistantMessageV3,
}
@@ -53,7 +54,7 @@ interface StreamResult {
* Process the stream and return full response with timing data
*/
async function processStream(
handler: OpenRouterHandler | OpenAiNativeHandler,
handler: OpenRouterHandler,
systemPrompt: string,
messages: Anthropic.Messages.MessageParam[],
): Promise<StreamResult> {
@@ -189,7 +190,19 @@ export async function runSingleEvaluation(input: TestInput): Promise<TestResult>
}
}
const provider = input.provider || "openrouter"
const options: ApiHandlerOptions = {
openRouterApiKey: apiKey,
openRouterModelId: modelId,
thinkingBudgetTokens: thinkingBudgetTokens,
openRouterModelInfo: {
maxTokens: 10_000,
contextWindow: 1_000_000,
supportsImages: true,
supportsPromptCache: true, // may need to turn this on
inputPrice: 0,
outputPrice: 0,
},
}
// Get the output of streaming output of this llm call
let streamResult: StreamResult
@@ -201,34 +214,10 @@ export async function runSingleEvaluation(input: TestInput): Promise<TestResult>
usage: { inputTokens: 0, outputTokens: 0, cacheWriteTokens: 0, cacheReadTokens: 0, totalCost: 0 },
}
} else {
// Live mode: provider-specific API call logic
// Live mode: existing API call logic
try {
let handler: OpenRouterHandler | OpenAiNativeHandler
if (provider === "openai") {
const openAiOptions = {
openAiNativeApiKey: apiKey,
apiModelId: modelId,
}
handler = new OpenAiNativeHandler(openAiOptions)
} else {
const openRouterOptions = {
openRouterApiKey: apiKey,
openRouterModelId: modelId,
thinkingBudgetTokens: thinkingBudgetTokens,
openRouterModelInfo: {
maxTokens: 10_000,
contextWindow: 1_000_000,
supportsImages: true,
supportsPromptCache: true,
inputPrice: 0,
outputPrice: 0,
},
}
handler = new OpenRouterHandler(openRouterOptions)
}
streamResult = await processStream(handler, systemPrompt, messages)
const openRouterHandler = new OpenRouterHandler(options)
streamResult = await processStream(openRouterHandler, systemPrompt, messages)
} catch (error: any) {
return {
success: false,
+6 -17
View File
@@ -49,25 +49,16 @@ type TestResultSet = { [test_id: string]: (TestResult & { test_id?: string })[]
class NodeTestRunner {
private apiKey: string | undefined
private provider: string
private currentRunId: string | null = null
private systemPromptHash: string | null = null
private processingFunctionsHash: string | null = null
private caseIdMap: Map<string, string> = new Map() // test_id -> case_id mapping
constructor(isReplay: boolean, provider: string = "openrouter") {
this.provider = provider
constructor(isReplay: boolean) {
if (!isReplay) {
if (provider === "openai") {
this.apiKey = process.env.OPENAI_API_KEY
if (!this.apiKey) {
throw new Error("OPENAI_API_KEY environment variable not set for a non-replay run with OpenAI provider.")
}
} else {
this.apiKey = process.env.OPENROUTER_API_KEY
if (!this.apiKey) {
throw new Error("OPENROUTER_API_KEY environment variable not set for a non-replay run with OpenRouter provider.")
}
this.apiKey = process.env.OPENROUTER_API_KEY
if (!this.apiKey) {
throw new Error("OPENROUTER_API_KEY environment variable not set for a non-replay run.")
}
}
}
@@ -644,7 +635,6 @@ class NodeTestRunner {
thinkingBudgetTokens: testConfig.thinking_tokens_budget,
originalDiffEditToolCallMessage: testConfig.replay ? testCase.original_diff_edit_tool_call_message : undefined,
diffApplyFile: testConfig.diff_apply_file,
provider: this.provider,
isVerbose: isVerbose,
}
@@ -937,7 +927,6 @@ async function main() {
.option("--parsing-function <name>", "The parsing function to use", "parseAssistantMessageV2")
.option("--diff-edit-function <name>", "The diff editing function to use", "diff-06-26-25")
.option("--thinking-budget <tokens>", "Set the thinking tokens budget", "0")
.option("--provider <provider>", "API provider to use (openrouter, openai)", "openrouter")
.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")
@@ -970,7 +959,7 @@ async function main() {
? parseInt(options.maxAttemptsPerCase, 10)
: validAttemptsPerCase * 10;
const runner = new NodeTestRunner(options.replay || !!options.replayRunId, options.provider)
const runner = new NodeTestRunner(options.replay || !!options.replayRunId)
if (options.replayRunId) {
if (!options.diffApplyFile) {
@@ -990,7 +979,7 @@ async function main() {
log(isVerbose, "Warning: Could not load OpenRouter model data. Context window filtering might be affected for OpenRouter models.");
}
const runner = new NodeTestRunner(options.replay, options.provider)
const runner = new NodeTestRunner(options.replay)
let allLoadedTestCases = runner.loadTestCases(testPath, isVerbose) // Pass isVerbose
const allProcessedTestCasesGlobal: ProcessedTestCase[] = allLoadedTestCases.map((tc) => ({
+7 -107
View File
@@ -331,42 +331,6 @@ def get_performance_grade(success_rate):
else:
return "C", "poor"
def get_error_description(error_enum, error_string=None):
"""Map error enum values to user-friendly descriptions"""
error_map = {
1: "No tool calls - Model didn't use the replace_in_file tool",
2: "Multiple tool calls - Model called multiple tools instead of one",
3: "Wrong tool call - Model used wrong tool (not replace_in_file)",
4: "Missing parameters - Tool call missing required path or diff",
5: "Wrong file edited - Model edited different file than expected",
6: "Wrong tool call - Model used wrong tool type",
7: "Wrong file edited - Model targeted incorrect file path",
8: "API/Stream error - Problem with model API connection",
9: "Configuration error - Invalid evaluation parameters",
10: "Function error - Invalid parsing/diff functions",
11: "Other error - Unexpected failure"
}
base_description = error_map.get(error_enum, f"Unknown error (code: {error_enum})")
if error_string:
return f"{base_description}: {error_string}"
return base_description
def get_error_guidance(error_enum):
"""Provide specific guidance based on error type"""
guidance_map = {
1: "💡 The model provided a response but didn't use the replace_in_file tool. Check the raw output to see what the model actually said.",
2: "💡 The model called multiple tools when it should only call replace_in_file once. Check the parsed tool call section.",
3: "💡 The model used a different tool instead of replace_in_file. This might indicate confusion about the task.",
4: "💡 The model called replace_in_file but didn't provide the required 'path' or 'diff' parameters.",
5: "💡 The model tried to edit a different file than expected. Check the parsed tool call to see which file it targeted.",
6: "💡 The model used the wrong tool type. Check the raw output to see what tool it attempted to use.",
7: "💡 The model tried to edit a different file path than expected. This could indicate path confusion or hallucination.",
}
return guidance_map.get(error_enum, "")
def render_hero_section(current_run, model_performance):
"""Render the hero section with key metrics"""
run_title = current_run['description'] if current_run['description'] else f"Run {current_run['run_id'][:8]}..."
@@ -606,16 +570,12 @@ def render_result_detail(result):
"""Render detailed view of a single result"""
st.markdown("### 🔬 Result Deep Dive")
# Check if this is a valid result (only invalid if no tool calls or wrong file)
is_valid = True
if not pd.isna(result['error_enum']):
# Only these specific errors make a result "invalid" for the benchmark:
# 1 = no_tool_calls, 5 = wrong_file_edited, 7 = wrong_file_edited
is_valid = result['error_enum'] not in [1, 5, 7]
# Check if this is a valid result
is_valid = (result['error_enum'] not in [1, 6, 7]) if not pd.isna(result['error_enum']) else True
# Show validity warning if needed
if not is_valid:
st.warning("⚠️ **This is an invalid result** - The model didn't call the replace_in_file tool or edited the wrong file. This result is excluded from success rate calculations.")
st.warning("⚠️ **This is an invalid result** - The model didn't properly call the diff edit tool or edited the wrong file. This result is excluded from success rate calculations.")
# Result metadata
col1, col2, col3, col4 = st.columns(4)
@@ -631,10 +591,7 @@ def render_result_detail(result):
st.markdown(f"**Round Trip:** {result['time_round_trip_ms']:.0f}ms")
with col4:
if pd.notna(result['cost_usd']) and result['cost_usd'] is not None:
st.markdown(f"**Cost:** ${result['cost_usd']:.4f}")
else:
st.markdown(f"**Cost:** Free")
st.markdown(f"**Cost:** ${result['cost_usd']:.4f}")
# Tabbed interface for different views
tab1, tab2, tab3, tab4 = st.tabs(["📄 File & Edits", "🤖 Raw Output", "🔧 Parsed Tool Call", "📊 Metrics"])
@@ -736,46 +693,8 @@ def render_file_and_edits_view(result):
# Show error information
st.error("❌ **Edit Failed**")
# Show detailed error reason
if not pd.isna(result['error_enum']):
error_description = get_error_description(
result['error_enum'],
result.get('error_string')
)
st.markdown(f"**Reason:** {error_description}")
# Show specific guidance based on error type
guidance = get_error_guidance(result['error_enum'])
if guidance:
st.info(guidance)
# For valid results that failed, check for diff application failures
elif not result['succeeded']:
# This is a valid result that failed - likely due to diff application issues
raw_output = result.get('raw_model_output', '')
# Check if we have specific error information in the raw output
if 'does not match anything in the file' in str(raw_output).lower():
st.warning("⚠️ **Diff Application Failed**")
st.info("💡 The SEARCH block in the diff didn't match any content in the original file. This usually means the model hallucinated code that doesn't exist.")
elif 'malformatted' in str(raw_output).lower() or 'malformed' in str(raw_output).lower():
st.warning("⚠️ **Diff Format Error**")
st.info("💡 The diff format was incorrect. Check the raw tool call to see the formatting issues.")
elif 'error:' in str(raw_output).lower():
# Try to extract the specific error message
lines = str(raw_output).split('\n')
error_lines = [line for line in lines if 'error:' in line.lower()]
if error_lines:
error_msg = error_lines[0].strip()
st.warning("⚠️ **Diff Application Failed**")
st.info(f"💡 {error_msg}")
else:
st.warning("⚠️ **Diff Application Failed**")
st.info("💡 The diff couldn't be applied to the original file. Check the raw output and parsed tool call for more details.")
else:
# Generic diff application failure
st.warning("⚠️ **Diff Application Failed**")
st.info("💡 The model made a valid tool call but the diff couldn't be applied to the original file. This usually indicates a mismatch between the expected and actual file content.")
st.markdown(f"**Error Code:** {result['error_enum']}")
else:
# Show successful edit information
st.success("✅ **Edit Successful**")
@@ -806,25 +725,8 @@ def render_file_and_edits_view(result):
if len(edited_lines) > 50:
st.text(f"... ({len(edited_lines) - 50} more lines)")
# Show raw and parsed tool calls if available
# Show parsed tool call if available
if not pd.isna(result['parsed_tool_call_json']):
with st.expander("View Raw Tool Call"):
# Extract the raw tool call text from the model output
raw_output = result['raw_model_output'] if not pd.isna(result['raw_model_output']) else ""
# Try to extract just the tool call portion
if raw_output and '<replace_in_file>' in raw_output:
# Find the tool call block
start_idx = raw_output.find('<replace_in_file>')
end_idx = raw_output.find('</replace_in_file>') + len('</replace_in_file>')
if start_idx != -1 and end_idx != -1:
raw_tool_call = raw_output[start_idx:end_idx]
st.code(raw_tool_call, language='xml')
else:
st.text("Tool call not found in raw output")
else:
st.text("No raw tool call available")
with st.expander("View Parsed Tool Call"):
try:
parsed_call = json.loads(result['parsed_tool_call_json'])
@@ -893,10 +795,8 @@ def render_metrics_view(result):
if not pd.isna(result['completion_tokens']):
st.metric("Completion Tokens", int(result['completion_tokens']))
if pd.notna(result['cost_usd']) and result['cost_usd'] is not None:
if not pd.isna(result['cost_usd']):
st.metric("Cost", f"${result['cost_usd']:.4f}")
else:
st.metric("Cost", "Free")
if not pd.isna(result['tokens_in_context']):
st.metric("Context Tokens", int(result['tokens_in_context']))
@@ -70,7 +70,246 @@ export interface ToolUse {
partial: boolean
}
// parseAssistantmessageV1 removed in https://github.com/cline/cline/pull/5425
/**
* @description **Version 1**
* Parses an assistant message string potentially containing mixed text and tool usage blocks
* marked with XML-like tags into an array of structured content objects.
*
* This version iterates through the message character by character, building an accumulator string.
* It maintains state to track whether it's currently parsing text, a tool use block, or a specific tool parameter.
* It detects the start and end of tool uses and parameters by checking if the accumulator ends with
* the corresponding opening or closing tags.
* Special handling is included for `write_to_file` and `new_rule` tool uses to correctly parse
* the `content` parameter, which might contain the closing tag itself, by looking for the *last*
* occurrence of the closing tag.
* If the input string ends mid-tag or mid-content, the last block (text or tool use) is marked as partial.
*
* @param assistantMessage The raw string output from the assistant.
* @returns An array of `AssistantMessageContent` objects, which can be `TextContent` or `ToolUse`.
* Blocks that were not fully closed by the end of the input string will have their `partial` flag set to `true`.
*/
export function parseAssistantMessageV1(assistantMessage: string): AssistantMessageContent[] {
const contentBlocks: AssistantMessageContent[] = []
let currentTextContent: TextContent | undefined = undefined
let currentTextContentStartIndex = 0
let currentToolUse: ToolUse | undefined = undefined
let currentToolUseStartIndex = 0
let currentParamName: ToolParamName | undefined = undefined
let currentParamValueStartIndex = 0
let accumulator = ""
for (let i = 0; i < assistantMessage.length; i++) {
const char = assistantMessage[i]
accumulator += char
// --- State: Parsing a Tool Parameter ---
// there should not be a param without a tool use
if (currentToolUse && currentParamName) {
const currentParamValue = accumulator.slice(currentParamValueStartIndex)
const paramClosingTag = `</${currentParamName}>`
if (currentParamValue.endsWith(paramClosingTag)) {
// End of param value found
currentToolUse.params[currentParamName] = currentParamValue.slice(0, -paramClosingTag.length).trim()
currentParamName = undefined // Go back to parsing tool content or looking for next param
continue // Move to next character
} else {
// Partial param value is accumulating
continue // Move to next character
}
}
// --- State: Parsing a Tool Use (but not a specific parameter) ---
// no currentParamName
if (currentToolUse) {
const currentToolValue = accumulator.slice(currentToolUseStartIndex)
const toolUseClosingTag = `</${currentToolUse.name}>`
if (currentToolValue.endsWith(toolUseClosingTag)) {
// End of a tool use found
currentToolUse.partial = false
contentBlocks.push(currentToolUse)
currentToolUse = undefined // Go back to parsing text or looking for next tool
// Reset text start index in case text follows immediately
currentTextContentStartIndex = i + 1
continue // Move to next character
} else {
// Check if starting a new parameter within the current tool use
const possibleParamOpeningTags = toolParamNames.map((name) => `<${name}>`)
let foundParamStart = false
for (const paramOpeningTag of possibleParamOpeningTags) {
if (accumulator.endsWith(paramOpeningTag)) {
// Start of a new parameter found
currentParamName = paramOpeningTag.slice(1, -1) as ToolParamName
currentParamValueStartIndex = accumulator.length
foundParamStart = true
break
}
}
if (foundParamStart) {
continue // Move to next character
}
// Special case for write_to_file/new_rule content param allowing nested tags
// Check if a </content> tag appears, potentially indicating the end of the content param
// even if the main tool closing tag hasn't been seen yet.
const contentParamName: ToolParamName = "content"
if (
(currentToolUse.name === "write_to_file" || currentToolUse.name === "new_rule") &&
accumulator.endsWith(`</${contentParamName}>`)
) {
const toolContent = accumulator.slice(currentToolUseStartIndex)
const contentStartTag = `<${contentParamName}>`
const contentEndTag = `</${contentParamName}>`
const contentStartIndex = toolContent.indexOf(contentStartTag) + contentStartTag.length
// Use lastIndexOf to handle cases where </content> might appear within the content itself
const contentEndIndex = toolContent.lastIndexOf(contentEndTag)
// Ensure we found valid start/end tags and end is after start
if (
contentStartIndex !== -1 &&
contentEndIndex !== -1 &&
contentEndIndex > contentStartIndex - contentStartTag.length // Ensure end tag is after start tag begins
) {
// Check if this content param was already being parsed. If so, update it.
// If not, and we just found the closing tag, assign it.
// This handles cases where the </content> detection might fire before
// the <content> tag detection logic, or if the content is very short.
if (currentParamName === contentParamName) {
// Already parsing content, now we found the end tag
currentToolUse.params[contentParamName] = toolContent.slice(contentStartIndex, contentEndIndex).trim()
currentParamName = undefined // Finished with this param
} else if (currentParamName === undefined) {
// Not parsing a param, but found </content>. Assume it closes the content block.
currentToolUse.params[contentParamName] = toolContent.slice(contentStartIndex, contentEndIndex).trim()
// We stay in the "parsing tool use" state, looking for more params or the tool end tag.
}
}
}
// If none of the above, partial tool value is accumulating
continue // Move to next character
}
}
// --- State: Parsing Text (or looking for start of a tool use) ---
// no currentToolUse
let didStartToolUse = false
const possibleToolUseOpeningTags = toolUseNames.map((name) => `<${name}>`)
for (const toolUseOpeningTag of possibleToolUseOpeningTags) {
if (accumulator.endsWith(toolUseOpeningTag)) {
// Start of a new tool use found
const toolName = toolUseOpeningTag.slice(1, -1) as ToolUseName
currentToolUse = {
type: "tool_use",
name: toolName,
params: {},
partial: true,
}
currentToolUseStartIndex = accumulator.length
// This also indicates the end of the current text content block (if any)
if (currentTextContent) {
currentTextContent.partial = false
// Extract text content, removing the part that formed the tool opening tag
const textEndIndex = accumulator.length - toolUseOpeningTag.length
currentTextContent.content = accumulator.slice(currentTextContentStartIndex, textEndIndex).trim()
// Only add if there's actual content
if (currentTextContent.content.length > 0) {
contentBlocks.push(currentTextContent)
}
currentTextContent = undefined
} else {
// Check if there was text before this tool use started
const textEndIndex = accumulator.length - toolUseOpeningTag.length
const potentialText = accumulator.slice(currentTextContentStartIndex, textEndIndex).trim()
if (potentialText.length > 0) {
contentBlocks.push({
type: "text",
content: potentialText,
partial: false, // Ended because tool use started
})
}
}
didStartToolUse = true
break // Found tool start, stop checking for others
}
}
if (!didStartToolUse) {
// No tool use started, so it must be text content accumulating
// (or continuing after a closed tool use)
if (currentTextContent === undefined) {
// Start of a new text block
currentTextContentStartIndex = i - (accumulator.length - currentTextContentStartIndex - 1) // Adjust start index based on how much we've accumulated since the last block ended or the beginning
// If accumulator starts from 0, start index is i
if (contentBlocks.length === 0 && currentToolUse === undefined) {
currentTextContentStartIndex = accumulator.length - 1 // i
} else {
// Re-calculate based on the actual start of the current text segment
// Find the end of the last block
let lastBlockEndIndex = 0
if (contentBlocks.length > 0) {
const lastBlock = contentBlocks[contentBlocks.length - 1]
// Approximation: find where the accumulator matches the end of the message string representation of the last block. This is complex.
// Simpler: Assume text starts right after the last block ended implicitly at index i.
lastBlockEndIndex = i // Where the loop *was* when the last block finished processing
// Need a more robust way to track the end index of the *raw string* corresponding to the last block.
// Let's stick to the accumulator slice approach for simplicity in this version.
// The start index should be where the current *unmatched* text began.
let lastProcessedIndex = -1
if (contentBlocks.length > 0) {
// This requires knowing the raw string length of the previous block, which V1 doesn't explicitly track easily.
// We'll approximate based on the current accumulator and start index logic.
// The issue arises if a tool tag was just closed. accumulator contains everything up to i.
// lastBlockEndIndex should point to the character *after* the closing tag of the last block.
}
// Reset start index to the beginning of the *current* potential text block
currentTextContentStartIndex = accumulator.length - 1 // Start accumulating from the current character `i`
}
// If we just closed a tool, text starts *after* its closing tag
// The logic needs refinement here for accurate start index after a tool closure.
// Let's assume for now the start index logic inside the loop handles it via slicing.
}
currentTextContent = {
type: "text",
content: "", // Content will be filled by slicing accumulator
partial: true,
}
}
// Update text content based on the accumulator from its start index
currentTextContent.content = accumulator.slice(currentTextContentStartIndex).trimStart() // Trim start to avoid leading space if text follows tool
}
} // End of loop
// --- Finalization after loop ---
// If a tool use was open at the end
if (currentToolUse) {
// If a parameter was open within that tool use
if (currentParamName) {
// The remaining accumulator content belongs to this partial parameter
currentToolUse.params[currentParamName] = accumulator.slice(currentParamValueStartIndex).trim()
}
// Add the potentially partial tool use block
contentBlocks.push(currentToolUse)
}
// If text content was being accumulated at the end
// Note: Only one of currentToolUse or currentTextContent can be defined here,
// as starting a tool use finalizes the preceding text block.
else if (currentTextContent) {
// Update content one last time
currentTextContent.content = accumulator.slice(currentTextContentStartIndex).trim()
// Add the potentially partial text block only if it contains content
if (currentTextContent.content.length > 0) {
contentBlocks.push(currentTextContent)
}
}
return contentBlocks
}
/**
* @description **Version 2**
-1
View File
@@ -104,6 +104,5 @@ export interface TestInput {
thinkingBudgetTokens: number
originalDiffEditToolCallMessage?: string
diffApplyFile?: string
provider?: string
isVerbose: boolean
}
+16826 -236
View File
File diff suppressed because it is too large Load Diff
+1 -4
View File
@@ -2,7 +2,7 @@
"name": "claude-dev",
"displayName": "Cline",
"description": "Autonomous coding agent right in your IDE, capable of creating/editing files, running commands, using the browser, and more with your permission every step of the way.",
"version": "3.21.0",
"version": "3.20.8",
"icon": "assets/icons/icon.png",
"engines": {
"vscode": "^1.84.0"
@@ -447,7 +447,6 @@
"@playwright/test": "^1.53.2",
"@sentry/browser": "^9.12.0",
"@streamparser/json": "^0.0.22",
"@types/uuid": "^10.0.0",
"@vscode/codicons": "^0.0.36",
"archiver": "^7.0.1",
"axios": "^1.8.2",
@@ -492,8 +491,6 @@
"tree-sitter-wasms": "^0.1.11",
"ts-morph": "^25.0.1",
"turndown": "^7.2.0",
"ulid": "^2.4.0",
"uuid": "^11.1.0",
"vscode-uri": "^3.1.0",
"web-tree-sitter": "^0.22.6",
"zod": "^3.24.2"
+5 -5
View File
@@ -17,16 +17,16 @@ export default defineConfig({
{
name: "setup test environment",
testMatch: /global\.setup\.ts/,
teardown: "cleanup test environment",
},
{
name: "cleanup test environment",
testMatch: /global\.teardown\.ts/,
},
{
name: "e2e tests",
testMatch: /.*\.test\.ts/,
dependencies: ["setup test environment"],
},
{
name: "cleanup test environment",
testMatch: /global\.teardown\.ts/,
dependencies: ["e2e tests"],
},
],
})
-9
View File
@@ -27,8 +27,6 @@ service ModelsService {
rpc updateApiConfigurationProto(UpdateApiConfigurationRequest) returns (Empty);
// Refreshes and returns Groq models
rpc refreshGroqModels(EmptyRequest) returns (OpenRouterCompatibleModelInfo);
// Refreshes and returns Baseten models
rpc refreshBasetenModels(EmptyRequest) returns (OpenRouterCompatibleModelInfo);
}
// List of VS Code LM models
@@ -132,7 +130,6 @@ enum ApiProvider {
MOONSHOT = 27;
HUGGINGFACE = 28;
HUAWEI_CLOUD_MAAS = 29;
BASETEN = 30;
}
// Model info for OpenAI-compatible models
@@ -234,8 +231,6 @@ message ModelsApiConfiguration {
optional string groq_api_key = 59;
optional string hugging_face_api_key = 60;
optional string huawei_cloud_maas_api_key = 61;
optional string baseten_api_key = 62;
optional string ollama_api_key = 63;
// Plan mode configurations
optional ApiProvider plan_mode_api_provider = 100;
@@ -264,8 +259,6 @@ message ModelsApiConfiguration {
optional OpenRouterModelInfo plan_mode_hugging_face_model_info = 123;
optional string plan_mode_huawei_cloud_maas_model_id = 124;
optional OpenRouterModelInfo plan_mode_huawei_cloud_maas_model_info = 125;
optional string plan_mode_baseten_model_id = 126;
optional OpenRouterModelInfo plan_mode_baseten_model_info = 127;
// Act mode configurations
optional ApiProvider act_mode_api_provider = 200;
@@ -294,8 +287,6 @@ message ModelsApiConfiguration {
optional OpenRouterModelInfo act_mode_hugging_face_model_info = 223;
optional string act_mode_huawei_cloud_maas_model_id = 224;
optional OpenRouterModelInfo act_mode_huawei_cloud_maas_model_info = 225;
optional string act_mode_baseten_model_id = 226;
optional OpenRouterModelInfo act_mode_baseten_model_info = 227;
repeated string favorited_model_ids = 300;
}
-1
View File
@@ -172,7 +172,6 @@ message ApiConfiguration {
optional string moonshot_api_key = 54;
optional string moonshot_api_line = 55;
optional string huawei_cloud_maas_api_key = 56;
optional string ollama_api_key = 57;
// Plan mode configurations
optional string plan_mode_api_provider = 100;
+1 -4
View File
@@ -227,7 +227,7 @@ service UiService {
rpc onDidShowAnnouncement(EmptyRequest) returns (Boolean);
// Subscribe to addToInput events (when user adds content via context menu)
rpc subscribeToAddToInput(StringRequest) returns (stream String);
rpc subscribeToAddToInput(EmptyRequest) returns (stream String);
// Subscribe to MCP button clicked events
rpc subscribeToMcpButtonClicked(WebviewProviderTypeRequest) returns (stream Empty);
@@ -267,7 +267,4 @@ service UiService {
// Opens a URL in the default browser
rpc openUrl(StringRequest) returns (Empty);
// Opens the Cline walkthrough
rpc openWalkthrough(EmptyRequest) returns (Empty);
}
+57
View File
@@ -0,0 +1,57 @@
syntax = "proto3";
import "cline/common.proto";
package cline;
option java_package = "bot.cline.proto";
option java_multiple_files = true;
service VoiceService {
rpc startRecording(StartRecordingRequest) returns (RecordingResult);
rpc stopRecording(StopRecordingRequest) returns (RecordedAudio);
rpc getRecordingStatus(GetRecordingStatusRequest) returns (RecordingStatus);
rpc transcribeAudio(TranscribeAudioRequest) returns (Transcription);
}
// Request messages
message StartRecordingRequest {
Metadata metadata = 1;
// Could add options later
}
message StopRecordingRequest {
Metadata metadata = 1;
}
message GetRecordingStatusRequest {
Metadata metadata = 1;
}
message TranscribeAudioRequest {
Metadata metadata = 1;
string audio_base64 = 2;
string language = 3; // optional language hint
}
// Plain, reusable response types
message RecordingResult {
bool success = 1;
string error = 2;
}
message RecordedAudio {
bool success = 1;
string audio_base64 = 2;
string error = 3;
}
message RecordingStatus {
bool is_recording = 1;
double duration_seconds = 2;
string error = 3;
}
message Transcription {
string text = 1;
string error = 2;
}
-41
View File
@@ -4,8 +4,6 @@ package host;
option java_package = "bot.cline.host.proto";
option java_multiple_files = true;
import "cline/common.proto";
// Provides methods for working with workspaces/projects.
service WorkspaceService {
// Returns a list of the top level directories of the workspace.
@@ -14,8 +12,6 @@ service WorkspaceService {
// Returns true if the document was saved, returns false if the document was not found, or did not
// need to be saved.
rpc saveOpenDocumentIfDirty(SaveOpenDocumentIfDirtyRequest) returns (SaveOpenDocumentIfDirtyResponse);
// Get diagnostics from the workspace.
rpc getDiagnostics(GetDiagnosticsRequest) returns (GetDiagnosticsResponse);
}
message GetWorkspacePathsRequest {
@@ -38,40 +34,3 @@ message SaveOpenDocumentIfDirtyResponse {
// Returns true if the document was saved.
optional bool was_saved = 1;
}
message GetDiagnosticsRequest {
optional cline.Metadata metadata = 1;
}
message GetDiagnosticsResponse {
repeated FileDiagnostics file_diagnostics = 1;
}
message FileDiagnostics {
string file_path = 1;
repeated Diagnostic diagnostics = 2;
}
message Diagnostic {
string message = 1;
DiagnosticRange range = 2;
DiagnosticSeverity severity = 3;
optional string source = 4;
}
message DiagnosticRange {
DiagnosticPosition start = 1;
DiagnosticPosition end = 2;
}
message DiagnosticPosition {
int32 line = 1;
int32 character = 2;
}
enum DiagnosticSeverity {
DIAGNOSTIC_ERROR = 0;
DIAGNOSTIC_WARNING = 1;
DIAGNOSTIC_INFORMATION = 2;
DIAGNOSTIC_HINT = 3;
}
+2 -2
View File
@@ -40,11 +40,11 @@ async function generateWebviewProtobusClients(protobusServices) {
}
if (!rpc.responseStream) {
rpcs.push(` static async ${rpcName}(request: ${requestType}): Promise<${responseType}> {
return this.makeUnaryRequest("${rpcName}", request, ${requestType}.toJSON, ${responseType}.fromJSON)
return this.makeRequest("${rpcName}", request)
}`)
} else {
rpcs.push(` static ${rpcName}(request: ${requestType}, callbacks: Callbacks<${responseType}>): ()=>void {
return this.makeStreamingRequest("${rpcName}", request, ${requestType}.toJSON, ${responseType}.fromJSON, callbacks)
return this.makeStreamingRequest("${rpcName}", request, callbacks)
}`)
}
}
-9
View File
@@ -32,7 +32,6 @@ import { GroqHandler } from "./providers/groq"
import { Mode } from "@shared/storage/types"
import { HuggingFaceHandler } from "./providers/huggingface"
import { HuaweiCloudMaaSHandler } from "./providers/huawei-cloud-maas"
import { BasetenHandler } from "./providers/baseten"
export interface ApiHandler {
createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream
@@ -113,7 +112,6 @@ function createHandlerForProvider(
case "ollama":
return new OllamaHandler({
ollamaBaseUrl: options.ollamaBaseUrl,
ollamaApiKey: options.ollamaApiKey,
ollamaModelId: mode === "plan" ? options.planModeOllamaModelId : options.actModeOllamaModelId,
ollamaApiOptionsCtxNum: options.ollamaApiOptionsCtxNum,
requestTimeoutMs: options.requestTimeoutMs,
@@ -259,13 +257,6 @@ function createHandlerForProvider(
groqModelInfo: mode === "plan" ? options.planModeGroqModelInfo : options.actModeGroqModelInfo,
apiModelId: mode === "plan" ? options.planModeApiModelId : options.actModeApiModelId,
})
case "baseten":
return new BasetenHandler({
basetenApiKey: options.basetenApiKey,
basetenModelId: mode === "plan" ? options.planModeBasetenModelId : options.actModeBasetenModelId,
basetenModelInfo: mode === "plan" ? options.planModeBasetenModelInfo : options.actModeBasetenModelInfo,
apiModelId: mode === "plan" ? options.planModeApiModelId : options.actModeApiModelId,
})
case "sapaicore":
return new SapAiCoreHandler({
sapAiCoreClientId: options.sapAiCoreClientId,
-2
View File
@@ -55,7 +55,6 @@ export class AnthropicHandler implements ApiHandler {
case "claude-3-5-sonnet-20241022":
case "claude-3-5-haiku-20241022":
case "claude-opus-4-20250514":
case "claude-opus-4-1-20250805":
case "claude-3-opus-20240229":
case "claude-3-haiku-20240307": {
/*
@@ -123,7 +122,6 @@ export class AnthropicHandler implements ApiHandler {
switch (modelId) {
case "claude-sonnet-4-20250514":
case "claude-opus-4-20250514":
case "claude-opus-4-1-20250805":
case "claude-3-7-sonnet-20250219":
case "claude-3-5-sonnet-20241022":
case "claude-3-5-haiku-20241022":
-165
View File
@@ -1,165 +0,0 @@
import { Anthropic } from "@anthropic-ai/sdk"
import OpenAI from "openai"
import { withRetry } from "../retry"
import { ApiHandler } from "../"
import { BasetenModelId, ModelInfo, basetenDefaultModelId, basetenModels } from "@shared/api"
import { calculateApiCostOpenAI } from "../../utils/cost"
import { convertToOpenAiMessages } from "../transform/openai-format"
import { ApiStream } from "../transform/stream"
interface BasetenHandlerOptions {
basetenApiKey?: string
basetenModelId?: string
basetenModelInfo?: ModelInfo
apiModelId?: string // For backward compatibility
}
export class BasetenHandler implements ApiHandler {
private options: BasetenHandlerOptions
private client: OpenAI | undefined
constructor(options: BasetenHandlerOptions) {
this.options = options
}
private ensureClient(): OpenAI {
if (!this.client) {
if (!this.options.basetenApiKey) {
throw new Error("Baseten API key is required")
}
try {
this.client = new OpenAI({
baseURL: "https://inference.baseten.co/v1",
apiKey: this.options.basetenApiKey,
})
} catch (error) {
throw new Error(`Error creating Baseten client: ${error.message}`)
}
}
return this.client
}
/**
* Gets the optimal max_tokens based on model capabilities
*/
private getOptimalMaxTokens(model: { id: BasetenModelId; info: ModelInfo }): number {
// Use model-specific max tokens if available
if (model.info.maxTokens && model.info.maxTokens > 0) {
return model.info.maxTokens
}
// Default fallback
return 8192
}
getModel(): { id: BasetenModelId; info: ModelInfo } {
// First priority: basetenModelId and basetenModelInfo
const basetenModelId = this.options.basetenModelId
const basetenModelInfo = this.options.basetenModelInfo
if (basetenModelId && basetenModelInfo) {
return { id: basetenModelId as BasetenModelId, info: basetenModelInfo }
}
// Second priority: basetenModelId with static model info
if (basetenModelId && basetenModelId in basetenModels) {
const id = basetenModelId as BasetenModelId
return { id, info: basetenModels[id] }
}
// Third priority: apiModelId (for backward compatibility)
const apiModelId = this.options.apiModelId
if (apiModelId && apiModelId in basetenModels) {
const id = apiModelId as BasetenModelId
return { id, info: basetenModels[id] }
}
// Default fallback
return {
id: basetenDefaultModelId,
info: basetenModels[basetenDefaultModelId],
}
}
private async *yieldUsage(modelInfo: ModelInfo, usage: any): ApiStream {
if (usage.prompt_tokens || usage.completion_tokens) {
const cost = calculateApiCostOpenAI(modelInfo, usage.prompt_tokens || 0, usage.completion_tokens || 0)
yield {
type: "usage",
inputTokens: usage.prompt_tokens || 0,
outputTokens: usage.completion_tokens || 0,
cacheWriteTokens: 0,
cacheReadTokens: 0,
totalCost: cost,
}
}
}
@withRetry()
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
const client = this.ensureClient()
const model = this.getModel()
const maxTokens = this.getOptimalMaxTokens(model)
const openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [
{ role: "system", content: systemPrompt },
...convertToOpenAiMessages(messages),
]
const stream = await client.chat.completions.create({
model: model.id,
max_tokens: maxTokens,
messages: openAiMessages,
stream: true,
stream_options: { include_usage: true },
temperature: 0,
})
let didOutputUsage = false
for await (const chunk of stream) {
const delta = chunk.choices[0]?.delta
// Handle reasoning field if present (for reasoning models with parsed output)
if ((delta as any)?.reasoning) {
const reasoningContent = (delta as any).reasoning as string
yield {
type: "reasoning",
reasoning: reasoningContent,
}
continue
}
// Handle content field
if (delta?.content) {
yield {
type: "text",
text: delta.content,
}
}
// Handle usage information - only output once
if (!didOutputUsage && chunk.usage) {
yield* this.yieldUsage(model.info, chunk.usage)
didOutputUsage = true
}
}
}
/**
* Checks if the current model supports vision/images
*/
supportsImages(): boolean {
const model = this.getModel()
return model.info.supportsImages === true
}
/**
* Checks if the current model supports tools
*/
supportsTools(): boolean {
const model = this.getModel()
// Baseten models support tools via OpenAI-compatible API
return true
}
}
+1 -1
View File
@@ -7,7 +7,7 @@ import { ApiHandler } from "../"
import { ApiHandlerOptions, geminiDefaultModelId, GeminiModelId, geminiModels, ModelInfo } from "@shared/api"
import { convertAnthropicMessageToGemini } from "../transform/gemini-format"
import { ApiStream } from "../transform/stream"
import { telemetryService } from "@services/posthog/PostHogClientProvider"
import { telemetryService } from "@services/posthog/telemetry/TelemetryService"
// Define a default TTL for the cache (e.g., 15 minutes in seconds)
const DEFAULT_CACHE_TTL_SECONDS = 900
+26 -126
View File
@@ -16,29 +16,9 @@ interface LiteLlmHandlerOptions {
taskId?: string
}
interface LiteLlmModelInfoResponse {
data: Array<{
model_name: string
litellm_params: {
model: string
[key: string]: any
}
model_info: {
input_cost_per_token: number
output_cost_per_token: number
cache_creation_input_token_cost?: number
cache_read_input_token_cost?: number
[key: string]: any
}
}>
}
export class LiteLlmHandler implements ApiHandler {
private options: LiteLlmHandlerOptions
private client: OpenAI | undefined
private modelInfoCache: LiteLlmModelInfoResponse | undefined
private modelInfoCacheTimestamp: number = 0
private readonly modelInfoCacheTTL = 5 * 60 * 1000 // 5 minutes
constructor(options: LiteLlmHandlerOptions) {
this.options = options
@@ -61,112 +41,35 @@ export class LiteLlmHandler implements ApiHandler {
return this.client
}
private async fetchModelInfo(): Promise<LiteLlmModelInfoResponse | undefined> {
// Check if cache is still valid
const now = Date.now()
if (this.modelInfoCache && now - this.modelInfoCacheTimestamp < this.modelInfoCacheTTL) {
return this.modelInfoCache
}
async calculateCost(prompt_tokens: number, completion_tokens: number): Promise<number | undefined> {
// Reference: https://github.com/BerriAI/litellm/blob/122ee634f434014267af104814022af1d9a0882f/litellm/proxy/spend_tracking/spend_management_endpoints.py#L1473
const client = this.ensureClient()
// Handle base URLs that already include /v1 to avoid double /v1/v1/
const baseUrl = client.baseURL.endsWith("/v1") ? client.baseURL : `${client.baseURL}/v1`
const url = `${baseUrl}/model/info`
const modelId = this.options.liteLlmModelId || liteLlmDefaultModelId
try {
const response = await fetch(url, {
method: "GET",
const response = await fetch(`${client.baseURL}/spend/calculate`, {
method: "POST",
headers: {
accept: "application/json",
"x-litellm-api-key": this.options.liteLlmApiKey || "",
"Content-Type": "application/json",
Authorization: `Bearer ${this.options.liteLlmApiKey}`,
},
body: JSON.stringify({
completion_response: {
model: modelId,
usage: {
prompt_tokens,
completion_tokens,
},
},
}),
})
if (response.ok) {
const data: LiteLlmModelInfoResponse = await response.json()
this.modelInfoCache = data
this.modelInfoCacheTimestamp = now
return data
const data: { cost: number } = await response.json()
return data.cost
} else {
console.warn("Failed to fetch LiteLLM model info:", response.statusText)
// Try with Authorization header instead
const retryResponse = await fetch(url, {
method: "GET",
headers: {
accept: "application/json",
Authorization: `Bearer ${this.options.liteLlmApiKey || ""}`,
},
})
if (retryResponse.ok) {
const data: LiteLlmModelInfoResponse = await retryResponse.json()
this.modelInfoCache = data
this.modelInfoCacheTimestamp = now
return data
} else {
console.warn("Failed to fetch LiteLLM model info with Authorization header:", retryResponse.statusText)
return undefined
}
console.error("Error calculating spend:", response.statusText)
return undefined
}
} catch (error) {
console.warn("Error fetching LiteLLM model info:", error)
return undefined
}
}
private async getModelCostInfo(publicModelName: string): Promise<{
inputCostPerToken: number
outputCostPerToken: number
cacheCreationCostPerToken?: number
cacheReadCostPerToken?: number
}> {
try {
const modelInfo = await this.fetchModelInfo()
if (modelInfo?.data) {
// Find the model by public name
const matchingModel = modelInfo.data.find((model) => model.model_name === publicModelName)
if (matchingModel?.model_info) {
return {
inputCostPerToken: matchingModel.model_info.input_cost_per_token || 0,
outputCostPerToken: matchingModel.model_info.output_cost_per_token || 0,
cacheCreationCostPerToken: matchingModel.model_info.cache_creation_input_token_cost,
cacheReadCostPerToken: matchingModel.model_info.cache_read_input_token_cost,
}
}
}
} catch (error) {
console.warn("Error getting LiteLLM model cost info:", error)
}
// Fallback to zero costs if we can't get the information
return {
inputCostPerToken: 0,
outputCostPerToken: 0,
}
}
async calculateCost(
prompt_tokens: number,
completion_tokens: number,
cache_creation_tokens?: number,
cache_read_tokens?: number,
): Promise<number | undefined> {
const publicModelId = this.options.liteLlmModelId || liteLlmDefaultModelId
try {
const costInfo = await this.getModelCostInfo(publicModelId)
// Calculate costs for different token types
const inputCost = Math.max(0, prompt_tokens - (cache_read_tokens || 0)) * costInfo.inputCostPerToken
const outputCost = completion_tokens * costInfo.outputCostPerToken
const cacheCreationCost = (cache_creation_tokens || 0) * (costInfo.cacheCreationCostPerToken || 0)
const cacheReadCost = (cache_read_tokens || 0) * (costInfo.cacheReadCostPerToken || 0)
const totalCost = inputCost + outputCost + cacheCreationCost + cacheReadCost
return totalCost
} catch (error) {
console.error("Error calculating spend:", error)
return undefined
@@ -233,6 +136,9 @@ export class LiteLlmHandler implements ApiHandler {
...(this.options.taskId && { litellm_session_id: `cline-${this.options.taskId}` }), // Add session ID for LiteLLM tracking
})
const inputCost = (await this.calculateCost(1e6, 0)) || 0
const outputCost = (await this.calculateCost(0, 1e6)) || 0
for await (const chunk of stream) {
const delta = chunk.choices[0]?.delta
@@ -259,6 +165,9 @@ export class LiteLlmHandler implements ApiHandler {
// Handle token usage information
if (chunk.usage) {
const totalCost =
(inputCost * chunk.usage.prompt_tokens) / 1e6 + (outputCost * chunk.usage.completion_tokens) / 1e6
// Extract cache-related information if available
// Need to use type assertion since these properties are not in the standard OpenAI types
const usage = chunk.usage as {
@@ -273,15 +182,6 @@ export class LiteLlmHandler implements ApiHandler {
const cacheWriteTokens = usage.cache_creation_input_tokens || usage.prompt_cache_miss_tokens || 0
const cacheReadTokens = usage.cache_read_input_tokens || usage.prompt_cache_hit_tokens || 0
// Calculate cost using the actual token usage including cache tokens
const totalCost =
(await this.calculateCost(
usage.prompt_tokens || 0,
usage.completion_tokens || 0,
cacheWriteTokens > 0 ? cacheWriteTokens : undefined,
cacheReadTokens > 0 ? cacheReadTokens : undefined,
)) || 0
yield {
type: "usage",
inputTokens: usage.prompt_tokens || 0,
+2 -14
View File
@@ -1,5 +1,5 @@
import { Anthropic } from "@anthropic-ai/sdk"
import { Message, Ollama, Config } from "ollama"
import { Message, Ollama } from "ollama"
import { ApiHandler } from "../"
import { ApiHandlerOptions, ModelInfo, openAiModelInfoSaneDefaults } from "../../shared/api"
import { convertToOllamaMessages } from "../transform/ollama-format"
@@ -8,7 +8,6 @@ import { withRetry } from "../retry"
interface OllamaHandlerOptions {
ollamaBaseUrl?: string
ollamaApiKey?: string
ollamaModelId?: string
ollamaApiOptionsCtxNum?: string
requestTimeoutMs?: number
@@ -25,18 +24,7 @@ export class OllamaHandler implements ApiHandler {
private ensureClient(): Ollama {
if (!this.client) {
try {
const clientOptions: Partial<Config> = {
host: this.options.ollamaBaseUrl || "http://localhost:11434",
}
// Add API key if provided (for Ollama cloud or authenticated instances)
if (this.options.ollamaApiKey) {
clientOptions.headers = {
Authorization: `Bearer ${this.options.ollamaApiKey}`,
}
}
this.client = new Ollama(clientOptions)
this.client = new Ollama({ host: this.options.ollamaBaseUrl || "http://localhost:11434" })
} catch (error) {
throw new Error(`Error creating Ollama client: ${error.message}`)
}
-27
View File
@@ -104,33 +104,6 @@ export class OpenAiNativeHandler implements ApiHandler {
}
break
}
case "nectarine-alpha-new-reasoning-effort-2025-07-25":
case "gpt-5-2025-08-07":
case "gpt-5-mini-2025-08-07":
case "gpt-5-nano-2025-08-07":
const stream = await client.chat.completions.create({
model: model.id,
temperature: 1,
messages: [{ role: "developer", content: systemPrompt }, ...convertToOpenAiMessages(messages)],
stream: true,
stream_options: { include_usage: true },
reasoning_effort: (this.options.reasoningEffort as ChatCompletionReasoningEffort) || "medium",
})
for await (const chunk of stream) {
const delta = chunk.choices[0]?.delta
if (delta?.content) {
yield {
type: "text",
text: delta.content,
}
}
if (chunk.usage) {
// Only last chunk contains usage
yield* this.yieldUsage(model.info, chunk.usage)
}
}
break
default: {
const stream = await client.chat.completions.create({
model: model.id,
+1 -4
View File
@@ -74,10 +74,7 @@ export class RequestyHandler implements ApiHandler {
? { thinking: { type: "enabled", budget_tokens: thinkingBudget } }
: { thinking: { type: "disabled" } }
const thinkingArgs =
model.id.includes("claude-3-7-sonnet") ||
model.id.includes("claude-sonnet-4") ||
model.id.includes("claude-opus-4") ||
model.id.includes("claude-opus-4-1")
model.id.includes("claude-3-7-sonnet") || model.id.includes("claude-sonnet-4") || model.id.includes("claude-opus-4")
? thinking
: {}
-1
View File
@@ -86,7 +86,6 @@ export class VertexHandler implements ApiHandler {
switch (modelId) {
case "claude-sonnet-4@20250514":
case "claude-opus-4-1@20250805":
case "claude-opus-4@20250514":
case "claude-3-7-sonnet@20250219":
case "claude-3-5-sonnet-v2@20241022":
-3
View File
@@ -24,7 +24,6 @@ export async function createOpenRouterStream(
// handles direct model.id match logic
switch (model.id) {
case "anthropic/claude-sonnet-4":
case "anthropic/claude-opus-4.1":
case "anthropic/claude-opus-4":
case "anthropic/claude-3.7-sonnet":
case "anthropic/claude-3.7-sonnet:beta":
@@ -83,7 +82,6 @@ export async function createOpenRouterStream(
let maxTokens: number | undefined
switch (model.id) {
case "anthropic/claude-sonnet-4":
case "anthropic/claude-opus-4.1":
case "anthropic/claude-opus-4":
case "anthropic/claude-3.7-sonnet":
case "anthropic/claude-3.7-sonnet:beta":
@@ -119,7 +117,6 @@ export async function createOpenRouterStream(
let reasoning: { max_tokens: number } | undefined = undefined
switch (model.id) {
case "anthropic/claude-sonnet-4":
case "anthropic/claude-opus-4.1":
case "anthropic/claude-opus-4":
case "anthropic/claude-3.7-sonnet":
case "anthropic/claude-3.7-sonnet:beta":
+1 -2
View File
@@ -1,6 +1,6 @@
export type AssistantMessageContent = TextContent | ToolUse
export { parseAssistantMessageV2, parseAssistantMessageV3 } from "./parse-assistant-message"
export { parseAssistantMessageV1, parseAssistantMessageV2, parseAssistantMessageV3 } from "./parse-assistant-message"
export interface TextContent {
type: "text"
@@ -60,7 +60,6 @@ export const toolParamNames = [
"steps_to_reproduce",
"api_request_output",
"additional_context",
"needs_more_exploration",
] as const
export type ToolParamName = (typeof toolParamNames)[number]
@@ -1,6 +1,245 @@
import { AssistantMessageContent, TextContent, ToolUse, ToolParamName, toolParamNames, toolUseNames, ToolUseName } from "." // Assuming types are defined in index.ts or a similar file
// parseAssistantmessageV1 removed in https://github.com/cline/cline/pull/5425
/**
* @description **Version 1**
* Parses an assistant message string potentially containing mixed text and tool usage blocks
* marked with XML-like tags into an array of structured content objects.
*
* This version iterates through the message character by character, building an accumulator string.
* It maintains state to track whether it's currently parsing text, a tool use block, or a specific tool parameter.
* It detects the start and end of tool uses and parameters by checking if the accumulator ends with
* the corresponding opening or closing tags.
* Special handling is included for `write_to_file` and `new_rule` tool uses to correctly parse
* the `content` parameter, which might contain the closing tag itself, by looking for the *last*
* occurrence of the closing tag.
* If the input string ends mid-tag or mid-content, the last block (text or tool use) is marked as partial.
*
* @param assistantMessage The raw string output from the assistant.
* @returns An array of `AssistantMessageContent` objects, which can be `TextContent` or `ToolUse`.
* Blocks that were not fully closed by the end of the input string will have their `partial` flag set to `true`.
*/
export function parseAssistantMessageV1(assistantMessage: string): AssistantMessageContent[] {
const contentBlocks: AssistantMessageContent[] = []
let currentTextContent: TextContent | undefined = undefined
let currentTextContentStartIndex = 0
let currentToolUse: ToolUse | undefined = undefined
let currentToolUseStartIndex = 0
let currentParamName: ToolParamName | undefined = undefined
let currentParamValueStartIndex = 0
let accumulator = ""
for (let i = 0; i < assistantMessage.length; i++) {
const char = assistantMessage[i]
accumulator += char
// --- State: Parsing a Tool Parameter ---
// there should not be a param without a tool use
if (currentToolUse && currentParamName) {
const currentParamValue = accumulator.slice(currentParamValueStartIndex)
const paramClosingTag = `</${currentParamName}>`
if (currentParamValue.endsWith(paramClosingTag)) {
// End of param value found
currentToolUse.params[currentParamName] = currentParamValue.slice(0, -paramClosingTag.length).trim()
currentParamName = undefined // Go back to parsing tool content or looking for next param
continue // Move to next character
} else {
// Partial param value is accumulating
continue // Move to next character
}
}
// --- State: Parsing a Tool Use (but not a specific parameter) ---
// no currentParamName
if (currentToolUse) {
const currentToolValue = accumulator.slice(currentToolUseStartIndex)
const toolUseClosingTag = `</${currentToolUse.name}>`
if (currentToolValue.endsWith(toolUseClosingTag)) {
// End of a tool use found
currentToolUse.partial = false
contentBlocks.push(currentToolUse)
currentToolUse = undefined // Go back to parsing text or looking for next tool
// Reset text start index in case text follows immediately
currentTextContentStartIndex = i + 1
continue // Move to next character
} else {
// Check if starting a new parameter within the current tool use
const possibleParamOpeningTags = toolParamNames.map((name) => `<${name}>`)
let foundParamStart = false
for (const paramOpeningTag of possibleParamOpeningTags) {
if (accumulator.endsWith(paramOpeningTag)) {
// Start of a new parameter found
currentParamName = paramOpeningTag.slice(1, -1) as ToolParamName
currentParamValueStartIndex = accumulator.length
foundParamStart = true
break
}
}
if (foundParamStart) {
continue // Move to next character
}
// Special case for write_to_file/new_rule content param allowing nested tags
// Check if a </content> tag appears, potentially indicating the end of the content param
// even if the main tool closing tag hasn't been seen yet.
const contentParamName: ToolParamName = "content"
if (
(currentToolUse.name === "write_to_file" || currentToolUse.name === "new_rule") &&
accumulator.endsWith(`</${contentParamName}>`)
) {
const toolContent = accumulator.slice(currentToolUseStartIndex)
const contentStartTag = `<${contentParamName}>`
const contentEndTag = `</${contentParamName}>`
const contentStartIndex = toolContent.indexOf(contentStartTag) + contentStartTag.length
// Use lastIndexOf to handle cases where </content> might appear within the content itself
const contentEndIndex = toolContent.lastIndexOf(contentEndTag)
// Ensure we found valid start/end tags and end is after start
if (
contentStartIndex !== -1 &&
contentEndIndex !== -1 &&
contentEndIndex > contentStartIndex - contentStartTag.length // Ensure end tag is after start tag begins
) {
// Check if this content param was already being parsed. If so, update it.
// If not, and we just found the closing tag, assign it.
// This handles cases where the </content> detection might fire before
// the <content> tag detection logic, or if the content is very short.
if (currentParamName === contentParamName) {
// Already parsing content, now we found the end tag
currentToolUse.params[contentParamName] = toolContent.slice(contentStartIndex, contentEndIndex).trim()
currentParamName = undefined // Finished with this param
} else if (currentParamName === undefined) {
// Not parsing a param, but found </content>. Assume it closes the content block.
currentToolUse.params[contentParamName] = toolContent.slice(contentStartIndex, contentEndIndex).trim()
// We stay in the "parsing tool use" state, looking for more params or the tool end tag.
}
}
}
// If none of the above, partial tool value is accumulating
continue // Move to next character
}
}
// --- State: Parsing Text (or looking for start of a tool use) ---
// no currentToolUse
let didStartToolUse = false
const possibleToolUseOpeningTags = toolUseNames.map((name) => `<${name}>`)
for (const toolUseOpeningTag of possibleToolUseOpeningTags) {
if (accumulator.endsWith(toolUseOpeningTag)) {
// Start of a new tool use found
const toolName = toolUseOpeningTag.slice(1, -1) as ToolUseName
currentToolUse = {
type: "tool_use",
name: toolName,
params: {},
partial: true,
}
currentToolUseStartIndex = accumulator.length
// This also indicates the end of the current text content block (if any)
if (currentTextContent) {
currentTextContent.partial = false
// Extract text content, removing the part that formed the tool opening tag
const textEndIndex = accumulator.length - toolUseOpeningTag.length
currentTextContent.content = accumulator.slice(currentTextContentStartIndex, textEndIndex).trim()
// Only add if there's actual content
if (currentTextContent.content.length > 0) {
contentBlocks.push(currentTextContent)
}
currentTextContent = undefined
} else {
// Check if there was text before this tool use started
const textEndIndex = accumulator.length - toolUseOpeningTag.length
const potentialText = accumulator.slice(currentTextContentStartIndex, textEndIndex).trim()
if (potentialText.length > 0) {
contentBlocks.push({
type: "text",
content: potentialText,
partial: false, // Ended because tool use started
})
}
}
didStartToolUse = true
break // Found tool start, stop checking for others
}
}
if (!didStartToolUse) {
// No tool use started, so it must be text content accumulating
// (or continuing after a closed tool use)
if (currentTextContent === undefined) {
// Start of a new text block
currentTextContentStartIndex = i - (accumulator.length - currentTextContentStartIndex - 1) // Adjust start index based on how much we've accumulated since the last block ended or the beginning
// If accumulator starts from 0, start index is i
if (contentBlocks.length === 0 && currentToolUse === undefined) {
currentTextContentStartIndex = accumulator.length - 1 // i
} else {
// Re-calculate based on the actual start of the current text segment
// Find the end of the last block
let lastBlockEndIndex = 0
if (contentBlocks.length > 0) {
const lastBlock = contentBlocks[contentBlocks.length - 1]
// Approximation: find where the accumulator matches the end of the message string representation of the last block. This is complex.
// Simpler: Assume text starts right after the last block ended implicitly at index i.
lastBlockEndIndex = i // Where the loop *was* when the last block finished processing
// Need a more robust way to track the end index of the *raw string* corresponding to the last block.
// Let's stick to the accumulator slice approach for simplicity in this version.
// The start index should be where the current *unmatched* text began.
let lastProcessedIndex = -1
if (contentBlocks.length > 0) {
// This requires knowing the raw string length of the previous block, which V1 doesn't explicitly track easily.
// We'll approximate based on the current accumulator and start index logic.
// The issue arises if a tool tag was just closed. accumulator contains everything up to i.
// lastBlockEndIndex should point to the character *after* the closing tag of the last block.
}
// Reset start index to the beginning of the *current* potential text block
currentTextContentStartIndex = accumulator.length - 1 // Start accumulating from the current character `i`
}
// If we just closed a tool, text starts *after* its closing tag
// The logic needs refinement here for accurate start index after a tool closure.
// Let's assume for now the start index logic inside the loop handles it via slicing.
}
currentTextContent = {
type: "text",
content: "", // Content will be filled by slicing accumulator
partial: true,
}
}
// Update text content based on the accumulator from its start index
currentTextContent.content = accumulator.slice(currentTextContentStartIndex).trimStart() // Trim start to avoid leading space if text follows tool
}
} // End of loop
// --- Finalization after loop ---
// If a tool use was open at the end
if (currentToolUse) {
// If a parameter was open within that tool use
if (currentParamName) {
// The remaining accumulator content belongs to this partial parameter
currentToolUse.params[currentParamName] = accumulator.slice(currentParamValueStartIndex).trim()
}
// Add the potentially partial tool use block
contentBlocks.push(currentToolUse)
}
// If text content was being accumulated at the end
// Note: Only one of currentToolUse or currentTextContent can be defined here,
// as starting a tool use finalizes the preceding text block.
else if (currentTextContent) {
// Update content one last time
currentTextContent.content = accumulator.slice(currentTextContentStartIndex).trim()
// Add the potentially partial text block only if it contains content
if (currentTextContent.content.length > 0) {
contentBlocks.push(currentTextContent)
}
}
return contentBlocks
}
/**
* @description **Version 2**
@@ -1,13 +1,14 @@
import { HostProvider } from "@/hosts/host-provider"
import { setVscodeHostProviderMock } from "@/test/host-provider-test-utils"
import * as diskModule from "@core/storage/disk"
import { describe, it, beforeEach, afterEach } from "mocha"
import { expect } from "chai"
import { afterEach, beforeEach, describe, it } from "mocha"
import * as path from "path"
import * as sinon from "sinon"
import * as vscode from "vscode"
import type { FileMetadataEntry, TaskMetadata } from "./ContextTrackerTypes"
import * as path from "path"
import { FileContextTracker } from "./FileContextTracker"
import * as diskModule from "@core/storage/disk"
import type { TaskMetadata, FileMetadataEntry } from "./ContextTrackerTypes"
import type { DiffViewProviderCreator, WebviewProviderCreator } from "@/hosts/host-provider"
import { HostProvider } from "@/hosts/host-provider"
import { vscodeHostBridgeClient } from "@/hosts/vscode/hostbridge/client/host-grpc-client"
describe("FileContextTracker", () => {
let sandbox: sinon.SinonSandbox
@@ -53,7 +54,14 @@ describe("FileContextTracker", () => {
getTaskMetadataStub = sandbox.stub(diskModule, "getTaskMetadata").resolves(mockTaskMetadata)
saveTaskMetadataStub = sandbox.stub(diskModule, "saveTaskMetadata").resolves()
setVscodeHostProviderMock()
// Reset HostProvider before initializing to avoid "already initialized" errors
HostProvider.reset()
HostProvider.initialize(
((_) => {}) as WebviewProviderCreator,
(() => {}) as DiffViewProviderCreator,
vscodeHostBridgeClient,
(_) => {},
)
// Create tracker instance
taskId = "test-task-id"
@@ -2,6 +2,8 @@ import { Controller } from "../index"
import { AuthService } from "@/services/auth/AuthService"
import { EmptyRequest, String } from "@shared/proto/cline/common"
const authService = AuthService.getInstance()
/**
* Handles the user clicking the login link in the UI.
* Generates a secure nonce for state validation, stores it in secrets,
@@ -11,5 +13,5 @@ import { EmptyRequest, String } from "@shared/proto/cline/common"
* @returns The login URL as a string.
*/
export async function accountLoginClicked(_controller: Controller, _: EmptyRequest): Promise<String> {
return await AuthService.getInstance().createAuthRequest()
return await authService.createAuthRequest()
}
@@ -3,6 +3,7 @@ import { Empty } from "@shared/proto/cline/common"
import type { EmptyRequest } from "@shared/proto/cline/common"
import type { Controller } from "../index"
const authService = AuthService.getInstance()
/**
* Handles the account logout action
* @param controller The controller instance
@@ -11,6 +12,6 @@ import type { Controller } from "../index"
*/
export async function accountLogoutClicked(controller: Controller, _request: EmptyRequest): Promise<Empty> {
await controller.handleSignOut()
await AuthService.getInstance().handleDeauth()
await authService.handleDeauth()
return Empty.create({})
}
@@ -1,13 +1,5 @@
import { AuthState, EmptyRequest } from "@/shared/proto/index.cline"
import { AuthService } from "@services/auth/AuthService"
import { Controller } from ".."
import { StreamingResponseHandler } from "../grpc-handler"
import { AuthService } from "../../../services/auth/AuthService"
export async function subscribeToAuthStatusUpdate(
controller: Controller,
request: EmptyRequest,
responseStream: StreamingResponseHandler<AuthState>,
requestId?: string,
): Promise<void> {
return AuthService.getInstance().subscribeToAuthStatusUpdate(controller, request, responseStream, requestId)
}
const authService = AuthService.getInstance()
export const subscribeToAuthStatusUpdate = authService.subscribeToAuthStatusUpdate.bind(authService)
export const sendAuthStatusUpdateEvent = authService.sendAuthStatusUpdate.bind(authService)
@@ -16,9 +16,11 @@ export async function relaunchChromeDebugMode(controller: Controller, _: EmptyRe
// Relaunch Chrome in debug mode
await browserSession.relaunchChromeDebugMode(controller)
// The actual result will be sent via the ProtoBus in the BrowserSession.relaunchChromeDebugMode method
// The actual result will be sent via postMessageToWebview in the BrowserSession.relaunchChromeDebugMode method
// Here we just return a message as a placeholder
return { value: "Chrome relaunch initiated" }
return StringMessage.create({
value: "Chrome relaunch initiated",
})
} catch (error) {
throw new Error(`Error relaunching Chrome: ${error instanceof Error ? error.message : globalThis.String(error)}`)
}
-412
View File
@@ -1,412 +0,0 @@
import { describe, it, beforeEach, afterEach } from "mocha"
import { expect } from "chai"
import * as sinon from "sinon"
import { handleGrpcRequest, handleGrpcRequestCancel, getRequestRegistry } from "./grpc-handler"
import { Controller } from "@core/controller"
import { GrpcRequest, GrpcCancel } from "@shared/WebviewMessage"
import { serviceHandlers } from "@generated/hosts/vscode/protobus-services"
describe("grpc-handler", () => {
let sandbox: sinon.SinonSandbox
let mockController: Controller
let mockPostMessageToWebview: sinon.SinonStub
let mockUnaryHandler: sinon.SinonStub
let mockUnaryFailingHandler: sinon.SinonStub
let mockStreamingHandler: sinon.SinonStub
let mockStreamingFailingHandler: sinon.SinonStub
const serviceName = "cline.TestService"
const mockResponse = { result: "result-1234" }
beforeEach(() => {
sandbox = sinon.createSandbox()
// Create a mock controller
mockController = {} as any
mockPostMessageToWebview = sandbox.stub().resolves()
// Create mock service handlers
mockUnaryHandler = sandbox.stub().resolves(mockResponse)
mockStreamingHandler = sandbox.stub().resolves()
mockUnaryFailingHandler = sandbox.stub().rejects(new Error("Test error unary"))
mockStreamingFailingHandler = sandbox.stub().rejects(new Error("Stream error"))
serviceHandlers[serviceName] = {
testUnary: mockUnaryHandler,
testUnaryFailing: mockUnaryFailingHandler,
testStreaming: mockStreamingHandler,
testStreamingFailing: mockStreamingFailingHandler,
}
})
afterEach(() => {
sandbox.restore()
})
describe("handleGrpcRequest", () => {
describe("Unary requests", () => {
it("should handle successful unary requests", async () => {
const request: GrpcRequest = {
service: serviceName,
method: "testUnary",
message: { input: "test" },
request_id: "test-123",
is_streaming: false,
}
await handleGrpcRequest(mockController, mockPostMessageToWebview, request)
// Verify the handler was called
expect(mockUnaryHandler.calledOnce).to.be.true
expect(mockUnaryHandler.firstCall.args[0]).to.equal(mockController)
expect(mockUnaryHandler.firstCall.args[1]).to.deep.equal({ input: "test" })
// Verify the response was sent
expect(mockPostMessageToWebview.calledOnce).to.be.true
const sentMessage = mockPostMessageToWebview.firstCall.args[0]
expect(sentMessage).to.deep.equal({
type: "grpc_response",
grpc_response: {
message: mockResponse,
request_id: "test-123",
},
})
})
it("should handle errors in unary requests", async () => {
const request: GrpcRequest = {
service: serviceName,
method: "testUnaryFailing",
message: { input: "test" },
request_id: "test-456",
is_streaming: false,
}
await handleGrpcRequest(mockController, mockPostMessageToWebview, request)
// Verify the error response was sent
expect(mockPostMessageToWebview.calledOnce).to.be.true
const sentMessage = mockPostMessageToWebview.firstCall.args[0]
expect(sentMessage).to.deep.equal({
type: "grpc_response",
grpc_response: {
error: "Test error unary",
request_id: "test-456",
is_streaming: false,
},
})
})
it("should handle unknown service errors", async () => {
const request: GrpcRequest = {
service: "UnknownService",
method: "someMethod",
message: {},
request_id: "test-789",
is_streaming: false,
}
await handleGrpcRequest(mockController, mockPostMessageToWebview, request)
// Verify the error response was sent
expect(mockPostMessageToWebview.calledOnce).to.be.true
const sentMessage = mockPostMessageToWebview.firstCall.args[0]
expect(sentMessage.type).to.equal("grpc_response")
expect(sentMessage.grpc_response?.error).to.include("Unknown service: UnknownService")
expect(sentMessage.grpc_response?.request_id).to.equal("test-789")
})
it("should handle unknown method errors", async () => {
const request: GrpcRequest = {
service: serviceName,
method: "unknownMethod",
message: {},
request_id: "test-999",
is_streaming: false,
}
await handleGrpcRequest(mockController, mockPostMessageToWebview, request)
// Verify the error response was sent
expect(mockPostMessageToWebview.calledOnce).to.be.true
const sentMessage = mockPostMessageToWebview.firstCall.args[0]
expect(sentMessage.type).to.equal("grpc_response")
expect(sentMessage.grpc_response?.error).to.include("Unknown rpc: cline.TestService.unknownMethod")
expect(sentMessage.grpc_response?.request_id).to.equal("test-999")
})
})
describe("Streaming requests", () => {
it("should handle successful streaming requests", async () => {
// Set up a streaming handler that sends multiple responses
const request: GrpcRequest = {
service: serviceName,
method: "testStreaming",
message: { input: "stream" },
request_id: "stream-123",
is_streaming: true,
}
// Reset the mock and set up the handler using callsFake
mockStreamingHandler.reset()
mockStreamingHandler.callsFake(async (controller: any, message: any, responseStream: any, requestId: string) => {
// Simulate streaming multiple messages
await responseStream({ value: 1 }, false, 0)
await responseStream({ value: 2 }, false, 1)
await responseStream({ value: 3 }, true, 2) // Last message
})
await handleGrpcRequest(mockController, mockPostMessageToWebview, request)
// Verify the handler was called
expect(mockStreamingHandler.calledOnce).to.be.true
expect(mockStreamingHandler.firstCall.args[0]).to.equal(mockController)
expect(mockStreamingHandler.firstCall.args[1]).to.deep.equal({ input: "stream" })
expect(mockStreamingHandler.firstCall.args[3]).to.equal("stream-123")
// Verify all streaming responses were sent
expect(mockPostMessageToWebview.callCount).to.equal(3)
// Check all responses
expect(mockPostMessageToWebview.firstCall.args[0]).to.deep.equal({
type: "grpc_response",
grpc_response: {
message: { value: 1 },
request_id: "stream-123",
is_streaming: true,
sequence_number: 0,
},
})
expect(mockPostMessageToWebview.secondCall.args[0]).to.deep.equal({
type: "grpc_response",
grpc_response: {
message: { value: 2 },
request_id: "stream-123",
is_streaming: true,
sequence_number: 1,
},
})
expect(mockPostMessageToWebview.thirdCall.args[0]).to.deep.equal({
type: "grpc_response",
grpc_response: {
message: { value: 3 },
request_id: "stream-123",
is_streaming: false, // Last message has is_streaming: false
sequence_number: 2,
},
})
})
it("should handle errors in streaming requests", async () => {
const request: GrpcRequest = {
service: serviceName,
method: "testStreamingFailing",
message: { input: "stream" },
request_id: "stream-456",
is_streaming: true,
}
await handleGrpcRequest(mockController, mockPostMessageToWebview, request)
// Verify the error response was sent
expect(mockPostMessageToWebview.calledOnce).to.be.true
const sentMessage = mockPostMessageToWebview.firstCall.args[0]
expect(sentMessage).to.deep.equal({
type: "grpc_response",
grpc_response: {
error: "Stream error",
request_id: "stream-456",
is_streaming: false,
},
})
})
it("should handle streaming with message, error, then another message", async () => {
// This test simulates a scenario where:
// 1. First message is sent successfully
// 2. An error occurs
// 3. Another message is attempted (which should not be sent after error)
const request: GrpcRequest = {
service: serviceName,
method: "testStreaming",
message: { input: "stream-with-error" },
request_id: "stream-error-mid",
is_streaming: true,
}
// Reset the mock and set up the handler to throw an error after being called
mockStreamingHandler.reset()
mockStreamingHandler.callsFake(async (controller: any, message: any, responseStream: any, requestId: string) => {
// Send first message successfully
await responseStream({ value: "first" }, false, 0)
// Throw an error
throw new Error("Mid-stream error")
})
await handleGrpcRequest(mockController, mockPostMessageToWebview, request)
// Verify the handler was called
expect(mockStreamingHandler.calledOnce).to.be.true
// Verify that we got the first message and then the error
expect(mockPostMessageToWebview.callCount).to.equal(2)
// Check first message was sent successfully
expect(mockPostMessageToWebview.firstCall.args[0]).to.deep.equal({
type: "grpc_response",
grpc_response: {
message: { value: "first" },
request_id: "stream-error-mid",
is_streaming: true,
sequence_number: 0,
},
})
// Check error response was sent
expect(mockPostMessageToWebview.secondCall.args[0]).to.deep.equal({
type: "grpc_response",
grpc_response: {
error: "Mid-stream error",
request_id: "stream-error-mid",
is_streaming: false,
},
})
// Try to send another message after the error (simulating what might happen
// if the handler tried to continue after an error)
const responseStream = mockStreamingHandler.firstCall.args[2]
// This should still work as the responseStream function is still valid
await responseStream({ value: "after-error" }, false, 1)
// Verify we now have 3 total calls (first message, error, after-error message)
expect(mockPostMessageToWebview.callCount).to.equal(3)
// Verify the message after error was still sent
// (In a real scenario, the handler would have stopped due to the error,
// but this tests that the responseStream function itself still works)
expect(mockPostMessageToWebview.thirdCall.args[0]).to.deep.equal({
type: "grpc_response",
grpc_response: {
message: { value: "after-error" },
request_id: "stream-error-mid",
is_streaming: true,
sequence_number: 1,
},
})
})
})
describe("handleGrpcRequestCancel", () => {
it("should cancel an active request", async () => {
// Register a request in the registry
const registry = getRequestRegistry()
const cleanupStub = sandbox.stub()
registry.registerRequest("cancel-123", cleanupStub)
const cancelRequest: GrpcCancel = {
request_id: "cancel-123",
}
await handleGrpcRequestCancel(mockPostMessageToWebview, cancelRequest)
// Verify the cleanup was called
expect(cleanupStub.calledOnce).to.be.true
// Verify the cancellation confirmation was sent
expect(mockPostMessageToWebview.calledOnce).to.be.true
const sentMessage = mockPostMessageToWebview.firstCall.args[0]
expect(sentMessage).to.deep.equal({
type: "grpc_response",
grpc_response: {
message: { cancelled: true },
request_id: "cancel-123",
is_streaming: false,
},
})
// Verify the request was removed from the registry
expect(registry.hasRequest("cancel-123")).to.be.false
})
it("should handle cancellation of non-existent request", async () => {
const cancelRequest: GrpcCancel = {
request_id: "non-existent",
}
await handleGrpcRequestCancel(mockPostMessageToWebview, cancelRequest)
// Verify no message was sent (request not found)
expect(mockPostMessageToWebview.called).to.be.false
})
it("should handle cleanup errors gracefully", async () => {
// Register a request with a failing cleanup
const registry = getRequestRegistry()
const cleanupStub = sandbox.stub().throws(new Error("Cleanup failed"))
registry.registerRequest("cancel-error", cleanupStub)
const cancelRequest: GrpcCancel = {
request_id: "cancel-error",
}
// Should not throw
await handleGrpcRequestCancel(mockPostMessageToWebview, cancelRequest)
// Verify the cleanup was attempted
expect(cleanupStub.calledOnce).to.be.true
// Verify the cancellation confirmation was still sent
expect(mockPostMessageToWebview.calledOnce).to.be.true
// Verify the request was removed despite the error
expect(registry.hasRequest("cancel-error")).to.be.false
})
})
describe("Concurrent requests", () => {
it("should handle concurrent requests", async () => {
// Set up handlers
mockUnaryHandler.resolves({ result: "unary" })
mockStreamingHandler.callsFake(async (_controller: any, _message: any, responseStream: any) => {
await responseStream({ value: "stream1" }, false, 0)
await responseStream({ value: "stream2" }, true, 1)
})
// Send multiple requests concurrently
const requests = [
handleGrpcRequest(mockController, mockPostMessageToWebview, {
service: serviceName,
method: "testUnary",
message: { id: 1 },
request_id: "concurrent-1",
is_streaming: false,
}),
handleGrpcRequest(mockController, mockPostMessageToWebview, {
service: serviceName,
method: "testStreaming",
message: { id: 2 },
request_id: "concurrent-2",
is_streaming: true,
}),
handleGrpcRequest(mockController, mockPostMessageToWebview, {
service: serviceName,
method: "testUnary",
message: { id: 3 },
request_id: "concurrent-3",
is_streaming: false,
}),
]
await Promise.all(requests)
// Verify all handlers were called
expect(mockUnaryHandler.callCount).to.equal(2)
expect(mockStreamingHandler.callCount).to.equal(1)
// Verify all responses were sent (2 unary + 2 streaming)
expect(mockPostMessageToWebview.callCount).to.equal(4)
})
})
})
})
+158 -101
View File
@@ -1,8 +1,6 @@
import { Controller } from "./index"
import { serviceHandlers } from "@generated/hosts/vscode/protobus-services"
import { GrpcRequestRegistry } from "./grpc-request-registry"
import { GrpcCancel, GrpcRequest } from "@/shared/WebviewMessage"
import { ExtensionMessage } from "@/shared/ExtensionMessage"
/**
* Type definition for a streaming response handler
@@ -13,122 +11,184 @@ export type StreamingResponseHandler<TResponse> = (
sequenceNumber?: number,
) => Promise<void>
export type PostMessageToWebview = (message: ExtensionMessage) => Thenable<boolean | undefined>
/**
* Handles gRPC requests from the webview
*/
export class GrpcHandler {
constructor(private controller: Controller) {}
/**
* Handle a gRPC request from the webview
* @param service The service name
* @param method The method name
* @param message The request message
* @param requestId The request ID for response correlation
* @param isStreaming Whether this is a streaming request
* @returns The response message or error for unary requests, void for streaming requests
*/
async handleRequest(
service: string,
method: string,
message: any,
requestId: string,
isStreaming: boolean = false,
): Promise<{
message?: any
error?: string
request_id: string
} | void> {
try {
// If this is a streaming request, use the streaming handler
if (isStreaming) {
await this.handleStreamingRequest(service, method, message, requestId)
return
}
// Get the service handler from the config
const handler = getHandler(service, method)
// Handle unary request
return {
message: await handler(this.controller, message),
request_id: requestId,
}
} catch (error) {
console.log("Protobus error:", error)
return {
error: error instanceof Error ? error.message : String(error),
request_id: requestId,
}
}
}
/**
* Handle a streaming gRPC request
* @param service The service name
* @param method The method name
* @param message The request message
* @param requestId The request ID for response correlation
*/
private async handleStreamingRequest(service: string, method: string, message: any, requestId: string): Promise<void> {
// Create a response stream function
const responseStream: StreamingResponseHandler<any> = async (
response: any,
isLast: boolean = false,
sequenceNumber?: number,
) => {
await this.controller.postMessageToWebview({
type: "grpc_response",
grpc_response: {
message: response,
request_id: requestId,
is_streaming: !isLast,
sequence_number: sequenceNumber,
},
})
}
try {
// Get the service handler from the config
const handler = getHandler(service, method)
// Handle streaming request and pass the requestId to all streaming handlers
await handler(this.controller, message, responseStream, requestId)
// Don't send a final message here - the stream should stay open for future updates
// The stream will be closed when the client disconnects or when the service explicitly ends it
} catch (error) {
// Send error response
console.log("Protobus error:", error)
await this.controller.postMessageToWebview({
type: "grpc_response",
grpc_response: {
error: error instanceof Error ? error.message : String(error),
request_id: requestId,
is_streaming: false,
},
})
}
}
}
// Registry to track active gRPC requests and their cleanup functions
const requestRegistry = new GrpcRequestRegistry()
/**
* Handles a gRPC request from the webview.
* Handle a gRPC request from the webview
* @param controller The controller instance
* @param request The gRPC request
*/
export async function handleGrpcRequest(
controller: Controller,
postMessageToWebview: PostMessageToWebview,
request: GrpcRequest,
): Promise<void> {
if (request.is_streaming) {
await handleStreamingRequest(controller, postMessageToWebview, request)
} else {
await handleUnaryRequest(controller, postMessageToWebview, request)
}
}
/**
* Handles a gRPC unary request from the webview.
*
* Calls the handler using the service and method name, and then posts the result back to the webview.
*/
async function handleUnaryRequest(
controller: Controller,
postMessageToWebview: PostMessageToWebview,
request: GrpcRequest,
): Promise<void> {
request: {
service: string
method: string
message: any
request_id: string
is_streaming?: boolean
},
) {
try {
// Get the service handler from the config
const handler = getHandler(request.service, request.method)
// Handle unary request
const response = await handler(controller, request.message)
// Send response to the webview
await postMessageToWebview({
const grpcHandler = new GrpcHandler(controller)
// For streaming requests, handleRequest handles sending responses directly
if (request.is_streaming) {
try {
await grpcHandler.handleRequest(request.service, request.method, request.message, request.request_id, true)
} finally {
// Note: We don't automatically clean up here anymore
// The request will be cleaned up when it completes or is cancelled
}
return
}
// For unary requests, we get a response and send it back
const response = (await grpcHandler.handleRequest(
request.service,
request.method,
request.message,
request.request_id,
false,
)) as {
message?: any
error?: string
request_id: string
}
// Send the response back to the webview
await controller.postMessageToWebview({
type: "grpc_response",
grpc_response: {
message: response,
request_id: request.request_id,
},
grpc_response: response,
})
} catch (error) {
// Send error response
console.log("Protobus error:", error)
await postMessageToWebview({
await controller.postMessageToWebview({
type: "grpc_response",
grpc_response: {
error: error instanceof Error ? error.message : String(error),
request_id: request.request_id,
is_streaming: false,
},
})
}
}
/**
* Handle a streaming gRPC request from the webview.
*
* Calls the handler using the service and method name, and creates a streaming response handler
* which posts results back to the webview.
*/
async function handleStreamingRequest(
controller: Controller,
postMessageToWebview: PostMessageToWebview,
request: GrpcRequest,
): Promise<void> {
// Create a response stream function
const responseStream: StreamingResponseHandler<any> = async (
response: any,
isLast: boolean = false,
sequenceNumber?: number,
) => {
await postMessageToWebview({
type: "grpc_response",
grpc_response: {
message: response,
request_id: request.request_id,
is_streaming: !isLast,
sequence_number: sequenceNumber,
},
})
}
try {
// Get the service handler from the config
const handler = getHandler(request.service, request.method)
// Handle streaming request and pass the requestId to all streaming handlers
await handler(controller, request.message, responseStream, request.request_id)
// Don't send a final message here - the stream should stay open for future updates
// The stream will be closed when the client disconnects or when the service explicitly ends it
} catch (error) {
// Send error response
console.log("Protobus error:", error)
await postMessageToWebview({
type: "grpc_response",
grpc_response: {
error: error instanceof Error ? error.message : String(error),
request_id: request.request_id,
is_streaming: false,
},
})
}
}
/**
* Handles a gRPC request cancellation from the webview.
* Handle a gRPC request cancellation from the webview
* @param controller The controller instance
* @param request The cancellation request
*/
export async function handleGrpcRequestCancel(postMessageToWebview: PostMessageToWebview, request: GrpcCancel) {
export async function handleGrpcRequestCancel(
controller: Controller,
request: {
request_id: string
},
) {
const cancelled = requestRegistry.cancelRequest(request.request_id)
if (cancelled) {
// Send a cancellation confirmation
await postMessageToWebview({
await controller.postMessageToWebview({
type: "grpc_response",
grpc_response: {
message: { cancelled: true },
@@ -141,17 +201,6 @@ export async function handleGrpcRequestCancel(postMessageToWebview: PostMessageT
}
}
// Registry to track active gRPC requests and their cleanup functions
const requestRegistry = new GrpcRequestRegistry()
/**
* Get the request registry instance
* This allows other parts of the code to access the registry
*/
export function getRequestRegistry(): GrpcRequestRegistry {
return requestRegistry
}
function getHandler(serviceName: string, methodName: string): any {
// Get the service handler from the config
const serviceConfig = serviceHandlers[serviceName]
@@ -164,3 +213,11 @@ function getHandler(serviceName: string, methodName: string): any {
}
return handler
}
/**
* Get the request registry instance
* This allows other parts of the code to access the registry
*/
export function getRequestRegistry(): GrpcRequestRegistry {
return requestRegistry
}
+66 -18
View File
@@ -1,9 +1,8 @@
import { clineEnvConfig } from "@/config"
import { HostProvider } from "@/hosts/host-provider"
import { AuthService } from "@/services/auth/AuthService"
import { PostHogClientProvider, telemetryService } from "@/services/posthog/PostHogClientProvider"
import { telemetryService } from "@/services/posthog/telemetry/TelemetryService"
import { ShowMessageType } from "@/shared/proto/host/window"
import { getLatestAnnouncementId } from "@/utils/announcements"
import { getCwd, getDesktopDir } from "@/utils/path"
import { Anthropic } from "@anthropic-ai/sdk"
import { buildApiHandler } from "@api/index"
@@ -14,12 +13,14 @@ import { ClineAccountService } from "@services/account/ClineAccountService"
import { McpHub } from "@services/mcp/McpHub"
import { ApiProvider, ModelInfo } from "@shared/api"
import { ChatContent } from "@shared/ChatContent"
import { ExtensionState, Platform } from "@shared/ExtensionMessage"
import { Mode } from "@shared/storage/types"
import { ClineRulesToggles } from "@shared/cline-rules"
import { ExtensionMessage, ExtensionState, Platform } from "@shared/ExtensionMessage"
import { HistoryItem } from "@shared/HistoryItem"
import { McpMarketplaceCatalog } from "@shared/mcp"
import { Mode } from "@shared/storage/types"
import { TelemetrySetting } from "@shared/TelemetrySetting"
import { UserInfo } from "@shared/UserInfo"
import { WebviewMessage } from "@shared/WebviewMessage"
import { fileExistsAtPath } from "@utils/fs"
import axios from "axios"
import fs from "fs/promises"
@@ -27,14 +28,15 @@ 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 { CacheService, PersistenceErrorEvent } from "../storage/CacheService"
import { ensureMcpServersDirectoryExists, ensureSettingsDirectoryExists, GlobalFileNames } from "../storage/disk"
import { getAllExtensionState, getGlobalState, updateGlobalState } from "../storage/state"
import { getAllExtensionState, getGlobalState, getWorkspaceState, storeSecret, updateGlobalState } from "../storage/state"
import { CacheService, PersistenceErrorEvent } from "../storage/CacheService"
import { Task } from "../task"
import { handleGrpcRequest, handleGrpcRequestCancel } from "./grpc-handler"
import { sendMcpMarketplaceCatalogEvent } from "./mcp/subscribeToMcpMarketplaceCatalog"
import { sendStateUpdate } from "./state/subscribeToState"
import { sendAddToInputEvent, sendAddToInputEventToClient } from "./ui/subscribeToAddToInput"
import { WebviewProvider } from "../webview"
import { sendAddToInputEvent } from "./ui/subscribeToAddToInput"
import { getLatestAnnouncementId } from "@/utils/announcements"
/*
https://github.com/microsoft/vscode-webview-ui-toolkit-samples/blob/main/default/weather-webview/src/providers/WeatherViewProvider.ts
@@ -44,6 +46,8 @@ https://github.com/KumarVariable/vscode-extension-sidebar-html/blob/master/src/c
export class Controller {
readonly id: string
private postMessage: (message: ExtensionMessage) => Thenable<boolean> | undefined
private disposables: vscode.Disposable[] = []
task?: Task
@@ -54,11 +58,13 @@ export class Controller {
constructor(
readonly context: vscode.ExtensionContext,
postMessage: (message: ExtensionMessage) => Thenable<boolean> | undefined,
id: string,
) {
this.id = id
HostProvider.get().logToChannel("ClineProvider instantiated")
this.postMessage = postMessage
this.accountService = ClineAccountService.getInstance()
this.cacheService = new CacheService(context)
const authService = AuthService.getInstance(this)
@@ -96,6 +102,7 @@ export class Controller {
this.mcpHub = new McpHub(
() => ensureMcpServersDirectoryExists(),
() => ensureSettingsDirectoryExists(this.context),
(msg) => this.postMessageToWebview(msg),
this.context.extension?.packageJSON?.version ?? "1.0.0",
)
@@ -234,6 +241,37 @@ export class Controller {
}
}
// Send any JSON serializable data to the react app
async postMessageToWebview(message: ExtensionMessage) {
await this.postMessage(message)
}
/**
* Sets up an event listener to listen for messages passed from the webview context and
* executes code based on the message that is received.
*
* @param webview A reference to the extension webview
*/
async handleWebviewMessage(message: WebviewMessage) {
switch (message.type) {
case "grpc_request": {
if (message.grpc_request) {
await handleGrpcRequest(this, message.grpc_request)
}
break
}
case "grpc_request_cancel": {
if (message.grpc_request_cancel) {
await handleGrpcRequestCancel(this, message.grpc_request_cancel)
}
break
}
default: {
console.error("Received unhandled WebviewMessage type:", JSON.stringify(message))
}
}
}
async updateTelemetrySetting(telemetrySetting: TelemetrySetting) {
await updateGlobalState(this.context, "telemetrySetting", telemetrySetting)
const isOptedIn = telemetrySetting !== "disabled"
@@ -305,8 +343,7 @@ export class Controller {
this.task.taskState.abandoned = true
}
await this.initTask(undefined, undefined, undefined, historyItem) // clears task again, so we need to abortTask manually above
// Dont send the state to the webview, the new Cline instance will send state when it's ready.
// Sending the state here sent an empty messages array to webview leading to virtuoso having to reload the entire list
// await this.postStateToWebview() // new Cline instance will post state when it's ready. having this here sent an empty messages array to webview leading to virtuoso having to reload the entire list
}
}
@@ -323,7 +360,7 @@ export class Controller {
// Get current API configuration from cache
const currentApiConfiguration = this.cacheService.getApiConfiguration()
const updatedConfig = { ...currentApiConfiguration }
let updatedConfig = { ...currentApiConfiguration }
if (planActSeparateModelsSetting) {
// Only update the current mode's provider
@@ -446,7 +483,7 @@ export class Controller {
/**
* RPC variant that silently refreshes the MCP marketplace catalog and returns the result
* Unlike silentlyRefreshMcpMarketplace, this doesn't send a message to the webview
* Unlike silentlyRefreshMcpMarketplace, this doesn't post a message to the webview
* @returns MCP marketplace catalog or undefined if refresh failed
*/
async silentlyRefreshMcpMarketplaceRPC() {
@@ -491,7 +528,7 @@ export class Controller {
if (this.task) {
this.task.api = buildApiHandler({ ...updatedConfig, taskId: this.task.taskId }, currentMode)
}
// Dont send settingsButtonClicked because its bad ux if user is on welcome
// await this.postMessageToWebview({ type: "action", action: "settingsButtonClicked" }) // bad ux if user is on welcome
}
private async ensureCacheDirectoryExists(): Promise<string> {
@@ -524,6 +561,10 @@ export class Controller {
// 'Add to Cline' context menu in editor and code action
async addSelectedCodeToChat(code: string, filePath: string, languageId: string, diagnostics?: vscode.Diagnostic[]) {
// Ensure the sidebar view is visible
await vscode.commands.executeCommand("claude-dev.SidebarProvider.focus")
await setTimeoutPromise(100)
// Post message to webview with the selected code
const fileMention = await this.getFileMentionFromPath(filePath)
@@ -533,10 +574,7 @@ export class Controller {
input += `\nProblems:\n${problemsString}`
}
const lastActiveWebview = WebviewProvider.getLastActiveInstance()
if (lastActiveWebview) {
await sendAddToInputEventToClient(lastActiveWebview.getClientId(), input)
}
await sendAddToInputEvent(input)
console.log("addSelectedCodeToChat", code, filePath, languageId)
}
@@ -546,6 +584,14 @@ export class Controller {
// Ensure the sidebar view is visible
await vscode.commands.executeCommand("claude-dev.SidebarProvider.focus")
await setTimeoutPromise(100)
// Post message to webview with the selected terminal output
// await this.postMessageToWebview({
// type: "addSelectedTerminalOutput",
// output,
// terminalName
// })
await sendAddToInputEvent(`Terminal output:\n\`\`\`\n${output}\n\`\`\``)
console.log("addSelectedTerminalOutputToChat", output, terminalName)
@@ -685,6 +731,7 @@ export class Controller {
localWindsurfRulesToggles,
localCursorRulesToggles,
localWorkflowToggles,
dictationSettings,
} = await getAllExtensionState(this.context)
const currentTaskItem = this.task?.taskId ? (taskHistory || []).find((item) => item.id === this.task?.taskId) : undefined
@@ -699,7 +746,7 @@ export class Controller {
const latestAnnouncementId = getLatestAnnouncementId(this.context)
const shouldShowAnnouncement = lastShownAnnouncementId !== latestAnnouncementId
const platform = process.platform as Platform
const distinctId = PostHogClientProvider.getInstance().distinctId
const distinctId = telemetryService.distinctId
const version = this.context.extension?.packageJSON?.version ?? ""
const uriScheme = vscode.env.uriScheme
@@ -739,6 +786,7 @@ export class Controller {
welcomeViewCompleted: welcomeViewCompleted as boolean, // Can be undefined but is set to either true or false by the migration that runs on extension launch in extension.ts
mcpResponsesCollapsed,
terminalOutputLineLimit,
dictationSettings,
}
}
@@ -1,234 +0,0 @@
import { Controller } from ".."
import { EmptyRequest } from "@shared/proto/cline/common"
import { OpenRouterCompatibleModelInfo, OpenRouterModelInfo } from "@shared/proto/cline/models"
import { getAllExtensionState } from "../../storage/state"
import { basetenModels } from "../../../shared/api"
import axios from "axios"
import path from "path"
import fs from "fs/promises"
import { fileExistsAtPath } from "@utils/fs"
import { GlobalFileNames } from "@core/storage/disk"
/**
* Refreshes the Baseten models and returns the updated model list
* @param controller The controller instance
* @param request Empty request object
* @returns Response containing the Baseten models
*/
export async function refreshBasetenModels(
controller: Controller,
request: EmptyRequest,
): Promise<OpenRouterCompatibleModelInfo> {
console.log("=== refreshBasetenModels called ===")
const basetenModelsFilePath = path.join(await ensureCacheDirectoryExists(controller), GlobalFileNames.basetenModels)
// Get the Baseten API key from the controller's state
const { apiConfiguration } = await getAllExtensionState(controller.context)
const basetenApiKey = apiConfiguration?.basetenApiKey
let models: Record<string, Partial<OpenRouterModelInfo>> = {}
try {
if (!basetenApiKey) {
console.log("No Baseten API key found, using static models as fallback")
// Don't throw an error, just use static models
for (const [modelId, modelInfo] of Object.entries(basetenModels)) {
models[modelId] = {
maxTokens: modelInfo.maxTokens,
contextWindow: modelInfo.contextWindow,
supportsImages: modelInfo.supportsImages,
supportsPromptCache: modelInfo.supportsPromptCache,
inputPrice: modelInfo.inputPrice,
outputPrice: modelInfo.outputPrice,
cacheWritesPrice: (modelInfo as any).cacheWritesPrice || 0,
cacheReadsPrice: (modelInfo as any).cacheReadsPrice || 0,
description: (modelInfo as any).description || `${modelId} model`,
}
}
} else {
// Ensure the API key is properly formatted
const cleanApiKey = basetenApiKey.trim()
if (!cleanApiKey) {
throw new Error("Invalid Baseten API key format")
}
console.log("Fetching Baseten models with API key:", cleanApiKey.substring(0, 10) + "...")
const response = await axios.get("https://inference.baseten.co/v1/models", {
headers: {
Authorization: `Bearer ${cleanApiKey}`,
"Content-Type": "application/json",
"User-Agent": "Cline-VSCode-Extension",
},
timeout: 10000, // 10 second timeout
})
if (response.data?.data) {
const rawModels = response.data.data
for (const rawModel of rawModels) {
// Filter out non-chat models and validate model capabilities
if (!isValidChatModel(rawModel)) {
continue
}
// Only include models that are listed in the static basetenModels
if (!(rawModel.id in basetenModels)) {
console.log(`Skipping model ${rawModel.id} - not in static basetenModels list`)
continue
}
// Check if we have static pricing information for this model
const staticModelInfo = basetenModels[rawModel.id as keyof typeof basetenModels]
const modelInfo: Partial<OpenRouterModelInfo> = {
maxTokens: staticModelInfo?.maxTokens || 8192,
contextWindow: staticModelInfo?.contextWindow || 8192,
supportsImages: staticModelInfo?.supportsImages || false,
supportsPromptCache: staticModelInfo?.supportsPromptCache || false,
inputPrice: staticModelInfo?.inputPrice || 0,
outputPrice: staticModelInfo?.outputPrice || 0,
cacheWritesPrice: staticModelInfo?.cacheWritesPrice || 0,
cacheReadsPrice: staticModelInfo?.cacheReadsPrice || 0,
description: generateModelDescription(rawModel, staticModelInfo),
}
models[rawModel.id] = modelInfo
}
} else {
console.error("Invalid response from Baseten API")
}
await fs.writeFile(basetenModelsFilePath, JSON.stringify(models))
console.log("Baseten models fetched and saved:", Object.keys(models))
}
} catch (error) {
console.error("Error fetching Baseten models:", error)
// Provide more specific error messages
let errorMessage = "Unknown error occurred"
if (axios.isAxiosError(error)) {
if (error.response?.status === 401) {
errorMessage = "Invalid Baseten API key. Please check your API key in settings."
} else if (error.response?.status === 403) {
errorMessage = "Access forbidden. Please verify your Baseten API key has the correct permissions."
} else if (error.response?.status === 429) {
errorMessage = "Rate limit exceeded. Please try again later."
} else if (error.code === "ECONNABORTED") {
errorMessage = "Request timeout. Please check your internet connection."
} else {
errorMessage = `API request failed: ${error.response?.status || error.code || "Unknown error"}`
}
} else if (error instanceof Error) {
errorMessage = error.message
}
console.error("Baseten API Error:", errorMessage)
// If we failed to fetch models, try to read cached models first
const cachedModels = await readBasetenModels(controller)
if (cachedModels && Object.keys(cachedModels).length > 0) {
console.log("Using cached Baseten models")
// Filter cached models to only include those in static basetenModels
for (const [modelId, modelInfo] of Object.entries(cachedModels)) {
if (modelId in basetenModels) {
models[modelId] = modelInfo
}
}
} else {
// Fall back to static models from shared/api.ts
console.log("Using static Baseten models as fallback")
for (const [modelId, modelInfo] of Object.entries(basetenModels)) {
models[modelId] = {
maxTokens: modelInfo.maxTokens,
contextWindow: modelInfo.contextWindow,
supportsImages: modelInfo.supportsImages,
supportsPromptCache: modelInfo.supportsPromptCache,
inputPrice: modelInfo.inputPrice,
outputPrice: modelInfo.outputPrice,
cacheWritesPrice: (modelInfo as any).cacheWritesPrice || 0,
cacheReadsPrice: (modelInfo as any).cacheReadsPrice || 0,
description: (modelInfo as any).description || `${modelId} model`,
}
}
}
}
// Convert the Record<string, Partial<OpenRouterModelInfo>> to Record<string, OpenRouterModelInfo>
// by filling in any missing required fields with defaults
const typedModels: Record<string, OpenRouterModelInfo> = {}
for (const [key, model] of Object.entries(models)) {
typedModels[key] = {
maxTokens: model.maxTokens ?? 8192,
contextWindow: model.contextWindow ?? 8192,
supportsImages: model.supportsImages ?? false,
supportsPromptCache: model.supportsPromptCache ?? false,
inputPrice: model.inputPrice ?? 0,
outputPrice: model.outputPrice ?? 0,
cacheWritesPrice: model.cacheWritesPrice ?? 0,
cacheReadsPrice: model.cacheReadsPrice ?? 0,
description: model.description ?? "",
tiers: model.tiers ?? [],
}
}
return OpenRouterCompatibleModelInfo.create({ models: typedModels })
}
/**
* Ensures the cache directory exists and returns its path
*/
async function ensureCacheDirectoryExists(controller: Controller): Promise<string> {
const cacheDir = path.join(controller.context.globalStorageUri.fsPath, "cache")
await fs.mkdir(cacheDir, { recursive: true })
return cacheDir
}
/**
* Reads cached Baseten models from disk
*/
async function readBasetenModels(controller: Controller): Promise<Record<string, Partial<OpenRouterModelInfo>> | undefined> {
const basetenModelsFilePath = path.join(await ensureCacheDirectoryExists(controller), GlobalFileNames.basetenModels)
const fileExists = await fileExistsAtPath(basetenModelsFilePath)
if (fileExists) {
try {
const fileContents = await fs.readFile(basetenModelsFilePath, "utf8")
return JSON.parse(fileContents)
} catch (error) {
console.error("Error reading cached Baseten models:", error)
return undefined
}
}
return undefined
}
/**
* Validates if a model is suitable for chat completions
*/
function isValidChatModel(rawModel: any): boolean {
// Filter out non-chat models (whisper, TTS, guard models, etc.)
if (rawModel.id.includes("whisper") || rawModel.id.includes("tts") || rawModel.id.includes("embedding")) {
return false
}
// Check if model supports chat completions
if (rawModel.object === "model" && rawModel.id) {
return true
}
return false
}
/**
* Generates a descriptive name for the model
*/
function generateModelDescription(rawModel: any, staticModelInfo?: any): string {
// Use static description if available
if (staticModelInfo?.description) {
return staticModelInfo.description
}
// Generate description based on model characteristics
const modelId = rawModel.id
const ownedBy = rawModel.owned_by || "Unknown"
return `${ownedBy} model: ${modelId}`
}
@@ -8,7 +8,6 @@ import path from "path"
import fs from "fs/promises"
import { fileExistsAtPath } from "@utils/fs"
import { GlobalFileNames } from "@core/storage/disk"
import { telemetryService } from "@/services/posthog/PostHogClientProvider"
/**
* Refreshes the Groq models and returns the updated model list
@@ -112,13 +111,7 @@ export async function refreshGroqModels(controller: Controller, request: EmptyRe
errorMessage = error.message
}
telemetryService.captureProviderApiError({
taskId: controller.task?.taskId || "",
ulid: controller.task?.ulid || "",
errorMessage,
errorStatus: error.status,
model: "groq",
})
console.error("Groq API Error:", errorMessage)
// If we failed to fetch models, try to read cached models first
const cachedModels = await readGroqModels(controller)
@@ -188,7 +181,7 @@ async function readGroqModels(controller: Controller): Promise<Record<string, Pa
*/
function isValidChatModel(rawModel: any): boolean {
// Check if model is active (if the property exists)
if (Object.hasOwn(rawModel, "active") && !rawModel.active) {
if (rawModel.hasOwnProperty("active") && !rawModel.active) {
return false
}
// Filter out non-chat models (whisper, TTS, guard models, etc.)
@@ -49,6 +49,7 @@ export async function refreshOpenRouterModels(
switch (rawModel.id) {
case "anthropic/claude-sonnet-4":
case "anthropic/claude-opus-4":
case "anthropic/claude-3-7-sonnet":
case "anthropic/claude-3-7-sonnet:beta":
case "anthropic/claude-3.7-sonnet":
@@ -61,12 +62,6 @@ export async function refreshOpenRouterModels(
modelInfo.cacheWritesPrice = 3.75
modelInfo.cacheReadsPrice = 0.3
break
case "anthropic/claude-opus-4.1":
case "anthropic/claude-opus-4":
modelInfo.supportsPromptCache = true
modelInfo.cacheWritesPrice = 18.75
modelInfo.cacheReadsPrice = 1.5
break
case "anthropic/claude-3.5-sonnet-20240620":
case "anthropic/claude-3.5-sonnet-20240620:beta":
modelInfo.supportsPromptCache = true
@@ -1,4 +1,4 @@
import { telemetryService } from "@services/posthog/PostHogClientProvider"
import { telemetryService } from "@/services/posthog/telemetry/TelemetryService"
import { Controller } from ".."
import { Empty, StringRequest } from "@shared/proto/cline/common"
import { updateGlobalState } from "@/core/storage/state"
+1 -1
View File
@@ -1,6 +1,6 @@
import { Controller } from ".."
import { Empty, StringRequest } from "@shared/proto/cline/common"
import { telemetryService } from "@services/posthog/PostHogClientProvider"
import { telemetryService } from "@/services/posthog/telemetry/TelemetryService"
/**
* Handles task feedback submission (thumbs up/down)
+9 -10
View File
@@ -1,17 +1,16 @@
import { WebviewProvider } from "@/core/webview"
import { EmptyRequest, String } from "@shared/proto/cline/common"
import type { Controller } from "../index"
import { EmptyRequest, String } from "@shared/proto/cline/common"
import { HostProvider } from "@/hosts/host-provider"
import { WebviewProviderType } from "@/shared/webview/types"
/**
* Returns the HTML content of the webview.
*
* This is only used by the standalone service. The Vscode extension gets the HTML directly from the webview when it
* resolved through `resolveWebviewView()`.
* Initialize webview when it launches
* @param controller The controller instance
* @param request The empty request
* @returns Empty response
*/
export async function getWebviewHtml(_controller: Controller, _: EmptyRequest): Promise<String> {
const webviewProvider = WebviewProvider.getLastActiveInstance()
if (!webviewProvider) {
throw new Error("No active webview")
}
const webviewProvider = HostProvider.get().createWebviewProvider(WebviewProviderType.SIDEBAR)
return Promise.resolve(String.create({ value: webviewProvider.getHtmlContent() }))
}
+3 -43
View File
@@ -4,12 +4,11 @@ import { EmptyRequest, Empty } from "@shared/proto/cline/common"
import { getAllExtensionState, getGlobalState, updateGlobalState } from "../../storage/state"
import { sendOpenRouterModelsEvent } from "../models/subscribeToOpenRouterModels"
import { sendMcpMarketplaceCatalogEvent } from "../mcp/subscribeToMcpMarketplaceCatalog"
import { telemetryService } from "@/services/posthog/PostHogClientProvider"
import { telemetryService } from "@/services/posthog/telemetry/TelemetryService"
import { OpenRouterCompatibleModelInfo } from "@shared/proto/cline/models"
import { McpMarketplaceCatalog } from "@shared/mcp"
import { refreshOpenRouterModels } from "../models/refreshOpenRouterModels"
import { refreshGroqModels } from "../models/refreshGroqModels"
import { refreshBasetenModels } from "../models/refreshBasetenModels"
/**
* Initialize webview when it launches
@@ -55,7 +54,7 @@ export async function initializeWebview(controller: Controller, request: EmptyRe
// Shared models: update both plan and act modes
const planModelId = apiConfiguration.planModeOpenRouterModelId
const actModelId = apiConfiguration.actModeOpenRouterModelId
const updatedConfig = { ...apiConfiguration }
let updatedConfig = { ...apiConfiguration }
// Update plan mode model info if we have a model ID
if (planModelId && response.models[planModelId]) {
@@ -101,7 +100,7 @@ export async function initializeWebview(controller: Controller, request: EmptyRe
// Shared models: update both plan and act modes
const planModelId = apiConfiguration.planModeGroqModelId
const actModelId = apiConfiguration.actModeGroqModelId
const updatedConfig = { ...apiConfiguration }
let updatedConfig = { ...apiConfiguration }
// Update plan mode model info if we have a model ID
if (planModelId && response.models[planModelId]) {
@@ -122,45 +121,6 @@ export async function initializeWebview(controller: Controller, request: EmptyRe
}
})
refreshBasetenModels(controller, EmptyRequest.create()).then(async (response) => {
if (response && response.models) {
// Update model info in state for Baseten (this needs to be done here since we don't want to update state while settings is open, and we may refresh models there)
const { apiConfiguration, planActSeparateModelsSetting } = await getAllExtensionState(controller.context)
const currentMode = await controller.getCurrentMode()
if (planActSeparateModelsSetting) {
// Separate models: update only current mode
const modelIdField = currentMode === "plan" ? "planModeBasetenModelId" : "actModeBasetenModelId"
const modelInfoField = currentMode === "plan" ? "planModeBasetenModelInfo" : "actModeBasetenModelInfo"
const modelId = apiConfiguration[modelIdField]
if (modelId && response.models[modelId]) {
await updateGlobalState(controller.context, modelInfoField, response.models[modelId])
await controller.postStateToWebview()
}
} else {
// Shared models: update both plan and act modes
const planModelId = apiConfiguration.planModeBasetenModelId
const actModelId = apiConfiguration.actModeBasetenModelId
// Update plan mode model info if we have a model ID
if (planModelId && response.models[planModelId]) {
await updateGlobalState(controller.context, "planModeBasetenModelInfo", response.models[planModelId])
}
// Update act mode model info if we have a model ID
if (actModelId && response.models[actModelId]) {
await updateGlobalState(controller.context, "actModeBasetenModelInfo", response.models[actModelId])
}
// Post state update if we updated any model info
if ((planModelId && response.models[planModelId]) || (actModelId && response.models[actModelId])) {
await controller.postStateToWebview()
}
}
}
})
// GUI relies on model info to be up-to-date to provide the most accurate pricing, so we need to fetch the latest details on launch.
// We do this for all users since many users switch between api providers and if they were to switch back to openrouter it would be showing outdated model info if we hadn't retrieved the latest at this point
// (see normalizeApiConfiguration > openrouter)
+1 -1
View File
@@ -2,7 +2,7 @@ import * as vscode from "vscode"
import type { Controller } from "../index"
import type { EmptyRequest } from "@shared/proto/cline/common"
import { Empty } from "@shared/proto/cline/common"
import { telemetryService } from "@services/posthog/PostHogClientProvider"
import { telemetryService } from "../../../services/posthog/telemetry/TelemetryService"
/**
* Opens the Cline walkthrough in VSCode
@@ -1,42 +1,33 @@
import type { String as ProtoString, StringRequest } from "@shared/proto/cline/common"
import { getRequestRegistry, type StreamingResponseHandler } from "../grpc-handler"
import type { Controller } from "../index"
import { Controller } from "../index"
import { EmptyRequest } from "@shared/proto/cline/common"
import { String as ProtoString } from "@shared/proto/cline/common"
import { StreamingResponseHandler, getRequestRegistry } from "../grpc-handler"
// Keep track of active addToInput subscriptions
const activeAddToInputSubscriptions = new Set<StreamingResponseHandler<ProtoString>>()
// Map client IDs to their subscription handlers for targeted sending
const addToInputSubscriptions = new Map<string, StreamingResponseHandler<ProtoString>>()
/**
* Subscribe to addToInput events
* @param controller The controller instance
* @param request The request containing the client ID
* @param request The empty request
* @param responseStream The streaming response handler
* @param requestId The ID of the request (passed by the gRPC handler)
*/
export async function subscribeToAddToInput(
_controller: Controller,
request: StringRequest,
_request: EmptyRequest,
responseStream: StreamingResponseHandler<ProtoString>,
requestId?: string,
): Promise<void> {
const clientId = request.value
if (!clientId) {
throw new Error("Client ID is required for addToInput subscription")
}
console.log("[DEBUG] set up addToInput subscription")
console.log("[DEBUG] set up addToInput subscription for client:", clientId)
// Add this subscription to both the general set and the client-specific map
// Add this subscription to the active subscriptions
activeAddToInputSubscriptions.add(responseStream)
addToInputSubscriptions.set(clientId, responseStream)
// Register cleanup when the connection is closed
const cleanup = () => {
activeAddToInputSubscriptions.delete(responseStream)
addToInputSubscriptions.delete(clientId)
console.log("[DEBUG] Cleaned up addToInput subscription for client:", clientId)
console.log("[DEBUG] Cleaned up addToInput subscription")
}
// Register the cleanup function with the request registry if we have a requestId
@@ -70,33 +61,3 @@ export async function sendAddToInputEvent(text: string): Promise<void> {
await Promise.all(promises)
}
/**
* Send an addToInput event to a specific webview by client ID
* @param clientId The ID of the client to send the event to
* @param text The text to add to the input
*/
export async function sendAddToInputEventToClient(clientId: string, text: string): Promise<void> {
const responseStream = addToInputSubscriptions.get(clientId)
if (!responseStream) {
console.warn(`No addToInput subscription found for client ID: ${clientId}`)
return
}
try {
const event: ProtoString = {
value: text,
}
await responseStream(
event,
false, // Not the last message
)
console.log("[DEBUG] sending addToInput event to client", clientId, ":", text.length, "chars")
} catch (error) {
console.error(`Error sending addToInput event to client ${clientId}:`, error)
// Remove the subscription if there was an error
addToInputSubscriptions.delete(clientId)
// Also remove from the general set
activeAddToInputSubscriptions.delete(responseStream)
}
}
@@ -0,0 +1,28 @@
import { RecordingStatus } from "@shared/proto/cline/voice"
import { GetRecordingStatusRequest } from "@shared/proto/cline/voice"
import { Controller } from ".."
/**
* Gets the current recording status
* @param controller The controller instance
* @param request The request (unused but required for consistency)
* @returns RecordingStatus with current status
*/
export async function getRecordingStatus(controller: Controller, request: GetRecordingStatusRequest): Promise<RecordingStatus> {
try {
// TODO: Implement actual audio recording service
// For now, return a default status
return RecordingStatus.create({
isRecording: false,
durationSeconds: 0,
error: "",
})
} catch (error) {
console.error("Error getting recording status:", error)
return RecordingStatus.create({
isRecording: false,
durationSeconds: 0,
error: error instanceof Error ? error.message : "Unknown error occurred",
})
}
}
@@ -0,0 +1,47 @@
import { Controller } from ".."
import { StartRecordingRequest, RecordingResult } from "@shared/proto/cline/voice"
import { audioRecordingService } from "@/services/dictation/AudioRecordingService"
import { telemetryService } from "@services/posthog/telemetry/TelemetryService"
import { HostProvider } from "@/hosts/host-provider"
import { ShowMessageType } from "@/shared/proto/host/window"
import { AuthService } from "@/services/auth/AuthService"
/**
* Starts audio recording using the Extension Host
* @param controller The controller instance
* @param request StartRecordingRequest
* @returns RecordingResult with success status
*/
export const startRecording = async (controller: Controller, _request: StartRecordingRequest): Promise<RecordingResult> => {
const taskId = controller.task?.taskId
try {
const userInfo = AuthService.getInstance().getInfo()
if (!userInfo?.user?.uid) {
throw new Error("User is not authenticated. Please log in first.")
}
const result = await audioRecordingService.startRecording()
// Capture telemetry for recording start
if (result.success) {
telemetryService.captureVoiceRecordingStarted(taskId, process.platform)
}
return RecordingResult.create({
success: result.success,
error: result.error || "",
})
} catch (error) {
console.error("Error starting recording:", error)
const errorMessage = error instanceof Error ? error.message : "Unknown error occurred"
HostProvider.window.showMessage({
type: ShowMessageType.ERROR,
message: `Voice recording error: ${errorMessage}`,
})
return RecordingResult.create({
success: false,
error: errorMessage,
})
}
}
@@ -0,0 +1,42 @@
import { Controller } from ".."
import { StopRecordingRequest, RecordedAudio } from "@shared/proto/cline/voice"
import { audioRecordingService } from "@/services/dictation/AudioRecordingService"
import { telemetryService } from "@services/posthog/telemetry/TelemetryService"
/**
* Stops audio recording and returns the recorded audio
* @param controller The controller instance
* @param request StopRecordingRequest
* @returns RecordedAudio with audio data
*/
export const stopRecording = async (controller: Controller, _request: StopRecordingRequest): Promise<RecordedAudio> => {
const taskId = controller.task?.taskId
const recordingStatus = audioRecordingService.getRecordingStatus()
const recordingDuration = recordingStatus.durationSeconds * 1000 // Convert to milliseconds
try {
const result = await audioRecordingService.stopRecording()
// Capture telemetry for recording stop
telemetryService.captureVoiceRecordingStopped(taskId, recordingDuration, result.success, process.platform)
// Calculate audio size if available
return RecordedAudio.create({
success: result.success,
audioBase64: result.audioBase64 || "",
error: result.error || "",
})
} catch (error) {
console.error("Error stopping recording:", error)
// Capture telemetry for recording failure
telemetryService.captureVoiceRecordingStopped(taskId, recordingDuration, false, process.platform)
return RecordedAudio.create({
success: false,
audioBase64: "",
error: error instanceof Error ? error.message : "Unknown error occurred",
})
}
}
@@ -0,0 +1,87 @@
import { Controller } from ".."
import { TranscribeAudioRequest, Transcription } from "@shared/proto/cline/voice"
import { voiceTranscriptionService } from "@/services/dictation/VoiceTranscriptionService"
import { telemetryService } from "@services/posthog/telemetry/TelemetryService"
import { HostProvider } from "@/hosts/host-provider"
import { ShowMessageType } from "@/shared/proto/host/window"
/**
* Transcribes audio using Cline transcription service
* @param controller The controller instance
* @param request TranscribeAudioRequest containing base64 audio data
* @returns Transcription with transcribed text or error
*/
export const transcribeAudio = async (controller: Controller, request: TranscribeAudioRequest): Promise<Transcription> => {
const taskId = controller.task?.taskId
const startTime = Date.now()
// Calculate audio size from base64
const audioSizeBytes = Math.ceil((request.audioBase64.length * 3) / 4)
// Capture telemetry for transcription start
telemetryService.captureVoiceTranscriptionStarted(taskId, audioSizeBytes, request.language || "en")
try {
// Transcribe the audio
const result = await voiceTranscriptionService.transcribeAudio(request.audioBase64, request.language || undefined)
const durationMs = Date.now() - startTime
// Handle transcription result
if (result.error) {
// Determine error type for telemetry
let errorType = "api_error"
if (result.error.includes("Authentication failed")) {
errorType = "invalid_jwt_token"
} else if (result.error.includes("Insufficient credits")) {
errorType = "insufficient_credits"
} else if (result.error.includes("Invalid audio format")) {
errorType = "invalid_audio_format"
} else if (result.error.includes("Cannot connect")) {
errorType = "connection_error"
} else if (result.error.includes("Network error")) {
errorType = "network_error"
}
// Capture telemetry for transcription error
telemetryService.captureVoiceTranscriptionError(taskId, errorType, result.error, durationMs)
let errorMessage = ""
// Show error notification if transcription failed
if (result.error.includes("Authentication failed")) {
errorMessage = "Authentication failed. Please log in again."
} else if (result.error.includes("Insufficient credits")) {
errorMessage = "Insufficient credits for transcription service."
} else if (result.error.includes("Cannot connect")) {
errorMessage = "Cannot connect to transcription service."
} else {
errorMessage = `Voice transcription failed: ${result.error}`
}
HostProvider.window.showMessage({
type: ShowMessageType.ERROR,
message: errorMessage,
})
} else if (result.text) {
// Capture telemetry for successful transcription
telemetryService.captureVoiceTranscriptionCompleted(taskId, result.text.length, durationMs, request.language || "en")
}
// Return the response
return Transcription.create({
text: result.text || "",
error: result.error || "",
})
} catch (error) {
console.error("Error transcribing audio:", error)
const durationMs = Date.now() - startTime
const errorMessage = error instanceof Error ? error.message : "Unknown error occurred"
// Capture telemetry for unexpected error
telemetryService.captureVoiceTranscriptionError(taskId, "unexpected_error", errorMessage, durationMs)
return Transcription.create({
text: "",
error: errorMessage,
})
}
}
+17 -11
View File
@@ -1,16 +1,16 @@
import { HostProvider } from "@/hosts/host-provider"
import { setVscodeHostProviderMock } from "@/test/host-provider-test-utils"
import { expect } from "chai"
import * as sinon from "sinon"
import * as path from "path"
import { parseMentions } from "../index"
import { UrlContentFetcher } from "@services/browser/UrlContentFetcher"
import { FileContextTracker } from "@core/context/context-tracking/FileContextTracker"
import * as extractTextModule from "@integrations/misc/extract-text"
import * as terminalModule from "@integrations/terminal/get-latest-output"
import { UrlContentFetcher } from "@services/browser/UrlContentFetcher"
import * as gitModule from "@utils/git"
import { expect } from "chai"
import * as fs from "fs"
import * as isBinaryFileModule from "isbinaryfile"
import * as path from "path"
import * as sinon from "sinon"
import { parseMentions } from "../index"
import * as terminalModule from "@integrations/terminal/get-latest-output"
import * as gitModule from "@utils/git"
import { DiffViewProviderCreator, HostProvider, WebviewProviderCreator } from "@/hosts/host-provider"
import * as fs from "fs"
import { vscodeHostBridgeClient } from "@/hosts/vscode/hostbridge/client/host-grpc-client"
describe("parseMentions", () => {
let sandbox: sinon.SinonSandbox
@@ -29,7 +29,13 @@ describe("parseMentions", () => {
beforeEach(() => {
sandbox = sinon.createSandbox()
setVscodeHostProviderMock()
HostProvider.reset()
HostProvider.initialize(
((_) => {}) as WebviewProviderCreator,
(() => {}) as DiffViewProviderCreator,
vscodeHostBridgeClient,
(_) => {},
)
// Create stubs for dependencies
urlContentFetcherStub = {
launchBrowser: sandbox.stub().resolves(),
+7 -2
View File
@@ -6,7 +6,7 @@ import { mentionRegexGlobal } from "@shared/context-mentions"
import fs from "fs/promises"
import { extractTextFromFile } from "@integrations/misc/extract-text"
import { isBinaryFile } from "isbinaryfile"
import { getWorkspaceProblemsString } from "@/integrations/diagnostics"
import { diagnosticsToProblemsString } from "@integrations/diagnostics"
import { getLatestTerminalOutput } from "@integrations/terminal/get-latest-output"
import { getCommitInfo } from "@utils/git"
import { getWorkingState } from "@utils/git"
@@ -225,7 +225,12 @@ async function getFileOrFolderContent(mentionPath: string, cwd: string): Promise
}
async function getWorkspaceProblems(): Promise<string> {
return await getWorkspaceProblemsString()
const diagnostics = vscode.languages.getDiagnostics()
const result = diagnosticsToProblemsString(diagnostics, [vscode.DiagnosticSeverity.Error, vscode.DiagnosticSeverity.Warning])
if (!result) {
return "No errors or warnings detected."
}
return result
}
function isFileMention(mention: string): boolean {
+3 -5
View File
@@ -268,15 +268,14 @@ Usage:
</new_task>
## plan_mode_respond
Description: Respond to the user's inquiry in an effort to plan a solution to the user's task. This tool should ONLY be used when you have already explored the relevant files and are ready to present a concrete plan. DO NOT use this tool to announce what files you're going to read - just read them first. This tool is only available in PLAN MODE. The environment_details will specify the current mode; if it is not PLAN_MODE then you should not use this tool.
However, if while writing your response you realize you actually need to do more exploration before providing a complete plan, you can add the optional needs_more_exploration parameter to indicate this. This allows you to acknowledge that you should have done more exploration first, and signals that your next message will use exploration tools instead.
Description: Respond to the user's inquiry in an effort to plan a solution to the user's task. This tool should ONLY be used when you have already explored the relevant files and are ready to present a concrete plan. DO NOT use this tool to announce what files you're going to read - just read them first. This tool is only available in PLAN MODE. The environment_details will specify the current mode, if it is not PLAN_MODE then you should not use this tool. For example, if the user's task is to create a website, you may start by asking some clarifying questions with the ask_followup_question tool if their message was vague, explore the codebase, read files, then present a detailed plan for how you will accomplish the task given the context, and perhaps engage in a back and forth to finalize the details before the user switches you to ACT_MODE to implement the solution.
CRITICAL: You must complete your information gathering (reading files, exploring the codebase) BEFORE using this tool. The user expects to see a well thought-out plan based on actual analysis, not intentions.
Parameters:
- response: (required) The response to provide to the user. Do not try to use tools in this parameter, this is simply a chat response. (You MUST use the response parameter, do not simply place the response text directly within <plan_mode_respond> tags.)
- needs_more_exploration: (optional) Set to true if while formulating your response that you found you need to do more exploration with tools, for example reading files. (Remember, you can explore the project with tools like read_file in PLAN MODE without the user having to toggle to ACT MODE.) Defaults to false if not specified.
Usage:
<plan_mode_respond>
<response>Your response here</response>
<needs_more_exploration>true or false (optional, but you MUST set to true if in <response> you need to read files or use other exploration tools)</needs_more_exploration>
</plan_mode_respond>
## load_mcp_documentation
@@ -620,7 +619,6 @@ RULES
- Be sure to consider the type of project (e.g. Python, JavaScript, web application) when determining the appropriate structure and files to include. Also consider what files may be most relevant to accomplishing the task, for example looking at a project's manifest file would help you understand the project's dependencies, which you could incorporate into any code you write.
- When making changes to code, always consider the context in which the code is being used. Ensure that your changes are compatible with the existing codebase and that they follow the project's coding standards and best practices.
- When you want to modify a file, use the replace_in_file or write_to_file tool directly with the desired changes. You do not need to display the changes before using the tool.
- Use Markdown **only where semantically correct** (e.g., \`inline code\`, \`\`\`code fences\`\`\`, lists, tables). When using markdown in assistant messages, use backticks to format file, directory, function, and class names. Use \( and \) for inline math, \[ and \] for block math.
- Do not ask for more information than necessary. Use the tools provided to accomplish the user's request efficiently and effectively. When you've completed your task, you must use the attempt_completion tool to present the result to the user. The user may provide feedback, which you can use to make improvements and try again.
- You are only allowed to ask the user questions using the ask_followup_question tool. Use this tool only when you need additional details to complete a task, and be sure to use a clear and concise question that will help you move forward with the task. However if you can use the available tools to avoid having to ask the user questions, you should do so. For example, if the user mentions a file that may be in an outside directory like the Desktop, you should use the list_files tool to list the files in the Desktop and check if the file they are talking about is there, rather than asking the user to provide the file path themselves.
- When the user is being vague, you should be proactive about asking clarifying questions using the ask_followup_question tool to ensure you understand their request. However, if you can infer the user's intent based on the context and available tools, you should proceed without asking unnecessary questions
+4 -7
View File
@@ -263,15 +263,12 @@ Usage:
</new_task>
## plan_mode_respond
Description: Respond to the user's inquiry in an effort to plan a solution to the user's task. This tool should ONLY be used when you have already explored the relevant files and are ready to present a concrete plan. DO NOT use this tool to announce what files you're going to read - just read them first. This tool is only available in PLAN MODE. The environment_details will specify the current mode; if it is not PLAN_MODE then you should not use this tool.
However, if while writing your response you realize you actually need to do more exploration before providing a complete plan, you can add the optional needs_more_exploration parameter to indicate this. This allows you to acknowledge that you should have done more exploration first, and signals that your next message will use exploration tools instead.
Description: Respond to the user's inquiry in an effort to plan a solution to the user's task. This tool should be used when you need to provide a response to a question or statement from the user about how you plan to accomplish the task. This tool is only available in PLAN MODE. The environment_details will specify the current mode, if it is not PLAN MODE then you should not use this tool. Depending on the user's message, you may ask questions to get clarification about the user's request, architect a solution to the task, and to brainstorm ideas with the user. For example, if the user's task is to create a website, you may start by asking some clarifying questions, then present a detailed plan for how you will accomplish the task given the context, and perhaps engage in a back and forth to finalize the details before the user switches you to ACT MODE to implement the solution.
Parameters:
- response: (required) The response to provide to the user. Do not try to use tools in this parameter, this is simply a chat response. (You MUST use the response parameter, do not simply place the response text directly within <plan_mode_respond> tags.)
- needs_more_exploration: (optional) Set to true if while formulating your response that you found you need to do more exploration with tools, for example reading files. (Remember, you can explore the project with tools like read_file in PLAN MODE without the user having to toggle to ACT MODE.) Defaults to false if not specified.
Usage:
<plan_mode_respond>
<response>Your response here</response>
<needs_more_exploration>true or false (optional, but you MUST set to true if in <response> you need to read files or use other exploration tools)</needs_more_exploration>
</plan_mode_respond>
## load_mcp_documentation
@@ -556,7 +553,7 @@ You have access to two tools for working with files: **write_to_file** and **rep
By thoughtfully selecting between write_to_file and replace_in_file, you can make your file editing process smoother, safer, and more efficient.
====
ACT MODE V.S. PLAN MODE
In each user message, the environment_details will specify the current mode. There are two modes:
@@ -570,8 +567,8 @@ In each user message, the environment_details will specify the current mode. The
## What is PLAN MODE?
- While you are usually in ACT MODE, the user may switch to PLAN MODE in order to have a back and forth with you to plan how to best accomplish the task.
- When starting in PLAN MODE, depending on the user's request, you may need to do some information gathering e.g. using read_file or search_files to get more context about the task. You may also ask the user clarifying questions with ask_followup_question to get a better understanding of the task.
- Once you've gained more context about the user's request, you should architect a detailed plan for how you will accomplish the task. Present the plan to the user using the plan_mode_respond tool.
- When starting in PLAN MODE, depending on the user's request, you may need to do some information gathering e.g. using read_file or search_files to get more context about the task. You may also ask the user clarifying questions to get a better understanding of the task.
- Once you've gained more context about the user's request, you should architect a detailed plan for how you will accomplish the task.
- Then you might ask the user if they are pleased with this plan, or if they would like to make any changes. Think of this as a brainstorming session where you can discuss the task and plan the best way to accomplish it.
- Finally once it seems like you've reached a good plan, ask the user to switch you back to ACT MODE to implement the solution.
-30
View File
@@ -200,7 +200,6 @@ export class CacheService {
openAiApiKey,
openAiHeaders,
ollamaBaseUrl,
ollamaApiKey,
ollamaApiOptionsCtxNum,
lmStudioBaseUrl,
anthropicBaseUrl,
@@ -239,7 +238,6 @@ export class CacheService {
sapAiCoreTokenUrl,
sapAiResourceGroup,
claudeCodePath,
basetenApiKey,
huggingFaceApiKey,
requestTimeoutMs,
// Plan mode configurations
@@ -265,8 +263,6 @@ export class CacheService {
planModeSapAiCoreModelId,
planModeGroqModelId,
planModeGroqModelInfo,
planModeBasetenModelId,
planModeBasetenModelInfo,
planModeHuggingFaceModelId,
planModeHuggingFaceModelInfo,
// Act mode configurations
@@ -292,8 +288,6 @@ export class CacheService {
actModeSapAiCoreModelId,
actModeGroqModelId,
actModeGroqModelInfo,
actModeBasetenModelId,
actModeBasetenModelInfo,
actModeHuggingFaceModelId,
actModeHuggingFaceModelInfo,
} = apiConfiguration
@@ -323,8 +317,6 @@ export class CacheService {
planModeSapAiCoreModelId,
planModeGroqModelId,
planModeGroqModelInfo,
planModeBasetenModelId,
planModeBasetenModelInfo,
planModeHuggingFaceModelId,
planModeHuggingFaceModelInfo,
@@ -351,8 +343,6 @@ export class CacheService {
actModeSapAiCoreModelId,
actModeGroqModelId,
actModeGroqModelInfo,
actModeBasetenModelId,
actModeBasetenModelInfo,
actModeHuggingFaceModelId,
actModeHuggingFaceModelInfo,
@@ -400,7 +390,6 @@ export class CacheService {
awsSessionToken,
awsBedrockApiKey,
openAiApiKey,
ollamaApiKey,
geminiApiKey,
openAiNativeApiKey,
deepSeekApiKey,
@@ -420,7 +409,6 @@ export class CacheService {
nebiusApiKey,
sapAiCoreClientId,
sapAiCoreClientSecret,
basetenApiKey,
huggingFaceApiKey,
})
}
@@ -600,7 +588,6 @@ export class CacheService {
openAiApiKey,
openAiHeaders,
ollamaBaseUrl,
ollamaApiKey,
ollamaApiOptionsCtxNum,
lmStudioBaseUrl,
anthropicBaseUrl,
@@ -627,7 +614,6 @@ export class CacheService {
sambanovaApiKey,
cerebrasApiKey,
groqApiKey,
basetenApiKey,
moonshotApiKey,
nebiusApiKey,
favoritedModelIds,
@@ -665,8 +651,6 @@ export class CacheService {
planModeSapAiCoreModelId,
planModeGroqModelId,
planModeGroqModelInfo,
planModeBasetenModelId,
planModeBasetenModelInfo,
planModeHuggingFaceModelId,
planModeHuggingFaceModelInfo,
// Act mode configurations
@@ -692,8 +676,6 @@ export class CacheService {
actModeSapAiCoreModelId,
actModeGroqModelId,
actModeGroqModelInfo,
actModeBasetenModelId,
actModeBasetenModelInfo,
actModeHuggingFaceModelId,
actModeHuggingFaceModelInfo,
} = apiConfiguration
@@ -723,8 +705,6 @@ export class CacheService {
planModeSapAiCoreModelId,
planModeGroqModelId,
planModeGroqModelInfo,
planModeBasetenModelId,
planModeBasetenModelInfo,
planModeHuggingFaceModelId,
planModeHuggingFaceModelInfo,
@@ -751,8 +731,6 @@ export class CacheService {
actModeSapAiCoreModelId,
actModeGroqModelId,
actModeGroqModelInfo,
actModeBasetenModelId,
actModeBasetenModelInfo,
actModeHuggingFaceModelId,
actModeHuggingFaceModelInfo,
@@ -805,7 +783,6 @@ export class CacheService {
awsSessionToken,
awsBedrockApiKey,
openAiApiKey,
ollamaApiKey,
geminiApiKey,
openAiNativeApiKey,
deepSeekApiKey,
@@ -821,7 +798,6 @@ export class CacheService {
sambanovaApiKey,
cerebrasApiKey,
groqApiKey,
basetenApiKey,
moonshotApiKey,
nebiusApiKey,
sapAiCoreClientId,
@@ -849,7 +825,6 @@ export class CacheService {
awsSessionToken: this.secretsCache.get("awsSessionToken"),
awsBedrockApiKey: this.secretsCache.get("awsBedrockApiKey"),
openAiApiKey: this.secretsCache.get("openAiApiKey"),
ollamaApiKey: this.secretsCache.get("ollamaApiKey"),
geminiApiKey: this.secretsCache.get("geminiApiKey"),
openAiNativeApiKey: this.secretsCache.get("openAiNativeApiKey"),
deepSeekApiKey: this.secretsCache.get("deepSeekApiKey"),
@@ -865,7 +840,6 @@ export class CacheService {
sambanovaApiKey: this.secretsCache.get("sambanovaApiKey"),
cerebrasApiKey: this.secretsCache.get("cerebrasApiKey"),
groqApiKey: this.secretsCache.get("groqApiKey"),
basetenApiKey: this.secretsCache.get("basetenApiKey"),
moonshotApiKey: this.secretsCache.get("moonshotApiKey"),
nebiusApiKey: this.secretsCache.get("nebiusApiKey"),
sapAiCoreClientId: this.secretsCache.get("sapAiCoreClientId"),
@@ -928,8 +902,6 @@ export class CacheService {
planModeSapAiCoreModelId: this.globalStateCache.get("planModeSapAiCoreModelId"),
planModeGroqModelId: this.globalStateCache.get("planModeGroqModelId"),
planModeGroqModelInfo: this.globalStateCache.get("planModeGroqModelInfo"),
planModeBasetenModelId: this.globalStateCache.get("planModeBasetenModelId"),
planModeBasetenModelInfo: this.globalStateCache.get("planModeBasetenModelInfo"),
planModeHuggingFaceModelId: this.globalStateCache.get("planModeHuggingFaceModelId"),
planModeHuggingFaceModelInfo: this.globalStateCache.get("planModeHuggingFaceModelInfo"),
@@ -956,8 +928,6 @@ export class CacheService {
actModeSapAiCoreModelId: this.globalStateCache.get("actModeSapAiCoreModelId"),
actModeGroqModelId: this.globalStateCache.get("actModeGroqModelId"),
actModeGroqModelInfo: this.globalStateCache.get("actModeGroqModelInfo"),
actModeBasetenModelId: this.globalStateCache.get("actModeBasetenModelId"),
actModeBasetenModelInfo: this.globalStateCache.get("actModeBasetenModelInfo"),
actModeHuggingFaceModelId: this.globalStateCache.get("actModeHuggingFaceModelId"),
actModeHuggingFaceModelInfo: this.globalStateCache.get("actModeHuggingFaceModelInfo"),
} as ApiConfiguration
-1
View File
@@ -14,7 +14,6 @@ export const GlobalFileNames = {
uiMessages: "ui_messages.json",
openRouterModels: "openrouter_models.json",
groqModels: "groq_models.json",
basetenModels: "baseten_models.json",
mcpSettings: "cline_mcp_settings.json",
clineRules: ".clinerules",
workflows: ".clinerules/workflows",
+1 -6
View File
@@ -7,7 +7,6 @@ export type SecretKey =
| "awsSessionToken"
| "awsBedrockApiKey"
| "openAiApiKey"
| "ollamaApiKey"
| "geminiApiKey"
| "openAiNativeApiKey"
| "deepSeekApiKey"
@@ -30,7 +29,6 @@ export type SecretKey =
| "sapAiCoreClientSecret"
| "groqApiKey"
| "huaweiCloudMaasApiKey"
| "basetenApiKey"
export type GlobalStateKey =
| "awsRegion"
@@ -89,6 +87,7 @@ export type GlobalStateKey =
// Settings around plan/act and ephemeral model configuration
| "preferredLanguage"
| "openaiReasoningEffort"
| "dictationSettings"
| "mode"
// Plan mode configurations
| "planModeApiProvider"
@@ -113,8 +112,6 @@ export type GlobalStateKey =
| "planModeSapAiCoreModelId"
| "planModeGroqModelId"
| "planModeGroqModelInfo"
| "planModeBasetenModelId"
| "planModeBasetenModelInfo"
| "planModeHuggingFaceModelId"
| "planModeHuggingFaceModelInfo"
| "planModeHuaweiCloudMaasModelId"
@@ -142,8 +139,6 @@ export type GlobalStateKey =
| "actModeSapAiCoreModelId"
| "actModeGroqModelId"
| "actModeGroqModelInfo"
| "actModeBasetenModelId"
| "actModeBasetenModelInfo"
| "actModeHuggingFaceModelId"
| "actModeHuggingFaceModelInfo"
| "actModeHuaweiCloudMaasModelId"
-1
View File
@@ -524,7 +524,6 @@ export async function migrateWelcomeViewCompleted(context: vscode.ExtensionConte
config.awsRegion,
config.vertexProjectId,
config.openAiApiKey,
config.ollamaApiKey,
config.planModeOllamaModelId,
config.planModeLmStudioModelId,
config.actModeOllamaModelId,
+5 -20
View File
@@ -2,11 +2,13 @@ import * as vscode from "vscode"
import { Mode, OpenaiReasoningEffort } from "@shared/storage/types"
import { DEFAULT_BROWSER_SETTINGS } from "@shared/BrowserSettings"
import { DEFAULT_AUTO_APPROVAL_SETTINGS } from "@shared/AutoApprovalSettings"
import { DEFAULT_DICTATION_SETTINGS } from "@shared/DictationSettings"
import { GlobalStateKey, LocalStateKey, SecretKey } from "./state-keys"
import { ApiConfiguration, ApiProvider, BedrockModelId, ModelInfo } from "@shared/api"
import { HistoryItem } from "@shared/HistoryItem"
import { AutoApprovalSettings } from "@shared/AutoApprovalSettings"
import { BrowserSettings } from "@shared/BrowserSettings"
import { DictationSettings } from "@shared/DictationSettings"
import { TelemetrySetting } from "@shared/TelemetrySetting"
import { UserInfo } from "@shared/UserInfo"
import { ClineRulesToggles } from "@shared/cline-rules"
@@ -133,7 +135,6 @@ export async function getAllExtensionState(context: vscode.ExtensionContext) {
openAiApiKey,
openAiHeaders,
ollamaBaseUrl,
ollamaApiKey,
ollamaApiOptionsCtxNum,
lmStudioBaseUrl,
anthropicBaseUrl,
@@ -168,7 +169,6 @@ export async function getAllExtensionState(context: vscode.ExtensionContext) {
sambanovaApiKey,
cerebrasApiKey,
groqApiKey,
basetenApiKey,
moonshotApiKey,
nebiusApiKey,
huggingFaceApiKey,
@@ -215,7 +215,6 @@ export async function getAllExtensionState(context: vscode.ExtensionContext) {
getSecret(context, "openAiApiKey") as Promise<string | undefined>,
getGlobalState(context, "openAiHeaders") as Promise<Record<string, string> | undefined>,
getGlobalState(context, "ollamaBaseUrl") as Promise<string | undefined>,
getSecret(context, "ollamaApiKey") as Promise<string | undefined>,
getGlobalState(context, "ollamaApiOptionsCtxNum") as Promise<string | undefined>,
getGlobalState(context, "lmStudioBaseUrl") as Promise<string | undefined>,
getGlobalState(context, "anthropicBaseUrl") as Promise<string | undefined>,
@@ -250,7 +249,6 @@ export async function getAllExtensionState(context: vscode.ExtensionContext) {
getSecret(context, "sambanovaApiKey") as Promise<string | undefined>,
getSecret(context, "cerebrasApiKey") as Promise<string | undefined>,
getSecret(context, "groqApiKey") as Promise<string | undefined>,
getSecret(context, "basetenApiKey") as Promise<string | undefined>,
getSecret(context, "moonshotApiKey") as Promise<string | undefined>,
getSecret(context, "nebiusApiKey") as Promise<string | undefined>,
getSecret(context, "huggingFaceApiKey") as Promise<string | undefined>,
@@ -311,8 +309,6 @@ export async function getAllExtensionState(context: vscode.ExtensionContext) {
planModeSapAiCoreModelId,
planModeGroqModelId,
planModeGroqModelInfo,
planModeBasetenModelId,
planModeBasetenModelInfo,
planModeHuggingFaceModelId,
planModeHuggingFaceModelInfo,
planModeHuaweiCloudMaasModelId,
@@ -340,12 +336,11 @@ export async function getAllExtensionState(context: vscode.ExtensionContext) {
actModeSapAiCoreModelId,
actModeGroqModelId,
actModeGroqModelInfo,
actModeBasetenModelId,
actModeBasetenModelInfo,
actModeHuggingFaceModelId,
actModeHuggingFaceModelInfo,
actModeHuaweiCloudMaasModelId,
actModeHuaweiCloudMaasModelInfo,
dictationSettings,
] = await Promise.all([
getGlobalState(context, "preferredLanguage") as Promise<string | undefined>,
getGlobalState(context, "openaiReasoningEffort") as Promise<OpenaiReasoningEffort | undefined>,
@@ -374,8 +369,6 @@ export async function getAllExtensionState(context: vscode.ExtensionContext) {
getGlobalState(context, "planModeSapAiCoreModelId") as Promise<string | undefined>,
getGlobalState(context, "planModeGroqModelId") as Promise<string | undefined>,
getGlobalState(context, "planModeGroqModelInfo") as Promise<ModelInfo | undefined>,
getGlobalState(context, "planModeBasetenModelId") as Promise<string | undefined>,
getGlobalState(context, "planModeBasetenModelInfo") as Promise<ModelInfo | undefined>,
getGlobalState(context, "planModeHuggingFaceModelId") as Promise<string | undefined>,
getGlobalState(context, "planModeHuggingFaceModelInfo") as Promise<ModelInfo | undefined>,
getGlobalState(context, "planModeHuaweiCloudMaasModelId") as Promise<string | undefined>,
@@ -403,12 +396,11 @@ export async function getAllExtensionState(context: vscode.ExtensionContext) {
getGlobalState(context, "actModeSapAiCoreModelId") as Promise<string | undefined>,
getGlobalState(context, "actModeGroqModelId") as Promise<string | undefined>,
getGlobalState(context, "actModeGroqModelInfo") as Promise<ModelInfo | undefined>,
getGlobalState(context, "actModeBasetenModelId") as Promise<string | undefined>,
getGlobalState(context, "actModeBasetenModelInfo") as Promise<ModelInfo | undefined>,
getGlobalState(context, "actModeHuggingFaceModelId") as Promise<string | undefined>,
getGlobalState(context, "actModeHuggingFaceModelInfo") as Promise<ModelInfo | undefined>,
getGlobalState(context, "actModeHuaweiCloudMaasModelId") as Promise<string | undefined>,
getGlobalState(context, "actModeHuaweiCloudMaasModelInfo") as Promise<ModelInfo | undefined>,
getGlobalState(context, "dictationSettings") as Promise<DictationSettings | undefined>,
])
let apiProvider: ApiProvider
@@ -470,7 +462,6 @@ export async function getAllExtensionState(context: vscode.ExtensionContext) {
openAiApiKey,
openAiHeaders: openAiHeaders || {},
ollamaBaseUrl,
ollamaApiKey,
ollamaApiOptionsCtxNum,
lmStudioBaseUrl,
anthropicBaseUrl,
@@ -499,7 +490,6 @@ export async function getAllExtensionState(context: vscode.ExtensionContext) {
sambanovaApiKey,
cerebrasApiKey,
groqApiKey,
basetenApiKey,
moonshotApiKey,
nebiusApiKey,
favoritedModelIds,
@@ -534,8 +524,6 @@ export async function getAllExtensionState(context: vscode.ExtensionContext) {
planModeSapAiCoreModelId,
planModeGroqModelId,
planModeGroqModelInfo,
planModeBasetenModelId,
planModeBasetenModelInfo,
planModeHuggingFaceModelId,
planModeHuggingFaceModelInfo,
planModeHuaweiCloudMaasModelId,
@@ -563,8 +551,6 @@ export async function getAllExtensionState(context: vscode.ExtensionContext) {
actModeSapAiCoreModelId,
actModeGroqModelId,
actModeGroqModelInfo,
actModeBasetenModelId,
actModeBasetenModelInfo,
actModeHuggingFaceModelId,
actModeHuggingFaceModelInfo,
actModeHuaweiCloudMaasModelId,
@@ -597,6 +583,7 @@ export async function getAllExtensionState(context: vscode.ExtensionContext) {
localWindsurfRulesToggles: localWindsurfRulesToggles || {},
localCursorRulesToggles: localCursorRulesToggles || {},
localWorkflowToggles: localWorkflowToggles || {},
dictationSettings: dictationSettings || DEFAULT_DICTATION_SETTINGS,
}
}
@@ -620,7 +607,6 @@ export async function resetGlobalState(controller: Controller) {
"awsSessionToken",
"awsBedrockApiKey",
"openAiApiKey",
"ollamaApiKey",
"geminiApiKey",
"openAiNativeApiKey",
"deepSeekApiKey",
@@ -637,7 +623,6 @@ export async function resetGlobalState(controller: Controller) {
"sambanovaApiKey",
"cerebrasApiKey",
"groqApiKey",
"basetenApiKey",
"moonshotApiKey",
"nebiusApiKey",
"huggingFaceApiKey",
+11 -33
View File
@@ -1,6 +1,6 @@
import { showSystemNotification } from "@/integrations/notifications"
import { listFiles } from "@/services/glob/list-files"
import { telemetryService } from "@/services/posthog/PostHogClientProvider"
import { telemetryService } from "@/services/posthog/telemetry/TelemetryService"
import { regexSearchFiles } from "@/services/ripgrep"
import { parseSourceCodeForDefinitionsTopLevel } from "@/services/tree-sitter"
import { findLast, findLastIndex, parsePartialArrayString } from "@/shared/array"
@@ -35,13 +35,7 @@ import { ClineAskResponse } from "@shared/WebviewMessage"
import { extractFileContent, FileContentResult } from "@integrations/misc/extract-file-content"
import { COMMAND_REQ_APP_STRING } from "@shared/combineCommandSequences"
import { fileExistsAtPath } from "@utils/fs"
import {
isClaude4ModelFamily,
isGemini2dot5ModelFamily,
isGrok4ModelFamily,
modelDoesntSupportWebp,
isNextGenModelFamily,
} from "@utils/model-utils"
import { isClaude4ModelFamily, isGemini2dot5ModelFamily, isGrok4ModelFamily, modelDoesntSupportWebp } from "@utils/model-utils"
import { fixModelHtmlEscaping, removeInvalidChars } from "@utils/string"
import { setTimeout as setTimeoutPromise } from "node:timers/promises"
import os from "os"
@@ -99,7 +93,6 @@ export class ToolExecutor {
private browserSettings: BrowserSettings,
private cwd: string,
private taskId: string,
private ulid: string,
private mode: Mode,
private strictPlanModeEnabled: boolean,
@@ -115,12 +108,7 @@ export class ToolExecutor {
type: ClineAsk,
text?: string,
partial?: boolean,
) => Promise<{
response: ClineAskResponse
text?: string
images?: string[]
files?: string[]
}>,
) => Promise<{ response: ClineAskResponse; text?: string; images?: string[]; files?: string[] }>,
private saveCheckpoint: (isAttemptCompletionMessage?: boolean) => Promise<void>,
private sayAndCreateMissingParamError: (toolName: ToolUseName, paramName: string, relPath?: string) => Promise<any>,
private removeLastPartialMessageIfExistsWithType: (type: "ask" | "say", askOrSay: ClineAsk | ClineSay) => Promise<void>,
@@ -154,7 +142,8 @@ export class ToolExecutor {
}
private pushToolResult = (content: ToolResponse, block: ToolUse) => {
const isNextGenModel = isNextGenModelFamily(this.api)
const isNextGenModel =
isClaude4ModelFamily(this.api) || isGemini2dot5ModelFamily(this.api) || isGrok4ModelFamily(this.api)
if (typeof content === "string") {
const resultText = content || "(tool did not return anything)"
@@ -483,7 +472,7 @@ export class ToolExecutor {
case "write_to_file":
case "replace_in_file": {
const relPath: string | undefined = block.params.path
const content: string | undefined = block.params.content // for write_to_file
let content: string | undefined = block.params.content // for write_to_file
let diff: string | undefined = block.params.diff // for replace_in_file
if (!relPath || (!content && !diff)) {
// checking for content/diff ensures relPath is complete
@@ -528,7 +517,8 @@ export class ToolExecutor {
const currentFullJson = block.params.diff
// Check if we should use streaming (e.g., for specific models)
const isNextGenModel = isNextGenModelFamily(this.api)
const isNextGenModel =
isClaude4ModelFamily(this.api) || isGemini2dot5ModelFamily(this.api) || isGrok4ModelFamily(this.api)
// Going through claude family of models
if (isNextGenModel && USE_EXPERIMENTAL_CLAUDE4_FEATURES && currentFullJson) {
const streamingResult = await this.handleStreamingJsonReplacement(block, relPath, currentFullJson)
@@ -1222,7 +1212,7 @@ export class ToolExecutor {
if (this.context) {
await this.browserSession.dispose()
const useWebp = this.api ? !modelDoesntSupportWebp(this.api) : true
let useWebp = this.api ? !modelDoesntSupportWebp(this.api) : true
this.browserSession = new BrowserSession(this.context, this.browserSettings, useWebp)
} else {
console.warn("no controller context available for browserSession")
@@ -2145,7 +2135,6 @@ export class ToolExecutor {
case "plan_mode_respond": {
const response: string | undefined = block.params.response
const optionsRaw: string | undefined = block.params.options
const needsMoreExploration: boolean = block.params.needs_more_exploration === "true"
const sharedMessage = {
response: this.removeClosingTag(block, "response", response),
options: parsePartialArrayString(this.removeClosingTag(block, "options", optionsRaw)),
@@ -2170,17 +2159,6 @@ export class ToolExecutor {
// })
// }
// The plan_mode_respond tool tends to run into this issue where the model realizes mid-tool call that it should have called another tool before calling plan_mode_respond. And it ends the plan_mode_respond tool call with 'Proceeding to reading files...' which doesn't do anything because we restrict to 1 tool call per message. As an escape hatch for the model, we provide it the optionality to tack on a parameter at the end of its response `needs_more_exploration`, which will allow the loop to continue.
if (needsMoreExploration) {
this.pushToolResult(
formatResponse.toolResult(
`[You have indicated that you need more exploration. Proceed with calling tools to continue the planning process.]`,
),
block,
)
break
}
// Store the number of options for telemetry
const options = parsePartialArrayString(optionsRaw || "[]")
@@ -2359,7 +2337,7 @@ export class ToolExecutor {
await this.say("completion_result", result, undefined, undefined, false)
await this.saveCheckpoint(true)
await addNewChangesFlagToLastCompletionResultMessage()
telemetryService.captureTaskCompleted(this.taskId, this.ulid)
telemetryService.captureTaskCompleted(this.taskId)
} else {
// we already sent a command message, meaning the complete completion message has also been sent
await this.saveCheckpoint(true)
@@ -2384,7 +2362,7 @@ export class ToolExecutor {
await this.say("completion_result", result, undefined, undefined, false)
await this.saveCheckpoint(true)
await addNewChangesFlagToLastCompletionResultMessage()
telemetryService.captureTaskCompleted(this.taskId, this.ulid)
telemetryService.captureTaskCompleted(this.taskId)
}
// we already sent completion_result says, an empty string asks relinquishes control over button and field
+40 -66
View File
@@ -14,7 +14,7 @@ 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/PostHogClientProvider"
import { telemetryService } from "@services/posthog/telemetry/TelemetryService"
import { ApiConfiguration } from "@shared/api"
import { findLast, findLastIndex } from "@shared/array"
import { AutoApprovalSettings } from "@shared/AutoApprovalSettings"
@@ -35,11 +35,10 @@ import pTimeout from "p-timeout"
import pWaitFor from "p-wait-for"
import * as path from "path"
import * as vscode from "vscode"
import { ulid } from "ulid"
import { HostProvider } from "@/hosts/host-provider"
import { ClineErrorType } from "@/services/error/ClineError"
import { errorService } from "@/services/posthog/PostHogClientProvider"
import { ErrorService } from "@/services/error/ErrorService"
import { parseAssistantMessageV2, parseAssistantMessageV3, ToolUseName } from "@core/assistant-message"
import {
checkIsAnthropicContextWindowError,
@@ -78,7 +77,7 @@ import { processFilesIntoText } from "@integrations/misc/extract-text"
import WorkspaceTracker from "@integrations/workspace/WorkspaceTracker"
import { McpHub } from "@services/mcp/McpHub"
import { convertClineMessageToProto } from "@shared/proto-conversions/cline-message"
import { isClaude4ModelFamily, isGemini2dot5ModelFamily, isGrok4ModelFamily, isNextGenModelFamily } from "@utils/model-utils"
import { isClaude4ModelFamily, isGemini2dot5ModelFamily, isGrok4ModelFamily } 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"
@@ -98,7 +97,6 @@ type UserContent = Array<Anthropic.ContentBlockParam>
export class Task {
// Core task variables
readonly taskId: string
readonly ulid: string
private taskIsFavorited?: boolean
private cwd: string
@@ -221,7 +219,6 @@ export class Task {
// Initialize taskId first
if (historyItem) {
this.taskId = historyItem.id
this.ulid = historyItem.ulid ?? ulid()
this.taskIsFavorited = historyItem.isFavorited
this.taskState.conversationHistoryDeletedRange = historyItem.conversationHistoryDeletedRange
if (historyItem.checkpointTrackerErrorMessage) {
@@ -229,7 +226,6 @@ export class Task {
}
} else if (task || images || files) {
this.taskId = Date.now().toString()
this.ulid = ulid()
} else {
throw new Error("Either historyItem or task/images must be provided")
}
@@ -237,7 +233,6 @@ export class Task {
this.messageStateHandler = new MessageStateHandler({
context,
taskId: this.taskId,
ulid: this.ulid,
taskState: this.taskState,
taskIsFavorited: this.taskIsFavorited,
updateTaskHistory: this.updateTaskHistory,
@@ -248,7 +243,7 @@ export class Task {
this.modelContextTracker = new ModelContextTracker(context, this.taskId)
// Prepare effective API configuration
const effectiveApiConfiguration: ApiConfiguration = {
let effectiveApiConfiguration: ApiConfiguration = {
...apiConfiguration,
taskId: this.taskId,
onRetryAttempt: async (attempt: number, maxRetries: number, delay: number, error: any) => {
@@ -311,10 +306,10 @@ export class Task {
// initialize telemetry
if (historyItem) {
// Open task from history
telemetryService.captureTaskRestarted(this.taskId, this.ulid, currentProvider)
telemetryService.captureTaskRestarted(this.taskId, currentProvider)
} else {
// New task started
telemetryService.captureTaskCreated(this.taskId, this.ulid, currentProvider)
telemetryService.captureTaskCreated(this.taskId, currentProvider)
}
this.toolExecutor = new ToolExecutor(
@@ -335,7 +330,6 @@ export class Task {
this.browserSettings,
cwd,
this.taskId,
this.ulid,
this.mode,
strictPlanModeEnabled,
this.say.bind(this),
@@ -480,7 +474,7 @@ export class Task {
if (!didWorkspaceRestoreFail) {
switch (restoreType) {
case "task":
case "taskAndWorkspace": {
case "taskAndWorkspace":
this.taskState.conversationHistoryDeletedRange = message.conversationHistoryDeletedRange
const apiConversationHistory = this.messageStateHandler.getApiConversationHistory()
const newConversationHistory = apiConversationHistory.slice(0, (message.conversationHistoryIndex || 0) + 2) // +1 since this index corresponds to the last user message, and another +1 since slice end index is exclusive
@@ -523,7 +517,6 @@ export class Task {
} satisfies ClineApiReqInfo),
)
break
}
case "workspace":
break
}
@@ -1050,9 +1043,9 @@ export class Task {
this.taskState.isInitialized = true
const imageBlocks: Anthropic.ImageBlockParam[] = formatResponse.imageBlocks(images)
let imageBlocks: Anthropic.ImageBlockParam[] = formatResponse.imageBlocks(images)
const userContent: UserContent = [
let userContent: UserContent = [
{
type: "text",
text: `<task>\n${task}\n</task>`,
@@ -1179,7 +1172,7 @@ export class Task {
throw new Error("Unexpected: No existing API conversation history")
}
const newUserContent: UserContent = [...modifiedOldUserContent]
let newUserContent: UserContent = [...modifiedOldUserContent]
const agoText = (() => {
const timestamp = lastClineMessage?.ts ?? Date.now()
@@ -1635,7 +1628,7 @@ export class Task {
// grouping command_output messages despite any gaps anyways)
await setTimeoutPromise(50)
const result = this.terminalManager.processOutput(outputLines)
let result = this.terminalManager.processOutput(outputLines)
if (userFeedback) {
await this.say("user_feedback", userFeedback.text, userFeedback.images, userFeedback.files)
@@ -1684,10 +1677,7 @@ export class Task {
}
}
private async getCurrentProviderInfo(): Promise<{
modelId: string
providerId: string
}> {
private async getCurrentProviderInfo(): Promise<{ modelId: string; providerId: string }> {
const modelId = this.api.getModel()?.id
const apiConfig = this.cacheService.getApiConfiguration()
const providerId = (this.mode === "plan" ? apiConfig.planModeApiProvider : apiConfig.actModeApiProvider) as string
@@ -1696,9 +1686,7 @@ export class Task {
async *attemptApiRequest(previousApiReqIndex: number): ApiStream {
// Wait for MCP servers to be connected before generating system prompt
await pWaitFor(() => this.mcpHub.isConnecting !== true, {
timeout: 10_000,
}).catch(() => {
await pWaitFor(() => this.mcpHub.isConnecting !== true, { timeout: 10_000 }).catch(() => {
console.error("MCP servers failed to connect in time")
})
@@ -1710,7 +1698,8 @@ export class Task {
const supportsBrowserUse = modelSupportsBrowserUse && !disableBrowserTool // only enable browser use if the model supports it and the user hasn't disabled it
const isNextGenModel = isNextGenModelFamily(this.api)
const isNextGenModel =
isClaude4ModelFamily(this.api) || isGemini2dot5ModelFamily(this.api) || isGrok4ModelFamily(this.api)
let systemPrompt = await SYSTEM_PROMPT(this.cwd, supportsBrowserUse, this.mcpHub, this.browserSettings, isNextGenModel)
const preferredLanguage = getLanguageKey(this.preferredLanguage as LanguageDisplay)
@@ -1774,7 +1763,7 @@ export class Task {
// saves task history item which we use to keep track of conversation history deleted range
}
const stream = this.api.createMessage(systemPrompt, contextManagementMetadata.truncatedConversationHistory)
let stream = this.api.createMessage(systemPrompt, contextManagementMetadata.truncatedConversationHistory)
const iterator = stream[Symbol.asyncIterator]()
@@ -1790,12 +1779,17 @@ export class Task {
const isOpenRouterContextWindowError = checkIsOpenRouterContextWindowError(error) && isOpenRouter
const isAnthropicContextWindowError = checkIsAnthropicContextWindowError(error) && isAnthropic
const { modelId, providerId } = await this.getCurrentProviderInfo()
const clineError = errorService.toClineError(error, modelId, providerId)
const clineError = ErrorService.toClineError(error, modelId, providerId)
// Capture provider failure telemetry using clineError
// TODO: Move into errorService
errorService.logMessage(clineError.message)
errorService.logException(clineError)
// TODO: Move into ErrorService
telemetryService.captureProviderApiError({
taskId: this.taskId,
model: modelInfo.id,
errorMessage: clineError.message,
errorStatus: clineError._error?.status,
requestId: clineError._error?.request_id,
})
if (isAnthropic && isAnthropicContextWindowError && !this.taskState.didAutomaticallyRetryFailedApiRequest) {
this.taskState.conversationHistoryDeletedRange = this.contextManager.getNextTruncationRange(
@@ -1874,24 +1868,12 @@ export class Task {
throw new Error("API request failed")
}
// Clear streamingFailedMessage when user manually retries
const manualRetryApiReqIndex = findLastIndex(
this.messageStateHandler.getClineMessages(),
(m) => m.say === "api_req_started",
)
if (manualRetryApiReqIndex !== -1) {
const clineMessages = this.messageStateHandler.getClineMessages()
const currentApiReqInfo: ClineApiReqInfo = JSON.parse(clineMessages[manualRetryApiReqIndex].text || "{}")
delete currentApiReqInfo.streamingFailedMessage
await this.messageStateHandler.updateClineMessage(manualRetryApiReqIndex, {
text: JSON.stringify(currentApiReqInfo),
})
// Do not retry automatically again if currently unauthenticated
if (clineError.isErrorType(ClineErrorType.Auth)) {
return
}
await this.say("api_req_retried")
// Reset the automatic retry flag so the request can proceed
this.taskState.didAutomaticallyRetryFailedApiRequest = false
}
// delegate generator output from the recursive call
yield* this.attemptApiRequest(previousApiReqIndex)
@@ -2233,7 +2215,7 @@ export class Task {
content: userContent,
})
telemetryService.captureConversationTurnEvent(this.taskId, this.ulid, providerId, modelId, "user")
telemetryService.captureConversationTurnEvent(this.taskId, providerId, modelId, "user")
// since we sent off a placeholder api_req_started message to update the webview while waiting to actually start the API request (to load potential details for example), we need to update the text of that message
const lastApiReqIndex = findLastIndex(this.messageStateHandler.getClineMessages(), (m) => m.say === "api_req_started")
@@ -2298,20 +2280,13 @@ export class Task {
})
await this.messageStateHandler.saveClineMessagesAndUpdateHistory()
telemetryService.captureConversationTurnEvent(
this.taskId,
this.ulid,
providerId,
this.api.getModel().id,
"assistant",
{
tokensIn: inputTokens,
tokensOut: outputTokens,
cacheWriteTokens,
cacheReadTokens,
totalCost,
},
)
telemetryService.captureConversationTurnEvent(this.taskId, providerId, this.api.getModel().id, "assistant", {
tokensIn: inputTokens,
tokensOut: outputTokens,
cacheWriteTokens,
cacheReadTokens,
totalCost,
})
// signals to provider that it can retrieve the saved messages from disk, as abortTask can not be awaited on in nature
this.taskState.didFinishAbortingStream = true
@@ -2357,7 +2332,7 @@ export class Task {
await this.say("reasoning", reasoningMessage, undefined, undefined, true)
}
break
case "text": {
case "text":
if (reasoningMessage && assistantMessage.length === 0) {
// complete reasoning message
await this.say("reasoning", reasoningMessage, undefined, undefined, false)
@@ -2365,7 +2340,7 @@ export class Task {
assistantMessage += chunk.text
// parse raw assistant message into content blocks
const prevLength = this.taskState.assistantMessageContent.length
const isNextGenModel = isNextGenModelFamily(this.api)
const isNextGenModel = isClaude4ModelFamily(this.api) || isGemini2dot5ModelFamily(this.api)
if (isNextGenModel && USE_EXPERIMENTAL_CLAUDE4_FEATURES) {
this.taskState.assistantMessageContent = parseAssistantMessageV3(assistantMessage)
} else {
@@ -2378,7 +2353,6 @@ export class Task {
// present content to user
this.presentAssistantMessage()
break
}
}
if (this.taskState.abort) {
@@ -2409,7 +2383,7 @@ export class Task {
// abandoned happens when extension is no longer waiting for the cline instance to finish aborting (error is thrown here when any function in the for loop throws due to this.abort)
if (!this.taskState.abandoned) {
this.abortTask() // if the stream failed, there's various states the task could be in (i.e. could have streamed some tools the user may have executed), so we just resort to replicating a cancel task
const clineError = errorService.toClineError(error, this.api.getModel().id)
const clineError = ErrorService.toClineError(error, this.api.getModel().id)
const errorMessage = clineError.serialize()
await abortStream("streaming_failed", errorMessage)
@@ -2480,7 +2454,7 @@ export class Task {
// need to save assistant responses to file before proceeding to tool use since user can exit at any moment and we wouldn't be able to save the assistant's response
let didEndLoop = false
if (assistantMessage.length > 0) {
telemetryService.captureConversationTurnEvent(this.taskId, this.ulid, providerId, modelId, "assistant", {
telemetryService.captureConversationTurnEvent(this.taskId, providerId, modelId, "assistant", {
tokensIn: inputTokens,
tokensOut: outputTokens,
cacheWriteTokens,
-4
View File
@@ -17,7 +17,6 @@ import { getCwd, getDesktopDir } from "@/utils/path"
interface MessageStateHandlerParams {
context: vscode.ExtensionContext
taskId: string
ulid: string
taskIsFavorited?: boolean
updateTaskHistory: (historyItem: HistoryItem) => Promise<HistoryItem[]>
taskState: TaskState
@@ -33,13 +32,11 @@ export class MessageStateHandler {
private updateTaskHistory: (historyItem: HistoryItem) => Promise<HistoryItem[]>
private context: vscode.ExtensionContext
private taskId: string
private ulid: string
private taskState: TaskState
constructor(params: MessageStateHandlerParams) {
this.context = params.context
this.taskId = params.taskId
this.ulid = params.ulid
this.taskState = params.taskState
this.taskIsFavorited = params.taskIsFavorited ?? false
this.updateTaskHistory = params.updateTaskHistory
@@ -92,7 +89,6 @@ export class MessageStateHandler {
const cwd = await getCwd(getDesktopDir())
await this.updateTaskHistory({
id: this.taskId,
ulid: this.ulid,
ts: lastRelevantMessage.ts,
task: taskMessage.text ?? "",
tokensIn: apiMetrics.totalTokensIn,
-357
View File
@@ -1,357 +0,0 @@
import axios from "axios"
import * as vscode from "vscode"
import { getNonce } from "./getNonce"
import { WebviewProviderType } from "@/shared/webview/types"
import { Controller } from "@core/controller/index"
import { findLast } from "@shared/array"
import { readFile } from "fs/promises"
import path from "node:path"
import { v4 as uuidv4 } from "uuid"
import { Uri } from "vscode"
import { HostProvider } from "@/hosts/host-provider"
import { ShowMessageType } from "@/shared/proto/host/window"
export abstract class WebviewProvider {
public static readonly sideBarId = "claude-dev.SidebarProvider" // used in package.json as the view's id. This value cannot be changed due to how vscode caches views based on their id, and updating the id would break existing instances of the extension.
public static readonly tabPanelId = "claude-dev.TabPanelProvider"
private static activeInstances: Set<WebviewProvider> = new Set()
private static clientIdMap = new Map<WebviewProvider, string>()
controller: Controller
private clientId: string
private static lastActiveControllerId: string | null = null
constructor(
readonly context: vscode.ExtensionContext,
private readonly providerType: WebviewProviderType,
) {
WebviewProvider.activeInstances.add(this)
this.clientId = uuidv4()
WebviewProvider.clientIdMap.set(this, this.clientId)
// Create controller with cache service
this.controller = new Controller(context, this.clientId)
WebviewProvider.setLastActiveControllerId(this.controller.id)
}
// Add a method to get the client ID
public getClientId(): string {
return this.clientId
}
// Add a static method to get the client ID for a specific instance
public static getClientIdForInstance(instance: WebviewProvider): string | undefined {
return WebviewProvider.clientIdMap.get(instance)
}
async dispose() {
await this.controller.dispose()
WebviewProvider.activeInstances.delete(this)
// Remove from client ID map
WebviewProvider.clientIdMap.delete(this)
}
public static getVisibleInstance(): WebviewProvider | undefined {
return findLast(Array.from(WebviewProvider.activeInstances), (instance) => instance.isVisible() === true)
}
public static getActiveInstance(): WebviewProvider | undefined {
return Array.from(WebviewProvider.activeInstances).find((instance) => {
const webview = instance.getWebview()
if (webview && webview.viewType === "claude-dev.TabPanelProvider" && "active" in webview) {
return webview.active === true
}
return false
})
}
public static getAllInstances(): WebviewProvider[] {
return Array.from(WebviewProvider.activeInstances)
}
public static getSidebarInstance() {
return Array.from(WebviewProvider.activeInstances).find(
(instance) => instance.providerType === WebviewProviderType.SIDEBAR,
)
}
public static getTabInstances(): WebviewProvider[] {
return Array.from(WebviewProvider.activeInstances).filter((instance) => instance.providerType === WebviewProviderType.TAB)
}
public static getLastActiveInstance(): WebviewProvider | undefined {
const lastActiveId = WebviewProvider.getLastActiveControllerId()
if (!lastActiveId) {
return undefined
}
return Array.from(WebviewProvider.activeInstances).find((instance) => instance.controller.id === lastActiveId)
}
/**
* Gets the last active controller ID with performance optimization
* @returns The last active controller ID or null
*/
public static getLastActiveControllerId(): string | null {
return WebviewProvider.lastActiveControllerId || WebviewProvider.getSidebarInstance()?.controller.id || null
}
/**
* Sets the last active controller ID with validation and performance optimization
* @param controllerId The controller ID to set as last active
*/
public static setLastActiveControllerId(controllerId: string | null): void {
// Only update if the value is actually different to avoid unnecessary operations
if (WebviewProvider.lastActiveControllerId !== controllerId) {
WebviewProvider.lastActiveControllerId = controllerId
}
}
public static async disposeAllInstances() {
const instances = Array.from(WebviewProvider.activeInstances)
for (const instance of instances) {
await instance.dispose()
}
}
/**
* Initializes and sets up the webview when it's first created.
*
* @param webviewView - The webview view or panel instance to be resolved
* @returns A promise that resolves when the webview has been fully initialized
*/
abstract resolveWebviewView(webviewView: vscode.WebviewView | vscode.WebviewPanel): Promise<void>
/**
* Gets the current webview instance.
*
* @returns The webview instance (WebviewView, WebviewPanel, or similar)
*/
abstract getWebview(): any
/**
* Converts a local URI to a webview URI that can be used within the webview.
*
* @param uri - The local URI to convert
* @returns A URI that can be used within the webview
*/
abstract getWebviewUri(uri: Uri): Uri
/**
* Gets the Content Security Policy source for the webview.
*
* @returns The CSP source string to be used in the webview's Content-Security-Policy
*/
abstract getCspSource(): string
/**
* Checks if the webview is currently visible to the user.
*
* @returns True if the webview is visible, false otherwise
*/
abstract isVisible(): boolean
/**
* Defines and returns the HTML that should be rendered within the webview panel.
*
* @remarks This is also the place where references to the React webview build files
* are created and inserted into the webview HTML.
*
* @param webview A reference to the extension webview
* @param extensionUri The URI of the directory containing the extension
* @returns A template string literal containing the HTML that should be
* rendered within the webview panel
*/
public getHtmlContent(): string {
// Get the local path to main script run in the webview,
// then convert it to a uri we can use in the webview.
// The CSS file from the React build output
const stylesUri = this.getExtensionUri("webview-ui", "build", "assets", "index.css")
// The JS file from the React build output
const scriptUri = this.getExtensionUri("webview-ui", "build", "assets", "index.js")
// The codicon font from the React build output
// https://github.com/microsoft/vscode-extension-samples/blob/main/webview-codicons-sample/src/extension.ts
// we installed this package in the extension so that we can access it how its intended from the extension (the font file is likely bundled in vscode), and we just import the css fileinto our react app we don't have access to it
// don't forget to add font-src ${webview.cspSource};
const codiconsUri = this.getExtensionUri("node_modules", "@vscode", "codicons", "dist", "codicon.css")
// const scriptUri = webview.asWebviewUri(vscode.Uri.joinPath(this._extensionUri, "assets", "main.js"))
// const styleResetUri = webview.asWebviewUri(vscode.Uri.joinPath(this._extensionUri, "assets", "reset.css"))
// const styleVSCodeUri = webview.asWebviewUri(vscode.Uri.joinPath(this._extensionUri, "assets", "vscode.css"))
// // Same for stylesheet
// const stylesheetUri = webview.asWebviewUri(vscode.Uri.joinPath(this._extensionUri, "assets", "main.css"))
// Use a nonce to only allow a specific script to be run.
/*
content security policy of your webview to only allow scripts that have a specific nonce
create a content security policy meta tag so that only loading scripts with a nonce is allowed
As your extension grows you will likely want to add custom styles, fonts, and/or images to your webview. If you do, you will need to update the content security policy meta tag to explicitly allow for these resources. E.g.
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src ${webview.cspSource}; font-src ${webview.cspSource}; img-src ${webview.cspSource} https:; script-src 'nonce-${nonce}';">
- 'unsafe-inline' is required for styles due to vscode-webview-toolkit's dynamic style injection
- since we pass base64 images to the webview, we need to specify img-src ${webview.cspSource} data:;
in meta tag we add nonce attribute: A cryptographic nonce (only used once) to allow scripts. The server must generate a unique nonce value each time it transmits a policy. It is critical to provide a nonce that cannot be guessed as bypassing a resource's policy is otherwise trivial.
*/
const nonce = getNonce()
// Tip: Install the es6-string-html VS Code extension to enable code highlighting below
return /*html*/ `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,shrink-to-fit=no">
<meta name="theme-color" content="#000000">
<link rel="stylesheet" type="text/css" href="${stylesUri}">
<link href="${codiconsUri}" rel="stylesheet" />
<meta http-equiv="Content-Security-Policy" content="default-src 'none';
connect-src https://*.posthog.com https://*.cline.bot https://*.firebaseauth.com https://*.firebaseio.com https://*.googleapis.com https://*.firebase.com;
font-src ${this.getCspSource()} data:;
style-src ${this.getCspSource()} 'unsafe-inline';
img-src ${this.getCspSource()} https: data:;
script-src 'nonce-${nonce}' 'unsafe-eval';">
<title>Cline</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<script type="text/javascript" nonce="${nonce}">
// Inject the provider type
window.WEBVIEW_PROVIDER_TYPE = ${JSON.stringify(this.providerType)};
// Inject the client ID
window.clineClientId = "${this.clientId}";
</script>
<script type="module" nonce="${nonce}" src="${scriptUri}"></script>
<script src="http://localhost:8097"></script>
</body>
</html>
`
}
/**
* Reads the Vite dev server port from the generated port file to avoid conflicts
* Returns a Promise that resolves to the port number
* If the file doesn't exist or can't be read, it resolves to the default port
*/
private getDevServerPort(): Promise<number> {
const DEFAULT_PORT = 25463
const portFilePath = path.join(__dirname, "..", "webview-ui", ".vite-port")
return readFile(portFilePath, "utf8")
.then((portFile) => {
const port = parseInt(portFile.trim()) || DEFAULT_PORT
console.info(`[getDevServerPort] Using dev server port ${port} from .vite-port file`)
return port
})
.catch((err) => {
console.warn(
`[getDevServerPort] Port file not found or couldn't be read at ${portFilePath}, using default port: ${DEFAULT_PORT}`,
)
return DEFAULT_PORT
})
}
/**
* Connects to the local Vite dev server to allow HMR, with fallback to the bundled assets
*
* @param webview A reference to the extension webview
* @returns A template string literal containing the HTML that should be
* rendered within the webview panel
*/
protected async getHMRHtmlContent(): Promise<string> {
const localPort = await this.getDevServerPort()
const localServerUrl = `localhost:${localPort}`
// Check if local dev server is running.
try {
await axios.get(`http://${localServerUrl}`)
} catch (error) {
// Only show the error message when in development mode.
if (process.env.IS_DEV) {
HostProvider.window.showMessage({
type: ShowMessageType.ERROR,
message:
"Cline: Local webview dev server is not running, HMR will not work. Please run 'npm run dev:webview' before launching the extension to enable HMR. Using bundled assets.",
})
}
return this.getHtmlContent()
}
const nonce = getNonce()
const stylesUri = this.getExtensionUri("webview-ui", "build", "assets", "index.css")
const codiconsUri = this.getExtensionUri("node_modules", "@vscode", "codicons", "dist", "codicon.css")
const scriptEntrypoint = "src/main.tsx"
const scriptUri = `http://${localServerUrl}/${scriptEntrypoint}`
const reactRefresh = /*html*/ `
<script nonce="${nonce}" type="module">
import RefreshRuntime from "http://${localServerUrl}/@react-refresh"
RefreshRuntime.injectIntoGlobalHook(window)
window.$RefreshReg$ = () => {}
window.$RefreshSig$ = () => (type) => type
window.__vite_plugin_react_preamble_installed__ = true
</script>
`
const csp = [
"default-src 'none'",
`font-src ${this.getCspSource()}`,
`style-src ${this.getCspSource()} 'unsafe-inline' https://* http://${localServerUrl} http://0.0.0.0:${localPort}`,
`img-src ${this.getCspSource()} https: data:`,
`script-src 'unsafe-eval' https://* http://${localServerUrl} http://0.0.0.0:${localPort} 'nonce-${nonce}'`,
`connect-src https://* ws://${localServerUrl} ws://0.0.0.0:${localPort} http://${localServerUrl} http://0.0.0.0:${localPort}`,
]
return /*html*/ `
<!DOCTYPE html>
<html lang="en">
<head>
${process.env.IS_DEV ? '<script src="http://localhost:8097"></script>' : ""}
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,shrink-to-fit=no">
<meta http-equiv="Content-Security-Policy" content="${csp.join("; ")}">
<link rel="stylesheet" type="text/css" href="${stylesUri}">
<link href="${codiconsUri}" rel="stylesheet" />
<title>Cline</title>
</head>
<body>
<div id="root"></div>
<script type="text/javascript" nonce="${nonce}">
// Inject the provider type
window.WEBVIEW_PROVIDER_TYPE = ${JSON.stringify(this.providerType)};
// Inject the client ID
window.clineClientId = "${this.clientId}";
</script>
${reactRefresh}
<script type="module" src="${scriptUri}"></script>
</body>
</html>
`
}
/**
* A helper function which will get the webview URI of a given file or resource in the extension directory.
*
* @remarks This URI can be used within a webview's HTML as a link to the
* given file/resource.
*
* @param pathList An array of strings representing the path to a file/resource in the extension directory.
* @returns A URI pointing to the file/resource
*/
private getExtensionUri(...pathList: string[]): Uri {
if (!this.getWebview()) {
throw Error("webview is not initialized.")
}
return this.getWebviewUri(Uri.joinPath(this.context.extensionUri, ...pathList))
}
}
+348 -1
View File
@@ -1 +1,348 @@
export { WebviewProvider } from "./WebviewProvider"
import axios from "axios"
import * as vscode from "vscode"
import { getNonce } from "./getNonce"
import { WebviewProviderType } from "@/shared/webview/types"
import { Controller } from "@core/controller/index"
import { CacheService } from "@core/storage/CacheService"
import { findLast } from "@shared/array"
import { readFile } from "fs/promises"
import path from "node:path"
import { v4 as uuidv4 } from "uuid"
import { Uri } from "vscode"
import { ExtensionMessage } from "@/shared/ExtensionMessage"
import { HostProvider } from "@/hosts/host-provider"
import { ShowMessageType } from "@/shared/proto/host/window"
export abstract class WebviewProvider {
public static readonly sideBarId = "claude-dev.SidebarProvider" // used in package.json as the view's id. This value cannot be changed due to how vscode caches views based on their id, and updating the id would break existing instances of the extension.
public static readonly tabPanelId = "claude-dev.TabPanelProvider"
private static activeInstances: Set<WebviewProvider> = new Set()
private static clientIdMap = new Map<WebviewProvider, string>()
protected disposables: vscode.Disposable[] = []
controller: Controller
private clientId: string
constructor(
readonly context: vscode.ExtensionContext,
private readonly providerType: WebviewProviderType,
) {
WebviewProvider.activeInstances.add(this)
this.clientId = uuidv4()
WebviewProvider.clientIdMap.set(this, this.clientId)
// Create controller with cache service
this.controller = new Controller(context, (message) => this.postMessageToWebview(message), this.clientId)
}
// Add a method to get the client ID
public getClientId(): string {
return this.clientId
}
// Add a static method to get the client ID for a specific instance
public static getClientIdForInstance(instance: WebviewProvider): string | undefined {
return WebviewProvider.clientIdMap.get(instance)
}
async dispose() {
while (this.disposables.length) {
const x = this.disposables.pop()
if (x) {
x.dispose()
}
}
await this.controller.dispose()
WebviewProvider.activeInstances.delete(this)
// Remove from client ID map
WebviewProvider.clientIdMap.delete(this)
}
public static getVisibleInstance(): WebviewProvider | undefined {
return findLast(Array.from(this.activeInstances), (instance) => instance.isVisible() === true)
}
public static getActiveInstance(): WebviewProvider | undefined {
return Array.from(this.activeInstances).find((instance) => {
if (
instance.getWebview() &&
instance.getWebview().viewType === "claude-dev.TabPanelProvider" &&
"active" in instance.getWebview()
) {
return instance.getWebview().active === true
}
return false
})
}
public static getAllInstances(): WebviewProvider[] {
return Array.from(this.activeInstances)
}
public static getSidebarInstance() {
return Array.from(this.activeInstances).find(
(instance) => instance.getWebview() && "onDidChangeVisibility" in instance.getWebview(),
)
}
public static getTabInstances(): WebviewProvider[] {
return Array.from(this.activeInstances).filter(
(instance) => instance.getWebview() && "onDidChangeViewState" in instance.getWebview(),
)
}
public static async disposeAllInstances() {
const instances = Array.from(this.activeInstances)
for (const instance of instances) {
await instance.dispose()
}
}
/**
* Initializes and sets up the webview when it's first created.
*
* @param webviewView - The webview view or panel instance to be resolved
* @returns A promise that resolves when the webview has been fully initialized
*/
abstract resolveWebviewView(webviewView: vscode.WebviewView | vscode.WebviewPanel): Promise<void>
/**
* Sends a message from the extension to the webview.
*
* @param message - The message to send to the webview
* @returns A thenable that resolves to a boolean indicating success, or undefined if the webview is not available
*/
abstract postMessageToWebview(message: ExtensionMessage): Thenable<boolean> | undefined
/**
* Gets the current webview instance.
*
* @returns The webview instance (WebviewView, WebviewPanel, or similar)
*/
abstract getWebview(): any
/**
* Converts a local URI to a webview URI that can be used within the webview.
*
* @param uri - The local URI to convert
* @returns A URI that can be used within the webview
*/
abstract getWebviewUri(uri: Uri): Uri
/**
* Gets the Content Security Policy source for the webview.
*
* @returns The CSP source string to be used in the webview's Content-Security-Policy
*/
abstract getCspSource(): string
/**
* Checks if the webview is currently visible to the user.
*
* @returns True if the webview is visible, false otherwise
*/
abstract isVisible(): boolean
/**
* Defines and returns the HTML that should be rendered within the webview panel.
*
* @remarks This is also the place where references to the React webview build files
* are created and inserted into the webview HTML.
*
* @param webview A reference to the extension webview
* @param extensionUri The URI of the directory containing the extension
* @returns A template string literal containing the HTML that should be
* rendered within the webview panel
*/
public getHtmlContent(): string {
// Get the local path to main script run in the webview,
// then convert it to a uri we can use in the webview.
// The CSS file from the React build output
const stylesUri = this.getExtensionUri("webview-ui", "build", "assets", "index.css")
// The JS file from the React build output
const scriptUri = this.getExtensionUri("webview-ui", "build", "assets", "index.js")
// The codicon font from the React build output
// https://github.com/microsoft/vscode-extension-samples/blob/main/webview-codicons-sample/src/extension.ts
// we installed this package in the extension so that we can access it how its intended from the extension (the font file is likely bundled in vscode), and we just import the css fileinto our react app we don't have access to it
// don't forget to add font-src ${webview.cspSource};
const codiconsUri = this.getExtensionUri("node_modules", "@vscode", "codicons", "dist", "codicon.css")
// const scriptUri = webview.asWebviewUri(vscode.Uri.joinPath(this._extensionUri, "assets", "main.js"))
// const styleResetUri = webview.asWebviewUri(vscode.Uri.joinPath(this._extensionUri, "assets", "reset.css"))
// const styleVSCodeUri = webview.asWebviewUri(vscode.Uri.joinPath(this._extensionUri, "assets", "vscode.css"))
// // Same for stylesheet
// const stylesheetUri = webview.asWebviewUri(vscode.Uri.joinPath(this._extensionUri, "assets", "main.css"))
// Use a nonce to only allow a specific script to be run.
/*
content security policy of your webview to only allow scripts that have a specific nonce
create a content security policy meta tag so that only loading scripts with a nonce is allowed
As your extension grows you will likely want to add custom styles, fonts, and/or images to your webview. If you do, you will need to update the content security policy meta tag to explicitly allow for these resources. E.g.
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src ${webview.cspSource}; font-src ${webview.cspSource}; img-src ${webview.cspSource} https:; script-src 'nonce-${nonce}';">
- 'unsafe-inline' is required for styles due to vscode-webview-toolkit's dynamic style injection
- since we pass base64 images to the webview, we need to specify img-src ${webview.cspSource} data:;
in meta tag we add nonce attribute: A cryptographic nonce (only used once) to allow scripts. The server must generate a unique nonce value each time it transmits a policy. It is critical to provide a nonce that cannot be guessed as bypassing a resource's policy is otherwise trivial.
*/
const nonce = getNonce()
// Tip: Install the es6-string-html VS Code extension to enable code highlighting below
return /*html*/ `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,shrink-to-fit=no">
<meta name="theme-color" content="#000000">
<link rel="stylesheet" type="text/css" href="${stylesUri}">
<link href="${codiconsUri}" rel="stylesheet" />
<meta http-equiv="Content-Security-Policy" content="default-src 'none';
connect-src https://*.posthog.com https://*.cline.bot https://*.firebaseauth.com https://*.firebaseio.com https://*.googleapis.com https://*.firebase.com;
font-src ${this.getCspSource()} data:;
style-src ${this.getCspSource()} 'unsafe-inline';
img-src ${this.getCspSource()} https: data:;
script-src 'nonce-${nonce}' 'unsafe-eval';">
<title>Cline</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<script type="text/javascript" nonce="${nonce}">
// Inject the provider type
window.WEBVIEW_PROVIDER_TYPE = ${JSON.stringify(this.providerType)};
// Inject the client ID
window.clineClientId = "${this.clientId}";
</script>
<script type="module" nonce="${nonce}" src="${scriptUri}"></script>
</body>
</html>
`
}
/**
* Reads the Vite dev server port from the generated port file to avoid conflicts
* Returns a Promise that resolves to the port number
* If the file doesn't exist or can't be read, it resolves to the default port
*/
private getDevServerPort(): Promise<number> {
const DEFAULT_PORT = 25463
const portFilePath = path.join(__dirname, "..", "webview-ui", ".vite-port")
return readFile(portFilePath, "utf8")
.then((portFile) => {
const port = parseInt(portFile.trim()) || DEFAULT_PORT
console.info(`[getDevServerPort] Using dev server port ${port} from .vite-port file`)
return port
})
.catch((err) => {
console.warn(
`[getDevServerPort] Port file not found or couldn't be read at ${portFilePath}, using default port: ${DEFAULT_PORT}`,
)
return DEFAULT_PORT
})
}
/**
* Connects to the local Vite dev server to allow HMR, with fallback to the bundled assets
*
* @param webview A reference to the extension webview
* @returns A template string literal containing the HTML that should be
* rendered within the webview panel
*/
protected async getHMRHtmlContent(): Promise<string> {
const localPort = await this.getDevServerPort()
const localServerUrl = `localhost:${localPort}`
// Check if local dev server is running.
try {
await axios.get(`http://${localServerUrl}`)
} catch (error) {
// Only show the error message when in development mode.
if (process.env.IS_DEV) {
HostProvider.window.showMessage({
type: ShowMessageType.ERROR,
message:
"Cline: Local webview dev server is not running, HMR will not work. Please run 'npm run dev:webview' before launching the extension to enable HMR. Using bundled assets.",
})
}
return this.getHtmlContent()
}
const nonce = getNonce()
const stylesUri = this.getExtensionUri("webview-ui", "build", "assets", "index.css")
const codiconsUri = this.getExtensionUri("node_modules", "@vscode", "codicons", "dist", "codicon.css")
const scriptEntrypoint = "src/main.tsx"
const scriptUri = `http://${localServerUrl}/${scriptEntrypoint}`
const reactRefresh = /*html*/ `
<script nonce="${nonce}" type="module">
import RefreshRuntime from "http://${localServerUrl}/@react-refresh"
RefreshRuntime.injectIntoGlobalHook(window)
window.$RefreshReg$ = () => {}
window.$RefreshSig$ = () => (type) => type
window.__vite_plugin_react_preamble_installed__ = true
</script>
`
const csp = [
"default-src 'none'",
`font-src ${this.getCspSource()}`,
`style-src ${this.getCspSource()} 'unsafe-inline' https://* http://${localServerUrl} http://0.0.0.0:${localPort}`,
`img-src ${this.getCspSource()} https: data:`,
`script-src 'unsafe-eval' https://* http://${localServerUrl} http://0.0.0.0:${localPort} 'nonce-${nonce}'`,
`connect-src https://* ws://${localServerUrl} ws://0.0.0.0:${localPort} http://${localServerUrl} http://0.0.0.0:${localPort}`,
]
return /*html*/ `
<!DOCTYPE html>
<html lang="en">
<head>
${process.env.IS_DEV ? '<script src="http://localhost:8097"></script>' : ""}
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,shrink-to-fit=no">
<meta http-equiv="Content-Security-Policy" content="${csp.join("; ")}">
<link rel="stylesheet" type="text/css" href="${stylesUri}">
<link href="${codiconsUri}" rel="stylesheet" />
<title>Cline</title>
</head>
<body>
<div id="root"></div>
<script type="text/javascript" nonce="${nonce}">
// Inject the provider type
window.WEBVIEW_PROVIDER_TYPE = ${JSON.stringify(this.providerType)};
// Inject the client ID
window.clineClientId = "${this.clientId}";
</script>
${reactRefresh}
<script type="module" src="${scriptUri}"></script>
</body>
</html>
`
}
/**
* A helper function which will get the webview URI of a given file or resource in the extension directory.
*
* @remarks This URI can be used within a webview's HTML as a link to the
* given file/resource.
*
* @param pathList An array of strings representing the path to a file/resource in the extension directory.
* @returns A URI pointing to the file/resource
*/
private getExtensionUri(...pathList: string[]): Uri {
if (!this.getWebview()) {
throw Error("webview is not initialized.")
}
return this.getWebviewUri(Uri.joinPath(this.context.extensionUri, ...pathList))
}
}
+92 -80
View File
@@ -1,11 +1,11 @@
// The module 'vscode' contains the VS Code extensibility API
// Import the module and reference it with the alias vscode in your code below
import assert from "node:assert"
import { setTimeout as setTimeoutPromise } from "node:timers/promises"
import { DIFF_VIEW_URI_SCHEME } from "@hosts/vscode/VscodeDiffViewProvider"
import { WebviewProviderType as WebviewProviderTypeEnum } from "@shared/proto/cline/ui"
import assert from "node:assert"
import { setTimeout as setTimeoutPromise } from "node:timers/promises"
import pWaitFor from "p-wait-for"
import { v4 as uuidv4 } from "uuid"
import * as vscode from "vscode"
import { sendAccountButtonClickedEvent } from "./core/controller/ui/subscribeToAccountButtonClicked"
import { sendChatButtonClickedEvent } from "./core/controller/ui/subscribeToChatButtonClicked"
@@ -19,23 +19,24 @@ import {
} from "./core/storage/state-migrations"
import { WebviewProvider } from "./core/webview"
import { createClineAPI } from "./exports"
import { ErrorService } from "./services/error/ErrorService"
import { Logger } from "./services/logging/Logger"
import { PostHogClientProvider } from "./services/posthog/PostHogClientProvider"
import { posthogClientProvider } from "./services/posthog/PostHogClientProvider"
import { telemetryService } from "./services/posthog/telemetry/TelemetryService"
import { cleanupTestMode, initializeTestMode } from "./services/test/TestMode"
import { WebviewProviderType } from "./shared/webview/types"
import "./utils/path" // necessary to have access to String.prototype.toPosix
import type { ExtensionContext } from "vscode"
import { HostProvider } from "@/hosts/host-provider"
import { vscodeHostBridgeClient } from "@/hosts/vscode/hostbridge/client/host-grpc-client"
import { readTextFromClipboard, writeTextToClipboard } from "@/utils/env"
import { ExtensionContext } from "vscode"
import { FileContextTracker } from "./core/context/context-tracking/FileContextTracker"
import { sendFocusChatInputEvent } from "./core/controller/ui/subscribeToFocusChatInput"
import { VscodeDiffViewProvider } from "./hosts/vscode/VscodeDiffViewProvider"
import { VscodeWebviewProvider } from "./hosts/vscode/VscodeWebviewProvider"
import { GitCommitGenerator } from "./integrations/git/commit-message-generator"
import { AuthService } from "./services/auth/AuthService"
import { telemetryService } from "./services/posthog/PostHogClientProvider"
import { ShowMessageType } from "./shared/proto/host/window"
import { SharedUriHandler } from "./services/uri/SharedUriHandler"
import { getLatestAnnouncementId } from "./utils/announcements"
@@ -53,10 +54,7 @@ https://github.com/microsoft/vscode-webview-ui-toolkit-samples/tree/main/framewo
export async function activate(context: vscode.ExtensionContext) {
maybeSetupHostProviders(context)
// Initialize PostHog client provider
const distinctId = context.globalState.get<string>("cline.distinctId")
PostHogClientProvider.getInstance(distinctId)
ErrorService.initialize()
Logger.log("Cline extension activated")
// Migrate custom instructions to global Cline rules (one-time cleanup)
@@ -104,10 +102,7 @@ export async function activate(context: vscode.ExtensionContext) {
: `Welcome to Cline v${currentVersion}`
await vscode.commands.executeCommand("claude-dev.SidebarProvider.focus")
await new Promise((resolve) => setTimeout(resolve, 200))
HostProvider.window.showMessage({
type: ShowMessageType.INFORMATION,
message,
})
HostProvider.window.showMessage({ type: ShowMessageType.INFORMATION, message })
}
// Always update the main version tracker for the next launch.
await context.globalState.update("clineVersion", currentVersion)
@@ -117,7 +112,15 @@ export async function activate(context: vscode.ExtensionContext) {
console.error(`Error during post-update actions: ${errorMessage}, Stack trace: ${error.stack}`)
}
telemetryService.captureExtensionActivated()
// backup id in case vscMachineID doesn't work
let installId = context.globalState.get<string>("installId")
if (!installId) {
installId = uuidv4()
await context.globalState.update("installId", installId)
}
telemetryService.captureExtensionActivated(installId)
context.subscriptions.push(
vscode.commands.registerCommand("cline.plusButtonClicked", async (webview: any) => {
@@ -203,7 +206,6 @@ export async function activate(context: vscode.ExtensionContext) {
// Lock the editor group so clicking on files doesn't open them over the panel
await setTimeoutPromise(100)
await vscode.commands.executeCommand("workbench.action.lockEditorGroup")
return tabWebview
}
context.subscriptions.push(vscode.commands.registerCommand("cline.popoutButtonClicked", openClineInNewTab))
@@ -290,16 +292,12 @@ export async function activate(context: vscode.ExtensionContext) {
context.subscriptions.push(
vscode.commands.registerCommand("cline.addToChat", async (range?: vscode.Range, diagnostics?: vscode.Diagnostic[]) => {
await vscode.commands.executeCommand("cline.focusChatInput") // Ensure Cline is visible and input focused
const activeWebview = WebviewProvider.getLastActiveInstance()
const clientId = activeWebview?.getClientId()
await pWaitFor(() => !!activeWebview)
await pWaitFor(() => !!WebviewProvider.getVisibleInstance())
const editor = vscode.window.activeTextEditor
if (!editor || !clientId) {
if (!editor) {
return
}
await sendFocusChatInputEvent(clientId)
// Use provided range if available, otherwise use current selection
// (vscode command passes an argument in the first param by default, so we need to ensure it's a Range object)
const textRange = range instanceof vscode.Range ? range : editor.selection
@@ -313,13 +311,14 @@ export async function activate(context: vscode.ExtensionContext) {
const filePath = editor.document.uri.fsPath
const languageId = editor.document.languageId
await activeWebview?.controller.addSelectedCodeToChat(
const visibleWebview = WebviewProvider.getVisibleInstance()
await visibleWebview?.controller.addSelectedCodeToChat(
selectedText,
filePath,
languageId,
Array.isArray(diagnostics) ? diagnostics : undefined,
)
telemetryService.captureButtonClick("codeAction_addToChat", activeWebview?.controller.task?.taskId)
telemetryService.captureButtonClick("codeAction_addToChat", visibleWebview?.controller.task?.taskId)
}),
)
@@ -338,7 +337,7 @@ export async function activate(context: vscode.ExtensionContext) {
await vscode.commands.executeCommand("workbench.action.terminal.copySelection")
// Get copied content
const terminalContents = (await readTextFromClipboard()).trim()
let terminalContents = (await readTextFromClipboard()).trim()
// Restore original clipboard content
await writeTextToClipboard(tempCopyBuffer)
@@ -480,8 +479,8 @@ export async function activate(context: vscode.ExtensionContext) {
vscode.commands.registerCommand("cline.fixWithCline", async (range: vscode.Range, diagnostics: vscode.Diagnostic[]) => {
// Add this line to focus the chat input first
await vscode.commands.executeCommand("cline.focusChatInput")
// Wait for a webview instance to become available after focusing
await pWaitFor(() => !!WebviewProvider.getLastActiveInstance())
// Wait for a webview instance to become visible after focusing
await pWaitFor(() => !!WebviewProvider.getVisibleInstance())
const editor = vscode.window.activeTextEditor
if (!editor) {
return
@@ -491,17 +490,17 @@ export async function activate(context: vscode.ExtensionContext) {
const filePath = editor.document.uri.fsPath
const languageId = editor.document.languageId
// Send to last active instance with diagnostics
const activeWebview = WebviewProvider.getLastActiveInstance()
await activeWebview?.controller.fixWithCline(selectedText, filePath, languageId, diagnostics)
telemetryService.captureButtonClick("codeAction_fixWithCline", activeWebview?.controller.task?.taskId)
// Send to sidebar provider with diagnostics
const visibleWebview = WebviewProvider.getVisibleInstance()
await visibleWebview?.controller.fixWithCline(selectedText, filePath, languageId, diagnostics)
telemetryService.captureButtonClick("codeAction_fixWithCline", visibleWebview?.controller.task?.taskId)
}),
)
context.subscriptions.push(
vscode.commands.registerCommand("cline.explainCode", async (range: vscode.Range) => {
await vscode.commands.executeCommand("cline.focusChatInput") // Ensure Cline is visible and input focused
await pWaitFor(() => !!WebviewProvider.getLastActiveInstance())
await pWaitFor(() => !!WebviewProvider.getVisibleInstance())
const editor = vscode.window.activeTextEditor
if (!editor) {
return
@@ -515,18 +514,18 @@ export async function activate(context: vscode.ExtensionContext) {
return
}
const filePath = editor.document.uri.fsPath
const activeWebview = WebviewProvider.getLastActiveInstance()
const fileMention = activeWebview?.controller.getFileMentionFromPath(filePath) || filePath
const visibleWebview = WebviewProvider.getVisibleInstance()
const fileMention = visibleWebview?.controller.getFileMentionFromPath(filePath) || filePath
const prompt = `Explain the following code from ${fileMention}:\n\`\`\`${editor.document.languageId}\n${selectedText}\n\`\`\``
await activeWebview?.controller.initTask(prompt)
telemetryService.captureButtonClick("codeAction_explainCode", activeWebview?.controller.task?.taskId)
await visibleWebview?.controller.initTask(prompt)
telemetryService.captureButtonClick("codeAction_explainCode", visibleWebview?.controller.task?.taskId)
}),
)
context.subscriptions.push(
vscode.commands.registerCommand("cline.improveCode", async (range: vscode.Range) => {
await vscode.commands.executeCommand("cline.focusChatInput") // Ensure Cline is visible and input focused
await pWaitFor(() => !!WebviewProvider.getLastActiveInstance())
await pWaitFor(() => !!WebviewProvider.getVisibleInstance())
const editor = vscode.window.activeTextEditor
if (!editor) {
return
@@ -540,62 +539,73 @@ export async function activate(context: vscode.ExtensionContext) {
return
}
const filePath = editor.document.uri.fsPath
const activeWebview = WebviewProvider.getLastActiveInstance()
const fileMention = activeWebview?.controller.getFileMentionFromPath(filePath) || filePath
const visibleWebview = WebviewProvider.getVisibleInstance()
const fileMention = visibleWebview?.controller.getFileMentionFromPath(filePath) || filePath
const prompt = `Improve the following code from ${fileMention} (e.g., suggest refactorings, optimizations, or better practices):\n\`\`\`${editor.document.languageId}\n${selectedText}\n\`\`\``
await activeWebview?.controller.initTask(prompt)
telemetryService.captureButtonClick("codeAction_improveCode", activeWebview?.controller.task?.taskId)
await visibleWebview?.controller.initTask(prompt)
telemetryService.captureButtonClick("codeAction_improveCode", visibleWebview?.controller.task?.taskId)
}),
)
// Register the focusChatInput command handler
context.subscriptions.push(
vscode.commands.registerCommand("cline.focusChatInput", async () => {
// Fast path: check for existing active instance
let activeWebview = WebviewProvider.getLastActiveInstance()
let activeWebviewProvider: WebviewProvider | undefined = WebviewProvider.getVisibleInstance()
if (activeWebview) {
// Instance exists - just reveal and focus it
const webview = activeWebview.getWebview()
if (webview) {
if (webview && "reveal" in webview) {
webview.reveal()
} else if ("show" in webview) {
webview.show()
// If a tab is visible and active, ensure it's fully revealed (might be redundant but safe)
if (activeWebviewProvider?.getWebview() && activeWebviewProvider.getWebview().hasOwnProperty("reveal")) {
const panelView = activeWebviewProvider.getWebview() as vscode.WebviewPanel
panelView.reveal(panelView.viewColumn)
} else if (!activeWebviewProvider) {
// No webview is currently visible, try to activate the sidebar
await vscode.commands.executeCommand("claude-dev.SidebarProvider.focus")
await new Promise((resolve) => setTimeout(resolve, 200)) // Allow time for focus
activeWebviewProvider = WebviewProvider.getSidebarInstance()
if (!activeWebviewProvider) {
// Sidebar didn't become active (might be closed or not in current view container)
// Check for existing tab panels
const tabInstances = WebviewProvider.getTabInstances()
if (tabInstances.length > 0) {
const potentialTabInstance = tabInstances[tabInstances.length - 1] // Get the most recent one
if (potentialTabInstance.getWebview() && potentialTabInstance.getWebview().hasOwnProperty("reveal")) {
const panelView = potentialTabInstance.getWebview() as vscode.WebviewPanel
panelView.reveal(panelView.viewColumn)
activeWebviewProvider = potentialTabInstance
}
}
}
} else {
// No active instance - need to find or create one
WebviewProvider.setLastActiveControllerId(null)
// Check for existing tab instances first (cheaper than focusing sidebar)
const tabInstances = WebviewProvider.getTabInstances()
if (tabInstances.length > 0) {
activeWebview = tabInstances[tabInstances.length - 1]
} else {
// Try to focus sidebar
await vscode.commands.executeCommand("claude-dev.SidebarProvider.focus")
// Small delay for focus to complete
await new Promise((resolve) => setTimeout(resolve, 200))
// Last resort: create new tab
activeWebview = WebviewProvider.getSidebarInstance() || (await openClineInNewTab())
if (!activeWebviewProvider) {
// No existing Cline view found at all, open a new tab
await vscode.commands.executeCommand("cline.openInNewTab")
// After openInNewTab, a new webview is created. We need to get this new instance.
// It might take a moment for it to register.
await pWaitFor(
() => {
const visibleInstance = WebviewProvider.getVisibleInstance()
// Ensure a boolean is returned
return !!(visibleInstance?.getWebview() && visibleInstance.getWebview().hasOwnProperty("reveal"))
},
{ timeout: 2000 },
)
activeWebviewProvider = WebviewProvider.getVisibleInstance()
}
}
// Send focus event
const clientId = activeWebview?.getClientId()
if (!clientId) {
// At this point, activeWebviewProvider should be the one we want to send the message to.
// It could still be undefined if opening a new tab failed or timed out.
if (activeWebviewProvider) {
// Use the gRPC streaming method instead of postMessageToWebview
const clientId = activeWebviewProvider.getClientId()
sendFocusChatInputEvent(clientId)
} else {
console.error("FocusChatInput: Could not find or activate a Cline webview to focus.")
HostProvider.window.showMessage({
type: ShowMessageType.ERROR,
message: "Could not activate Cline view. Please try opening it manually from the Activity Bar.",
})
return
}
sendFocusChatInputEvent(clientId)
telemetryService.captureButtonClick("command_focusChatInput", activeWebview.controller?.task?.taskId)
telemetryService.captureButtonClick("command_focusChatInput", activeWebviewProvider?.controller.task?.taskId)
}),
)
@@ -644,25 +654,27 @@ function maybeSetupHostProviders(context: ExtensionContext) {
if (!HostProvider.isInitialized()) {
console.log("Setting up vscode host providers...")
const createWebview = (type: WebviewProviderType) => new VscodeWebviewProvider(context, type)
const createDiffView = () => new VscodeDiffViewProvider()
const createWebview = function (type: WebviewProviderType) {
return new VscodeWebviewProvider(context, type)
}
const createDiffView = function () {
return new VscodeDiffViewProvider()
}
const outputChannel = vscode.window.createOutputChannel("Cline")
context.subscriptions.push(outputChannel)
const getCallbackUri = async () => `${vscode.env.uriScheme || "vscode"}://saoudrizwan.claude-dev`
HostProvider.initialize(createWebview, createDiffView, vscodeHostBridgeClient, outputChannel.appendLine, getCallbackUri)
HostProvider.initialize(createWebview, createDiffView, vscodeHostBridgeClient, outputChannel.appendLine)
}
}
// This method is called when your extension is deactivated
export async function deactivate() {
PostHogClientProvider.getInstance().dispose()
// Dispose all webview instances
await WebviewProvider.disposeAllInstances()
// Clean up test mode
cleanupTestMode()
await posthogClientProvider.shutdown()
Logger.log("Cline extension deactivated")
}
+23 -17
View File
@@ -1,6 +1,7 @@
import type { IncomingMessage, Server, ServerResponse } from "node:http"
import http from "node:http"
import type { AddressInfo } from "node:net"
import { clineEnvConfig } from "@/config"
import { openExternal } from "@/utils/env"
import { SharedUriHandler } from "@/services/uri/SharedUriHandler"
@@ -35,25 +36,30 @@ export class AuthHandler {
this.enabled = enabled
}
public async getCallbackUri(): Promise<string> {
if (!this.enabled) {
throw Error("AuthHandler was not enabled")
}
if (!this.server) {
// If server creation is already in progress, wait for it
if (this.serverCreationPromise) {
await this.serverCreationPromise
} else {
// Start server creation and track the promise
this.serverCreationPromise = this.createServer()
await this.serverCreationPromise
public async getCallbackUri(): Promise<string | undefined> {
try {
if (!this.enabled) {
return undefined
}
} else {
this.updateTimeout()
}
return `http://127.0.0.1:${this.port}`
if (!this.server) {
// If server creation is already in progress, wait for it
if (this.serverCreationPromise) {
await this.serverCreationPromise
} else {
// Start server creation and track the promise
this.serverCreationPromise = this.createServer()
await this.serverCreationPromise
}
} else {
this.updateTimeout()
}
return `http://127.0.0.1:${this.port}`
} catch (error) {
console.error("AuthHandler.getCallbackUri error:", error)
return undefined
}
}
private async createServer(): Promise<void> {
+2 -22
View File
@@ -1,6 +1,5 @@
import { HostProvider } from "@/hosts/host-provider"
import { DiffViewProvider } from "@/integrations/editor/DiffViewProvider"
import { DiagnosticSeverity } from "@/shared/proto/host/workspace"
import { status } from "@grpc/grpc-js"
export class ExternalDiffViewProvider extends DiffViewProvider {
@@ -79,27 +78,8 @@ export class ExternalDiffViewProvider extends DiffViewProvider {
}
protected override async getNewDiagnosticProblems(): Promise<string> {
// Get diagnostics using the HostBridge workspace service
const response = await HostProvider.workspace.getDiagnostics({})
if (response.fileDiagnostics.length === 0) {
return ""
}
let result = ""
for (const fileDiagnostics of response.fileDiagnostics) {
const errors = fileDiagnostics.diagnostics.filter((d) => d.severity === DiagnosticSeverity.DIAGNOSTIC_ERROR)
if (errors.length > 0) {
result += `\n\n${fileDiagnostics.filePath}`
for (const diagnostic of errors) {
const line = (diagnostic.range?.start?.line || 0) + 1 // Proto lines are 0-indexed
const source = diagnostic.source ? `${diagnostic.source} ` : ""
result += `\n- [${source}Error] Line ${line}: ${diagnostic.message}`
}
}
}
return result.trim()
console.log(`Called ExternalDiffViewProvider.getNewDiagnosticProblems() stub`)
return ""
}
protected override async closeDiffView(): Promise<void> {
+6 -1
View File
@@ -1,7 +1,8 @@
import { ExtensionMessage } from "@/shared/ExtensionMessage"
import { WebviewProviderType } from "@/shared/webview/types"
import { WebviewProvider } from "@/core/webview"
import * as vscode from "vscode"
import { URI } from "vscode-uri"
import { WebviewProvider } from "@core/webview"
export class ExternalWebviewProvider extends WebviewProvider {
// This hostname cannot be changed without updating the external webview handler.
@@ -20,6 +21,10 @@ export class ExternalWebviewProvider extends WebviewProvider {
override getCspSource() {
return `'self' https://${this.RESOURCE_HOSTNAME}`
}
override postMessageToWebview(message: ExtensionMessage) {
console.log(`postMessageToWebview: ${message}`)
return undefined
}
override isVisible() {
return true
}
+1 -8
View File
@@ -1,4 +1,4 @@
import { WebviewProvider } from "@/core/webview"
import { WebviewProvider } from "@core/webview"
import { HostBridgeClientProvider } from "./host-provider-types"
import { WebviewProviderType } from "@/shared/webview/types"
import { DiffViewProvider } from "@/integrations/editor/DiffViewProvider"
@@ -26,22 +26,17 @@ export class HostProvider {
// Logs to a user-visible output channel.
logToChannel: LogToChannel
// Returns a callback URI that will redirect to Cline.
getCallbackUri: () => Promise<string>
// Private constructor to enforce singleton pattern
private constructor(
createWebviewProvider: WebviewProviderCreator,
createDiffViewProvider: DiffViewProviderCreator,
hostBridge: HostBridgeClientProvider,
logToChannel: LogToChannel,
getCallbackUri: () => Promise<string>,
) {
this.createWebviewProvider = createWebviewProvider
this.createDiffViewProvider = createDiffViewProvider
this.hostBridge = hostBridge
this.logToChannel = logToChannel
this.getCallbackUri = getCallbackUri
}
public static initialize(
@@ -49,7 +44,6 @@ export class HostProvider {
diffViewProviderCreator: DiffViewProviderCreator,
hostBridgeProvider: HostBridgeClientProvider,
logToChannel: LogToChannel,
getCallbackUri: () => Promise<string>,
): HostProvider {
if (HostProvider.instance) {
throw new Error("Host providers have already been initialized.")
@@ -59,7 +53,6 @@ export class HostProvider {
diffViewProviderCreator,
hostBridgeProvider,
logToChannel,
getCallbackUri,
)
return HostProvider.instance
}
+1 -1
View File
@@ -3,7 +3,7 @@ import * as path from "path"
import * as vscode from "vscode"
import { DecorationController } from "@/hosts/vscode/DecorationController"
import { DiffViewProvider } from "@integrations/editor/DiffViewProvider"
import { diagnosticsToProblemsString, getNewDiagnostics } from "./diagnostics"
import { diagnosticsToProblemsString, getNewDiagnostics } from "@/integrations/diagnostics"
export const DIFF_VIEW_URI_SCHEME = "cline-diff"
+6 -55
View File
@@ -4,11 +4,9 @@ import { WebviewProvider } from "@core/webview"
import { getTheme } from "@integrations/theme/getTheme"
import type { Uri } from "vscode"
import * as vscode from "vscode"
import { HostProvider } from "@/hosts/host-provider"
import type { ExtensionMessage } from "@/shared/ExtensionMessage"
import type { WebviewProviderType } from "@/shared/webview/types"
import { WebviewMessage } from "@/shared/WebviewMessage"
import { handleGrpcRequest, handleGrpcRequestCancel } from "@/core/controller/grpc-handler"
import { HostProvider } from "@/hosts/host-provider"
/*
https://github.com/microsoft/vscode-webview-ui-toolkit-samples/blob/main/default/weather-webview/src/providers/WeatherViewProvider.ts
@@ -16,8 +14,7 @@ https://github.com/KumarVariable/vscode-extension-sidebar-html/blob/master/src/c
*/
export class VscodeWebviewProvider extends WebviewProvider implements vscode.WebviewViewProvider {
private webview?: vscode.WebviewView | vscode.WebviewPanel
private disposables: vscode.Disposable[] = []
public webview?: vscode.WebviewView | vscode.WebviewPanel
constructor(context: vscode.ExtensionContext, providerType: WebviewProviderType) {
super(context, providerType)
@@ -35,6 +32,9 @@ export class VscodeWebviewProvider extends WebviewProvider implements vscode.Web
}
return this.webview.webview.cspSource
}
override postMessageToWebview(message: ExtensionMessage) {
return this.webview?.webview.postMessage(message)
}
override isVisible() {
return this.webview?.visible || false
}
@@ -71,7 +71,6 @@ export class VscodeWebviewProvider extends WebviewProvider implements vscode.Web
webviewView.onDidChangeViewState(
async (e) => {
if (e?.webviewPanel?.visible && e.webviewPanel?.active) {
WebviewProvider.setLastActiveControllerId(this.controller.id)
// Only send the event if the webview is active (focused)
await sendDidBecomeVisibleEvent(this.controller.id)
}
@@ -84,7 +83,6 @@ export class VscodeWebviewProvider extends WebviewProvider implements vscode.Web
webviewView.onDidChangeVisibility(
async () => {
if (this.webview?.visible) {
WebviewProvider.setLastActiveControllerId(this.controller.id)
await sendDidBecomeVisibleEvent(this.controller.id)
}
},
@@ -97,9 +95,6 @@ export class VscodeWebviewProvider extends WebviewProvider implements vscode.Web
// This happens when the user closes the view or when the view is closed programmatically
webviewView.onDidDispose(
async () => {
if (WebviewProvider.getLastActiveControllerId() === this.controller.id) {
WebviewProvider.setLastActiveControllerId(null)
}
await this.dispose()
},
null,
@@ -160,61 +155,17 @@ export class VscodeWebviewProvider extends WebviewProvider implements vscode.Web
private setWebviewMessageListener(webview: vscode.Webview) {
webview.onDidReceiveMessage(
(message) => {
this.handleWebviewMessage(message)
this.controller.handleWebviewMessage(message)
},
null,
this.disposables,
)
}
/**
* Sets up an event listener to listen for messages passed from the webview context and
* executes code based on the message that is received.
*
* @param webview A reference to the extension webview
*/
async handleWebviewMessage(message: WebviewMessage) {
const postMessageToWebview = (response: ExtensionMessage) => this.postMessageToWebview(response)
switch (message.type) {
case "grpc_request": {
if (message.grpc_request) {
await handleGrpcRequest(this.controller, postMessageToWebview, message.grpc_request)
}
break
}
case "grpc_request_cancel": {
if (message.grpc_request_cancel) {
await handleGrpcRequestCancel(postMessageToWebview, message.grpc_request_cancel)
}
break
}
default: {
console.error("Received unhandled WebviewMessage type:", JSON.stringify(message))
}
}
}
/**
* Sends a message from the extension to the webview.
*
* @param message - The message to send to the webview
* @returns A thenable that resolves to a boolean indicating success, or undefined if the webview is not available
*/
private async postMessageToWebview(message: ExtensionMessage): Promise<boolean | undefined> {
return this.webview?.webview.postMessage(message)
}
override async dispose() {
if (this.webview && "dispose" in this.webview) {
this.webview.dispose()
}
while (this.disposables.length) {
const x = this.disposables.pop()
if (x) {
x.dispose()
}
}
super.dispose()
}
}
-109
View File
@@ -1,109 +0,0 @@
import * as vscode from "vscode"
import * as path from "path"
import deepEqual from "fast-deep-equal"
import { getCwd } from "@/utils/path"
export function getNewDiagnostics(
oldDiagnostics: [vscode.Uri, vscode.Diagnostic[]][],
newDiagnostics: [vscode.Uri, vscode.Diagnostic[]][],
): [vscode.Uri, vscode.Diagnostic[]][] {
const newProblems: [vscode.Uri, vscode.Diagnostic[]][] = []
const oldMap = new Map(oldDiagnostics)
for (const [uri, newDiags] of newDiagnostics) {
const oldDiags = oldMap.get(uri) || []
const newProblemsForUri = newDiags.filter((newDiag) => !oldDiags.some((oldDiag) => deepEqual(oldDiag, newDiag)))
if (newProblemsForUri.length > 0) {
newProblems.push([uri, newProblemsForUri])
}
}
return newProblems
}
// Usage:
// const oldDiagnostics = // ... your old diagnostics array
// const newDiagnostics = // ... your new diagnostics array
// const newProblems = getNewDiagnostics(oldDiagnostics, newDiagnostics);
// Example usage with mocks:
//
// // Mock old diagnostics
// const oldDiagnostics: [vscode.Uri, vscode.Diagnostic[]][] = [
// [vscode.Uri.file("/path/to/file1.ts"), [
// new vscode.Diagnostic(new vscode.Range(0, 0, 0, 10), "Old error in file1", vscode.DiagnosticSeverity.Error)
// ]],
// [vscode.Uri.file("/path/to/file2.ts"), [
// new vscode.Diagnostic(new vscode.Range(5, 5, 5, 15), "Old warning in file2", vscode.DiagnosticSeverity.Warning)
// ]]
// ];
//
// // Mock new diagnostics
// const newDiagnostics: [vscode.Uri, vscode.Diagnostic[]][] = [
// [vscode.Uri.file("/path/to/file1.ts"), [
// new vscode.Diagnostic(new vscode.Range(0, 0, 0, 10), "Old error in file1", vscode.DiagnosticSeverity.Error),
// new vscode.Diagnostic(new vscode.Range(2, 2, 2, 12), "New error in file1", vscode.DiagnosticSeverity.Error)
// ]],
// [vscode.Uri.file("/path/to/file2.ts"), [
// new vscode.Diagnostic(new vscode.Range(5, 5, 5, 15), "Old warning in file2", vscode.DiagnosticSeverity.Warning)
// ]],
// [vscode.Uri.file("/path/to/file3.ts"), [
// new vscode.Diagnostic(new vscode.Range(1, 1, 1, 11), "New error in file3", vscode.DiagnosticSeverity.Error)
// ]]
// ];
//
// const newProblems = getNewDiagnostics(oldDiagnostics, newDiagnostics);
//
// console.log("New problems:");
// for (const [uri, diagnostics] of newProblems) {
// console.log(`File: ${uri.fsPath}`);
// for (const diagnostic of diagnostics) {
// console.log(`- ${diagnostic.message} (${diagnostic.range.start.line}:${diagnostic.range.start.character})`);
// }
// }
//
// // Expected output:
// // New problems:
// // File: /path/to/file1.ts
// // - New error in file1 (2:2)
// // File: /path/to/file3.ts
// // - New error in file3 (1:1)
// will return empty string if no problems with the given severity are found
export async function diagnosticsToProblemsString(
diagnostics: [vscode.Uri, vscode.Diagnostic[]][],
severities: vscode.DiagnosticSeverity[],
): Promise<string> {
const cwd = await getCwd()
let result = ""
for (const [uri, fileDiagnostics] of diagnostics) {
const problems = fileDiagnostics.filter((d) => severities.includes(d.severity))
if (problems.length > 0) {
result += `\n\n${path.relative(cwd, uri.fsPath).toPosix()}`
for (const diagnostic of problems) {
let label: string
switch (diagnostic.severity) {
case vscode.DiagnosticSeverity.Error:
label = "Error"
break
case vscode.DiagnosticSeverity.Warning:
label = "Warning"
break
case vscode.DiagnosticSeverity.Information:
label = "Information"
break
case vscode.DiagnosticSeverity.Hint:
label = "Hint"
break
default:
label = "Diagnostic"
}
const line = diagnostic.range.start.line + 1 // VSCode lines are 0-indexed
const source = diagnostic.source ? `${diagnostic.source} ` : ""
result += `\n- [${source}${label}] Line ${line}: ${diagnostic.message}`
}
}
}
return result.trim()
}
@@ -1,69 +0,0 @@
import * as vscode from "vscode"
import {
GetDiagnosticsRequest,
GetDiagnosticsResponse,
FileDiagnostics,
Diagnostic,
DiagnosticRange,
DiagnosticPosition,
DiagnosticSeverity,
} from "@/shared/proto/host/workspace"
export async function getDiagnostics(request: GetDiagnosticsRequest): Promise<GetDiagnosticsResponse> {
// Get all diagnostics from VS Code
const vscodeAllDiagnostics = vscode.languages.getDiagnostics()
const fileDiagnostics: FileDiagnostics[] = []
for (const [uri, diagnostics] of vscodeAllDiagnostics) {
if (diagnostics.length > 0) {
const convertedDiagnostics: Diagnostic[] = diagnostics.map((vsDiagnostic) => {
// Convert VS Code severity to proto severity
let severity: DiagnosticSeverity
switch (vsDiagnostic.severity) {
case vscode.DiagnosticSeverity.Error:
severity = DiagnosticSeverity.DIAGNOSTIC_ERROR
break
case vscode.DiagnosticSeverity.Warning:
severity = DiagnosticSeverity.DIAGNOSTIC_WARNING
break
case vscode.DiagnosticSeverity.Information:
severity = DiagnosticSeverity.DIAGNOSTIC_INFORMATION
break
case vscode.DiagnosticSeverity.Hint:
severity = DiagnosticSeverity.DIAGNOSTIC_HINT
break
default:
severity = DiagnosticSeverity.DIAGNOSTIC_ERROR
}
return Diagnostic.create({
message: vsDiagnostic.message,
range: DiagnosticRange.create({
start: DiagnosticPosition.create({
line: vsDiagnostic.range.start.line,
character: vsDiagnostic.range.start.character,
}),
end: DiagnosticPosition.create({
line: vsDiagnostic.range.end.line,
character: vsDiagnostic.range.end.character,
}),
}),
severity: severity,
source: vsDiagnostic.source || undefined,
})
})
fileDiagnostics.push(
FileDiagnostics.create({
filePath: uri.fsPath,
diagnostics: convertedDiagnostics,
}),
)
}
}
return GetDiagnosticsResponse.create({
fileDiagnostics: fileDiagnostics,
})
}
@@ -1,10 +1,10 @@
import { fileExistsAtPath } from "@utils/fs"
import fs from "fs/promises"
import { globby } from "globby"
import * as path from "path"
import simpleGit, { type SimpleGit } from "simple-git"
import { telemetryService } from "@/services/posthog/PostHogClientProvider"
import simpleGit, { SimpleGit } from "simple-git"
import { fileExistsAtPath } from "@utils/fs"
import { getLfsPatterns, writeExcludesFile } from "./CheckpointExclusions"
import { telemetryService } from "@/services/posthog/telemetry/TelemetryService"
interface CheckpointAddResult {
success: boolean
@@ -1,7 +1,7 @@
import { telemetryService } from "@services/posthog/PostHogClientProvider"
import fs from "fs/promises"
import * as path from "path"
import simpleGit from "simple-git"
import { telemetryService } from "@/services/posthog/telemetry/TelemetryService"
import { GitOperations } from "./CheckpointGitOperations"
import { getShadowGitPath, getWorkingDirectory, hashWorkingDir } from "./CheckpointUtils"
+82 -26
View File
@@ -1,49 +1,105 @@
import { HostProvider } from "@/hosts/host-provider"
import { GetDiagnosticsRequest, DiagnosticSeverity } from "@/shared/proto/host/workspace"
import { Metadata } from "@/shared/proto/cline/common"
import * as vscode from "vscode"
import * as path from "path"
import deepEqual from "fast-deep-equal"
import { getCwd } from "@/utils/path"
/**
* Host-agnostic function to get workspace problems as a formatted string
* Used by @problems mention for cross-host compatibility
*/
export async function getWorkspaceProblemsString(): Promise<string> {
const response = await HostProvider.workspace.getDiagnostics(
GetDiagnosticsRequest.create({
metadata: Metadata.create({}),
}),
)
export function getNewDiagnostics(
oldDiagnostics: [vscode.Uri, vscode.Diagnostic[]][],
newDiagnostics: [vscode.Uri, vscode.Diagnostic[]][],
): [vscode.Uri, vscode.Diagnostic[]][] {
const newProblems: [vscode.Uri, vscode.Diagnostic[]][] = []
const oldMap = new Map(oldDiagnostics)
if (response.fileDiagnostics.length === 0) {
return "No errors or warnings detected."
for (const [uri, newDiags] of newDiagnostics) {
const oldDiags = oldMap.get(uri) || []
const newProblemsForUri = newDiags.filter((newDiag) => !oldDiags.some((oldDiag) => deepEqual(oldDiag, newDiag)))
if (newProblemsForUri.length > 0) {
newProblems.push([uri, newProblemsForUri])
}
}
let result = ""
for (const fileDiagnostics of response.fileDiagnostics) {
const problems = fileDiagnostics.diagnostics.filter(
(d) => d.severity === DiagnosticSeverity.DIAGNOSTIC_ERROR || d.severity === DiagnosticSeverity.DIAGNOSTIC_WARNING,
)
return newProblems
}
// Usage:
// const oldDiagnostics = // ... your old diagnostics array
// const newDiagnostics = // ... your new diagnostics array
// const newProblems = getNewDiagnostics(oldDiagnostics, newDiagnostics);
// Example usage with mocks:
//
// // Mock old diagnostics
// const oldDiagnostics: [vscode.Uri, vscode.Diagnostic[]][] = [
// [vscode.Uri.file("/path/to/file1.ts"), [
// new vscode.Diagnostic(new vscode.Range(0, 0, 0, 10), "Old error in file1", vscode.DiagnosticSeverity.Error)
// ]],
// [vscode.Uri.file("/path/to/file2.ts"), [
// new vscode.Diagnostic(new vscode.Range(5, 5, 5, 15), "Old warning in file2", vscode.DiagnosticSeverity.Warning)
// ]]
// ];
//
// // Mock new diagnostics
// const newDiagnostics: [vscode.Uri, vscode.Diagnostic[]][] = [
// [vscode.Uri.file("/path/to/file1.ts"), [
// new vscode.Diagnostic(new vscode.Range(0, 0, 0, 10), "Old error in file1", vscode.DiagnosticSeverity.Error),
// new vscode.Diagnostic(new vscode.Range(2, 2, 2, 12), "New error in file1", vscode.DiagnosticSeverity.Error)
// ]],
// [vscode.Uri.file("/path/to/file2.ts"), [
// new vscode.Diagnostic(new vscode.Range(5, 5, 5, 15), "Old warning in file2", vscode.DiagnosticSeverity.Warning)
// ]],
// [vscode.Uri.file("/path/to/file3.ts"), [
// new vscode.Diagnostic(new vscode.Range(1, 1, 1, 11), "New error in file3", vscode.DiagnosticSeverity.Error)
// ]]
// ];
//
// const newProblems = getNewProblems(oldDiagnostics, newDiagnostics);
//
// console.log("New problems:");
// for (const [uri, diagnostics] of newProblems) {
// console.log(`File: ${uri.fsPath}`);
// for (const diagnostic of diagnostics) {
// console.log(`- ${diagnostic.message} (${diagnostic.range.start.line}:${diagnostic.range.start.character})`);
// }
// }
//
// // Expected output:
// // New problems:
// // File: /path/to/file1.ts
// // - New error in file1 (2:2)
// // File: /path/to/file3.ts
// // - New error in file3 (1:1)
// will return empty string if no problems with the given severity are found
export async function diagnosticsToProblemsString(
diagnostics: [vscode.Uri, vscode.Diagnostic[]][],
severities: vscode.DiagnosticSeverity[],
): Promise<string> {
const cwd = await getCwd()
let result = ""
for (const [uri, fileDiagnostics] of diagnostics) {
const problems = fileDiagnostics.filter((d) => severities.includes(d.severity))
if (problems.length > 0) {
result += `\n\n${fileDiagnostics.filePath}`
result += `\n\n${path.relative(cwd, uri.fsPath).toPosix()}`
for (const diagnostic of problems) {
let label: string
switch (diagnostic.severity) {
case DiagnosticSeverity.DIAGNOSTIC_ERROR:
case vscode.DiagnosticSeverity.Error:
label = "Error"
break
case DiagnosticSeverity.DIAGNOSTIC_WARNING:
case vscode.DiagnosticSeverity.Warning:
label = "Warning"
break
case DiagnosticSeverity.DIAGNOSTIC_INFORMATION:
case vscode.DiagnosticSeverity.Information:
label = "Information"
break
case DiagnosticSeverity.DIAGNOSTIC_HINT:
case vscode.DiagnosticSeverity.Hint:
label = "Hint"
break
default:
label = "Diagnostic"
}
const line = (diagnostic.range?.start?.line || 0) + 1 // Proto lines are 0-indexed
const line = diagnostic.range.start.line + 1 // VSCode lines are 0-indexed
const source = diagnostic.source ? `${diagnostic.source} ` : ""
result += `\n- [${source}${label}] Line ${line}: ${diagnostic.message}`
}
@@ -1,10 +1,11 @@
import { setVscodeHostProviderMock } from "@/test/host-provider-test-utils"
import { afterEach, beforeEach, describe, it } from "mocha"
import { describe, it, beforeEach, afterEach } from "mocha"
import "should"
import * as sinon from "sinon"
import * as vscode from "vscode"
import { TerminalProcess } from "./TerminalProcess"
import * as vscode from "vscode"
import { TerminalRegistry } from "./TerminalRegistry"
import { DiffViewProviderCreator, HostProvider, WebviewProviderCreator } from "@/hosts/host-provider"
import { vscodeHostBridgeClient } from "@/hosts/vscode/hostbridge/client/host-grpc-client"
declare module "vscode" {
// https://github.com/microsoft/vscode/blob/f0417069c62e20f3667506f4b7e53ca0004b4e3e/src/vscode-dts/vscode.d.ts#L7442
@@ -37,7 +38,13 @@ describe("TerminalProcess (Integration Tests)", () => {
beforeEach(() => {
sandbox = sinon.createSandbox({ useFakeTimers: true })
setVscodeHostProviderMock()
HostProvider.reset()
HostProvider.initialize(
((_) => {}) as WebviewProviderCreator,
(() => {}) as DiffViewProviderCreator,
vscodeHostBridgeClient,
(s: string) => console.log(s),
)
process = new TerminalProcess()
})
@@ -240,4 +240,22 @@ export class ClineAccountService {
await this._authService.restoreRefreshTokenAndRetrieveAuthInfo()
}
}
/**
* Transcribes audio using the Cline transcription service
* @param audioBase64 - Base64 encoded audio data
* @param language - Optional language hint for transcription
* @returns Promise with transcribed text or error
*/
async transcribeAudio(audioBase64: string, language?: string): Promise<{ text: string }> {
const response = await this.authenticatedRequest<{ text: string }>(`/api/v1/chat/transcriptions`, {
method: "POST",
data: {
audioData: audioBase64,
language: language || "en",
},
})
return response
}
}
+12 -13
View File
@@ -1,13 +1,14 @@
import vscode from "vscode"
import { clineEnvConfig } from "@/config"
import { Controller } from "@/core/controller"
import { getRequestRegistry, type StreamingResponseHandler } from "@/core/controller/grpc-handler"
import { featureFlagsService, telemetryService } from "@services/posthog/PostHogClientProvider"
import { storeSecret } from "@/core/storage/state"
import { telemetryService } from "@services/posthog/telemetry/TelemetryService"
import { AuthState, UserInfo } from "@shared/proto/cline/account"
import { type EmptyRequest, String } from "@shared/proto/cline/common"
import { AuthHandler } from "@/hosts/external/AuthHandler"
import { FirebaseAuthProvider } from "./providers/FirebaseAuthProvider"
import { openExternal } from "@/utils/env"
import { FEATURE_FLAGS } from "@/shared/services/feature-flags/feature-flags"
import { HostProvider } from "@/hosts/host-provider"
const DefaultClineAccountURI = `${clineEnvConfig.appBaseUrl}/auth`
let authProviders: any[] = []
@@ -195,7 +196,8 @@ export class AuthService {
throw new Error("Authentication URI is not configured")
}
const callbackHost = await HostProvider.get().getCallbackUri()
const callbackHost =
(await AuthHandler.getInstance().getCallbackUri()) || `${vscode.env.uriScheme || "vscode"}://saoudrizwan.claude-dev`
const callbackUrl = `${callbackHost}/auth`
// Use URL object for more graceful query construction
@@ -232,7 +234,12 @@ export class AuthService {
this._clineAuthInfo = await this._provider.provider.signIn(this._controller, token, provider)
this._authenticated = true
if (this._clineAuthInfo) {
telemetryService.identifyAccount(this._clineAuthInfo.userInfo)
}
await this.sendAuthStatusUpdate()
// return this._clineAuthInfo
} catch (error) {
console.error("Error signing in with custom token:", error)
throw error
@@ -260,6 +267,7 @@ export class AuthService {
this._clineAuthInfo = await this._provider.provider.retrieveClineAuthInfo(this._controller)
if (this._clineAuthInfo) {
this._authenticated = true
telemetryService.identifyAccount(this._clineAuthInfo.userInfo)
await this.sendAuthStatusUpdate()
} else {
console.warn("No user found after restoring auth token")
@@ -324,15 +332,6 @@ export class AuthService {
false, // Not the last message
)
// Identify the user in telemetry if available
// Fetch the feature flags for the user
if (this._clineAuthInfo?.userInfo?.id) {
telemetryService.identifyAccount(this._clineAuthInfo.userInfo)
for (const flag of Object.values(FEATURE_FLAGS)) {
await featureFlagsService?.isFeatureFlagEnabled(flag)
}
}
// Update the state in the webview
if (controller) {
await controller.postStateToWebview()
@@ -1,11 +1,12 @@
import { errorService } from "@services/posthog/PostHogClientProvider"
import { getSecret, storeSecret } from "@/core/storage/state"
import { ErrorService } from "@/services/error/ErrorService"
import axios from "axios"
import { initializeApp } from "firebase/app"
import { GithubAuthProvider, GoogleAuthProvider, getAuth, type OAuthCredential, signInWithCredential, User } from "firebase/auth"
import { GithubAuthProvider, GoogleAuthProvider, User, getAuth, signInWithCredential } from "firebase/auth"
import { ExtensionContext } from "vscode"
import { ClineAccountUserInfo, ClineAuthInfo } from "../AuthService"
import { jwtDecode } from "jwt-decode"
import type { ExtensionContext } from "vscode"
import { clineEnvConfig } from "@/config"
import type { ClineAccountUserInfo, ClineAuthInfo } from "../AuthService"
import { Controller } from "@/core/controller"
export class FirebaseAuthProvider {
@@ -89,8 +90,8 @@ export class FirebaseAuthProvider {
// return userCredential.user
} catch (error) {
console.error("Firebase restore token error", error)
errorService.logMessage("Firebase restore token error", "error")
errorService.logException(error)
ErrorService.logMessage("Firebase restore token error", "error")
ErrorService.logException(error)
throw error
}
}
@@ -102,7 +103,7 @@ export class FirebaseAuthProvider {
*/
async signIn(controller: Controller, token: string, provider: string): Promise<ClineAuthInfo | null> {
try {
let credential: OAuthCredential
let credential
switch (provider) {
case "google":
credential = GoogleAuthProvider.credential(token)
@@ -125,16 +126,16 @@ export class FirebaseAuthProvider {
try {
controller.cacheService.setSecret("clineAccountId", userCredential.refreshToken)
} catch (error) {
errorService.logMessage("Firebase store token error", "error")
errorService.logException(error)
ErrorService.logMessage("Firebase store token error", "error")
ErrorService.logException(error)
throw error
}
// userCredential = await this._signInWithCredential(context, credential)
return await this.retrieveClineAuthInfo(controller)
} catch (error) {
errorService.logMessage("Firebase sign-in error", "error")
errorService.logException(error)
ErrorService.logMessage("Firebase sign-in error", "error")
ErrorService.logException(error)
throw error
}
}
+4 -7
View File
@@ -15,7 +15,7 @@ import { BrowserSettings } from "@shared/BrowserSettings"
import { discoverChromeInstances, testBrowserConnection, isPortOpen } from "./BrowserDiscovery"
import * as chromeLauncher from "chrome-launcher"
import { Controller } from "@core/controller"
import { telemetryService } from "@/services/posthog/PostHogClientProvider"
import { telemetryService } from "@/services/posthog/telemetry/TelemetryService"
import os from "os"
interface PCRStats {
@@ -88,10 +88,7 @@ export class BrowserSession {
// First check browserSettings (from UI, stored in global state)
await this.migrateChromeExecutablePathSetting()
if (this.browserSettings.chromeExecutablePath && (await fileExistsAtPath(this.browserSettings.chromeExecutablePath))) {
return {
path: this.browserSettings.chromeExecutablePath,
isBundled: false,
}
return { path: this.browserSettings.chromeExecutablePath, isBundled: false }
}
// Then try to find system Chrome
@@ -557,8 +554,8 @@ export class BrowserSession {
const minStableSizeIterations = 3
while (checkCounts++ <= maxChecks) {
const html = await page.content()
const currentHTMLSize = html.length
let html = await page.content()
let currentHTMLSize = html.length
// let bodyHTMLSize = await page.evaluate(() => document.body.innerHTML.length)
console.info("last: ", lastHTMLSize, " <> curr: ", currentHTMLSize)
@@ -0,0 +1,220 @@
import * as fs from "fs"
import * as path from "path"
import * as os from "os"
import { spawn, ChildProcess } from "child_process"
import { Logger } from "@services/logging/Logger"
import { AUDIO_PROGRAM_CONFIG } from "@/shared/audioProgramConstants"
function isExecutable(filePath: string): boolean {
try {
fs.accessSync(filePath, fs.constants.X_OK)
return true
} catch (e) {
return false
}
}
export class AudioRecordingService {
private recordingProcess: ChildProcess | null = null
private isRecording: boolean = false
private startTime: number = 0
private outputFile: string = ""
constructor() {}
async startRecording(): Promise<{ success: boolean; error?: string }> {
try {
if (this.isRecording) {
return { success: false, error: "Already recording" }
}
// Check if recording software is available
const checkResult = this.checkRecordingDependencies()
if (!checkResult.available) {
return { success: false, error: checkResult.error }
}
// Create temporary file for audio output
const tempDir = os.tmpdir()
this.outputFile = path.join(tempDir, `cline_recording_${Date.now()}.webm`)
Logger.info("Starting audio recording...")
// Get the recording program path
const recordProgram = this.getRecordProgram()
if (!recordProgram) {
return { success: false, error: "Recording program not found" }
}
Logger.info(`Using recording program: ${recordProgram.path}`)
// Set up recording arguments
const args = recordProgram.getArgs(this.outputFile)
// Spawn the recording process
this.recordingProcess = spawn(recordProgram.path, args)
this.isRecording = true
this.startTime = Date.now()
// Handle process errors
this.recordingProcess.on("error", (error) => {
Logger.error(`Recording process error: ${error.message}`)
this.isRecording = false
})
// Handle process exit
this.recordingProcess.on("exit", (code) => {
if (code !== 0 && code !== null) {
Logger.warn(`Recording process exited with code: ${code}`)
}
})
this.recordingProcess.stderr?.on("data", (data) => {
const message = data.toString().trim()
if (message && !message.includes("In:") && !message.includes("Out:")) {
Logger.info(`Recording stderr: ${message}`)
}
})
Logger.info("Audio recording started successfully")
return { success: true }
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error)
Logger.error("Failed to start audio recording: " + errorMessage)
return { success: false, error: `Failed to start recording: ${errorMessage}` }
}
}
async stopRecording(): Promise<{ success: boolean; audioBase64?: string; error?: string }> {
try {
if (!this.isRecording || !this.recordingProcess) {
return { success: false, error: "Not currently recording" }
}
Logger.info("Stopping audio recording...")
// Send SIGINT to stop recording gracefully (like Ctrl+C)
this.recordingProcess.kill("SIGINT")
// Wait for the process to finish
await new Promise<void>((resolve) => {
if (this.recordingProcess) {
// Timeout after 5 seconds
const timeoutId = setTimeout(() => {
resolve()
}, 5000)
this.recordingProcess.on("exit", (code) => {
clearTimeout(timeoutId) // Clear the timeout since process exited
resolve()
})
} else {
resolve()
}
})
this.recordingProcess = null
this.isRecording = false
// Wait a moment for file to be fully written
await new Promise((resolve) => setTimeout(resolve, 500))
// Read the audio file and convert to base64
if (!fs.existsSync(this.outputFile)) {
return { success: false, error: "Recording file not found" }
}
const audioBuffer = fs.readFileSync(this.outputFile)
const audioBase64 = audioBuffer.toString("base64")
// Clean up temporary file
try {
fs.unlinkSync(this.outputFile)
} catch (cleanupError) {
Logger.warn(
"Failed to cleanup temporary audio file: " +
(cleanupError instanceof Error ? cleanupError.message : String(cleanupError)),
)
}
Logger.info("Audio recording stopped and converted to base64")
return { success: true, audioBase64 }
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error)
Logger.error("Failed to stop audio recording: " + errorMessage)
return { success: false, error: `Failed to stop recording: ${errorMessage}` }
}
}
getRecordingStatus(): { isRecording: boolean; durationSeconds: number; error?: string } {
const durationSeconds = this.isRecording ? (Date.now() - this.startTime) / 1000 : 0
return {
isRecording: this.isRecording,
durationSeconds,
}
}
private checkRecordingDependencies(): { available: boolean; error?: string } {
const program = this.getRecordProgram()
if (!program) {
const platform = os.platform() as keyof typeof AUDIO_PROGRAM_CONFIG
const config = AUDIO_PROGRAM_CONFIG[platform]
const error = config ? config.error : `Audio recording is not supported on platform: ${platform}`
return { available: false, error }
}
return { available: true }
}
private getRecordProgram(): { path: string; getArgs: (outputFile: string) => string[] } | undefined {
const platform = os.platform() as keyof typeof AUDIO_PROGRAM_CONFIG
const config = AUDIO_PROGRAM_CONFIG[platform]
if (!config) {
return undefined
}
// 1. Check if the command is in the system's PATH
const pathDirs = (process.env.PATH || "").split(path.delimiter)
for (const dir of pathDirs) {
const fullPath = path.join(dir, config.command)
if (fs.existsSync(fullPath) && isExecutable(fullPath)) {
return { path: fullPath, getArgs: config.getArgs }
}
}
// 2. Check fallback paths if not in PATH
for (const p of config.fallbackPaths) {
if (fs.existsSync(p) && isExecutable(p)) {
return { path: p, getArgs: config.getArgs }
}
}
return undefined
}
// Cleanup method
cleanup(): void {
if (this.isRecording && this.recordingProcess) {
try {
this.recordingProcess.kill("SIGINT")
this.recordingProcess = null
this.isRecording = false
} catch (error) {
Logger.error("Error during cleanup: " + (error instanceof Error ? error.message : String(error)))
}
}
// Clean up any leftover temp files
if (this.outputFile && fs.existsSync(this.outputFile)) {
try {
fs.unlinkSync(this.outputFile)
} catch (error) {
Logger.warn(
"Failed to cleanup temp file during service cleanup: " +
(error instanceof Error ? error.message : String(error)),
)
}
}
}
}
export const audioRecordingService = new AudioRecordingService()
@@ -0,0 +1,54 @@
import { Logger } from "@services/logging/Logger"
import { ClineAccountService } from "@/services/account/ClineAccountService"
import axios from "axios"
export class VoiceTranscriptionService {
private clineAccountService: ClineAccountService
constructor() {
this.clineAccountService = ClineAccountService.getInstance()
}
async transcribeAudio(audioBase64: string, language?: string): Promise<{ text?: string; error?: string }> {
try {
Logger.info("Transcribing audio with Cline transcription service...")
const result = await this.clineAccountService.transcribeAudio(audioBase64, language)
Logger.info("Transcription successful")
return { text: result.text }
} catch (error) {
Logger.error("Voice transcription error:", error)
// Handle axios errors with proper status code mapping
if (axios.isAxiosError(error)) {
const status = error.response?.status
const message = error.response?.data?.message || error.message
switch (status) {
case 401:
return { error: "Authentication failed. Please reauthenticate your Cline account" }
case 402:
return { error: "Insufficient credits for transcription service." }
case 400:
return { error: "Invalid audio format or request data." }
case 500:
return { error: "Transcription server error. Please try again later." }
default:
return { error: `Transcription failed: ${message}` }
}
}
// Handle network errors
const errorMessage = error instanceof Error ? error.message : String(error)
if (errorMessage.includes("ECONNREFUSED") || errorMessage.includes("Network Error")) {
return { error: "Cannot connect to transcription service." }
}
return { error: `Network error: ${errorMessage}` }
}
}
}
export const voiceTranscriptionService = new VoiceTranscriptionService()
+88 -40
View File
@@ -1,55 +1,103 @@
import * as Sentry from "@sentry/browser"
import * as vscode from "vscode"
import { telemetryService } from "../posthog/telemetry/TelemetryService"
import * as pkg from "../../../package.json"
import type { PostHogClientProvider } from "../posthog/PostHogClientProvider"
import { ClineError } from "./ClineError"
let telemetryLevel = vscode.workspace.getConfiguration("telemetry").get<string>("telemetryLevel", "all")
let isTelemetryEnabled = ["all", "error"].includes(telemetryLevel)
vscode.workspace.onDidChangeConfiguration(() => {
telemetryLevel = vscode.workspace.getConfiguration("telemetry").get<string>("telemetryLevel", "all")
isTelemetryEnabled = ["all", "error"].includes(telemetryLevel)
ErrorService.toggleEnabled(isTelemetryEnabled)
if (isTelemetryEnabled) {
ErrorService.setLevel(telemetryLevel as "error" | "all")
}
})
const isDev = process.env.IS_DEV === "true"
export class ErrorService {
private posthogProvider: PostHogClientProvider
private static serviceEnabled: boolean
private static serviceLevel: string
constructor(posthogProvider: PostHogClientProvider, _distinctId: string) {
this.posthogProvider = posthogProvider
}
public logException(error: Error | ClineError): void {
const errorDetails = {
message: error.message,
stack: error.stack,
name: error.name,
extension_version: pkg.version,
is_dev: isDev,
}
if (error instanceof ClineError) {
Object.assign(errorDetails, {
modelId: error.modelId,
providerId: error.providerId,
serialized_error: error.serialize(),
})
}
this.posthogProvider.log("extension.error", {
error_type: "exception",
...errorDetails,
timestamp: new Date().toISOString(),
static initialize() {
// Initialize sentry
Sentry.init({
dsn: "https://7936780e3f0f0290fcf8d4a395c249b7@o4509028819664896.ingest.us.sentry.io/4509052955983872",
environment: process.env.NODE_ENV,
release: `cline@${pkg.version}`,
integrations: [Sentry.browserTracingIntegration(), Sentry.replayIntegration()],
beforeSend(event) {
// TelemetryService keeps track of whether the user has opted in to telemetry/error reporting
const isUserManuallyOptedIn = telemetryService.isTelemetryEnabled()
if (isUserManuallyOptedIn && ErrorService.isEnabled() && !isDev) {
return event
}
return null
},
})
console.error("[ErrorService] Logging", error)
ErrorService.toggleEnabled(true)
ErrorService.setLevel("error")
}
public logMessage(message: string, level: "error" | "warning" | "log" | "debug" | "info" = "log"): void {
this.posthogProvider.log("extension.message", {
message: message.substring(0, 500),
level,
extension_version: pkg.version,
is_dev: isDev,
timestamp: new Date().toISOString(),
})
static toggleEnabled(state: boolean) {
if (state === false) {
ErrorService.serviceEnabled = false
return
}
// If we are trying to enable the service, check that we are allowed to.
if (isTelemetryEnabled) {
ErrorService.serviceEnabled = true
}
}
public toClineError(rawError: unknown, modelId?: string, providerId?: string): ClineError {
const transformed = ClineError.transform(rawError, modelId, providerId)
this.logException(transformed)
return transformed
static setLevel(level: "error" | "all") {
switch (telemetryLevel) {
case "error": {
if (level === "error") {
ErrorService.serviceLevel = level
}
break
}
default: {
ErrorService.serviceLevel = level
}
}
}
static logException(error: Error | ClineError): void {
// Don't log if telemetry is off
const isUserManuallyOptedIn = telemetryService.isTelemetryEnabled()
if (!isUserManuallyOptedIn || !ErrorService.isEnabled()) {
return
}
// Log the error to Sentry
Sentry.captureException(error)
}
static logMessage(message: string, level: "error" | "warning" | "log" | "debug" | "info" = "log"): void {
// Don't log if telemetry is off
const isUserManuallyOptedIn = telemetryService.isTelemetryEnabled()
if (!isUserManuallyOptedIn || !ErrorService.isEnabled()) {
return
}
if (ErrorService.serviceLevel === "error" && level === "error") {
// Log the message if allowed
Sentry.captureMessage(message, { level })
return
}
// Log the message if allowed
Sentry.captureMessage(message, { level })
}
static isEnabled(): boolean {
return ErrorService.serviceEnabled
}
static toClineError(rawError: any, modelId?: string, providerId?: string): ClineError {
return ClineError.transform(rawError, modelId, providerId)
}
}
+4 -5
View File
@@ -1,19 +1,18 @@
import { HostProvider } from "@/hosts/host-provider"
import { errorService } from "../posthog/PostHogClientProvider"
import { ErrorService } from "../error/ErrorService"
/**
* Simple logging utility for the extension's backend code.
*/
export class Logger {
public readonly channelName = "Cline Dev Logger"
static error(message: string, error?: Error) {
Logger.#output("ERROR", message, error)
errorService.logMessage(message, "error")
error && errorService.logException(error)
ErrorService.logMessage(message, "error")
error && ErrorService.logException(error)
}
static warn(message: string) {
Logger.#output("WARN", message)
errorService.logMessage(message, "warning")
ErrorService.logMessage(message, "warning")
}
static log(message: string) {
Logger.#output("LOG", message)
+46 -28
View File
@@ -1,11 +1,8 @@
import { HostProvider } from "@/hosts/host-provider"
import { ShowMessageType } from "@/shared/proto/host/window"
import { sendMcpServersUpdate } from "@core/controller/mcp/subscribeToMcpServers"
import { GlobalFileNames } from "@core/storage/disk"
import { Client } from "@modelcontextprotocol/sdk/client/index.js"
import { StdioClientTransport, getDefaultEnvironment } from "@modelcontextprotocol/sdk/client/stdio.js"
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"
import { getDefaultEnvironment, StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"
import ReconnectingEventSource from "reconnecting-eventsource"
import {
CallToolResultSchema,
ListResourcesResultSchema,
@@ -13,6 +10,16 @@ import {
ListToolsResultSchema,
ReadResourceResultSchema,
} from "@modelcontextprotocol/sdk/types.js"
import { sendMcpServersUpdate } from "@core/controller/mcp/subscribeToMcpServers"
import { convertMcpServersToProtoMcpServers } from "@shared/proto-conversions/mcp/mcp-server-conversion"
import chokidar, { FSWatcher } from "chokidar"
import { setTimeout as setTimeoutPromise } from "node:timers/promises"
import deepEqual from "fast-deep-equal"
import * as fs from "fs/promises"
import * as path from "path"
import * as vscode from "vscode"
import { z } from "zod"
import { FileChangeEvent_ChangeType, SubscribeToFileRequest } from "../../shared/proto/host/watch"
import {
DEFAULT_MCP_TIMEOUT_SECONDS,
McpResource,
@@ -23,24 +30,19 @@ import {
McpToolCallResponse,
MIN_MCP_TIMEOUT_SECONDS,
} from "@shared/mcp"
import { convertMcpServersToProtoMcpServers } from "@shared/proto-conversions/mcp/mcp-server-conversion"
import { fileExistsAtPath } from "@utils/fs"
import { secondsToMs } from "@utils/time"
import chokidar, { FSWatcher } from "chokidar"
import deepEqual from "fast-deep-equal"
import * as fs from "fs/promises"
import { setTimeout as setTimeoutPromise } from "node:timers/promises"
import * as path from "path"
import ReconnectingEventSource from "reconnecting-eventsource"
import * as vscode from "vscode"
import { z } from "zod"
import { FileChangeEvent_ChangeType, SubscribeToFileRequest } from "../../shared/proto/host/watch"
import { GlobalFileNames } from "@core/storage/disk"
import { ExtensionMessage } from "@shared/ExtensionMessage"
import { DEFAULT_REQUEST_TIMEOUT_MS } from "./constants"
import { BaseConfigSchema, McpSettingsSchema, ServerConfigSchema } from "./schemas"
import { McpConnection, McpServerConfig, Transport } from "./types"
import { BaseConfigSchema, ServerConfigSchema, McpSettingsSchema } from "./schemas"
import { HostProvider } from "@/hosts/host-provider"
import { ShowMessageType } from "@/shared/proto/host/window"
export class McpHub {
getMcpServersPath: () => Promise<string>
private getSettingsDirectoryPath: () => Promise<string>
private postMessageToWebview: (message: ExtensionMessage) => Promise<void>
private clientVersion: string
private disposables: vscode.Disposable[] = []
@@ -63,10 +65,12 @@ export class McpHub {
constructor(
getMcpServersPath: () => Promise<string>,
getSettingsDirectoryPath: () => Promise<string>,
postMessageToWebview: (message: ExtensionMessage) => Promise<void>,
clientVersion: string,
) {
this.getMcpServersPath = getMcpServersPath
this.getSettingsDirectoryPath = getSettingsDirectoryPath
this.postMessageToWebview = postMessageToWebview
this.clientVersion = clientVersion
this.watchMcpSettingsFile()
this.initializeMcpServers()
@@ -133,7 +137,7 @@ export class McpHub {
const settingsPath = await this.getMcpSettingsFilePath()
// Subscribe to file changes using the gRPC WatchService
//console.log("[DEBUG] subscribing to mcp file changes")
console.log("[DEBUG] subscribing to mcp file changes")
const cancelSubscription = HostProvider.watch.subscribeToFile(
SubscribeToFileRequest.create({
path: settingsPath,
@@ -189,7 +193,7 @@ export class McpHub {
this.connections = this.connections.filter((conn) => conn.server.name !== name)
if (config.disabled) {
//console.log(`[MCP Debug] Creating disabled connection object for server "${name}"`)
console.log(`[MCP Debug] Creating disabled connection object for server "${name}"`)
// Create a connection object for disabled server so it appears in UI
const disabledConnection: McpConnection = {
server: {
@@ -344,7 +348,7 @@ export class McpHub {
connection.server.error = ""
// Register notification handler for real-time messages
//console.log(`[MCP Debug] Setting up notification handlers for server: ${name}`)
console.log(`[MCP Debug] Setting up notification handlers for server: ${name}`)
//console.log(`[MCP Debug] Client instance:`, connection.client)
//console.log(`[MCP Debug] Transport type:`, config.type)
@@ -368,25 +372,25 @@ export class McpHub {
// Set the notification handler
connection.client.setNotificationHandler(NotificationMessageSchema as any, async (notification: any) => {
//console.log(`[MCP Notification] ${name}:`, JSON.stringify(notification, null, 2))
console.log(`[MCP Notification] ${name}:`, JSON.stringify(notification, null, 2))
const params = notification.params || {}
const level = params.level || "info"
const data = params.data || params.message || ""
const logger = params.logger || ""
//console.log(`[MCP Message Notification] ${name}: level=${level}, data=${data}, logger=${logger}`)
console.log(`[MCP Message Notification] ${name}: level=${level}, data=${data}, logger=${logger}`)
// Format the message
const message = logger ? `[${logger}] ${data}` : data
// Send notification directly to active task if callback is set
if (this.notificationCallback) {
//console.log(`[MCP Debug] Sending notification to active task: ${message}`)
console.log(`[MCP Debug] Sending notification to active task: ${message}`)
this.notificationCallback(name, level, message)
} else {
// Fallback: store for later retrieval
//console.log(`[MCP Debug] No active task, storing notification: ${message}`)
console.log(`[MCP Debug] No active task, storing notification: ${message}`)
this.pendingNotifications.push({
serverName: name,
level,
@@ -394,12 +398,26 @@ export class McpHub {
timestamp: Date.now(),
})
}
// Forward to webview if available
if (this.postMessageToWebview) {
await this.postMessageToWebview({
type: "mcpNotification",
serverName: name,
notification: {
level,
data,
logger,
timestamp: Date.now(),
},
} as any)
}
})
//console.log(`[MCP Debug] Successfully set notifications/message handler for ${name}`)
console.log(`[MCP Debug] Successfully set notifications/message handler for ${name}`)
// Also set a fallback handler for any other notification types
connection.client.fallbackNotificationHandler = async (notification: any) => {
//console.log(`[MCP Fallback Notification] ${name}:`, JSON.stringify(notification, null, 2))
console.log(`[MCP Fallback Notification] ${name}:`, JSON.stringify(notification, null, 2))
// Show in VS Code for visibility
HostProvider.window.showMessage({
@@ -407,7 +425,7 @@ export class McpHub {
message: `MCP ${name}: ${notification.method || "unknown"} - ${JSON.stringify(notification.params || {})}`,
})
}
//console.log(`[MCP Debug] Successfully set fallback notification handler for ${name}`)
console.log(`[MCP Debug] Successfully set fallback notification handler for ${name}`)
} catch (error) {
console.error(`[MCP Debug] Error setting notification handlers for ${name}:`, error)
}
@@ -1101,7 +1119,7 @@ export class McpHub {
*/
setNotificationCallback(callback: (serverName: string, level: string, message: string) => void): void {
this.notificationCallback = callback
//console.log("[MCP Debug] Notification callback set")
console.log("[MCP Debug] Notification callback set")
}
/**
@@ -1109,7 +1127,7 @@ export class McpHub {
*/
clearNotificationCallback(): void {
this.notificationCallback = undefined
//console.log("[MCP Debug] Notification callback cleared")
console.log("[MCP Debug] Notification callback cleared")
}
async dispose(): Promise<void> {
+19 -137
View File
@@ -1,149 +1,31 @@
import { PostHog } from "posthog-node"
import { v4 as uuidv4 } from "uuid"
import * as vscode from "vscode"
import { posthogConfig } from "../../shared/services/config/posthog-config"
import type { ClineAccountUserInfo } from "../auth/AuthService"
import { ErrorService } from "../error/ErrorService"
import { FeatureFlagsService } from "./feature-flags/FeatureFlagsService"
import { TelemetryService } from "./telemetry/TelemetryService"
const ENV_ID = vscode?.env?.machineId ?? process?.env?.UUID ?? uuidv4()
class PostHogClientProvider {
private static instance: PostHogClientProvider
private client: PostHog
interface TelemetrySettings {
cline: boolean
host: boolean
level?: "all" | "off" | "error" | "crash"
}
export class PostHogClientProvider {
private static _instance: PostHogClientProvider | null = null
public static getInstance(id?: string): PostHogClientProvider {
if (!PostHogClientProvider._instance) {
PostHogClientProvider._instance = new PostHogClientProvider(id)
}
return PostHogClientProvider._instance
}
protected telemetrySettings: TelemetrySettings = {
cline: true,
host: true,
level: "all",
}
public readonly client: PostHog
public readonly featureFlags: FeatureFlagsService
public readonly telemetry: TelemetryService
public readonly error: ErrorService
private constructor(public distinctId = ENV_ID) {
// Initialize PostHog client
private constructor() {
this.client = new PostHog(posthogConfig.apiKey, {
host: posthogConfig.host,
enableExceptionAutocapture: true,
})
vscode.env.onDidChangeTelemetryEnabled((isTelemetryEnabled) => {
this.telemetrySettings.host = isTelemetryEnabled
})
if (vscode?.env?.isTelemetryEnabled === false) {
this.telemetrySettings.host = false
}
const config = vscode.workspace.getConfiguration("cline")
if (config.get("telemetrySetting") === "disabled") {
this.telemetrySettings.cline = false
}
this.telemetrySettings.level = this.telemetryLevel
// Initialize services
this.telemetry = new TelemetryService(this)
this.error = new ErrorService(this, this.distinctId)
this.featureFlags = new FeatureFlagsService(
(flag: string) => this.client.getFeatureFlag(flag, this.distinctId),
(flag: string) => this.client.getFeatureFlagPayload(flag, this.distinctId),
)
}
private get isTelemetryEnabled(): boolean {
return this.telemetrySettings.cline && this.telemetrySettings.host
}
/** Whether telemetry is currently enabled based on user and VSCode settings */
private get telemetryLevel(): TelemetrySettings["level"] {
if (!vscode?.env?.isTelemetryEnabled) {
return "off"
}
const config = vscode.workspace.getConfiguration("telemetry")
return config?.get<TelemetrySettings["level"]>("telemetryLevel") || "all"
}
public toggleOptIn(optIn: boolean): void {
if (optIn && !this.telemetrySettings.cline) {
this.client.optIn()
}
if (!optIn && this.telemetrySettings.cline) {
this.client.optOut()
}
this.telemetrySettings.cline = optIn
}
/**
* Identifies the accounts user
* If userInfo is provided, it will use that to identify the user.
* Otherwise, it will use the DISTINCT_ID as the distinct ID.
* @param userInfo The user's information
*/
public identifyAccount(userInfo?: ClineAccountUserInfo, properties: Record<string, unknown> = {}): void {
if (!this.isTelemetryEnabled) {
return
}
if (userInfo && userInfo?.id !== this.distinctId) {
this.client.identify({
distinctId: userInfo.id,
properties: {
uuid: userInfo.id,
email: userInfo.email,
name: userInfo.displayName,
...properties,
alias: this.distinctId,
},
})
this.distinctId = userInfo.id
}
}
public log(event: string, properties?: Record<string, unknown>): void {
if (!this.isTelemetryEnabled || this.telemetryLevel === "off") {
return
}
// Filter events based on telemetry level
if (this.telemetryLevel === "error") {
if (!event.includes("error")) {
return
}
}
this.client.capture({
distinctId: this.distinctId,
event,
properties,
enableExceptionAutocapture: false,
})
}
public dispose(): void {
this.client.shutdown().catch((error) => console.error("Error shutting down PostHog client:", error))
public static getInstance(): PostHogClientProvider {
if (!PostHogClientProvider.instance) {
PostHogClientProvider.instance = new PostHogClientProvider()
}
return PostHogClientProvider.instance
}
public getClient(): PostHog {
return this.client
}
public async shutdown(): Promise<void> {
await this.client.shutdown()
}
}
const getFeatureFlagsService = (): FeatureFlagsService => PostHogClientProvider.getInstance().featureFlags
const getErrorService = (): ErrorService => PostHogClientProvider.getInstance().error
const getTelemetryService = (): TelemetryService => PostHogClientProvider.getInstance().telemetry
// Service accessors
export const featureFlagsService = getFeatureFlagsService()
export const errorService = getErrorService()
export const telemetryService = getTelemetryService()
export const posthogClientProvider = PostHogClientProvider.getInstance()
@@ -1,45 +1,40 @@
/**
* FeatureFlagsService provides feature flag functionality that works independently
* of telemetry settings. Feature flags are always available to ensure proper
* functionality of the extension regardless of user's telemetry preferences.
*/
export class FeatureFlagsService {
public constructor(
private readonly getFeatureFlag: (flag: string) => Promise<boolean | string | undefined>,
private readonly getFeatureFlagPayload: (flag: string) => Promise<unknown>,
) {
console.log("[FeatureFlagsService] Initialized with distinctId:")
import { PostHog } from "posthog-node"
import { posthogClientProvider } from "../PostHogClientProvider"
class FeatureFlagsService {
private static instance: FeatureFlagsService
private readonly client: PostHog
private constructor() {
// Get the shared client
this.client = posthogClientProvider.getClient()
}
public static getInstance(): FeatureFlagsService {
if (!FeatureFlagsService.instance) {
FeatureFlagsService.instance = new FeatureFlagsService()
}
return FeatureFlagsService.instance
}
/**
* Check if a feature flag is enabled
* This method works regardless of telemetry settings to ensure feature flags
* can control extension behavior independently of user privacy preferences.
*
* @param flagName The feature flag key
* @returns Boolean indicating if the feature is enabled
*/
public async isFeatureFlagEnabled(flagName: string): Promise<boolean> {
try {
const flagEnabled = await this.getFeatureFlag(flagName)
return flagEnabled === true
const payload = await this.client.getFeatureFlagPayload(flagName, "_irrelevant_" /* optional params */)
if (payload && typeof payload === "object" && "enabled" in payload) {
return Boolean(payload.enabled)
}
console.warn(`Feature flag ${flagName} not found or missing enabled property.`)
return false
} catch (error) {
console.error(`Error checking if feature flag ${flagName} is enabled:`, error)
return false
}
}
/**
* Get the feature flag payload for advanced use cases
* @param flagName The feature flag key
* @returns The feature flag payload or null if not found
*/
public async getPayload(flagName: string): Promise<unknown> {
try {
return await this.getFeatureFlagPayload(flagName)
} catch (error) {
console.error(`Error retrieving feature flag payload for ${flagName}:`, error)
return null
}
}
}
export const featureFlagsService = FeatureFlagsService.getInstance()
@@ -1,3 +1,4 @@
import { PostHog } from "posthog-node"
import * as vscode from "vscode"
import { version as extensionVersion } from "../../../../package.json"
import { HostProvider } from "@hosts/host-provider"
@@ -5,7 +6,7 @@ import { ShowMessageType } from "@shared/proto/host/window"
import type { TaskFeedbackType } from "@shared/WebviewMessage"
import type { BrowserSettings } from "@shared/BrowserSettings"
import type { PostHogClientProvider } from "../PostHogClientProvider"
import { posthogClientProvider } from "../PostHogClientProvider"
import { Mode } from "@/shared/storage/types"
import { ClineAccountUserInfo } from "@/services/auth/AuthService"
@@ -20,18 +21,19 @@ import { ClineAccountUserInfo } from "@/services/auth/AuthService"
* When adding a new category, add it both here and to the initial values in telemetryCategoryEnabled
* Ensure `if (!this.isCategoryEnabled('<category_name>')` is added to the capture method
*/
type TelemetryCategory = "checkpoints" | "browser"
type TelemetryCategory = "checkpoints" | "browser" | "voice"
/**
* Maximum length for error messages to prevent excessive data
*/
const MAX_ERROR_MESSAGE_LENGTH = 500
export class TelemetryService {
class TelemetryService {
// Map to control specific telemetry categories (event types)
private telemetryCategoryEnabled: Map<TelemetryCategory, boolean> = new Map([
["checkpoints", false], // Checkpoints telemetry disabled
["browser", true], // Browser telemetry enabled
["voice", true], // Voice telemetry enabled
])
// Event constants for tracking user interactions and system events
@@ -40,7 +42,6 @@ export class TelemetryService {
USER: {
OPT_OUT: "user.opt_out",
TELEMETRY_ENABLED: "user.telemetry_enabled",
EXTENSION_ACTIVATED: "user.extension_activated",
},
TASK: {
@@ -92,20 +93,47 @@ export class TelemetryService {
// Tracks when a button is clicked
BUTTON_CLICKED: "ui.button_clicked",
},
// Voice-related events for tracking voice recording and transcription usage
VOICE: {
// Tracks when voice recording is started
RECORDING_STARTED: "voice.recording_started",
// Tracks when voice recording is stopped
RECORDING_STOPPED: "voice.recording_stopped",
// Tracks when voice transcription is started
TRANSCRIPTION_STARTED: "voice.transcription_started",
// Tracks when voice transcription is completed successfully
TRANSCRIPTION_COMPLETED: "voice.transcription_completed",
// Tracks when voice transcription fails
TRANSCRIPTION_ERROR: "voice.transcription_error",
// Tracks when voice feature is enabled or disabled in settings
},
}
/** Singleton instance of the TelemetryService */
private static instance: TelemetryService
/** PostHog client instance for sending analytics events */
private client: PostHog
/** Unique identifier for the current VSCode instance */
public distinctId: string = vscode.env.machineId
/** Whether telemetry is currently enabled based on user and VSCode settings */
private telemetryEnabled: boolean = false
/** Current version of the extension */
private readonly version: string = extensionVersion
/** Whether the extension is running in development mode */
private readonly isDev = process.env.IS_DEV
/**
* Constructor that accepts a PostHogClientProvider instance
* @param provider PostHogClientProvider instance for sending analytics events
* Private constructor to enforce singleton pattern
* Initializes PostHog client with configuration
*/
public constructor(private provider: PostHogClientProvider) {
this.capture({ event: TelemetryService.EVENTS.USER.TELEMETRY_ENABLED })
console.info("[TelemetryService] Initialized with PostHogClientProvider")
private constructor() {
this.client = posthogClientProvider.getClient()
}
private setDistinctId(installId: string) {
if (this.distinctId === "someValue.machineId") {
this.distinctId = installId
}
}
/**
@@ -115,9 +143,13 @@ export class TelemetryService {
*/
public async updateTelemetryState(didUserOptIn: boolean): Promise<void> {
// First check global telemetry level - telemetry should only be enabled when level is "all"
const telemetryLevel = vscode.workspace.getConfiguration("telemetry").get<string>("telemetryLevel", "all")
const globalTelemetryEnabled = telemetryLevel === "all"
// We only enable telemetry if global vscode telemetry is enabled
if (!vscode.env.isTelemetryEnabled) {
if (globalTelemetryEnabled) {
this.telemetryEnabled = didUserOptIn
} else {
// Only show warning if user has opted in to Cline telemetry but VS Code telemetry is disabled
if (didUserOptIn) {
void HostProvider.window
@@ -135,9 +167,34 @@ export class TelemetryService {
}
})
}
this.telemetryEnabled = false
}
this.provider.toggleOptIn(didUserOptIn)
// Update PostHog client state based on telemetry preference
if (this.telemetryEnabled) {
this.client.optIn()
this.client.identify({ distinctId: this.distinctId })
} else {
this.client.capture({
distinctId: this.distinctId,
event: TelemetryService.EVENTS.USER.OPT_OUT,
properties: this.addProperties({}),
})
await new Promise((resolve) => setTimeout(resolve, 1000)) // Delay 1 second before opting out
this.client.optOut()
}
}
/**
* Gets or creates the singleton instance of TelemetryService
* @returns The TelemetryService instance
*/
public static getInstance(): TelemetryService {
if (!TelemetryService.instance) {
TelemetryService.instance = new TelemetryService()
}
return TelemetryService.instance
}
private addProperties(properties: any): any {
@@ -151,17 +208,30 @@ export class TelemetryService {
/**
* Captures a telemetry event if telemetry is enabled
* @param event The event to capture with its properties
* @param collect Optional flag to determine if the event should be collected for batch sending (unused for now)
*/
public capture(event: { event: string; properties?: unknown }): void {
public capture(event: { event: string; properties?: any }, collect?: boolean): void {
if (!this.telemetryEnabled) {
return
}
const propertiesWithVersion = this.addProperties(event.properties)
// Use the provider's log method instead of direct client capture
this.provider.log(event.event, propertiesWithVersion)
const capturedEvent = {
event: event.event,
properties: propertiesWithVersion,
}
this.client.capture({ ...capturedEvent, distinctId: this.distinctId })
}
public captureExtensionActivated() {
// Use provider's log method for the activation event
this.provider.log(TelemetryService.EVENTS.USER.EXTENSION_ACTIVATED)
public captureExtensionActivated(installId: string) {
this.setDistinctId(installId)
if (this.telemetryEnabled) {
this.client.identify({ distinctId: this.distinctId })
this.client.capture({ distinctId: this.distinctId, event: TelemetryService.EVENTS.USER.EXTENSION_ACTIVATED })
}
}
/**
@@ -169,10 +239,24 @@ export class TelemetryService {
* @param userInfo The user's information
*/
public identifyAccount(userInfo: ClineAccountUserInfo) {
const propertiesWithVersion = this.addProperties({})
if (!this.telemetryEnabled) {
return
}
// Use the provider's log method instead of direct client capture
this.provider.identifyAccount(userInfo, propertiesWithVersion)
if (!this.client) {
console.warn("Telemetry client is not initialized. Skipping identifyAccount.")
return
}
this.client.identify({
distinctId: userInfo.id,
properties: {
uuid: userInfo.id,
email: userInfo.email,
name: userInfo.displayName,
...this.addProperties({}),
},
})
}
// Task events
@@ -181,10 +265,10 @@ export class TelemetryService {
* @param taskId Unique identifier for the new task
* @param apiProvider Optional API provider
*/
public captureTaskCreated(taskId: string, ulid: string, apiProvider?: string) {
public captureTaskCreated(taskId: string, apiProvider?: string) {
this.capture({
event: TelemetryService.EVENTS.TASK.CREATED,
properties: { taskId, ulid, apiProvider },
properties: { taskId, apiProvider },
})
}
@@ -193,10 +277,10 @@ export class TelemetryService {
* @param taskId Unique identifier for the new task
* @param apiProvider Optional API provider
*/
public captureTaskRestarted(taskId: string, ulid: string, apiProvider?: string) {
public captureTaskRestarted(taskId: string, apiProvider?: string) {
this.capture({
event: TelemetryService.EVENTS.TASK.RESTARTED,
properties: { taskId, ulid, apiProvider },
properties: { taskId, apiProvider },
})
}
@@ -204,10 +288,10 @@ export class TelemetryService {
* Records when cline calls the task completion_result tool signifying that cline is done with the task
* @param taskId Unique identifier for the task
*/
public captureTaskCompleted(taskId: string, ulid: string) {
public captureTaskCompleted(taskId: string) {
this.capture({
event: TelemetryService.EVENTS.TASK.COMPLETED,
properties: { taskId, ulid },
properties: { taskId },
})
}
@@ -221,7 +305,6 @@ export class TelemetryService {
*/
public captureConversationTurnEvent(
taskId: string,
ulid: string,
provider: string = "unknown",
model: string = "unknown",
source: "user" | "assistant",
@@ -234,14 +317,13 @@ export class TelemetryService {
} = {},
) {
// Ensure required parameters are provided
if (!taskId || !ulid || !provider || !model || !source) {
if (!taskId || !provider || !model || !source) {
console.warn("TelemetryService: Missing required parameters for message capture")
return
}
const properties: Record<string, unknown> = {
const properties: Record<string, any> = {
taskId,
ulid,
provider,
model,
source,
@@ -295,10 +377,7 @@ export class TelemetryService {
* @param feedbackType The type of feedback ("thumbs_up" or "thumbs_down")
*/
public captureTaskFeedback(taskId: string, feedbackType: TaskFeedbackType) {
console.info("TelemetryService: Capturing task feedback", {
taskId,
feedbackType,
})
console.info("TelemetryService: Capturing task feedback", { taskId, feedbackType })
this.capture({
event: TelemetryService.EVENTS.TASK.FEEDBACK,
properties: {
@@ -479,7 +558,7 @@ export class TelemetryService {
action?: string
url?: string
isRemote?: boolean
[key: string]: unknown
[key: string]: any
},
) {
if (!this.isCategoryEnabled("browser")) {
@@ -600,7 +679,6 @@ export class TelemetryService {
*/
public captureProviderApiError(args: {
taskId: string
ulid: string
model: string
errorMessage: string
errorStatus?: number | undefined
@@ -616,6 +694,172 @@ export class TelemetryService {
})
}
// Voice events
/**
* Records when voice recording is started
* @param taskId Optional task identifier if recording was started during a task
* @param platform The platform where recording is happening (macOS, Windows, Linux)
* @param collect If true, collect event instead of sending
*/
public captureVoiceRecordingStarted(taskId?: string, platform?: string, collect: boolean = false) {
if (!this.isCategoryEnabled("voice")) {
return
}
this.capture(
{
event: TelemetryService.EVENTS.VOICE.RECORDING_STARTED,
properties: {
taskId,
platform: platform || process.platform,
timestamp: new Date().toISOString(),
},
},
collect,
)
}
/**
* Records when voice recording is stopped
* @param taskId Optional task identifier if recording was stopped during a task
* @param durationMs Duration of the recording in milliseconds
* @param success Whether the recording was successful
* @param platform The platform where recording happened
* @param collect If true, collect event instead of sending
*/
public captureVoiceRecordingStopped(
taskId?: string,
durationMs?: number,
success?: boolean,
platform?: string,
collect?: boolean,
) {
if (!this.isCategoryEnabled("voice")) {
return
}
this.capture(
{
event: TelemetryService.EVENTS.VOICE.RECORDING_STOPPED,
properties: {
taskId,
durationMs,
success,
platform: platform || process.platform,
timestamp: new Date().toISOString(),
},
},
collect || false,
)
}
/**
* Records when voice transcription is started
* @param taskId Optional task identifier if transcription was started during a task
* @param audioSizeBytes Size of the audio data being transcribed
* @param language Language hint provided for transcription
* @param collect If true, collect event instead of sending
*/
public captureVoiceTranscriptionStarted(
taskId?: string,
audioSizeBytes?: number,
language?: string,
collect: boolean = false,
) {
if (!this.isCategoryEnabled("voice")) {
return
}
this.capture(
{
event: TelemetryService.EVENTS.VOICE.TRANSCRIPTION_STARTED,
properties: {
taskId,
audioSizeBytes,
language,
timestamp: new Date().toISOString(),
},
},
collect,
)
}
/**
* Records when voice transcription is completed successfully
* @param taskId Optional task identifier if transcription was completed during a task
* @param transcriptionLength Length of the transcribed text
* @param durationMs Time taken for transcription in milliseconds
* @param language Language used for transcription
* @param collect If true, collect event instead of sending
*/
public captureVoiceTranscriptionCompleted(
taskId?: string,
transcriptionLength?: number,
durationMs?: number,
language?: string,
collect: boolean = false,
) {
if (!this.isCategoryEnabled("voice")) {
return
}
this.capture(
{
event: TelemetryService.EVENTS.VOICE.TRANSCRIPTION_COMPLETED,
properties: {
taskId,
transcriptionLength,
durationMs,
language,
timestamp: new Date().toISOString(),
},
},
collect,
)
}
/**
* Records when voice transcription fails
* @param taskId Optional task identifier if transcription failed during a task
* @param errorType Type of error that occurred (e.g., "no_openai_key", "api_error", "network_error")
* @param errorMessage The error message
* @param durationMs Time taken before failure in milliseconds
* @param collect If true, collect event instead of sending
*/
public captureVoiceTranscriptionError(
taskId?: string,
errorType?: string,
errorMessage?: string,
durationMs?: number,
collect: boolean = false,
) {
if (!this.isCategoryEnabled("voice")) {
return
}
this.capture(
{
event: TelemetryService.EVENTS.VOICE.TRANSCRIPTION_ERROR,
properties: {
taskId,
errorType,
errorMessage,
durationMs,
timestamp: new Date().toISOString(),
},
},
collect,
)
}
/**
* Checks if telemetry is enabled
* @returns Boolean indicating whether telemetry is enabled
*/
public isTelemetryEnabled(): boolean {
return this.telemetryEnabled
}
/**
* Checks if a specific telemetry category is enabled
* @param category The telemetry category to check
@@ -625,4 +869,10 @@ export class TelemetryService {
// Default to true if category has not been explicitly configured
return this.telemetryCategoryEnabled.get(category) ?? true
}
public async shutdown(): Promise<void> {
await this.client.shutdown()
}
}
export const telemetryService = TelemetryService.getInstance()
+221 -22
View File
@@ -1,23 +1,26 @@
import { getCwd } from "@/utils/path"
import { getSavedApiConversationHistory, getSavedClineMessages } from "@core/storage/disk"
import { getAllExtensionState, updateGlobalState } from "@core/storage/state"
import { WebviewProvider } from "@core/webview"
import { Logger } from "@services/logging/Logger"
import { ApiProvider } from "@shared/api"
import { AutoApprovalSettings } from "@shared/AutoApprovalSettings"
import { HistoryItem } from "@shared/HistoryItem"
import { execa } from "execa"
import * as http from "http"
import * as path from "path"
import * as vscode from "vscode"
import { calculateToolSuccessRate, getFileChanges, initializeGitRepository, validateWorkspacePath } from "./GitHelper"
import { Controller } from "@/core/controller"
import * as path from "path"
import { execa } from "execa"
import { Logger } from "@services/logging/Logger"
import { WebviewProvider } from "@core/webview"
import { AutoApprovalSettings } from "@shared/AutoApprovalSettings"
import { validateWorkspacePath, initializeGitRepository, getFileChanges, calculateToolSuccessRate } from "./GitHelper"
import { updateGlobalState, getAllExtensionState, storeSecret } from "@core/storage/state"
import { ClineAsk, ExtensionMessage } from "@shared/ExtensionMessage"
import { ApiProvider } from "@shared/api"
import { HistoryItem } from "@shared/HistoryItem"
import { getSavedClineMessages, getSavedApiConversationHistory } from "@core/storage/disk"
import { AskResponseRequest } from "@shared/proto/cline/task"
import { getCwd } from "@/utils/path"
import { askResponse } from "@core/controller/task/askResponse"
/**
* Creates a tracker to monitor tool calls and failures during task execution
* @param webviewProvider The webview provider instance
* @returns Object tracking tool calls and failures
*/
function createToolCallTracker(): {
function createToolCallTracker(webviewProvider: WebviewProvider): {
toolCalls: Record<string, number>
toolFailures: Record<string, number>
} {
@@ -25,6 +28,36 @@ function createToolCallTracker(): {
toolCalls: {} as Record<string, number>,
toolFailures: {} as Record<string, number>,
}
// Intercept messages to track tool usage
const originalPostMessageToWebview = webviewProvider.controller.postMessageToWebview
webviewProvider.controller.postMessageToWebview = async (message: ExtensionMessage) => {
// NOTE: Tool tracking via partialMessage has been migrated to gRPC streaming
// This interceptor is kept for potential future use with other message types
// Track tool calls - commented out as partialMessage is now handled via gRPC
// if (message.type === "partialMessage" && message.partialMessage?.say === "tool") {
// const toolName = (message.partialMessage.text as any)?.tool
// if (toolName) {
// tracker.toolCalls[toolName] = (tracker.toolCalls[toolName] || 0) + 1
// }
// }
// Track tool failures - commented out as partialMessage is now handled via gRPC
// if (message.type === "partialMessage" && message.partialMessage?.say === "error") {
// const errorText = message.partialMessage.text
// if (errorText && errorText.includes("Error executing tool")) {
// const match = errorText.match(/Error executing tool: (\w+)/)
// if (match && match[1]) {
// const toolName = match[1]
// tracker.toolFailures[toolName] = (tracker.toolFailures[toolName] || 0) + 1
// }
// }
// }
return originalPostMessageToWebview.call(webviewProvider.controller, message)
}
return tracker
}
@@ -39,15 +72,24 @@ function createTaskCompletionTracker(): Promise<void> {
})
}
// Function to mark the current task as completed
function completeTask(): void {
if (taskCompletionResolver) {
taskCompletionResolver()
taskCompletionResolver = null
Logger.log("Task marked as completed")
}
}
let testServer: http.Server | undefined
let messageCatcherDisposable: vscode.Disposable | undefined
/**
* Updates the auto approval settings to enable all actions
* @param context The VSCode extension context
* @param controller The webview provider instance
* @param provider The webview provider instance
*/
async function updateAutoApprovalSettings(context: vscode.ExtensionContext, controller?: Controller) {
async function updateAutoApprovalSettings(context: vscode.ExtensionContext, provider?: WebviewProvider) {
try {
const { autoApprovalSettings } = await getAllExtensionState(context)
@@ -72,8 +114,8 @@ async function updateAutoApprovalSettings(context: vscode.ExtensionContext, cont
Logger.log("Auto approval settings updated for test mode")
// Update the webview with the new state
if (controller) {
await controller.postStateToWebview()
if (provider?.controller) {
await provider.controller.postStateToWebview()
}
} catch (error) {
Logger.log(`Error updating auto approval settings: ${error}`)
@@ -85,7 +127,7 @@ async function updateAutoApprovalSettings(context: vscode.ExtensionContext, cont
* @param webviewProvider The webview provider instance to use for message catching
* @returns The created HTTP server instance
*/
export function createTestServer(controller: Controller): http.Server {
export function createTestServer(webviewProvider?: WebviewProvider): http.Server {
// Try to show the Cline sidebar
Logger.log("[createTestServer] Opening Cline in sidebar...")
vscode.commands.executeCommand("workbench.view.claude-dev-ActivityBar")
@@ -93,9 +135,10 @@ export function createTestServer(controller: Controller): http.Server {
// Then ensure the webview is focused/loaded
vscode.commands.executeCommand("claude-dev.SidebarProvider.focus")
// Update auto approval settings is available
updateAutoApprovalSettings(controller.context, controller)
// Update auto approval settings if webviewProvider is available
if (webviewProvider?.controller?.context) {
updateAutoApprovalSettings(webviewProvider.controller.context, webviewProvider)
}
const PORT = 9876
testServer = http.createServer((req, res) => {
@@ -243,7 +286,7 @@ export function createTestServer(controller: Controller): http.Server {
}
// Initialize tool call tracker
const toolTracker = createToolCallTracker()
const toolTracker = createToolCallTracker(visibleWebview)
// Record task start time
const taskStartTime = Date.now()
@@ -435,9 +478,165 @@ export function createTestServer(controller: Controller): http.Server {
Logger.log(`Test server error: ${error}`)
})
// Set up message catcher for the provided webview instance or try to get the visible one
if (webviewProvider) {
messageCatcherDisposable = createMessageCatcher(webviewProvider)
} else {
const visibleWebview = WebviewProvider.getVisibleInstance()
if (visibleWebview) {
messageCatcherDisposable = createMessageCatcher(visibleWebview)
} else {
Logger.log("No visible webview instance found for message catcher")
}
}
return testServer
}
/**
* Creates a message catcher that logs all messages sent to the webview
* and automatically responds to messages that require user intervention
* @param webviewProvider The webview provider instance
* @returns A disposable that can be used to clean up the message catcher
*/
export function createMessageCatcher(webviewProvider: WebviewProvider): vscode.Disposable {
Logger.log("Cline message catcher registered")
if (webviewProvider && webviewProvider.controller) {
const originalPostMessageToWebview = webviewProvider.controller.postMessageToWebview
// Intercept outgoing messages from extension to webview
webviewProvider.controller.postMessageToWebview = async (message: ExtensionMessage) => {
// NOTE: Completion and ask message detection has been migrated to gRPC streaming
// This interceptor is kept for potential future use with other message types
// Check for completion_result message - commented out as partialMessage is now handled via gRPC
// if (message.type === "partialMessage" && message.partialMessage?.say === "completion_result") {
// // Complete the current task
// completeTask()
// }
// Check for ask messages that require user intervention - commented out as partialMessage is now handled via gRPC
// if (message.type === "partialMessage" && message.partialMessage?.type === "ask" && !message.partialMessage.partial) {
// const askType = message.partialMessage.ask as ClineAsk
// const askText = message.partialMessage.text
// // Automatically respond to different types of asks
// setTimeout(async () => {
// await autoRespondToAsk(webviewProvider, askType, askText)
// }, 100) // Small delay to ensure the message is processed first
// }
return originalPostMessageToWebview.call(webviewProvider.controller, message)
}
} else {
Logger.log("No visible webview instance found for message catcher")
}
return new vscode.Disposable(() => {
// Cleanup function if needed
Logger.log("Cline message catcher disposed")
})
}
/**
* Automatically responds to ask messages to continue task execution without user intervention
* @param webviewProvider The webview provider instance
* @param askType The type of ask message
* @param askText The text content of the ask message
*/
async function autoRespondToAsk(webviewProvider: WebviewProvider, askType: ClineAsk, askText?: string): Promise<void> {
if (!webviewProvider.controller) {
return
}
Logger.log(`Auto-responding to ask type: ${askType}`)
// Default to approving most actions
let responseType = "yesButtonClicked"
let responseText: string | undefined
let responseImages: string[] | undefined
// Handle specific ask types differently if needed
switch (askType) {
case "followup":
// For follow-up questions, provide a generic response
responseType = "messageResponse"
responseText = "I can't answer any questions right now, use your best judgment."
break
case "api_req_failed":
// Always retry API requests
responseType = "yesButtonClicked" // "Retry" button
break
case "completion_result":
// Accept the completion
responseType = "messageResponse"
responseText = "Task completed successfully."
break
case "mistake_limit_reached":
// Provide guidance to continue
responseType = "messageResponse"
responseText = "Try breaking down the task into smaller steps."
break
case "auto_approval_max_req_reached":
// Reset the count to continue
responseType = "yesButtonClicked" // "Reset and continue" button
break
case "resume_task":
case "resume_completed_task":
// Resume the task
responseType = "messageResponse"
break
case "new_task":
// Decline creating a new task to keep the current task running
responseType = "messageResponse"
responseText = "Continue with the current task."
break
case "plan_mode_respond":
// Respond to plan mode with a message to toggle to Act mode
responseType = "messageResponse"
responseText = "PLAN_MODE_TOGGLE_RESPONSE" // Special marker to toggle to Act mode
// Automatically toggle to Act mode after responding
setTimeout(async () => {
try {
if (webviewProvider.controller) {
Logger.log("Auto-toggling to Act mode from Plan mode")
await webviewProvider.controller.togglePlanActMode("act")
}
} catch (error) {
Logger.log(`Error toggling to Act mode: ${error}`)
}
}, 500) // Small delay to ensure the response is processed first
break
// For all other ask types (tool, command, browser_action_launch, use_mcp_server),
// we use the default "yesButtonClicked" to approve the action
}
// Send the response message using the backend controller method
try {
await askResponse(
webviewProvider.controller,
AskResponseRequest.create({
responseType,
text: responseText,
images: responseImages,
}),
)
Logger.log(`Auto-responded to ${askType} with ${responseType}`)
} catch (error) {
Logger.log(`Error sending askResponse: ${error}`)
}
}
/**
* Shuts down the test server if it exists
*/

Some files were not shown because too many files have changed in this diff Show More