mirror of
https://github.com/cline/cline.git
synced 2026-09-07 22:16:30 +08:00
Compare commits
46
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c1651c5233 | ||
|
|
028fe70019 | ||
|
|
41c5e809bd | ||
|
|
2aa5156905 | ||
|
|
51b619e0d5 | ||
|
|
85fb76a996 | ||
|
|
d73a7cfd06 | ||
|
|
489dfbc932 | ||
|
|
314c416788 | ||
|
|
3b19c2ec95 | ||
|
|
e04cbea504 | ||
|
|
affac119f5 | ||
|
|
3847a2545c | ||
|
|
15593bac2a | ||
|
|
84267efb9e | ||
|
|
985ce56809 | ||
|
|
cad28c4c0c | ||
|
|
4a22f7dbd2 | ||
|
|
a430226caa | ||
|
|
5885a3cc1d | ||
|
|
759ef873ae | ||
|
|
782e4ff6e0 | ||
|
|
4bb00241bf | ||
|
|
c325faf8db | ||
|
|
20f8f9c9cf | ||
|
|
5be163f49d | ||
|
|
7843ab937a | ||
|
|
5ed4319d21 | ||
|
|
a8971b807a | ||
|
|
51c4e0aceb | ||
|
|
1cf62941cd | ||
|
|
cc2472f500 | ||
|
|
677e544c51 | ||
|
|
d3c8fbbf1d | ||
|
|
1b06633253 | ||
|
|
4ab8559fce | ||
|
|
259368e0a3 | ||
|
|
9b7839efcd | ||
|
|
47a2ae83de | ||
|
|
1b5590e26c | ||
|
|
9e493341d2 | ||
|
|
1d4cd3187b | ||
|
|
32f0f9618c | ||
|
|
3001f883c2 | ||
|
|
a64e60b8f6 | ||
|
|
3a0e6a471b |
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Adding safety guard for workspace root
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
calibrate input token counts when using anthropic models of sap ai core provider
|
||||
@@ -219,6 +219,9 @@ EOF
|
||||
|
||||
## Basic PR Commands
|
||||
```bash
|
||||
# Get current PR number
|
||||
gh pr view --json number -q .number
|
||||
|
||||
# List open PRs
|
||||
gh pr list
|
||||
|
||||
|
||||
+3
-1
@@ -1 +1,3 @@
|
||||
* @saoudrizwan @ocasta181 @NightTrek @pashpashpash @dcbartlett @saito-sv @Garoth
|
||||
/docs/
|
||||
/.github/ @saoudrizwan @dcbartlett
|
||||
/README.md @saoudrizwan @nickbaumann98
|
||||
@@ -1,5 +1,29 @@
|
||||
# Changelog
|
||||
|
||||
## [3.23.0]
|
||||
|
||||
- Add caching support for Bedrock inferences using SAP AI Core and minor refactor
|
||||
- Improve visibility for mode switch background color on different themes
|
||||
- Fix terminal commands putting webview in blocked state
|
||||
|
||||
## [3.22.0]
|
||||
|
||||
- Implemented a retry strategy for Cerebras to handle rate limit issues due to its generation speed
|
||||
- Add support for GPT-5 models to SAP AI Core Provider
|
||||
- Support sending context to active webview when editor panels are opened.
|
||||
- Fix bug where running out of credits on Cline accounts would show '402 empty body' response instead of 'buy credits' component
|
||||
- Fix LiteLLM Proxy Provider Cost Tracking
|
||||
|
||||
## [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!)
|
||||
|
||||
@@ -16,6 +16,7 @@ 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)
|
||||
|
||||
@@ -52,6 +52,7 @@ 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`
|
||||
|
||||
@@ -4,7 +4,6 @@ import { ApiHandlerOptions } from "../../src/shared/api"
|
||||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
|
||||
import {
|
||||
parseAssistantMessageV1,
|
||||
parseAssistantMessageV2,
|
||||
parseAssistantMessageV3,
|
||||
AssistantMessageContent,
|
||||
@@ -18,7 +17,6 @@ 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,
|
||||
}
|
||||
|
||||
@@ -70,246 +70,7 @@ export interface ToolUse {
|
||||
partial: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* @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
|
||||
}
|
||||
// parseAssistantmessageV1 removed in https://github.com/cline/cline/pull/5425
|
||||
|
||||
/**
|
||||
* @description **Version 2**
|
||||
|
||||
Generated
+236
-16826
File diff suppressed because it is too large
Load Diff
+6
-3
@@ -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.20.12",
|
||||
"version": "3.23.0",
|
||||
"icon": "assets/icons/icon.png",
|
||||
"engines": {
|
||||
"vscode": "^1.84.0"
|
||||
@@ -362,8 +362,8 @@
|
||||
"test:unit": "TS_NODE_PROJECT='./tsconfig.unit-test.json' mocha",
|
||||
"test:coverage": "vscode-test --coverage",
|
||||
"e2e": "playwright test -c playwright.config.ts",
|
||||
"test:e2e": "playwright install && vsce package --no-dependencies --out dist/e2e.vsix && node src/test/e2e/utils/build.js && playwright test",
|
||||
"test:e2e:optimal": "vsce package --no-dependencies --out dist/e2e.vsix && node src/test/e2e/utils/build.js && playwright test",
|
||||
"test:e2e": "playwright install && vsce package --no-dependencies --allow-package-secrets sendgrid --out dist/e2e.vsix && node src/test/e2e/utils/build.js && playwright test",
|
||||
"test:e2e:optimal": "vsce package --no-dependencies --allow-package-secrets sendgrid --out dist/e2e.vsix && node src/test/e2e/utils/build.js && playwright test",
|
||||
"install:all": "npm install && cd webview-ui && npm install",
|
||||
"dev:webview": "cd webview-ui && npm run dev",
|
||||
"build:webview": "cd webview-ui && npm run build",
|
||||
@@ -447,6 +447,7 @@
|
||||
"@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",
|
||||
@@ -491,6 +492,8 @@
|
||||
"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"
|
||||
|
||||
@@ -17,15 +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/,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
@@ -55,6 +55,11 @@ message Boolean {
|
||||
bool value = 1;
|
||||
}
|
||||
|
||||
// the same as Boolean, but avoiding name conflicts
|
||||
message BooleanResponse {
|
||||
bool value = 1;
|
||||
}
|
||||
|
||||
message StringArray {
|
||||
repeated string values = 1;
|
||||
}
|
||||
|
||||
+12
-2
@@ -55,8 +55,11 @@ service FileService {
|
||||
// Toggles a workflow on or off
|
||||
rpc toggleWorkflow(ToggleWorkflowRequest) returns (ClineRulesToggles);
|
||||
|
||||
// Subscribe to workspace file updates
|
||||
rpc subscribeToWorkspaceUpdates(EmptyRequest) returns (stream StringArray);
|
||||
// Check if file exists in the project
|
||||
rpc ifFileExistsRelativePath(StringRequest) returns (BooleanResponse);
|
||||
|
||||
// Open a file in editor by a relative path
|
||||
rpc openFileRelativePath(StringRequest) returns (Empty);
|
||||
}
|
||||
|
||||
// Response for refreshRules operation
|
||||
@@ -87,12 +90,19 @@ message RelativePaths {
|
||||
repeated string paths = 1;
|
||||
}
|
||||
|
||||
// Enum for file search type filtering
|
||||
enum FileSearchType {
|
||||
FILE = 0;
|
||||
FOLDER = 1;
|
||||
}
|
||||
|
||||
// Request for file search operations
|
||||
message FileSearchRequest {
|
||||
Metadata metadata = 1;
|
||||
string query = 2; // Search query string
|
||||
optional string mentions_request_id = 3; // Optional request ID for tracking requests
|
||||
optional int32 limit = 4; // Optional limit for results (default: 20)
|
||||
optional FileSearchType selected_type = 5; // Optional selected type filter
|
||||
}
|
||||
|
||||
// Result for file search operations
|
||||
|
||||
@@ -235,6 +235,7 @@ message ModelsApiConfiguration {
|
||||
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;
|
||||
|
||||
@@ -172,6 +172,7 @@ 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;
|
||||
|
||||
@@ -227,7 +227,7 @@ service UiService {
|
||||
rpc onDidShowAnnouncement(EmptyRequest) returns (Boolean);
|
||||
|
||||
// Subscribe to addToInput events (when user adds content via context menu)
|
||||
rpc subscribeToAddToInput(EmptyRequest) returns (stream String);
|
||||
rpc subscribeToAddToInput(StringRequest) returns (stream String);
|
||||
|
||||
// Subscribe to MCP button clicked events
|
||||
rpc subscribeToMcpButtonClicked(WebviewProviderTypeRequest) returns (stream Empty);
|
||||
@@ -267,4 +267,7 @@ service UiService {
|
||||
|
||||
// Opens a URL in the default browser
|
||||
rpc openUrl(StringRequest) returns (Empty);
|
||||
|
||||
// Opens the Cline walkthrough
|
||||
rpc openWalkthrough(EmptyRequest) returns (Empty);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package host;
|
||||
option java_package = "bot.cline.host.proto";
|
||||
option java_multiple_files = true;
|
||||
|
||||
// This is for use in integration tests to get the contents of the webview.
|
||||
service TestingService {
|
||||
rpc getWebviewHtml(GetWebviewHtmlRequest) returns (GetWebviewHtmlResponse);
|
||||
}
|
||||
|
||||
message GetWebviewHtmlRequest {
|
||||
}
|
||||
|
||||
message GetWebviewHtmlResponse {
|
||||
optional string html = 1;
|
||||
}
|
||||
@@ -4,6 +4,8 @@ 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.
|
||||
@@ -12,6 +14,8 @@ 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 {
|
||||
@@ -34,3 +38,40 @@ 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;
|
||||
}
|
||||
|
||||
@@ -113,6 +113,7 @@ 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,
|
||||
@@ -273,6 +274,8 @@ function createHandlerForProvider(
|
||||
sapAiResourceGroup: options.sapAiResourceGroup,
|
||||
sapAiCoreBaseUrl: options.sapAiCoreBaseUrl,
|
||||
apiModelId: mode === "plan" ? options.planModeApiModelId : options.actModeApiModelId,
|
||||
thinkingBudgetTokens:
|
||||
mode === "plan" ? options.planModeThinkingBudgetTokens : options.actModeThinkingBudgetTokens,
|
||||
})
|
||||
case "claude-code":
|
||||
return new ClaudeCodeHandler({
|
||||
|
||||
@@ -39,7 +39,11 @@ export class CerebrasHandler implements ApiHandler {
|
||||
return this.client
|
||||
}
|
||||
|
||||
@withRetry()
|
||||
@withRetry({
|
||||
maxRetries: 6, // More retries to be patient with rate limits
|
||||
baseDelay: 5000, // Start with 5 second delay
|
||||
maxDelay: 60000, // Allow up to 60 second delays to respect rate limits
|
||||
})
|
||||
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
|
||||
const client = this.ensureClient()
|
||||
|
||||
@@ -170,7 +174,25 @@ export class CerebrasHandler implements ApiHandler {
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
// Enhanced error handling for Cerebras API
|
||||
if (error?.status === 429 || error?.code === "rate_limit_exceeded") {
|
||||
// Rate limit error - will be handled by retry decorator with patient backoff
|
||||
const limits = this.getRateLimits()
|
||||
throw new Error(`Cerebras API rate limit exceeded.`)
|
||||
} else if (error?.status === 401) {
|
||||
throw new Error("Cerebras API authentication failed. Please check your API key.")
|
||||
} else if (error?.status === 403) {
|
||||
throw new Error("Cerebras API access denied. Please check your API key permissions.")
|
||||
} else if (error?.status >= 500) {
|
||||
// Server errors - retryable
|
||||
throw new Error(`Cerebras API server error (${error.status}): ${error.message || "Unknown server error"}`)
|
||||
} else if (error?.status === 400) {
|
||||
// Client errors - not retryable
|
||||
throw new Error(`Cerebras API bad request: ${error.message || "Invalid request parameters"}`)
|
||||
}
|
||||
|
||||
// Re-throw original error for other cases
|
||||
throw error
|
||||
}
|
||||
}
|
||||
@@ -193,6 +215,35 @@ export class CerebrasHandler implements ApiHandler {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get rate limit information for the current model
|
||||
*
|
||||
* These limits are used for informational purposes and to calculate appropriate
|
||||
* retry delays. Since Cerebras inference is extremely fast, users hit these limits
|
||||
* quickly, so we need to be patient with retries to maximize usage efficiency.
|
||||
*
|
||||
* @returns Rate limit configuration for the model
|
||||
*/
|
||||
private getRateLimits(): { requestsPerMinute: number; tokensPerMinute: number } {
|
||||
const modelId = this.getModel().id
|
||||
|
||||
switch (modelId) {
|
||||
case "qwen-3-coder-480b":
|
||||
case "qwen-3-coder-480b-free":
|
||||
return { requestsPerMinute: 10, tokensPerMinute: 150_000 }
|
||||
case "qwen-3-235b-a22b-instruct-2507":
|
||||
case "qwen-3-235b-a22b-thinking-2507":
|
||||
return { requestsPerMinute: 30, tokensPerMinute: 60_000 }
|
||||
case "llama-3.3-70b":
|
||||
case "gpt-oss-120b":
|
||||
case "qwen-3-32b":
|
||||
return { requestsPerMinute: 30, tokensPerMinute: 64_000 }
|
||||
default:
|
||||
// Default rate limits for unknown models
|
||||
return { requestsPerMinute: 30, tokensPerMinute: 60_000 }
|
||||
}
|
||||
}
|
||||
|
||||
private calculateCost({ inputTokens, outputTokens }: { inputTokens: number; outputTokens: number }): number {
|
||||
const model = this.getModel()
|
||||
const inputPrice = model.info.inputPrice || 0
|
||||
|
||||
+34
-65
@@ -24,6 +24,14 @@ interface ClineHandlerOptions {
|
||||
clineAccountId?: string
|
||||
}
|
||||
|
||||
interface ClineStreamUsageChunk extends OpenAI.CompletionUsage {
|
||||
cost?: number
|
||||
cost_details?: {
|
||||
upstream_inference_cost?: number
|
||||
downstream_inference_cost?: number
|
||||
}
|
||||
}
|
||||
|
||||
export class ClineHandler implements ApiHandler {
|
||||
private options: ClineHandlerOptions
|
||||
private clineAccountService = ClineAccountService.getInstance()
|
||||
@@ -31,7 +39,6 @@ export class ClineHandler implements ApiHandler {
|
||||
private client: OpenAI | undefined
|
||||
private readonly _baseUrl = clineEnvConfig.apiBaseUrl
|
||||
lastGenerationId?: string
|
||||
private counter = 0
|
||||
|
||||
constructor(options: ClineHandlerOptions) {
|
||||
this.options = options
|
||||
@@ -55,8 +62,9 @@ export class ClineHandler implements ApiHandler {
|
||||
"X-Cline-Version": extensionVersion,
|
||||
},
|
||||
})
|
||||
} catch (error: any) {
|
||||
throw new Error(`Error creating Cline client: ${error.message}`)
|
||||
} catch (error) {
|
||||
console.error(`Error creating Cline client: ${error.message}`)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
// Ensure the client is always using the latest auth token
|
||||
@@ -87,10 +95,10 @@ export class ClineHandler implements ApiHandler {
|
||||
// openrouter returns an error object instead of the openai sdk throwing an error
|
||||
if ("error" in chunk) {
|
||||
const error = chunk.error as OpenRouterErrorResponse["error"]
|
||||
console.error(`Cline API Error: ${error?.code} - ${error?.message}`)
|
||||
// Include metadata in the error message if available
|
||||
const metadataStr = error.metadata ? `\nMetadata: ${JSON.stringify(error.metadata, null, 2)}` : ""
|
||||
throw new Error(`Cline API Error ${error.code}: ${error.message}${metadataStr}`)
|
||||
console.error(`Cline API Error ${error.code}: ${error.message}${metadataStr}`)
|
||||
throw error
|
||||
}
|
||||
if (!this.lastGenerationId && chunk.id) {
|
||||
this.lastGenerationId = chunk.id
|
||||
@@ -99,12 +107,9 @@ export class ClineHandler implements ApiHandler {
|
||||
// Check for mid-stream error via finish_reason
|
||||
const choice = chunk.choices?.[0]
|
||||
// OpenRouter may return finish_reason = "error" with error details
|
||||
if ((choice?.finish_reason as string) === "error") {
|
||||
const choiceWithError = choice as any
|
||||
if (choiceWithError.error) {
|
||||
const error = choiceWithError.error
|
||||
console.error(`Cline Mid-Stream Error: ${error.code || error.type || "Unknown"} - ${error.message}`)
|
||||
throw new Error(`Cline Mid-Stream Error: ${error.code || error.type || "Unknown"} - ${error.message}`)
|
||||
if (choice?.finish_reason && String(choice?.finish_reason) === "error") {
|
||||
if ("error" in choice && choice?.error) {
|
||||
throw choice.error
|
||||
} else {
|
||||
throw new Error(
|
||||
"Cline Mid-Stream Error: Stream terminated with error status but no error details provided",
|
||||
@@ -130,38 +135,15 @@ export class ClineHandler implements ApiHandler {
|
||||
}
|
||||
}
|
||||
|
||||
if (!didOutputUsage && chunk.usage) {
|
||||
// @ts-ignore-next-line
|
||||
let totalCost = (chunk.usage.cost || 0) + (chunk.usage.cost_details?.upstream_inference_cost || 0)
|
||||
const modelId = this.getModel().id
|
||||
|
||||
// const provider = modelId.split("/")[0]
|
||||
// // If provider is x-ai, set totalCost to 0 (we're doing a promo)
|
||||
// if (provider === "x-ai") {
|
||||
// totalCost = 0
|
||||
// }
|
||||
|
||||
if (modelId.includes("gemini")) {
|
||||
yield {
|
||||
type: "usage",
|
||||
cacheWriteTokens: 0,
|
||||
cacheReadTokens: chunk.usage.prompt_tokens_details?.cached_tokens || 0,
|
||||
inputTokens:
|
||||
(chunk.usage.prompt_tokens || 0) - (chunk.usage.prompt_tokens_details?.cached_tokens || 0),
|
||||
outputTokens: chunk.usage.completion_tokens || 0,
|
||||
// @ts-ignore-next-line
|
||||
totalCost,
|
||||
}
|
||||
} else {
|
||||
yield {
|
||||
type: "usage",
|
||||
cacheWriteTokens: 0,
|
||||
cacheReadTokens: chunk.usage.prompt_tokens_details?.cached_tokens || 0,
|
||||
inputTokens: chunk.usage.prompt_tokens || 0,
|
||||
outputTokens: chunk.usage.completion_tokens || 0,
|
||||
// @ts-ignore-next-line
|
||||
totalCost,
|
||||
}
|
||||
const streamUsage = chunk.usage as ClineStreamUsageChunk | undefined
|
||||
if (!didOutputUsage && streamUsage) {
|
||||
yield {
|
||||
type: "usage",
|
||||
cacheWriteTokens: 0,
|
||||
cacheReadTokens: streamUsage.prompt_tokens_details?.cached_tokens || 0,
|
||||
inputTokens: (streamUsage.prompt_tokens || 0) - (streamUsage.prompt_tokens_details?.cached_tokens || 0),
|
||||
outputTokens: streamUsage.completion_tokens || 0,
|
||||
totalCost: (streamUsage.cost || 0) + (streamUsage.cost_details?.upstream_inference_cost || 0),
|
||||
}
|
||||
didOutputUsage = true
|
||||
}
|
||||
@@ -195,27 +177,14 @@ export class ClineHandler implements ApiHandler {
|
||||
})
|
||||
|
||||
const generation = response.data
|
||||
let modelId = this.options.openRouterModelId
|
||||
if (modelId && modelId.includes("gemini")) {
|
||||
return {
|
||||
type: "usage",
|
||||
cacheWriteTokens: 0,
|
||||
cacheReadTokens: generation?.native_tokens_cached || 0,
|
||||
// openrouter generation endpoint fails often
|
||||
inputTokens: (generation?.native_tokens_prompt || 0) - (generation?.native_tokens_cached || 0),
|
||||
outputTokens: generation?.native_tokens_completion || 0,
|
||||
totalCost: generation?.total_cost || 0,
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
type: "usage",
|
||||
cacheWriteTokens: 0,
|
||||
cacheReadTokens: generation?.native_tokens_cached || 0,
|
||||
// openrouter generation endpoint fails often
|
||||
inputTokens: generation?.native_tokens_prompt || 0,
|
||||
outputTokens: generation?.native_tokens_completion || 0,
|
||||
totalCost: generation?.total_cost || 0,
|
||||
}
|
||||
return {
|
||||
type: "usage",
|
||||
cacheWriteTokens: 0,
|
||||
cacheReadTokens: generation?.native_tokens_cached || 0,
|
||||
// openrouter generation endpoint fails often
|
||||
inputTokens: (generation?.native_tokens_prompt || 0) - (generation?.native_tokens_cached || 0),
|
||||
outputTokens: generation?.native_tokens_completion || 0,
|
||||
totalCost: generation?.total_cost || 0,
|
||||
}
|
||||
} catch (error) {
|
||||
// ignore if fails
|
||||
@@ -226,7 +195,7 @@ export class ClineHandler implements ApiHandler {
|
||||
}
|
||||
|
||||
getModel(): { id: string; info: ModelInfo } {
|
||||
let modelId = this.options.openRouterModelId
|
||||
const modelId = this.options.openRouterModelId
|
||||
const modelInfo = this.options.openRouterModelInfo
|
||||
if (modelId && modelInfo) {
|
||||
return { id: modelId, info: modelInfo }
|
||||
|
||||
+126
-26
@@ -16,9 +16,29 @@ 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
|
||||
@@ -41,35 +61,112 @@ export class LiteLlmHandler implements ApiHandler {
|
||||
return this.client
|
||||
}
|
||||
|
||||
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
|
||||
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
|
||||
}
|
||||
|
||||
const client = this.ensureClient()
|
||||
const modelId = this.options.liteLlmModelId || liteLlmDefaultModelId
|
||||
// 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`
|
||||
|
||||
try {
|
||||
const response = await fetch(`${client.baseURL}/spend/calculate`, {
|
||||
method: "POST",
|
||||
const response = await fetch(url, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${this.options.liteLlmApiKey}`,
|
||||
accept: "application/json",
|
||||
"x-litellm-api-key": this.options.liteLlmApiKey || "",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
completion_response: {
|
||||
model: modelId,
|
||||
usage: {
|
||||
prompt_tokens,
|
||||
completion_tokens,
|
||||
},
|
||||
},
|
||||
}),
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
const data: { cost: number } = await response.json()
|
||||
return data.cost
|
||||
const data: LiteLlmModelInfoResponse = await response.json()
|
||||
this.modelInfoCache = data
|
||||
this.modelInfoCacheTimestamp = now
|
||||
return data
|
||||
} else {
|
||||
console.error("Error calculating spend:", response.statusText)
|
||||
return undefined
|
||||
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
|
||||
}
|
||||
}
|
||||
} 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
|
||||
@@ -136,9 +233,6 @@ 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
|
||||
|
||||
@@ -165,9 +259,6 @@ 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 {
|
||||
@@ -182,6 +273,15 @@ 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,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
import { Message, Ollama } from "ollama"
|
||||
import { Message, Ollama, Config } from "ollama"
|
||||
import { ApiHandler } from "../"
|
||||
import { ApiHandlerOptions, ModelInfo, openAiModelInfoSaneDefaults } from "../../shared/api"
|
||||
import { convertToOllamaMessages } from "../transform/ollama-format"
|
||||
@@ -8,6 +8,7 @@ import { withRetry } from "../retry"
|
||||
|
||||
interface OllamaHandlerOptions {
|
||||
ollamaBaseUrl?: string
|
||||
ollamaApiKey?: string
|
||||
ollamaModelId?: string
|
||||
ollamaApiOptionsCtxNum?: string
|
||||
requestTimeoutMs?: number
|
||||
@@ -24,7 +25,18 @@ export class OllamaHandler implements ApiHandler {
|
||||
private ensureClient(): Ollama {
|
||||
if (!this.client) {
|
||||
try {
|
||||
this.client = new Ollama({ host: this.options.ollamaBaseUrl || "http://localhost:11434" })
|
||||
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)
|
||||
} catch (error) {
|
||||
throw new Error(`Error creating Ollama client: ${error.message}`)
|
||||
}
|
||||
|
||||
@@ -104,6 +104,33 @@ 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,
|
||||
|
||||
@@ -132,27 +132,14 @@ export class OpenRouterHandler implements ApiHandler {
|
||||
}
|
||||
|
||||
if (!didOutputUsage && chunk.usage) {
|
||||
let modelId = this.options.openRouterModelId
|
||||
if (modelId && modelId.includes("gemini")) {
|
||||
yield {
|
||||
type: "usage",
|
||||
cacheWriteTokens: 0,
|
||||
cacheReadTokens: chunk.usage.prompt_tokens_details?.cached_tokens || 0,
|
||||
inputTokens: (chunk.usage.prompt_tokens || 0) - (chunk.usage.prompt_tokens_details?.cached_tokens || 0),
|
||||
outputTokens: chunk.usage.completion_tokens || 0,
|
||||
// @ts-ignore-next-line
|
||||
totalCost: (chunk.usage.cost || 0) + (chunk.usage.cost_details?.upstream_inference_cost || 0),
|
||||
}
|
||||
} else {
|
||||
yield {
|
||||
type: "usage",
|
||||
cacheWriteTokens: 0,
|
||||
cacheReadTokens: chunk.usage.prompt_tokens_details?.cached_tokens || 0,
|
||||
inputTokens: chunk.usage.prompt_tokens || 0,
|
||||
outputTokens: chunk.usage.completion_tokens || 0,
|
||||
// @ts-ignore-next-line
|
||||
totalCost: (chunk.usage.cost || 0) + (chunk.usage.cost_details?.upstream_inference_cost || 0),
|
||||
}
|
||||
yield {
|
||||
type: "usage",
|
||||
cacheWriteTokens: 0,
|
||||
cacheReadTokens: chunk.usage.prompt_tokens_details?.cached_tokens || 0,
|
||||
inputTokens: (chunk.usage.prompt_tokens || 0) - (chunk.usage.prompt_tokens_details?.cached_tokens || 0),
|
||||
outputTokens: chunk.usage.completion_tokens || 0,
|
||||
// @ts-ignore-next-line
|
||||
totalCost: (chunk.usage.cost || 0) + (chunk.usage.cost_details?.upstream_inference_cost || 0),
|
||||
}
|
||||
didOutputUsage = true
|
||||
}
|
||||
@@ -174,27 +161,14 @@ export class OpenRouterHandler implements ApiHandler {
|
||||
const generationIterator = this.fetchGenerationDetails(this.lastGenerationId)
|
||||
const generation = (await generationIterator.next()).value
|
||||
// console.log("OpenRouter generation details:", generation)
|
||||
let modelId = this.options.openRouterModelId
|
||||
if (modelId && modelId.includes("gemini")) {
|
||||
return {
|
||||
type: "usage",
|
||||
cacheWriteTokens: 0,
|
||||
cacheReadTokens: generation?.native_tokens_cached || 0,
|
||||
// openrouter generation endpoint fails often
|
||||
inputTokens: (generation?.native_tokens_prompt || 0) - (generation?.native_tokens_cached || 0),
|
||||
outputTokens: generation?.native_tokens_completion || 0,
|
||||
totalCost: generation?.total_cost || 0,
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
type: "usage",
|
||||
cacheWriteTokens: 0,
|
||||
cacheReadTokens: generation?.native_tokens_cached || 0,
|
||||
// openrouter generation endpoint fails often
|
||||
inputTokens: generation?.native_tokens_prompt || 0,
|
||||
outputTokens: generation?.native_tokens_completion || 0,
|
||||
totalCost: generation?.total_cost || 0,
|
||||
}
|
||||
return {
|
||||
type: "usage",
|
||||
cacheWriteTokens: 0,
|
||||
cacheReadTokens: generation?.native_tokens_cached || 0,
|
||||
// openrouter generation endpoint fails often
|
||||
inputTokens: (generation?.native_tokens_prompt || 0) - (generation?.native_tokens_cached || 0),
|
||||
outputTokens: generation?.native_tokens_completion || 0,
|
||||
totalCost: generation?.total_cost || 0,
|
||||
}
|
||||
} catch (error) {
|
||||
// ignore if fails
|
||||
|
||||
@@ -74,7 +74,10 @@ 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-3-7-sonnet") ||
|
||||
model.id.includes("claude-sonnet-4") ||
|
||||
model.id.includes("claude-opus-4") ||
|
||||
model.id.includes("claude-opus-4-1")
|
||||
? thinking
|
||||
: {}
|
||||
|
||||
|
||||
+374
-145
@@ -5,6 +5,11 @@ import { ApiHandler } from "../"
|
||||
import { ModelInfo, sapAiCoreDefaultModelId, SapAiCoreModelId, sapAiCoreModels } from "../../shared/api"
|
||||
import { convertToOpenAiMessages } from "../transform/openai-format"
|
||||
import { ApiStream } from "../transform/stream"
|
||||
import {
|
||||
type Message as BedrockMessage,
|
||||
type ContentBlock as BedrockContentBlock,
|
||||
ConversationRole as BedrockConversationRole,
|
||||
} from "@aws-sdk/client-bedrock-runtime"
|
||||
|
||||
interface SapAiCoreHandlerOptions {
|
||||
sapAiCoreClientId?: string
|
||||
@@ -13,6 +18,7 @@ interface SapAiCoreHandlerOptions {
|
||||
sapAiResourceGroup?: string
|
||||
sapAiCoreBaseUrl?: string
|
||||
apiModelId?: string
|
||||
thinkingBudgetTokens?: number
|
||||
}
|
||||
|
||||
interface Deployment {
|
||||
@@ -27,6 +33,307 @@ interface Token {
|
||||
token_type: string
|
||||
expires_at: number
|
||||
}
|
||||
|
||||
// Bedrock namespace containing caching-related functions
|
||||
namespace Bedrock {
|
||||
// Define cache point type for AWS Bedrock
|
||||
interface CachePointContentBlock {
|
||||
cachePoint: {
|
||||
type: "default"
|
||||
}
|
||||
}
|
||||
|
||||
// Define types for supported content types
|
||||
type SupportedContentType = "text" | "image" | "thinking"
|
||||
|
||||
interface ContentItem {
|
||||
type: SupportedContentType
|
||||
text?: string
|
||||
source?: {
|
||||
data: string | Buffer | Uint8Array
|
||||
media_type?: string
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepares system messages with optional caching support
|
||||
*/
|
||||
export function prepareSystemMessages(systemPrompt: string, enableCaching: boolean): any[] | undefined {
|
||||
if (!systemPrompt) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
if (enableCaching) {
|
||||
return [{ text: systemPrompt }, { cachePoint: { type: "default" } }]
|
||||
}
|
||||
|
||||
return [{ text: systemPrompt }]
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies cache control to messages for prompt caching using AWS Bedrock's cachePoint system
|
||||
* AWS Bedrock uses cachePoint objects instead of Anthropic's cache_control approach
|
||||
*/
|
||||
export function applyCacheControlToMessages(
|
||||
messages: BedrockMessage[],
|
||||
lastUserMsgIndex: number,
|
||||
secondLastMsgUserIndex: number,
|
||||
): BedrockMessage[] {
|
||||
return messages.map((message, index) => {
|
||||
// Add cachePoint to the last user message and second-to-last user message
|
||||
if (index === lastUserMsgIndex || index === secondLastMsgUserIndex) {
|
||||
// Clone the message to avoid modifying the original
|
||||
const messageWithCache = { ...message }
|
||||
|
||||
if (messageWithCache.content && Array.isArray(messageWithCache.content)) {
|
||||
// Add cachePoint to the end of the content array
|
||||
messageWithCache.content = [
|
||||
...messageWithCache.content,
|
||||
{
|
||||
cachePoint: {
|
||||
type: "default",
|
||||
},
|
||||
} as CachePointContentBlock, // Properly typed cache point for AWS SDK
|
||||
]
|
||||
}
|
||||
|
||||
return messageWithCache
|
||||
}
|
||||
|
||||
return message
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats messages for models using the Converse API specification
|
||||
* Used by both Anthropic and Nova models to avoid code duplication
|
||||
*/
|
||||
export function formatMessagesForConverseAPI(messages: Anthropic.Messages.MessageParam[]): BedrockMessage[] {
|
||||
return messages.map((message) => {
|
||||
// Determine role (user or assistant)
|
||||
const role = message.role === "user" ? BedrockConversationRole.USER : BedrockConversationRole.ASSISTANT
|
||||
|
||||
// Process content based on type
|
||||
let content: BedrockContentBlock[] = []
|
||||
|
||||
if (typeof message.content === "string") {
|
||||
// Simple text content
|
||||
content = [{ text: message.content }]
|
||||
} else if (Array.isArray(message.content)) {
|
||||
// Convert Anthropic content format to Converse API content format
|
||||
const processedContent = message.content
|
||||
.map((item) => {
|
||||
// Text content
|
||||
if (item.type === "text") {
|
||||
return { text: item.text }
|
||||
}
|
||||
|
||||
// Image content
|
||||
if (item.type === "image") {
|
||||
return processImageContent(item)
|
||||
}
|
||||
|
||||
// Log unsupported content types for debugging
|
||||
console.warn(`Unsupported content type: ${(item as ContentItem).type}`)
|
||||
return null
|
||||
})
|
||||
.filter((item): item is BedrockContentBlock => item !== null)
|
||||
|
||||
content = processedContent
|
||||
}
|
||||
|
||||
// Return formatted message
|
||||
return {
|
||||
role,
|
||||
content,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes image content with proper error handling and user notification
|
||||
*/
|
||||
function processImageContent(item: any): BedrockContentBlock | null {
|
||||
let imageData: Uint8Array
|
||||
let format: "png" | "jpeg" | "gif" | "webp" = "jpeg" // default format
|
||||
|
||||
// Extract format from media_type if available
|
||||
if (item.source.media_type) {
|
||||
// Extract format from media_type (e.g., "image/jpeg" -> "jpeg")
|
||||
const formatMatch = item.source.media_type.match(/image\/(\w+)/)
|
||||
if (formatMatch && formatMatch[1]) {
|
||||
const extractedFormat = formatMatch[1]
|
||||
// Ensure format is one of the allowed values
|
||||
if (["png", "jpeg", "gif", "webp"].includes(extractedFormat)) {
|
||||
format = extractedFormat as "png" | "jpeg" | "gif" | "webp"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get image data with improved error handling
|
||||
try {
|
||||
if (typeof item.source.data === "string") {
|
||||
// Handle base64 encoded data
|
||||
const base64Data = item.source.data.replace(/^data:image\/\w+;base64,/, "")
|
||||
imageData = new Uint8Array(Buffer.from(base64Data, "base64"))
|
||||
} else if (item.source.data && typeof item.source.data === "object") {
|
||||
// Try to convert to Uint8Array
|
||||
imageData = new Uint8Array(Buffer.from(item.source.data as Buffer | Uint8Array))
|
||||
} else {
|
||||
throw new Error("Unsupported image data format")
|
||||
}
|
||||
|
||||
return {
|
||||
image: {
|
||||
format,
|
||||
source: {
|
||||
bytes: imageData,
|
||||
},
|
||||
},
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to process image content:", error)
|
||||
// Return a text content indicating the error instead of null
|
||||
// This ensures users are aware of the issue
|
||||
return {
|
||||
text: `[ERROR: Failed to process image - ${error instanceof Error ? error.message : "Unknown error"}]`,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Gemini namespace containing caching-related functions and types
|
||||
namespace Gemini {
|
||||
/**
|
||||
* Process Gemini streaming response with enhanced thinking content support and caching awareness
|
||||
*/
|
||||
export function processStreamChunk(data: any): {
|
||||
text?: string
|
||||
reasoning?: string
|
||||
usageMetadata?: {
|
||||
promptTokenCount?: number
|
||||
candidatesTokenCount?: number
|
||||
thoughtsTokenCount?: number
|
||||
cachedContentTokenCount?: number
|
||||
}
|
||||
} {
|
||||
const result: ReturnType<typeof processStreamChunk> = {}
|
||||
|
||||
// Handle thinking content from Gemini's response
|
||||
const candidateForThoughts = data?.candidates?.[0]
|
||||
const partsForThoughts = candidateForThoughts?.content?.parts
|
||||
let thoughts = ""
|
||||
|
||||
if (partsForThoughts) {
|
||||
for (const part of partsForThoughts) {
|
||||
const { thought, text } = part
|
||||
if (thought && text) {
|
||||
thoughts += text + "\n"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (thoughts.trim() !== "") {
|
||||
result.reasoning = thoughts.trim()
|
||||
}
|
||||
|
||||
// Handle regular text content
|
||||
if (data.text) {
|
||||
result.text = data.text
|
||||
}
|
||||
|
||||
// Handle content parts for non-thought text
|
||||
if (data.candidates && data.candidates[0]?.content?.parts) {
|
||||
let nonThoughtText = ""
|
||||
for (const part of data.candidates[0].content.parts) {
|
||||
if (part.text && !part.thought) {
|
||||
nonThoughtText += part.text
|
||||
}
|
||||
}
|
||||
if (nonThoughtText && !result.text) {
|
||||
result.text = nonThoughtText
|
||||
}
|
||||
}
|
||||
|
||||
// Handle usage metadata with caching support
|
||||
if (data.usageMetadata) {
|
||||
result.usageMetadata = {
|
||||
promptTokenCount: data.usageMetadata.promptTokenCount,
|
||||
candidatesTokenCount: data.usageMetadata.candidatesTokenCount,
|
||||
thoughtsTokenCount: data.usageMetadata.thoughtsTokenCount,
|
||||
cachedContentTokenCount: data.usageMetadata.cachedContentTokenCount,
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
function convertAnthropicMessageToGemini(message: Anthropic.Messages.MessageParam) {
|
||||
const role = message.role === "assistant" ? "model" : "user"
|
||||
const parts = []
|
||||
|
||||
if (typeof message.content === "string") {
|
||||
parts.push({ text: message.content })
|
||||
} else if (Array.isArray(message.content)) {
|
||||
for (const block of message.content) {
|
||||
if (block.type === "text") {
|
||||
parts.push({ text: block.text })
|
||||
} else if (block.type === "image") {
|
||||
parts.push({
|
||||
inlineData: {
|
||||
mimeType: block.source.media_type,
|
||||
data: block.source.data,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { role, parts }
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare Gemini request payload with thinking configuration and implicit caching support
|
||||
*/
|
||||
export function prepareRequestPayload(
|
||||
systemPrompt: string,
|
||||
messages: Anthropic.Messages.MessageParam[],
|
||||
model: { id: SapAiCoreModelId; info: ModelInfo },
|
||||
thinkingBudgetTokens?: number,
|
||||
): any {
|
||||
const contents = messages.map(convertAnthropicMessageToGemini)
|
||||
|
||||
const payload = {
|
||||
contents,
|
||||
systemInstruction: {
|
||||
parts: [
|
||||
{
|
||||
text: systemPrompt,
|
||||
},
|
||||
],
|
||||
},
|
||||
generationConfig: {
|
||||
maxOutputTokens: model.info.maxTokens,
|
||||
temperature: 0.0,
|
||||
},
|
||||
}
|
||||
|
||||
// Add thinking config if the model supports it and budget is provided
|
||||
const thinkingBudget = thinkingBudgetTokens ?? 0
|
||||
const maxBudget = model.info.thinkingConfig?.maxBudget ?? 0
|
||||
|
||||
if (thinkingBudget > 0 && model.info.thinkingConfig) {
|
||||
// Add thinking configuration to the payload
|
||||
;(payload as any).thinkingConfig = {
|
||||
thinkingBudget: thinkingBudget,
|
||||
includeThoughts: true,
|
||||
}
|
||||
}
|
||||
|
||||
return payload
|
||||
}
|
||||
}
|
||||
|
||||
export class SapAiCoreHandler implements ApiHandler {
|
||||
private options: SapAiCoreHandlerOptions
|
||||
private token?: Token
|
||||
@@ -142,7 +449,20 @@ export class SapAiCoreHandler implements ApiHandler {
|
||||
"anthropic--claude-3-opus",
|
||||
]
|
||||
|
||||
const openAIModels = ["gpt-4o", "gpt-4", "gpt-4o-mini", "o1", "gpt-4.1", "gpt-4.1-nano", "o3-mini", "o3", "o4-mini"]
|
||||
const openAIModels = [
|
||||
"gpt-4o",
|
||||
"gpt-4",
|
||||
"gpt-4o-mini",
|
||||
"o1",
|
||||
"gpt-4.1",
|
||||
"gpt-4.1-nano",
|
||||
"gpt-5",
|
||||
"gpt-5-nano",
|
||||
"gpt-5-mini",
|
||||
"o3-mini",
|
||||
"o3",
|
||||
"o4-mini",
|
||||
]
|
||||
|
||||
const geminiModels = ["gemini-2.5-flash", "gemini-2.5-pro"]
|
||||
|
||||
@@ -151,21 +471,47 @@ export class SapAiCoreHandler implements ApiHandler {
|
||||
if (anthropicModels.includes(model.id)) {
|
||||
url = `${this.options.sapAiCoreBaseUrl}/v2/inference/deployments/${deploymentId}/invoke-with-response-stream`
|
||||
|
||||
// Format messages for Converse API. Note that the Invoke API has
|
||||
// the same format for messages as the Converse API.
|
||||
const formattedMessages = Bedrock.formatMessagesForConverseAPI(messages)
|
||||
|
||||
// Get message indices for caching
|
||||
const userMsgIndices = messages.reduce(
|
||||
(acc, msg, index) => (msg.role === "user" ? [...acc, index] : acc),
|
||||
[] as number[],
|
||||
)
|
||||
const lastUserMsgIndex = userMsgIndices[userMsgIndices.length - 1] ?? -1
|
||||
const secondLastMsgUserIndex = userMsgIndices[userMsgIndices.length - 2] ?? -1
|
||||
|
||||
if (
|
||||
model.id === "anthropic--claude-4-sonnet" ||
|
||||
model.id === "anthropic--claude-4-opus" ||
|
||||
model.id === "anthropic--claude-3.7-sonnet"
|
||||
) {
|
||||
// Use converse-stream endpoint with caching support
|
||||
url = `${this.options.sapAiCoreBaseUrl}/v2/inference/deployments/${deploymentId}/converse-stream`
|
||||
|
||||
// Apply caching controls to messages (enabled by default)
|
||||
const messagesWithCache = Bedrock.applyCacheControlToMessages(
|
||||
formattedMessages,
|
||||
lastUserMsgIndex,
|
||||
secondLastMsgUserIndex,
|
||||
)
|
||||
|
||||
// Prepare system message with caching support (enabled by default)
|
||||
const systemMessages = Bedrock.prepareSystemMessages(systemPrompt, true)
|
||||
|
||||
payload = {
|
||||
inferenceConfig: {
|
||||
maxTokens: model.info.maxTokens,
|
||||
temperature: 0.0,
|
||||
},
|
||||
system: systemPrompt ? [{ text: systemPrompt }] : undefined,
|
||||
messages: this.formatAnthropicMessages(messages),
|
||||
system: systemMessages,
|
||||
messages: messagesWithCache,
|
||||
}
|
||||
} else {
|
||||
// Use invoke-with-response-stream endpoint
|
||||
// TODO: add caching support using Anthropic-native cache_control blocks
|
||||
payload = {
|
||||
max_tokens: model.info.maxTokens,
|
||||
system: systemPrompt,
|
||||
@@ -191,7 +537,7 @@ export class SapAiCoreHandler implements ApiHandler {
|
||||
stream_options: { include_usage: true },
|
||||
}
|
||||
|
||||
if (["o1", "o3-mini", "o3", "o4-mini"].includes(model.id)) {
|
||||
if (["o1", "o3-mini", "o3", "o4-mini", "gpt-5", "gpt-5-nano", "gpt-5-mini"].includes(model.id)) {
|
||||
delete payload.max_tokens
|
||||
delete payload.temperature
|
||||
}
|
||||
@@ -202,7 +548,7 @@ export class SapAiCoreHandler implements ApiHandler {
|
||||
}
|
||||
} else if (geminiModels.includes(model.id)) {
|
||||
url = `${this.options.sapAiCoreBaseUrl}/v2/inference/deployments/${deploymentId}/models/${model.id}:streamGenerateContent`
|
||||
payload = this.convertToGeminiFormat(systemPrompt, messages)
|
||||
payload = Gemini.prepareRequestPayload(systemPrompt, messages, model, this.options.thinkingBudgetTokens)
|
||||
} else {
|
||||
throw new Error(`Unsupported model: ${model.id}`)
|
||||
}
|
||||
@@ -359,9 +705,17 @@ export class SapAiCoreHandler implements ApiHandler {
|
||||
|
||||
// Handle metadata (token usage)
|
||||
if (data.metadata?.usage) {
|
||||
const inputTokens = data.metadata.usage.inputTokens || 0
|
||||
let inputTokens = data.metadata.usage.inputTokens || 0
|
||||
const outputTokens = data.metadata.usage.outputTokens || 0
|
||||
|
||||
// calibrate input token
|
||||
const totalTokens = data.metadata.usage.totalTokens || 0
|
||||
const cacheReadInputTokens = data.metadata.usage.cacheReadInputTokens || 0
|
||||
const cacheWriteOutputTokens = data.metadata.usage.cacheWriteOutputTokens || 0
|
||||
if (inputTokens + outputTokens + cacheReadInputTokens + cacheWriteOutputTokens !== totalTokens) {
|
||||
inputTokens = totalTokens - outputTokens - cacheReadInputTokens - cacheWriteOutputTokens
|
||||
}
|
||||
|
||||
yield {
|
||||
type: "usage",
|
||||
inputTokens,
|
||||
@@ -493,50 +847,31 @@ export class SapAiCoreHandler implements ApiHandler {
|
||||
const jsonData = line.slice(6)
|
||||
try {
|
||||
const data = JSON.parse(jsonData)
|
||||
const candidateForThoughts = data?.candidates?.[0]
|
||||
const partsForThoughts = candidateForThoughts?.content?.parts
|
||||
let thoughts = ""
|
||||
|
||||
if (partsForThoughts) {
|
||||
for (const part of partsForThoughts) {
|
||||
const { thought, text } = part
|
||||
if (thought && text) {
|
||||
thoughts += text + "\n"
|
||||
}
|
||||
}
|
||||
}
|
||||
// Use Gemini namespace to process the chunk
|
||||
const processed = Gemini.processStreamChunk(data)
|
||||
|
||||
if (thoughts.trim() !== "") {
|
||||
// Yield reasoning if present
|
||||
if (processed.reasoning) {
|
||||
yield {
|
||||
type: "reasoning",
|
||||
reasoning: thoughts.trim(),
|
||||
reasoning: processed.reasoning,
|
||||
}
|
||||
}
|
||||
|
||||
if (data.text) {
|
||||
// Yield text if present
|
||||
if (processed.text) {
|
||||
yield {
|
||||
type: "text",
|
||||
text: data.text,
|
||||
text: processed.text,
|
||||
}
|
||||
}
|
||||
|
||||
if (data.candidates && data.candidates[0]?.content?.parts) {
|
||||
for (const part of data.candidates[0].content.parts) {
|
||||
if (part.text && !part.thought) {
|
||||
// Only non-thought text
|
||||
yield {
|
||||
type: "text",
|
||||
text: part.text,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (data.usageMetadata) {
|
||||
promptTokens = data.usageMetadata.promptTokenCount ?? promptTokens
|
||||
outputTokens = data.usageMetadata.candidatesTokenCount ?? outputTokens
|
||||
thoughtsTokenCount = data.usageMetadata.thoughtsTokenCount ?? thoughtsTokenCount
|
||||
cacheReadTokens = data.usageMetadata.cachedContentTokenCount ?? cacheReadTokens
|
||||
if (processed.usageMetadata) {
|
||||
promptTokens = processed.usageMetadata.promptTokenCount ?? promptTokens
|
||||
outputTokens = processed.usageMetadata.candidatesTokenCount ?? outputTokens
|
||||
thoughtsTokenCount = processed.usageMetadata.thoughtsTokenCount ?? thoughtsTokenCount
|
||||
cacheReadTokens = processed.usageMetadata.cachedContentTokenCount ?? cacheReadTokens
|
||||
|
||||
yield {
|
||||
type: "usage",
|
||||
@@ -544,6 +879,7 @@ export class SapAiCoreHandler implements ApiHandler {
|
||||
outputTokens,
|
||||
thoughtsTokenCount,
|
||||
cacheReadTokens,
|
||||
cacheWriteTokens: 0,
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -581,111 +917,4 @@ export class SapAiCoreHandler implements ApiHandler {
|
||||
}
|
||||
return { id: sapAiCoreDefaultModelId, info: sapAiCoreModels[sapAiCoreDefaultModelId] }
|
||||
}
|
||||
|
||||
private getValidImageFormat(mediaType: string): string {
|
||||
const format = mediaType.split("/")[1]?.toLowerCase()
|
||||
const validFormats = ["png", "jpeg", "gif", "webp"]
|
||||
|
||||
if (validFormats.includes(format)) {
|
||||
return format
|
||||
}
|
||||
throw new Error(`Unsupported image format: ${format}`)
|
||||
}
|
||||
|
||||
private convertToGeminiFormat(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]) {
|
||||
const contents = messages.map(this.convertAnthropicMessageToGemini)
|
||||
|
||||
const payload = {
|
||||
contents,
|
||||
systemInstruction: {
|
||||
parts: [
|
||||
{
|
||||
text: systemPrompt,
|
||||
},
|
||||
],
|
||||
},
|
||||
generationConfig: {
|
||||
maxOutputTokens: this.getModel().info.maxTokens,
|
||||
temperature: 0.0,
|
||||
},
|
||||
}
|
||||
|
||||
return payload
|
||||
}
|
||||
|
||||
private convertAnthropicMessageToGemini(message: Anthropic.Messages.MessageParam) {
|
||||
const role = message.role === "assistant" ? "model" : "user"
|
||||
const parts = []
|
||||
|
||||
if (typeof message.content === "string") {
|
||||
parts.push({ text: message.content })
|
||||
} else if (Array.isArray(message.content)) {
|
||||
for (const block of message.content) {
|
||||
if (block.type === "text") {
|
||||
parts.push({ text: block.text })
|
||||
} else if (block.type === "image") {
|
||||
parts.push({
|
||||
inlineData: {
|
||||
mimeType: block.source.media_type,
|
||||
data: block.source.data,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { role, parts }
|
||||
}
|
||||
private formatAnthropicMessages(messages: Anthropic.Messages.MessageParam[]): any[] {
|
||||
return messages.map((m) => {
|
||||
const contentBlocks: any[] = []
|
||||
|
||||
if (typeof m.content === "string") {
|
||||
contentBlocks.push({ text: m.content })
|
||||
} else if (Array.isArray(m.content)) {
|
||||
for (const block of m.content) {
|
||||
if (block.type === "text") {
|
||||
if (!block.text) {
|
||||
throw new Error('Text block is missing the "text" field.')
|
||||
}
|
||||
contentBlocks.push({ text: block.text })
|
||||
} else if (block.type === "image") {
|
||||
if (!block.source) {
|
||||
throw new Error('Image block is missing the "source" field.')
|
||||
}
|
||||
|
||||
const { type, media_type, data } = block.source
|
||||
|
||||
if (!type || !media_type || !data) {
|
||||
throw new Error('Image source must have "type", "media_type", and "data" fields.')
|
||||
}
|
||||
|
||||
if (type !== "base64") {
|
||||
throw new Error(`Unsupported image source type: ${type}. Only "base64" is supported.`)
|
||||
}
|
||||
|
||||
const format = this.getValidImageFormat(media_type)
|
||||
|
||||
contentBlocks.push({
|
||||
image: {
|
||||
format,
|
||||
source: {
|
||||
bytes: data,
|
||||
},
|
||||
},
|
||||
})
|
||||
} else {
|
||||
throw new Error(`Unsupported content block type: ${block.type}`)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
throw new Error("Unsupported content format.")
|
||||
}
|
||||
|
||||
return {
|
||||
role: m.role,
|
||||
content: contentBlocks,
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,6 +86,7 @@ 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":
|
||||
|
||||
@@ -24,6 +24,7 @@ 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":
|
||||
@@ -82,6 +83,7 @@ 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":
|
||||
@@ -117,6 +119,7 @@ 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":
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import * as vscode from "vscode"
|
||||
import {
|
||||
migrateCustomInstructionsToGlobalRules,
|
||||
migrateWelcomeViewCompleted,
|
||||
migrateWorkspaceToGlobalStorage,
|
||||
} from "./core/storage/state-migrations"
|
||||
import { WebviewProvider } from "./core/webview"
|
||||
import { Logger } from "./services/logging/Logger"
|
||||
import { PostHogClientProvider } from "./services/posthog/PostHogClientProvider"
|
||||
import { WebviewProviderType } from "./shared/webview/types"
|
||||
import "./utils/path" // necessary to have access to String.prototype.toPosix
|
||||
|
||||
import { HostProvider } from "@/hosts/host-provider"
|
||||
import { FileContextTracker } from "./core/context/context-tracking/FileContextTracker"
|
||||
import { telemetryService } from "./services/posthog/PostHogClientProvider"
|
||||
import { ShowMessageType } from "./shared/proto/host/window"
|
||||
import { getLatestAnnouncementId } from "./utils/announcements"
|
||||
/**
|
||||
* Performs intialization for Cline that is common to all platforms.
|
||||
*
|
||||
* @param context
|
||||
* @returns The webview provider
|
||||
*/
|
||||
export async function initialize(context: vscode.ExtensionContext): Promise<WebviewProvider> {
|
||||
// Initialize PostHog client provider
|
||||
const distinctId = context.globalState.get<string>("cline.distinctId")
|
||||
PostHogClientProvider.getInstance(distinctId)
|
||||
|
||||
// Migrate custom instructions to global Cline rules (one-time cleanup)
|
||||
await migrateCustomInstructionsToGlobalRules(context)
|
||||
|
||||
// Migrate welcomeViewCompleted setting based on existing API keys (one-time cleanup)
|
||||
await migrateWelcomeViewCompleted(context)
|
||||
|
||||
// Migrate workspace storage values back to global storage (reverting previous migration)
|
||||
await migrateWorkspaceToGlobalStorage(context)
|
||||
|
||||
// Clean up orphaned file context warnings (startup cleanup)
|
||||
await FileContextTracker.cleanupOrphanedWarnings(context)
|
||||
|
||||
const sidebarWebview = HostProvider.get().createWebviewProvider(WebviewProviderType.SIDEBAR)
|
||||
|
||||
await showVersionUpdateAnnouncement(context)
|
||||
|
||||
telemetryService.captureExtensionActivated()
|
||||
|
||||
return sidebarWebview
|
||||
}
|
||||
|
||||
async function showVersionUpdateAnnouncement(context: vscode.ExtensionContext) {
|
||||
// Version checking for autoupdate notification
|
||||
const currentVersion = context.extension.packageJSON.version
|
||||
const previousVersion = context.globalState.get<string>("clineVersion")
|
||||
// Perform post-update actions if necessary
|
||||
try {
|
||||
if (!previousVersion || currentVersion !== previousVersion) {
|
||||
Logger.log(`Cline version changed: ${previousVersion} -> ${currentVersion}. First run or update detected.`)
|
||||
|
||||
// Use the same condition as announcements: focus when there's a new announcement to show
|
||||
const lastShownAnnouncementId = context.globalState.get<string>("lastShownAnnouncementId")
|
||||
const latestAnnouncementId = getLatestAnnouncementId(context)
|
||||
|
||||
if (lastShownAnnouncementId !== latestAnnouncementId) {
|
||||
// Focus Cline when there's a new announcement to show (major/minor updates or fresh installs)
|
||||
const message = previousVersion
|
||||
? `Cline has been updated to v${currentVersion}`
|
||||
: `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,
|
||||
})
|
||||
}
|
||||
// Always update the main version tracker for the next launch.
|
||||
await context.globalState.update("clineVersion", currentVersion)
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
console.error(`Error during post-update actions: ${errorMessage}, Stack trace: ${error.stack}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs cleanup when Cline is deactivated that is common to all platforms.
|
||||
*/
|
||||
export async function tearDown(): Promise<void> {
|
||||
PostHogClientProvider.getInstance().dispose()
|
||||
|
||||
// Dispose all webview instances
|
||||
await WebviewProvider.disposeAllInstances()
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
export type AssistantMessageContent = TextContent | ToolUse
|
||||
|
||||
export { parseAssistantMessageV1, parseAssistantMessageV2, parseAssistantMessageV3 } from "./parse-assistant-message"
|
||||
export { parseAssistantMessageV2, parseAssistantMessageV3 } from "./parse-assistant-message"
|
||||
|
||||
export interface TextContent {
|
||||
type: "text"
|
||||
@@ -60,6 +60,7 @@ export const toolParamNames = [
|
||||
"steps_to_reproduce",
|
||||
"api_request_output",
|
||||
"additional_context",
|
||||
"needs_more_exploration",
|
||||
] as const
|
||||
|
||||
export type ToolParamName = (typeof toolParamNames)[number]
|
||||
|
||||
@@ -1,245 +1,6 @@
|
||||
import { AssistantMessageContent, TextContent, ToolUse, ToolParamName, toolParamNames, toolUseNames, ToolUseName } from "." // Assuming types are defined in index.ts or a similar file
|
||||
|
||||
/**
|
||||
* @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
|
||||
}
|
||||
// parseAssistantmessageV1 removed in https://github.com/cline/cline/pull/5425
|
||||
|
||||
/**
|
||||
* @description **Version 2**
|
||||
|
||||
@@ -2,8 +2,6 @@ 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,
|
||||
@@ -13,5 +11,5 @@ const authService = AuthService.getInstance()
|
||||
* @returns The login URL as a string.
|
||||
*/
|
||||
export async function accountLoginClicked(_controller: Controller, _: EmptyRequest): Promise<String> {
|
||||
return await authService.createAuthRequest()
|
||||
return await AuthService.getInstance().createAuthRequest()
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ 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
|
||||
@@ -12,6 +11,6 @@ const authService = AuthService.getInstance()
|
||||
*/
|
||||
export async function accountLogoutClicked(controller: Controller, _request: EmptyRequest): Promise<Empty> {
|
||||
await controller.handleSignOut()
|
||||
await authService.handleDeauth()
|
||||
await AuthService.getInstance().handleDeauth()
|
||||
return Empty.create({})
|
||||
}
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
import { AuthService } from "../../../services/auth/AuthService"
|
||||
import { AuthState, EmptyRequest } from "@/shared/proto/index.cline"
|
||||
import { AuthService } from "@services/auth/AuthService"
|
||||
import { Controller } from ".."
|
||||
import { StreamingResponseHandler } from "../grpc-handler"
|
||||
|
||||
const authService = AuthService.getInstance()
|
||||
export const subscribeToAuthStatusUpdate = authService.subscribeToAuthStatusUpdate.bind(authService)
|
||||
export const sendAuthStatusUpdateEvent = authService.sendAuthStatusUpdate.bind(authService)
|
||||
export async function subscribeToAuthStatusUpdate(
|
||||
controller: Controller,
|
||||
request: EmptyRequest,
|
||||
responseStream: StreamingResponseHandler<AuthState>,
|
||||
requestId?: string,
|
||||
): Promise<void> {
|
||||
return AuthService.getInstance().subscribeToAuthStatusUpdate(controller, request, responseStream, requestId)
|
||||
}
|
||||
|
||||
@@ -16,11 +16,9 @@ export async function relaunchChromeDebugMode(controller: Controller, _: EmptyRe
|
||||
// Relaunch Chrome in debug mode
|
||||
await browserSession.relaunchChromeDebugMode(controller)
|
||||
|
||||
// The actual result will be sent via postMessageToWebview in the BrowserSession.relaunchChromeDebugMode method
|
||||
// The actual result will be sent via the ProtoBus in the BrowserSession.relaunchChromeDebugMode method
|
||||
// Here we just return a message as a placeholder
|
||||
return StringMessage.create({
|
||||
value: "Chrome relaunch initiated",
|
||||
})
|
||||
return { value: "Chrome relaunch initiated" }
|
||||
} catch (error) {
|
||||
throw new Error(`Error relaunching Chrome: ${error instanceof Error ? error.message : globalThis.String(error)}`)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import { describe, it, beforeEach, afterEach } from "mocha"
|
||||
import { expect } from "chai"
|
||||
import * as sinon from "sinon"
|
||||
import { ifFileExistsRelativePath } from "../ifFileExistsRelativePath"
|
||||
import { Controller } from "@core/controller"
|
||||
import { StringRequest, BooleanResponse } from "@shared/proto/cline/common"
|
||||
import * as pathUtils from "@utils/path"
|
||||
|
||||
describe("ifFileExistsRelativePath", () => {
|
||||
let sandbox: sinon.SinonSandbox
|
||||
let mockController: Controller
|
||||
let getWorkspacePathStub: sinon.SinonStub
|
||||
let consoleErrorStub: sinon.SinonStub
|
||||
|
||||
beforeEach(() => {
|
||||
sandbox = sinon.createSandbox()
|
||||
|
||||
// Create a mock controller
|
||||
mockController = {} as any
|
||||
|
||||
// Stub getWorkspacePath utility
|
||||
getWorkspacePathStub = sandbox.stub(pathUtils, "getWorkspacePath")
|
||||
|
||||
// Stub console.error to prevent test output pollution
|
||||
consoleErrorStub = sandbox.stub(console, "error")
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
sandbox.restore()
|
||||
})
|
||||
|
||||
it("should return BooleanResponse with boolean value", async () => {
|
||||
getWorkspacePathStub.resolves("/workspace")
|
||||
|
||||
const request = StringRequest.create({
|
||||
value: "src/test.ts",
|
||||
})
|
||||
|
||||
const result = await ifFileExistsRelativePath(mockController, request)
|
||||
|
||||
// The result should be a BooleanResponse object
|
||||
expect(result).to.have.property("value")
|
||||
expect(typeof result.value).to.equal("boolean")
|
||||
})
|
||||
|
||||
it("should return false and log error when no workspace path is available", async () => {
|
||||
const noWorkspaceScenarios = [null, undefined]
|
||||
|
||||
for (const workspaceValue of noWorkspaceScenarios) {
|
||||
getWorkspacePathStub.resolves(workspaceValue)
|
||||
consoleErrorStub.resetHistory()
|
||||
|
||||
const request = StringRequest.create({
|
||||
value: "src/test.ts",
|
||||
})
|
||||
|
||||
const result = await ifFileExistsRelativePath(mockController, request)
|
||||
|
||||
expect(result).to.deep.equal(BooleanResponse.create({ value: false }))
|
||||
expect(consoleErrorStub.called).to.be.true
|
||||
}
|
||||
})
|
||||
|
||||
it("should return false when path is invalid", async () => {
|
||||
getWorkspacePathStub.resolves("/workspace")
|
||||
|
||||
const invalidPaths = ["", undefined]
|
||||
|
||||
for (const invalidPath of invalidPaths) {
|
||||
const request = StringRequest.create({
|
||||
value: invalidPath,
|
||||
})
|
||||
|
||||
const result = await ifFileExistsRelativePath(mockController, request)
|
||||
|
||||
expect(result).to.deep.equal(BooleanResponse.create({ value: false }))
|
||||
}
|
||||
})
|
||||
|
||||
it("should handle valid relative paths correctly", async () => {
|
||||
getWorkspacePathStub.resolves("/workspace")
|
||||
|
||||
// Test with valid workspace-relative paths only
|
||||
const validPaths = ["src/file.ts", "./src/file.ts", "package.json", ".gitignore", "src/components/ui/Button/Button.tsx"]
|
||||
|
||||
for (const testPath of validPaths) {
|
||||
const request = StringRequest.create({
|
||||
value: testPath,
|
||||
})
|
||||
|
||||
const result = await ifFileExistsRelativePath(mockController, request)
|
||||
|
||||
// Each should return a BooleanResponse
|
||||
expect(result).to.have.property("value")
|
||||
expect(typeof result.value).to.equal("boolean")
|
||||
}
|
||||
|
||||
// Verify that getWorkspacePath was called for each path
|
||||
expect(getWorkspacePathStub.callCount).to.equal(validPaths.length)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,117 @@
|
||||
import { describe, it, beforeEach, afterEach } from "mocha"
|
||||
import { expect } from "chai"
|
||||
import * as sinon from "sinon"
|
||||
import { openFileRelativePath } from "../openFileRelativePath"
|
||||
import { Controller } from "@core/controller"
|
||||
import { StringRequest, Empty } from "@shared/proto/cline/common"
|
||||
import * as openFileIntegration from "@integrations/misc/open-file"
|
||||
import * as pathUtils from "@utils/path"
|
||||
import * as path from "path"
|
||||
|
||||
describe("openFileRelativePath", () => {
|
||||
let sandbox: sinon.SinonSandbox
|
||||
let mockController: Controller
|
||||
let openFileIntegrationStub: sinon.SinonStub
|
||||
let getWorkspacePathStub: sinon.SinonStub
|
||||
let consoleErrorStub: sinon.SinonStub
|
||||
|
||||
beforeEach(() => {
|
||||
sandbox = sinon.createSandbox()
|
||||
|
||||
// Create a mock controller
|
||||
mockController = {} as any
|
||||
|
||||
// Stub the openFileIntegration function
|
||||
openFileIntegrationStub = sandbox.stub(openFileIntegration, "openFile")
|
||||
|
||||
// Stub getWorkspacePath utility
|
||||
getWorkspacePathStub = sandbox.stub(pathUtils, "getWorkspacePath")
|
||||
|
||||
// Stub console.error to prevent test output pollution
|
||||
consoleErrorStub = sandbox.stub(console, "error")
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
sandbox.restore()
|
||||
})
|
||||
|
||||
it("should return Empty response on successful execution", async () => {
|
||||
getWorkspacePathStub.resolves("/workspace")
|
||||
|
||||
const request = StringRequest.create({
|
||||
value: "src/test.ts",
|
||||
})
|
||||
|
||||
const result = await openFileRelativePath(mockController, request)
|
||||
|
||||
expect(result).to.deep.equal(Empty.create())
|
||||
})
|
||||
|
||||
it("should call openFileIntegration with absolute path when relative path is provided", async () => {
|
||||
const workspacePath = "/workspace"
|
||||
const relativePath = "src/components/Test.tsx"
|
||||
const expectedAbsolutePath = path.resolve(workspacePath, relativePath)
|
||||
|
||||
getWorkspacePathStub.resolves(workspacePath)
|
||||
|
||||
const request = StringRequest.create({
|
||||
value: relativePath,
|
||||
})
|
||||
|
||||
await openFileRelativePath(mockController, request)
|
||||
|
||||
expect(openFileIntegrationStub.calledOnceWith(expectedAbsolutePath)).to.be.true
|
||||
})
|
||||
|
||||
it("should not call openFileIntegration when path is invalid", async () => {
|
||||
getWorkspacePathStub.resolves("/workspace")
|
||||
|
||||
const invalidPaths = ["", undefined]
|
||||
|
||||
for (const invalidPath of invalidPaths) {
|
||||
const request = StringRequest.create({
|
||||
value: invalidPath,
|
||||
})
|
||||
|
||||
await openFileRelativePath(mockController, request)
|
||||
|
||||
expect(openFileIntegrationStub.called).to.be.false
|
||||
openFileIntegrationStub.resetHistory()
|
||||
}
|
||||
})
|
||||
|
||||
it("should return Empty and log error when no workspace path is available", async () => {
|
||||
const noWorkspaceScenarios = [null, undefined]
|
||||
|
||||
for (const workspaceValue of noWorkspaceScenarios) {
|
||||
getWorkspacePathStub.resolves(workspaceValue)
|
||||
consoleErrorStub.resetHistory()
|
||||
|
||||
const request = StringRequest.create({
|
||||
value: "src/test.ts",
|
||||
})
|
||||
|
||||
const result = await openFileRelativePath(mockController, request)
|
||||
|
||||
expect(result).to.deep.equal(Empty.create())
|
||||
expect(consoleErrorStub.called).to.be.true
|
||||
expect(openFileIntegrationStub.called).to.be.false
|
||||
}
|
||||
})
|
||||
|
||||
it("should handle nested directory paths", async () => {
|
||||
const workspacePath = "/workspace"
|
||||
const relativePath = "src/components/ui/Button/Button.tsx"
|
||||
const expectedAbsolutePath = path.resolve(workspacePath, relativePath)
|
||||
|
||||
getWorkspacePathStub.resolves(workspacePath)
|
||||
|
||||
const request = StringRequest.create({
|
||||
value: relativePath,
|
||||
})
|
||||
|
||||
await openFileRelativePath(mockController, request)
|
||||
|
||||
expect(openFileIntegrationStub.calledOnceWith(expectedAbsolutePath)).to.be.true
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,35 @@
|
||||
import * as path from "path"
|
||||
import * as fs from "fs"
|
||||
import { Controller } from ".."
|
||||
import { StringRequest, BooleanResponse } from "@shared/proto/cline/common"
|
||||
import { getWorkspacePath } from "@utils/path"
|
||||
|
||||
/**
|
||||
* Check if a file exists in the project using a relative path
|
||||
* @param controller The controller instance
|
||||
* @param request The request containing the relative file path to check
|
||||
* @returns BooleanResponse indicating whether the file exists
|
||||
*/
|
||||
export async function ifFileExistsRelativePath(_controller: Controller, request: StringRequest): Promise<BooleanResponse> {
|
||||
const workspacePath = await getWorkspacePath()
|
||||
|
||||
if (!workspacePath) {
|
||||
// If no workspace is open, return false
|
||||
console.error("Error in ifFileExistsRelativePath: No workspace path available") // TODO
|
||||
return BooleanResponse.create({ value: false })
|
||||
}
|
||||
|
||||
if (!request.value) {
|
||||
// If no path provided, return false
|
||||
return BooleanResponse.create({ value: false })
|
||||
}
|
||||
|
||||
// Resolve the relative path to absolute path
|
||||
const absolutePath = path.resolve(workspacePath, request.value)
|
||||
// Check if the file exists
|
||||
try {
|
||||
return BooleanResponse.create({ value: fs.statSync(absolutePath).isFile() })
|
||||
} catch {
|
||||
return BooleanResponse.create({ value: false })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import * as path from "path"
|
||||
import { Controller } from ".."
|
||||
import { Empty, StringRequest } from "@shared/proto/cline/common"
|
||||
import { openFile as openFileIntegration } from "@integrations/misc/open-file"
|
||||
import { getWorkspacePath } from "@utils/path"
|
||||
|
||||
/**
|
||||
* Opens a file in the editor by a relative path
|
||||
* @param controller The controller instance
|
||||
* @param request The request message containing the relative file path in the 'value' field
|
||||
* @returns Empty response
|
||||
*/
|
||||
export async function openFileRelativePath(_controller: Controller, request: StringRequest): Promise<Empty> {
|
||||
const workspacePath = await getWorkspacePath()
|
||||
|
||||
if (!workspacePath) {
|
||||
console.error("Error in openFileRelativePath: No workspace path available")
|
||||
return Empty.create()
|
||||
}
|
||||
|
||||
if (request.value) {
|
||||
// Resolve the relative path to absolute path
|
||||
const absolutePath = path.resolve(workspacePath, request.value)
|
||||
|
||||
// Open the file using the existing integration
|
||||
openFileIntegration(absolutePath)
|
||||
}
|
||||
|
||||
return Empty.create()
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Controller } from ".."
|
||||
import { FileSearchRequest, FileSearchResults } from "@shared/proto/cline/file"
|
||||
import { FileSearchRequest, FileSearchResults, FileSearchType } from "@shared/proto/cline/file"
|
||||
import { searchWorkspaceFiles } from "@services/search/file-search"
|
||||
import { getWorkspacePath } from "@utils/path"
|
||||
import { convertSearchResultsToProtoFileInfos } from "@shared/proto-conversions/file/search-result-conversion"
|
||||
@@ -23,11 +23,20 @@ export async function searchFiles(_controller: Controller, request: FileSearchRe
|
||||
}
|
||||
|
||||
try {
|
||||
// Map enum to string for the search service
|
||||
let selectedTypeString: "file" | "folder" | undefined = undefined
|
||||
if (request.selectedType === FileSearchType.FILE) {
|
||||
selectedTypeString = "file"
|
||||
} else if (request.selectedType === FileSearchType.FOLDER) {
|
||||
selectedTypeString = "folder"
|
||||
}
|
||||
|
||||
// Call file search service with query from request
|
||||
const searchResults = await searchWorkspaceFiles(
|
||||
request.query || "",
|
||||
workspacePath,
|
||||
request.limit || 20, // Use default limit of 20 if not specified
|
||||
selectedTypeString,
|
||||
)
|
||||
|
||||
// Convert search results to proto FileInfo objects using the conversion function
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
import { Controller } from "../index"
|
||||
import { EmptyRequest, StringArray } from "@shared/proto/cline/common"
|
||||
import { StreamingResponseHandler, getRequestRegistry } from "../grpc-handler"
|
||||
|
||||
// Keep track of active subscriptions
|
||||
const activeWorkspaceUpdateSubscriptions = new Set<StreamingResponseHandler<StringArray>>()
|
||||
|
||||
/**
|
||||
* Subscribe to workspace file updates
|
||||
* @param controller The controller instance
|
||||
* @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 subscribeToWorkspaceUpdates(
|
||||
_controller: Controller,
|
||||
_request: EmptyRequest,
|
||||
responseStream: StreamingResponseHandler<StringArray>,
|
||||
requestId?: string,
|
||||
): Promise<void> {
|
||||
// Add this subscription to the active subscriptions
|
||||
activeWorkspaceUpdateSubscriptions.add(responseStream)
|
||||
|
||||
// Register cleanup when the connection is closed
|
||||
const cleanup = () => {
|
||||
activeWorkspaceUpdateSubscriptions.delete(responseStream)
|
||||
}
|
||||
|
||||
// Register the cleanup function with the request registry if we have a requestId
|
||||
if (requestId) {
|
||||
getRequestRegistry().registerRequest(requestId, cleanup, { type: "workspace_update_subscription" }, responseStream)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a workspace update event to all active subscribers
|
||||
* @param filePaths Array of file paths to send
|
||||
*/
|
||||
export async function sendWorkspaceUpdateEvent(filePaths: string[]): Promise<void> {
|
||||
// Send the event to all active subscribers
|
||||
const promises = Array.from(activeWorkspaceUpdateSubscriptions).map(async (responseStream) => {
|
||||
try {
|
||||
const event = StringArray.create({
|
||||
values: filePaths,
|
||||
})
|
||||
await responseStream(
|
||||
event,
|
||||
false, // Not the last message
|
||||
)
|
||||
} catch (error) {
|
||||
console.error("Error sending workspace update event:", error)
|
||||
// Remove the subscription if there was an error
|
||||
activeWorkspaceUpdateSubscriptions.delete(responseStream)
|
||||
}
|
||||
})
|
||||
|
||||
await Promise.all(promises)
|
||||
}
|
||||
@@ -8,7 +8,8 @@ import { serviceHandlers } from "@generated/hosts/vscode/protobus-services"
|
||||
|
||||
describe("grpc-handler", () => {
|
||||
let sandbox: sinon.SinonSandbox
|
||||
let mockController: sinon.SinonStubbedInstance<Controller>
|
||||
let mockController: Controller
|
||||
let mockPostMessageToWebview: sinon.SinonStub
|
||||
|
||||
let mockUnaryHandler: sinon.SinonStub
|
||||
let mockUnaryFailingHandler: sinon.SinonStub
|
||||
@@ -22,9 +23,8 @@ describe("grpc-handler", () => {
|
||||
sandbox = sinon.createSandbox()
|
||||
|
||||
// Create a mock controller
|
||||
mockController = {
|
||||
postMessageToWebview: sandbox.stub().resolves(),
|
||||
} as any
|
||||
mockController = {} as any
|
||||
mockPostMessageToWebview = sandbox.stub().resolves()
|
||||
|
||||
// Create mock service handlers
|
||||
mockUnaryHandler = sandbox.stub().resolves(mockResponse)
|
||||
@@ -54,7 +54,7 @@ describe("grpc-handler", () => {
|
||||
is_streaming: false,
|
||||
}
|
||||
|
||||
await handleGrpcRequest(mockController as any, request)
|
||||
await handleGrpcRequest(mockController, mockPostMessageToWebview, request)
|
||||
|
||||
// Verify the handler was called
|
||||
expect(mockUnaryHandler.calledOnce).to.be.true
|
||||
@@ -62,8 +62,8 @@ describe("grpc-handler", () => {
|
||||
expect(mockUnaryHandler.firstCall.args[1]).to.deep.equal({ input: "test" })
|
||||
|
||||
// Verify the response was sent
|
||||
expect(mockController.postMessageToWebview.calledOnce).to.be.true
|
||||
const sentMessage = mockController.postMessageToWebview.firstCall.args[0]
|
||||
expect(mockPostMessageToWebview.calledOnce).to.be.true
|
||||
const sentMessage = mockPostMessageToWebview.firstCall.args[0]
|
||||
expect(sentMessage).to.deep.equal({
|
||||
type: "grpc_response",
|
||||
grpc_response: {
|
||||
@@ -82,11 +82,11 @@ describe("grpc-handler", () => {
|
||||
is_streaming: false,
|
||||
}
|
||||
|
||||
await handleGrpcRequest(mockController as any, request)
|
||||
await handleGrpcRequest(mockController, mockPostMessageToWebview, request)
|
||||
|
||||
// Verify the error response was sent
|
||||
expect(mockController.postMessageToWebview.calledOnce).to.be.true
|
||||
const sentMessage = mockController.postMessageToWebview.firstCall.args[0]
|
||||
expect(mockPostMessageToWebview.calledOnce).to.be.true
|
||||
const sentMessage = mockPostMessageToWebview.firstCall.args[0]
|
||||
expect(sentMessage).to.deep.equal({
|
||||
type: "grpc_response",
|
||||
grpc_response: {
|
||||
@@ -106,11 +106,11 @@ describe("grpc-handler", () => {
|
||||
is_streaming: false,
|
||||
}
|
||||
|
||||
await handleGrpcRequest(mockController as any, request)
|
||||
await handleGrpcRequest(mockController, mockPostMessageToWebview, request)
|
||||
|
||||
// Verify the error response was sent
|
||||
expect(mockController.postMessageToWebview.calledOnce).to.be.true
|
||||
const sentMessage = mockController.postMessageToWebview.firstCall.args[0]
|
||||
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")
|
||||
@@ -125,11 +125,11 @@ describe("grpc-handler", () => {
|
||||
is_streaming: false,
|
||||
}
|
||||
|
||||
await handleGrpcRequest(mockController as any, request)
|
||||
await handleGrpcRequest(mockController, mockPostMessageToWebview, request)
|
||||
|
||||
// Verify the error response was sent
|
||||
expect(mockController.postMessageToWebview.calledOnce).to.be.true
|
||||
const sentMessage = mockController.postMessageToWebview.firstCall.args[0]
|
||||
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")
|
||||
@@ -155,7 +155,7 @@ describe("grpc-handler", () => {
|
||||
await responseStream({ value: 3 }, true, 2) // Last message
|
||||
})
|
||||
|
||||
await handleGrpcRequest(mockController as any, request)
|
||||
await handleGrpcRequest(mockController, mockPostMessageToWebview, request)
|
||||
|
||||
// Verify the handler was called
|
||||
expect(mockStreamingHandler.calledOnce).to.be.true
|
||||
@@ -164,10 +164,10 @@ describe("grpc-handler", () => {
|
||||
expect(mockStreamingHandler.firstCall.args[3]).to.equal("stream-123")
|
||||
|
||||
// Verify all streaming responses were sent
|
||||
expect(mockController.postMessageToWebview.callCount).to.equal(3)
|
||||
expect(mockPostMessageToWebview.callCount).to.equal(3)
|
||||
|
||||
// Check all responses
|
||||
expect(mockController.postMessageToWebview.firstCall.args[0]).to.deep.equal({
|
||||
expect(mockPostMessageToWebview.firstCall.args[0]).to.deep.equal({
|
||||
type: "grpc_response",
|
||||
grpc_response: {
|
||||
message: { value: 1 },
|
||||
@@ -176,7 +176,7 @@ describe("grpc-handler", () => {
|
||||
sequence_number: 0,
|
||||
},
|
||||
})
|
||||
expect(mockController.postMessageToWebview.secondCall.args[0]).to.deep.equal({
|
||||
expect(mockPostMessageToWebview.secondCall.args[0]).to.deep.equal({
|
||||
type: "grpc_response",
|
||||
grpc_response: {
|
||||
message: { value: 2 },
|
||||
@@ -185,7 +185,7 @@ describe("grpc-handler", () => {
|
||||
sequence_number: 1,
|
||||
},
|
||||
})
|
||||
expect(mockController.postMessageToWebview.thirdCall.args[0]).to.deep.equal({
|
||||
expect(mockPostMessageToWebview.thirdCall.args[0]).to.deep.equal({
|
||||
type: "grpc_response",
|
||||
grpc_response: {
|
||||
message: { value: 3 },
|
||||
@@ -205,11 +205,11 @@ describe("grpc-handler", () => {
|
||||
is_streaming: true,
|
||||
}
|
||||
|
||||
await handleGrpcRequest(mockController as any, request)
|
||||
await handleGrpcRequest(mockController, mockPostMessageToWebview, request)
|
||||
|
||||
// Verify the error response was sent
|
||||
expect(mockController.postMessageToWebview.calledOnce).to.be.true
|
||||
const sentMessage = mockController.postMessageToWebview.firstCall.args[0]
|
||||
expect(mockPostMessageToWebview.calledOnce).to.be.true
|
||||
const sentMessage = mockPostMessageToWebview.firstCall.args[0]
|
||||
expect(sentMessage).to.deep.equal({
|
||||
type: "grpc_response",
|
||||
grpc_response: {
|
||||
@@ -243,16 +243,16 @@ describe("grpc-handler", () => {
|
||||
throw new Error("Mid-stream error")
|
||||
})
|
||||
|
||||
await handleGrpcRequest(mockController as any, request)
|
||||
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(mockController.postMessageToWebview.callCount).to.equal(2)
|
||||
expect(mockPostMessageToWebview.callCount).to.equal(2)
|
||||
|
||||
// Check first message was sent successfully
|
||||
expect(mockController.postMessageToWebview.firstCall.args[0]).to.deep.equal({
|
||||
expect(mockPostMessageToWebview.firstCall.args[0]).to.deep.equal({
|
||||
type: "grpc_response",
|
||||
grpc_response: {
|
||||
message: { value: "first" },
|
||||
@@ -263,7 +263,7 @@ describe("grpc-handler", () => {
|
||||
})
|
||||
|
||||
// Check error response was sent
|
||||
expect(mockController.postMessageToWebview.secondCall.args[0]).to.deep.equal({
|
||||
expect(mockPostMessageToWebview.secondCall.args[0]).to.deep.equal({
|
||||
type: "grpc_response",
|
||||
grpc_response: {
|
||||
error: "Mid-stream error",
|
||||
@@ -280,12 +280,12 @@ describe("grpc-handler", () => {
|
||||
await responseStream({ value: "after-error" }, false, 1)
|
||||
|
||||
// Verify we now have 3 total calls (first message, error, after-error message)
|
||||
expect(mockController.postMessageToWebview.callCount).to.equal(3)
|
||||
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(mockController.postMessageToWebview.thirdCall.args[0]).to.deep.equal({
|
||||
expect(mockPostMessageToWebview.thirdCall.args[0]).to.deep.equal({
|
||||
type: "grpc_response",
|
||||
grpc_response: {
|
||||
message: { value: "after-error" },
|
||||
@@ -308,14 +308,14 @@ describe("grpc-handler", () => {
|
||||
request_id: "cancel-123",
|
||||
}
|
||||
|
||||
await handleGrpcRequestCancel(mockController as any, cancelRequest)
|
||||
await handleGrpcRequestCancel(mockPostMessageToWebview, cancelRequest)
|
||||
|
||||
// Verify the cleanup was called
|
||||
expect(cleanupStub.calledOnce).to.be.true
|
||||
|
||||
// Verify the cancellation confirmation was sent
|
||||
expect(mockController.postMessageToWebview.calledOnce).to.be.true
|
||||
const sentMessage = mockController.postMessageToWebview.firstCall.args[0]
|
||||
expect(mockPostMessageToWebview.calledOnce).to.be.true
|
||||
const sentMessage = mockPostMessageToWebview.firstCall.args[0]
|
||||
expect(sentMessage).to.deep.equal({
|
||||
type: "grpc_response",
|
||||
grpc_response: {
|
||||
@@ -334,10 +334,10 @@ describe("grpc-handler", () => {
|
||||
request_id: "non-existent",
|
||||
}
|
||||
|
||||
await handleGrpcRequestCancel(mockController as any, cancelRequest)
|
||||
await handleGrpcRequestCancel(mockPostMessageToWebview, cancelRequest)
|
||||
|
||||
// Verify no message was sent (request not found)
|
||||
expect(mockController.postMessageToWebview.called).to.be.false
|
||||
expect(mockPostMessageToWebview.called).to.be.false
|
||||
})
|
||||
|
||||
it("should handle cleanup errors gracefully", async () => {
|
||||
@@ -351,13 +351,13 @@ describe("grpc-handler", () => {
|
||||
}
|
||||
|
||||
// Should not throw
|
||||
await handleGrpcRequestCancel(mockController as any, cancelRequest)
|
||||
await handleGrpcRequestCancel(mockPostMessageToWebview, cancelRequest)
|
||||
|
||||
// Verify the cleanup was attempted
|
||||
expect(cleanupStub.calledOnce).to.be.true
|
||||
|
||||
// Verify the cancellation confirmation was still sent
|
||||
expect(mockController.postMessageToWebview.calledOnce).to.be.true
|
||||
expect(mockPostMessageToWebview.calledOnce).to.be.true
|
||||
|
||||
// Verify the request was removed despite the error
|
||||
expect(registry.hasRequest("cancel-error")).to.be.false
|
||||
@@ -368,28 +368,28 @@ describe("grpc-handler", () => {
|
||||
it("should handle concurrent requests", async () => {
|
||||
// Set up handlers
|
||||
mockUnaryHandler.resolves({ result: "unary" })
|
||||
mockStreamingHandler.callsFake(async (controller: any, message: any, responseStream: any) => {
|
||||
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 as any, {
|
||||
handleGrpcRequest(mockController, mockPostMessageToWebview, {
|
||||
service: serviceName,
|
||||
method: "testUnary",
|
||||
message: { id: 1 },
|
||||
request_id: "concurrent-1",
|
||||
is_streaming: false,
|
||||
}),
|
||||
handleGrpcRequest(mockController as any, {
|
||||
handleGrpcRequest(mockController, mockPostMessageToWebview, {
|
||||
service: serviceName,
|
||||
method: "testStreaming",
|
||||
message: { id: 2 },
|
||||
request_id: "concurrent-2",
|
||||
is_streaming: true,
|
||||
}),
|
||||
handleGrpcRequest(mockController as any, {
|
||||
handleGrpcRequest(mockController, mockPostMessageToWebview, {
|
||||
service: serviceName,
|
||||
method: "testUnary",
|
||||
message: { id: 3 },
|
||||
@@ -405,7 +405,7 @@ describe("grpc-handler", () => {
|
||||
expect(mockStreamingHandler.callCount).to.equal(1)
|
||||
|
||||
// Verify all responses were sent (2 unary + 2 streaming)
|
||||
expect(mockController.postMessageToWebview.callCount).to.equal(4)
|
||||
expect(mockPostMessageToWebview.callCount).to.equal(4)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,6 +2,7 @@ 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
|
||||
@@ -12,14 +13,20 @@ export type StreamingResponseHandler<TResponse> = (
|
||||
sequenceNumber?: number,
|
||||
) => Promise<void>
|
||||
|
||||
export type PostMessageToWebview = (message: ExtensionMessage) => Thenable<boolean | undefined>
|
||||
|
||||
/**
|
||||
* Handles a gRPC request from the webview.
|
||||
*/
|
||||
export async function handleGrpcRequest(controller: Controller, request: GrpcRequest): Promise<void> {
|
||||
export async function handleGrpcRequest(
|
||||
controller: Controller,
|
||||
postMessageToWebview: PostMessageToWebview,
|
||||
request: GrpcRequest,
|
||||
): Promise<void> {
|
||||
if (request.is_streaming) {
|
||||
await handleStreamingRequest(controller, request)
|
||||
await handleStreamingRequest(controller, postMessageToWebview, request)
|
||||
} else {
|
||||
await handleUnaryRequest(controller, request)
|
||||
await handleUnaryRequest(controller, postMessageToWebview, request)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,14 +35,18 @@ export async function handleGrpcRequest(controller: Controller, request: GrpcReq
|
||||
*
|
||||
* Calls the handler using the service and method name, and then posts the result back to the webview.
|
||||
*/
|
||||
async function handleUnaryRequest(controller: Controller, request: GrpcRequest): Promise<void> {
|
||||
async function handleUnaryRequest(
|
||||
controller: Controller,
|
||||
postMessageToWebview: PostMessageToWebview,
|
||||
request: GrpcRequest,
|
||||
): Promise<void> {
|
||||
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 controller.postMessageToWebview({
|
||||
await postMessageToWebview({
|
||||
type: "grpc_response",
|
||||
grpc_response: {
|
||||
message: response,
|
||||
@@ -45,7 +56,7 @@ async function handleUnaryRequest(controller: Controller, request: GrpcRequest):
|
||||
} catch (error) {
|
||||
// Send error response
|
||||
console.log("Protobus error:", error)
|
||||
await controller.postMessageToWebview({
|
||||
await postMessageToWebview({
|
||||
type: "grpc_response",
|
||||
grpc_response: {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
@@ -62,14 +73,18 @@ async function handleUnaryRequest(controller: Controller, request: GrpcRequest):
|
||||
* 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, request: GrpcRequest): Promise<void> {
|
||||
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 controller.postMessageToWebview({
|
||||
await postMessageToWebview({
|
||||
type: "grpc_response",
|
||||
grpc_response: {
|
||||
message: response,
|
||||
@@ -92,7 +107,7 @@ async function handleStreamingRequest(controller: Controller, request: GrpcReque
|
||||
} catch (error) {
|
||||
// Send error response
|
||||
console.log("Protobus error:", error)
|
||||
await controller.postMessageToWebview({
|
||||
await postMessageToWebview({
|
||||
type: "grpc_response",
|
||||
grpc_response: {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
@@ -108,12 +123,12 @@ async function handleStreamingRequest(controller: Controller, request: GrpcReque
|
||||
* @param controller The controller instance
|
||||
* @param request The cancellation request
|
||||
*/
|
||||
export async function handleGrpcRequestCancel(controller: Controller, request: GrpcCancel) {
|
||||
export async function handleGrpcRequestCancel(postMessageToWebview: PostMessageToWebview, request: GrpcCancel) {
|
||||
const cancelled = requestRegistry.cancelRequest(request.request_id)
|
||||
|
||||
if (cancelled) {
|
||||
// Send a cancellation confirmation
|
||||
await controller.postMessageToWebview({
|
||||
await postMessageToWebview({
|
||||
type: "grpc_response",
|
||||
grpc_response: {
|
||||
message: { cancelled: true },
|
||||
|
||||
@@ -3,24 +3,22 @@ import { HostProvider } from "@/hosts/host-provider"
|
||||
import { AuthService } from "@/services/auth/AuthService"
|
||||
import { PostHogClientProvider, telemetryService } from "@/services/posthog/PostHogClientProvider"
|
||||
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"
|
||||
import { cleanupLegacyCheckpoints } from "@integrations/checkpoints/CheckpointMigration"
|
||||
import { downloadTask } from "@integrations/misc/export-markdown"
|
||||
import WorkspaceTracker from "@integrations/workspace/WorkspaceTracker"
|
||||
import { ClineAccountService } from "@services/account/ClineAccountService"
|
||||
import { McpHub } from "@services/mcp/McpHub"
|
||||
import { ApiProvider, ModelInfo } from "@shared/api"
|
||||
import { ChatContent } from "@shared/ChatContent"
|
||||
import { Mode } from "@shared/storage/types"
|
||||
import { ClineRulesToggles } from "@shared/cline-rules"
|
||||
import { ExtensionMessage, ExtensionState, Platform } from "@shared/ExtensionMessage"
|
||||
import { 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"
|
||||
@@ -28,15 +26,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 { ensureMcpServersDirectoryExists, ensureSettingsDirectoryExists, GlobalFileNames } from "../storage/disk"
|
||||
import { getAllExtensionState, getGlobalState, getWorkspaceState, storeSecret, updateGlobalState } from "../storage/state"
|
||||
import { CacheService, PersistenceErrorEvent } from "../storage/CacheService"
|
||||
import { ensureMcpServersDirectoryExists, ensureSettingsDirectoryExists, GlobalFileNames } from "../storage/disk"
|
||||
import { getAllExtensionState, getGlobalState, updateGlobalState } from "../storage/state"
|
||||
import { Task } from "../task"
|
||||
import { handleGrpcRequest, handleGrpcRequestCancel } from "./grpc-handler"
|
||||
import { sendMcpMarketplaceCatalogEvent } from "./mcp/subscribeToMcpMarketplaceCatalog"
|
||||
import { sendStateUpdate } from "./state/subscribeToState"
|
||||
import { sendAddToInputEvent } from "./ui/subscribeToAddToInput"
|
||||
import { getLatestAnnouncementId } from "@/utils/announcements"
|
||||
import { sendAddToInputEvent, sendAddToInputEventToClient } from "./ui/subscribeToAddToInput"
|
||||
import { WebviewProvider } from "../webview"
|
||||
|
||||
/*
|
||||
https://github.com/microsoft/vscode-webview-ui-toolkit-samples/blob/main/default/weather-webview/src/providers/WeatherViewProvider.ts
|
||||
|
||||
@@ -45,25 +43,20 @@ 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
|
||||
|
||||
workspaceTracker: WorkspaceTracker
|
||||
mcpHub: McpHub
|
||||
accountService: ClineAccountService
|
||||
readonly cacheService: CacheService
|
||||
|
||||
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)
|
||||
@@ -97,7 +90,6 @@ export class Controller {
|
||||
}
|
||||
}
|
||||
|
||||
this.workspaceTracker = new WorkspaceTracker()
|
||||
this.mcpHub = new McpHub(
|
||||
() => ensureMcpServersDirectoryExists(),
|
||||
() => ensureSettingsDirectoryExists(this.context),
|
||||
@@ -127,7 +119,6 @@ export class Controller {
|
||||
x.dispose()
|
||||
}
|
||||
}
|
||||
this.workspaceTracker.dispose()
|
||||
this.mcpHub.dispose()
|
||||
|
||||
console.error("Controller disposed")
|
||||
@@ -206,7 +197,6 @@ export class Controller {
|
||||
this.task = new Task(
|
||||
this.context,
|
||||
this.mcpHub,
|
||||
this.workspaceTracker,
|
||||
(historyItem) => this.updateTaskHistory(historyItem),
|
||||
() => this.postStateToWebview(),
|
||||
(taskId) => this.reinitExistingTaskFromId(taskId),
|
||||
@@ -239,37 +229,6 @@ 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"
|
||||
@@ -341,7 +300,8 @@ 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
|
||||
// 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
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -481,7 +441,7 @@ export class Controller {
|
||||
|
||||
/**
|
||||
* RPC variant that silently refreshes the MCP marketplace catalog and returns the result
|
||||
* Unlike silentlyRefreshMcpMarketplace, this doesn't post a message to the webview
|
||||
* Unlike silentlyRefreshMcpMarketplace, this doesn't send a message to the webview
|
||||
* @returns MCP marketplace catalog or undefined if refresh failed
|
||||
*/
|
||||
async silentlyRefreshMcpMarketplaceRPC() {
|
||||
@@ -526,7 +486,7 @@ export class Controller {
|
||||
if (this.task) {
|
||||
this.task.api = buildApiHandler({ ...updatedConfig, taskId: this.task.taskId }, currentMode)
|
||||
}
|
||||
// await this.postMessageToWebview({ type: "action", action: "settingsButtonClicked" }) // bad ux if user is on welcome
|
||||
// Dont send settingsButtonClicked because its bad ux if user is on welcome
|
||||
}
|
||||
|
||||
private async ensureCacheDirectoryExists(): Promise<string> {
|
||||
@@ -559,10 +519,6 @@ 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)
|
||||
|
||||
@@ -572,7 +528,10 @@ export class Controller {
|
||||
input += `\nProblems:\n${problemsString}`
|
||||
}
|
||||
|
||||
await sendAddToInputEvent(input)
|
||||
const lastActiveWebview = WebviewProvider.getLastActiveInstance()
|
||||
if (lastActiveWebview) {
|
||||
await sendAddToInputEventToClient(lastActiveWebview.getClientId(), input)
|
||||
}
|
||||
|
||||
console.log("addSelectedCodeToChat", code, filePath, languageId)
|
||||
}
|
||||
@@ -582,14 +541,6 @@ 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)
|
||||
|
||||
@@ -114,6 +114,7 @@ export async function refreshGroqModels(controller: Controller, request: EmptyRe
|
||||
|
||||
telemetryService.captureProviderApiError({
|
||||
taskId: controller.task?.taskId || "",
|
||||
ulid: controller.task?.ulid || "",
|
||||
errorMessage,
|
||||
errorStatus: error.status,
|
||||
model: "groq",
|
||||
|
||||
@@ -49,7 +49,6 @@ 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":
|
||||
@@ -62,6 +61,12 @@ 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
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Controller } from "../index"
|
||||
import { EmptyRequest } from "@shared/proto/cline/common"
|
||||
import { StreamingResponseHandler, getRequestRegistry } from "../grpc-handler"
|
||||
import { State } from "@shared/proto/cline/state"
|
||||
import { ExtensionState } from "@/shared/ExtensionMessage"
|
||||
|
||||
// Keep track of active state subscriptions by controller ID
|
||||
const activeStateSubscriptions = new Map<string, StreamingResponseHandler<State>>()
|
||||
@@ -52,7 +53,7 @@ export async function subscribeToState(
|
||||
* @param controllerId The ID of the controller to send the state to
|
||||
* @param state The state to send
|
||||
*/
|
||||
export async function sendStateUpdate(controllerId: string, state: any): Promise<void> {
|
||||
export async function sendStateUpdate(controllerId: string, state: ExtensionState): Promise<void> {
|
||||
// Get the subscription for this specific controller
|
||||
const responseStream = activeStateSubscriptions.get(controllerId)
|
||||
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
import type { Controller } from "../index"
|
||||
import { WebviewProvider } from "@/core/webview"
|
||||
import { EmptyRequest, String } from "@shared/proto/cline/common"
|
||||
import { HostProvider } from "@/hosts/host-provider"
|
||||
import { WebviewProviderType } from "@/shared/webview/types"
|
||||
import type { Controller } from "../index"
|
||||
|
||||
/**
|
||||
* Initialize webview when it launches
|
||||
* @param controller The controller instance
|
||||
* @param request The empty request
|
||||
* @returns Empty response
|
||||
* 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()`.
|
||||
*/
|
||||
export async function getWebviewHtml(_controller: Controller, _: EmptyRequest): Promise<String> {
|
||||
const webviewProvider = HostProvider.get().createWebviewProvider(WebviewProviderType.SIDEBAR)
|
||||
|
||||
const webviewProvider = WebviewProvider.getLastActiveInstance()
|
||||
if (!webviewProvider) {
|
||||
throw new Error("No active webview")
|
||||
}
|
||||
return Promise.resolve(String.create({ value: webviewProvider.getHtmlContent() }))
|
||||
}
|
||||
|
||||
@@ -19,9 +19,6 @@ import { refreshBasetenModels } from "../models/refreshBasetenModels"
|
||||
*/
|
||||
export async function initializeWebview(controller: Controller, request: EmptyRequest): Promise<Empty> {
|
||||
try {
|
||||
// Populate file paths for workspace tracker (don't await)
|
||||
controller.workspaceTracker?.populateFilePaths()
|
||||
|
||||
// Post last cached models in case the call to endpoint fails
|
||||
controller.readOpenRouterModels().then((openRouterModels) => {
|
||||
if (openRouterModels) {
|
||||
|
||||
@@ -1,33 +1,42 @@
|
||||
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"
|
||||
import type { String as ProtoString, StringRequest } from "@shared/proto/cline/common"
|
||||
import { getRequestRegistry, type StreamingResponseHandler } from "../grpc-handler"
|
||||
import type { Controller } from "../index"
|
||||
|
||||
// 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 empty request
|
||||
* @param request The request containing the client ID
|
||||
* @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: EmptyRequest,
|
||||
request: StringRequest,
|
||||
responseStream: StreamingResponseHandler<ProtoString>,
|
||||
requestId?: string,
|
||||
): Promise<void> {
|
||||
console.log("[DEBUG] set up addToInput subscription")
|
||||
const clientId = request.value
|
||||
if (!clientId) {
|
||||
throw new Error("Client ID is required for addToInput subscription")
|
||||
}
|
||||
|
||||
// Add this subscription to the active subscriptions
|
||||
console.log("[DEBUG] set up addToInput subscription for client:", clientId)
|
||||
|
||||
// Add this subscription to both the general set and the client-specific map
|
||||
activeAddToInputSubscriptions.add(responseStream)
|
||||
addToInputSubscriptions.set(clientId, responseStream)
|
||||
|
||||
// Register cleanup when the connection is closed
|
||||
const cleanup = () => {
|
||||
activeAddToInputSubscriptions.delete(responseStream)
|
||||
console.log("[DEBUG] Cleaned up addToInput subscription")
|
||||
addToInputSubscriptions.delete(clientId)
|
||||
console.log("[DEBUG] Cleaned up addToInput subscription for client:", clientId)
|
||||
}
|
||||
|
||||
// Register the cleanup function with the request registry if we have a requestId
|
||||
@@ -61,3 +70,33 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ import { mentionRegexGlobal } from "@shared/context-mentions"
|
||||
import fs from "fs/promises"
|
||||
import { extractTextFromFile } from "@integrations/misc/extract-text"
|
||||
import { isBinaryFile } from "isbinaryfile"
|
||||
import { diagnosticsToProblemsString } from "@integrations/diagnostics"
|
||||
import { getLatestTerminalOutput } from "@integrations/terminal/get-latest-output"
|
||||
import { getCommitInfo } from "@utils/git"
|
||||
import { getWorkingState } from "@utils/git"
|
||||
@@ -15,6 +14,8 @@ import { getCwd } from "@/utils/path"
|
||||
import { openExternal } from "@utils/env"
|
||||
import { HostProvider } from "@/hosts/host-provider"
|
||||
import { ShowMessageType } from "@/shared/proto/host/window"
|
||||
import { diagnosticsToProblemsString } from "@integrations/diagnostics"
|
||||
import { DiagnosticSeverity } from "@/shared/proto/index.host"
|
||||
|
||||
export async function openMention(mention?: string): Promise<void> {
|
||||
if (!mention) {
|
||||
@@ -89,6 +90,14 @@ export async function parseMentions(
|
||||
const uniqueMentions = Array.from(new Set(mentions))
|
||||
|
||||
for (const mention of uniqueMentions) {
|
||||
// Safety guard: skip a bare "/" mention. This can surface from parsed strings or tool output and would resolve to the
|
||||
// workspace root. Expanding it would scan the entire project, inflate context, and can trigger recursive loops.
|
||||
// If root-level expansion is ever desired, gate it behind an explicit syntax (e.g. "@root" or "@folder:/")
|
||||
// and enforce strict size/.clineignore limits instead.
|
||||
if (mention === "/") {
|
||||
continue
|
||||
}
|
||||
|
||||
if (mention.startsWith("http")) {
|
||||
let result: string
|
||||
if (launchBrowserError) {
|
||||
@@ -225,12 +234,14 @@ async function getFileOrFolderContent(mentionPath: string, cwd: string): Promise
|
||||
}
|
||||
|
||||
async function getWorkspaceProblems(): Promise<string> {
|
||||
const diagnostics = vscode.languages.getDiagnostics()
|
||||
const result = diagnosticsToProblemsString(diagnostics, [vscode.DiagnosticSeverity.Error, vscode.DiagnosticSeverity.Warning])
|
||||
if (!result) {
|
||||
const response = await HostProvider.workspace.getDiagnostics({})
|
||||
if (response.fileDiagnostics.length === 0) {
|
||||
return "No errors or warnings detected."
|
||||
}
|
||||
return result
|
||||
return diagnosticsToProblemsString(response.fileDiagnostics, [
|
||||
DiagnosticSeverity.DIAGNOSTIC_ERROR,
|
||||
DiagnosticSeverity.DIAGNOSTIC_WARNING,
|
||||
])
|
||||
}
|
||||
|
||||
function isFileMention(mention: string): boolean {
|
||||
|
||||
@@ -268,14 +268,15 @@ 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. 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.
|
||||
|
||||
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.
|
||||
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
|
||||
@@ -619,6 +620,7 @@ 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
|
||||
|
||||
@@ -263,12 +263,15 @@ 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 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.
|
||||
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.
|
||||
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
|
||||
@@ -553,7 +556,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:
|
||||
@@ -567,8 +570,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 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.
|
||||
- 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.
|
||||
- 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.
|
||||
|
||||
|
||||
@@ -200,6 +200,7 @@ export class CacheService {
|
||||
openAiApiKey,
|
||||
openAiHeaders,
|
||||
ollamaBaseUrl,
|
||||
ollamaApiKey,
|
||||
ollamaApiOptionsCtxNum,
|
||||
lmStudioBaseUrl,
|
||||
anthropicBaseUrl,
|
||||
@@ -399,6 +400,7 @@ export class CacheService {
|
||||
awsSessionToken,
|
||||
awsBedrockApiKey,
|
||||
openAiApiKey,
|
||||
ollamaApiKey,
|
||||
geminiApiKey,
|
||||
openAiNativeApiKey,
|
||||
deepSeekApiKey,
|
||||
@@ -598,6 +600,7 @@ export class CacheService {
|
||||
openAiApiKey,
|
||||
openAiHeaders,
|
||||
ollamaBaseUrl,
|
||||
ollamaApiKey,
|
||||
ollamaApiOptionsCtxNum,
|
||||
lmStudioBaseUrl,
|
||||
anthropicBaseUrl,
|
||||
@@ -802,6 +805,7 @@ export class CacheService {
|
||||
awsSessionToken,
|
||||
awsBedrockApiKey,
|
||||
openAiApiKey,
|
||||
ollamaApiKey,
|
||||
geminiApiKey,
|
||||
openAiNativeApiKey,
|
||||
deepSeekApiKey,
|
||||
@@ -845,6 +849,7 @@ 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"),
|
||||
|
||||
@@ -7,6 +7,7 @@ export type SecretKey =
|
||||
| "awsSessionToken"
|
||||
| "awsBedrockApiKey"
|
||||
| "openAiApiKey"
|
||||
| "ollamaApiKey"
|
||||
| "geminiApiKey"
|
||||
| "openAiNativeApiKey"
|
||||
| "deepSeekApiKey"
|
||||
|
||||
@@ -524,6 +524,7 @@ export async function migrateWelcomeViewCompleted(context: vscode.ExtensionConte
|
||||
config.awsRegion,
|
||||
config.vertexProjectId,
|
||||
config.openAiApiKey,
|
||||
config.ollamaApiKey,
|
||||
config.planModeOllamaModelId,
|
||||
config.planModeLmStudioModelId,
|
||||
config.actModeOllamaModelId,
|
||||
|
||||
@@ -133,6 +133,7 @@ export async function getAllExtensionState(context: vscode.ExtensionContext) {
|
||||
openAiApiKey,
|
||||
openAiHeaders,
|
||||
ollamaBaseUrl,
|
||||
ollamaApiKey,
|
||||
ollamaApiOptionsCtxNum,
|
||||
lmStudioBaseUrl,
|
||||
anthropicBaseUrl,
|
||||
@@ -214,6 +215,7 @@ 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>,
|
||||
@@ -468,6 +470,7 @@ export async function getAllExtensionState(context: vscode.ExtensionContext) {
|
||||
openAiApiKey,
|
||||
openAiHeaders: openAiHeaders || {},
|
||||
ollamaBaseUrl,
|
||||
ollamaApiKey,
|
||||
ollamaApiOptionsCtxNum,
|
||||
lmStudioBaseUrl,
|
||||
anthropicBaseUrl,
|
||||
@@ -617,6 +620,7 @@ export async function resetGlobalState(controller: Controller) {
|
||||
"awsSessionToken",
|
||||
"awsBedrockApiKey",
|
||||
"openAiApiKey",
|
||||
"ollamaApiKey",
|
||||
"geminiApiKey",
|
||||
"openAiNativeApiKey",
|
||||
"deepSeekApiKey",
|
||||
|
||||
@@ -12,7 +12,6 @@ import { FileContextTracker } from "@core/context/context-tracking/FileContextTr
|
||||
import { ClineIgnoreController } from "@core/ignore/ClineIgnoreController"
|
||||
import { DiffViewProvider } from "@integrations/editor/DiffViewProvider"
|
||||
import { extractTextFromFile, processFilesIntoText } from "@integrations/misc/extract-text"
|
||||
import WorkspaceTracker from "@integrations/workspace/WorkspaceTracker"
|
||||
import { BrowserSession } from "@services/browser/BrowserSession"
|
||||
import { UrlContentFetcher } from "@services/browser/UrlContentFetcher"
|
||||
import { McpHub } from "@services/mcp/McpHub"
|
||||
@@ -35,7 +34,13 @@ 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 } from "@utils/model-utils"
|
||||
import {
|
||||
isClaude4ModelFamily,
|
||||
isGemini2dot5ModelFamily,
|
||||
isGrok4ModelFamily,
|
||||
modelDoesntSupportWebp,
|
||||
isNextGenModelFamily,
|
||||
} from "@utils/model-utils"
|
||||
import { fixModelHtmlEscaping, removeInvalidChars } from "@utils/string"
|
||||
import { setTimeout as setTimeoutPromise } from "node:timers/promises"
|
||||
import os from "os"
|
||||
@@ -84,7 +89,6 @@ export class ToolExecutor {
|
||||
private mcpHub: McpHub,
|
||||
private fileContextTracker: FileContextTracker,
|
||||
private clineIgnoreController: ClineIgnoreController,
|
||||
private workspaceTracker: WorkspaceTracker,
|
||||
private contextManager: ContextManager,
|
||||
private cacheService: CacheService,
|
||||
|
||||
@@ -93,6 +97,7 @@ export class ToolExecutor {
|
||||
private browserSettings: BrowserSettings,
|
||||
private cwd: string,
|
||||
private taskId: string,
|
||||
private ulid: string,
|
||||
private mode: Mode,
|
||||
private strictPlanModeEnabled: boolean,
|
||||
|
||||
@@ -147,8 +152,7 @@ export class ToolExecutor {
|
||||
}
|
||||
|
||||
private pushToolResult = (content: ToolResponse, block: ToolUse) => {
|
||||
const isNextGenModel =
|
||||
isClaude4ModelFamily(this.api) || isGemini2dot5ModelFamily(this.api) || isGrok4ModelFamily(this.api)
|
||||
const isNextGenModel = isNextGenModelFamily(this.api)
|
||||
|
||||
if (typeof content === "string") {
|
||||
const resultText = content || "(tool did not return anything)"
|
||||
@@ -522,8 +526,7 @@ export class ToolExecutor {
|
||||
|
||||
const currentFullJson = block.params.diff
|
||||
// Check if we should use streaming (e.g., for specific models)
|
||||
const isNextGenModel =
|
||||
isClaude4ModelFamily(this.api) || isGemini2dot5ModelFamily(this.api) || isGrok4ModelFamily(this.api)
|
||||
const isNextGenModel = isNextGenModelFamily(this.api)
|
||||
// Going through claude family of models
|
||||
if (isNextGenModel && USE_EXPERIMENTAL_CLAUDE4_FEATURES && currentFullJson) {
|
||||
const streamingResult = await this.handleStreamingJsonReplacement(block, relPath, currentFullJson)
|
||||
@@ -791,10 +794,6 @@ export class ToolExecutor {
|
||||
)
|
||||
}
|
||||
|
||||
if (!fileExists) {
|
||||
this.workspaceTracker.populateFilePaths()
|
||||
}
|
||||
|
||||
await this.diffViewProvider.reset()
|
||||
|
||||
await this.saveCheckpoint()
|
||||
@@ -1426,9 +1425,6 @@ export class ToolExecutor {
|
||||
this.taskState.didRejectTool = true
|
||||
}
|
||||
|
||||
// Re-populate file paths in case the command modified the workspace (vscode listeners do not trigger unless the user manually creates/deletes files)
|
||||
this.workspaceTracker.populateFilePaths()
|
||||
|
||||
this.pushToolResult(result, block)
|
||||
|
||||
await this.saveCheckpoint()
|
||||
@@ -2140,6 +2136,7 @@ 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)),
|
||||
@@ -2164,6 +2161,17 @@ 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 || "[]")
|
||||
|
||||
@@ -2342,7 +2350,7 @@ export class ToolExecutor {
|
||||
await this.say("completion_result", result, undefined, undefined, false)
|
||||
await this.saveCheckpoint(true)
|
||||
await addNewChangesFlagToLastCompletionResultMessage()
|
||||
telemetryService.captureTaskCompleted(this.taskId)
|
||||
telemetryService.captureTaskCompleted(this.taskId, this.ulid)
|
||||
} else {
|
||||
// we already sent a command message, meaning the complete completion message has also been sent
|
||||
await this.saveCheckpoint(true)
|
||||
@@ -2367,7 +2375,7 @@ export class ToolExecutor {
|
||||
await this.say("completion_result", result, undefined, undefined, false)
|
||||
await this.saveCheckpoint(true)
|
||||
await addNewChangesFlagToLastCompletionResultMessage()
|
||||
telemetryService.captureTaskCompleted(this.taskId)
|
||||
telemetryService.captureTaskCompleted(this.taskId, this.ulid)
|
||||
}
|
||||
|
||||
// we already sent completion_result says, an empty string asks relinquishes control over button and field
|
||||
|
||||
+27
-20
@@ -35,6 +35,7 @@ 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"
|
||||
@@ -74,10 +75,9 @@ import {
|
||||
} from "@core/storage/disk"
|
||||
import { getGlobalState } from "@core/storage/state"
|
||||
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 } from "@utils/model-utils"
|
||||
import { isClaude4ModelFamily, isGemini2dot5ModelFamily, isGrok4ModelFamily, isNextGenModelFamily } 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"
|
||||
@@ -97,6 +97,7 @@ type UserContent = Array<Anthropic.ContentBlockParam>
|
||||
export class Task {
|
||||
// Core task variables
|
||||
readonly taskId: string
|
||||
readonly ulid: string
|
||||
private taskIsFavorited?: boolean
|
||||
private cwd: string
|
||||
|
||||
@@ -108,7 +109,6 @@ export class Task {
|
||||
// Core dependencies
|
||||
private context: vscode.ExtensionContext
|
||||
private mcpHub: McpHub
|
||||
private workspaceTracker: WorkspaceTracker
|
||||
|
||||
// Service handlers
|
||||
api: ApiHandler
|
||||
@@ -146,7 +146,6 @@ export class Task {
|
||||
constructor(
|
||||
context: vscode.ExtensionContext,
|
||||
mcpHub: McpHub,
|
||||
workspaceTracker: WorkspaceTracker,
|
||||
updateTaskHistory: (historyItem: HistoryItem) => Promise<HistoryItem[]>,
|
||||
postStateToWebview: () => Promise<void>,
|
||||
reinitExistingTaskFromId: (taskId: string) => Promise<void>,
|
||||
@@ -173,7 +172,6 @@ export class Task {
|
||||
this.taskState = new TaskState()
|
||||
this.context = context
|
||||
this.mcpHub = mcpHub
|
||||
this.workspaceTracker = workspaceTracker
|
||||
this.updateTaskHistory = updateTaskHistory
|
||||
this.postStateToWebview = postStateToWebview
|
||||
this.reinitExistingTaskFromId = reinitExistingTaskFromId
|
||||
@@ -219,6 +217,7 @@ 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) {
|
||||
@@ -226,6 +225,7 @@ 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")
|
||||
}
|
||||
@@ -233,6 +233,7 @@ export class Task {
|
||||
this.messageStateHandler = new MessageStateHandler({
|
||||
context,
|
||||
taskId: this.taskId,
|
||||
ulid: this.ulid,
|
||||
taskState: this.taskState,
|
||||
taskIsFavorited: this.taskIsFavorited,
|
||||
updateTaskHistory: this.updateTaskHistory,
|
||||
@@ -306,10 +307,10 @@ export class Task {
|
||||
// initialize telemetry
|
||||
if (historyItem) {
|
||||
// Open task from history
|
||||
telemetryService.captureTaskRestarted(this.taskId, currentProvider)
|
||||
telemetryService.captureTaskRestarted(this.taskId, this.ulid, currentProvider)
|
||||
} else {
|
||||
// New task started
|
||||
telemetryService.captureTaskCreated(this.taskId, currentProvider)
|
||||
telemetryService.captureTaskCreated(this.taskId, this.ulid, currentProvider)
|
||||
}
|
||||
|
||||
this.toolExecutor = new ToolExecutor(
|
||||
@@ -323,13 +324,13 @@ export class Task {
|
||||
this.mcpHub,
|
||||
this.fileContextTracker,
|
||||
this.clineIgnoreController,
|
||||
this.workspaceTracker,
|
||||
this.contextManager,
|
||||
this.cacheService,
|
||||
this.autoApprovalSettings,
|
||||
this.browserSettings,
|
||||
cwd,
|
||||
this.taskId,
|
||||
this.ulid,
|
||||
this.mode,
|
||||
strictPlanModeEnabled,
|
||||
this.say.bind(this),
|
||||
@@ -1704,8 +1705,7 @@ 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 =
|
||||
isClaude4ModelFamily(this.api) || isGemini2dot5ModelFamily(this.api) || isGrok4ModelFamily(this.api)
|
||||
const isNextGenModel = isNextGenModelFamily(this.api)
|
||||
let systemPrompt = await SYSTEM_PROMPT(this.cwd, supportsBrowserUse, this.mcpHub, this.browserSettings, isNextGenModel)
|
||||
|
||||
const preferredLanguage = getLanguageKey(this.preferredLanguage as LanguageDisplay)
|
||||
@@ -2228,7 +2228,7 @@ export class Task {
|
||||
content: userContent,
|
||||
})
|
||||
|
||||
telemetryService.captureConversationTurnEvent(this.taskId, providerId, modelId, "user")
|
||||
telemetryService.captureConversationTurnEvent(this.taskId, this.ulid, 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")
|
||||
@@ -2293,13 +2293,20 @@ export class Task {
|
||||
})
|
||||
await this.messageStateHandler.saveClineMessagesAndUpdateHistory()
|
||||
|
||||
telemetryService.captureConversationTurnEvent(this.taskId, providerId, this.api.getModel().id, "assistant", {
|
||||
tokensIn: inputTokens,
|
||||
tokensOut: outputTokens,
|
||||
cacheWriteTokens,
|
||||
cacheReadTokens,
|
||||
totalCost,
|
||||
})
|
||||
telemetryService.captureConversationTurnEvent(
|
||||
this.taskId,
|
||||
this.ulid,
|
||||
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
|
||||
@@ -2353,7 +2360,7 @@ export class Task {
|
||||
assistantMessage += chunk.text
|
||||
// parse raw assistant message into content blocks
|
||||
const prevLength = this.taskState.assistantMessageContent.length
|
||||
const isNextGenModel = isClaude4ModelFamily(this.api) || isGemini2dot5ModelFamily(this.api)
|
||||
const isNextGenModel = isNextGenModelFamily(this.api)
|
||||
if (isNextGenModel && USE_EXPERIMENTAL_CLAUDE4_FEATURES) {
|
||||
this.taskState.assistantMessageContent = parseAssistantMessageV3(assistantMessage)
|
||||
} else {
|
||||
@@ -2468,7 +2475,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, providerId, modelId, "assistant", {
|
||||
telemetryService.captureConversationTurnEvent(this.taskId, this.ulid, providerId, modelId, "assistant", {
|
||||
tokensIn: inputTokens,
|
||||
tokensOut: outputTokens,
|
||||
cacheWriteTokens,
|
||||
|
||||
@@ -17,6 +17,7 @@ import { getCwd, getDesktopDir } from "@/utils/path"
|
||||
interface MessageStateHandlerParams {
|
||||
context: vscode.ExtensionContext
|
||||
taskId: string
|
||||
ulid: string
|
||||
taskIsFavorited?: boolean
|
||||
updateTaskHistory: (historyItem: HistoryItem) => Promise<HistoryItem[]>
|
||||
taskState: TaskState
|
||||
@@ -32,11 +33,13 @@ 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
|
||||
@@ -89,6 +92,7 @@ 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,
|
||||
|
||||
@@ -0,0 +1,357 @@
|
||||
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))
|
||||
}
|
||||
}
|
||||
+1
-340
@@ -1,340 +1 @@
|
||||
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 { 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>()
|
||||
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() {
|
||||
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))
|
||||
}
|
||||
}
|
||||
export { WebviewProvider } from "./WebviewProvider"
|
||||
|
||||
+74
-145
@@ -1,10 +1,10 @@
|
||||
// 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 * as vscode from "vscode"
|
||||
import { sendAccountButtonClickedEvent } from "./core/controller/ui/subscribeToAccountButtonClicked"
|
||||
@@ -12,33 +12,26 @@ import { sendChatButtonClickedEvent } from "./core/controller/ui/subscribeToChat
|
||||
import { sendHistoryButtonClickedEvent } from "./core/controller/ui/subscribeToHistoryButtonClicked"
|
||||
import { sendMcpButtonClickedEvent } from "./core/controller/ui/subscribeToMcpButtonClicked"
|
||||
import { sendSettingsButtonClickedEvent } from "./core/controller/ui/subscribeToSettingsButtonClicked"
|
||||
import {
|
||||
migrateCustomInstructionsToGlobalRules,
|
||||
migrateWelcomeViewCompleted,
|
||||
migrateWorkspaceToGlobalStorage,
|
||||
} from "./core/storage/state-migrations"
|
||||
import { WebviewProvider } from "./core/webview"
|
||||
import { createClineAPI } from "./exports"
|
||||
import { Logger } from "./services/logging/Logger"
|
||||
import { PostHogClientProvider } from "./services/posthog/PostHogClientProvider"
|
||||
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 { FileContextTracker } from "./core/context/context-tracking/FileContextTracker"
|
||||
import type { ExtensionContext } from "vscode"
|
||||
import { initialize, tearDown } from "./common"
|
||||
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"
|
||||
import { ShowMessageType } from "./shared/proto/host/window"
|
||||
/*
|
||||
Built using https://github.com/microsoft/vscode-webview-ui-toolkit
|
||||
|
||||
@@ -51,31 +44,12 @@ https://github.com/microsoft/vscode-webview-ui-toolkit-samples/tree/main/framewo
|
||||
// This method is called when your extension is activated
|
||||
// Your extension is activated the very first time the command is executed
|
||||
export async function activate(context: vscode.ExtensionContext) {
|
||||
maybeSetupHostProviders(context)
|
||||
setupHostProvider(context)
|
||||
|
||||
// Initialize PostHog client provider
|
||||
const distinctId = context.globalState.get<string>("cline.distinctId")
|
||||
PostHogClientProvider.getInstance(distinctId)
|
||||
const sidebarWebview = (await initialize(context)) as VscodeWebviewProvider
|
||||
|
||||
Logger.log("Cline extension activated")
|
||||
|
||||
// Migrate custom instructions to global Cline rules (one-time cleanup)
|
||||
await migrateCustomInstructionsToGlobalRules(context)
|
||||
|
||||
// Migrate welcomeViewCompleted setting based on existing API keys (one-time cleanup)
|
||||
await migrateWelcomeViewCompleted(context)
|
||||
|
||||
// Migrate workspace storage values back to global storage (reverting previous migration)
|
||||
await migrateWorkspaceToGlobalStorage(context)
|
||||
|
||||
// Clean up orphaned file context warnings (startup cleanup)
|
||||
await FileContextTracker.cleanupOrphanedWarnings(context)
|
||||
|
||||
// Version checking for autoupdate notification
|
||||
const currentVersion = context.extension.packageJSON.version
|
||||
const previousVersion = context.globalState.get<string>("clineVersion")
|
||||
const sidebarWebview = HostProvider.get().createWebviewProvider(WebviewProviderType.SIDEBAR)
|
||||
|
||||
const testModeWatchers = await initializeTestMode(sidebarWebview)
|
||||
// Initialize test mode and add disposables to context
|
||||
context.subscriptions.push(...testModeWatchers)
|
||||
@@ -88,37 +62,6 @@ export async function activate(context: vscode.ExtensionContext) {
|
||||
}),
|
||||
)
|
||||
|
||||
// Perform post-update actions if necessary
|
||||
try {
|
||||
if (!previousVersion || currentVersion !== previousVersion) {
|
||||
Logger.log(`Cline version changed: ${previousVersion} -> ${currentVersion}. First run or update detected.`)
|
||||
|
||||
// Use the same condition as announcements: focus when there's a new announcement to show
|
||||
const lastShownAnnouncementId = context.globalState.get<string>("lastShownAnnouncementId")
|
||||
const latestAnnouncementId = getLatestAnnouncementId(context)
|
||||
|
||||
if (lastShownAnnouncementId !== latestAnnouncementId) {
|
||||
// Focus Cline when there's a new announcement to show (major/minor updates or fresh installs)
|
||||
const message = previousVersion
|
||||
? `Cline has been updated to v${currentVersion}`
|
||||
: `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,
|
||||
})
|
||||
}
|
||||
// Always update the main version tracker for the next launch.
|
||||
await context.globalState.update("clineVersion", currentVersion)
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
console.error(`Error during post-update actions: ${errorMessage}, Stack trace: ${error.stack}`)
|
||||
}
|
||||
|
||||
telemetryService.captureExtensionActivated()
|
||||
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand("cline.plusButtonClicked", async (webview: any) => {
|
||||
console.log("[DEBUG] plusButtonClicked", webview)
|
||||
@@ -203,6 +146,7 @@ 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))
|
||||
@@ -289,12 +233,16 @@ 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
|
||||
await pWaitFor(() => !!WebviewProvider.getVisibleInstance())
|
||||
const activeWebview = WebviewProvider.getLastActiveInstance()
|
||||
const clientId = activeWebview?.getClientId()
|
||||
await pWaitFor(() => !!activeWebview)
|
||||
const editor = vscode.window.activeTextEditor
|
||||
if (!editor) {
|
||||
if (!editor || !clientId) {
|
||||
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
|
||||
@@ -308,14 +256,13 @@ export async function activate(context: vscode.ExtensionContext) {
|
||||
const filePath = editor.document.uri.fsPath
|
||||
const languageId = editor.document.languageId
|
||||
|
||||
const visibleWebview = WebviewProvider.getVisibleInstance()
|
||||
await visibleWebview?.controller.addSelectedCodeToChat(
|
||||
await activeWebview?.controller.addSelectedCodeToChat(
|
||||
selectedText,
|
||||
filePath,
|
||||
languageId,
|
||||
Array.isArray(diagnostics) ? diagnostics : undefined,
|
||||
)
|
||||
telemetryService.captureButtonClick("codeAction_addToChat", visibleWebview?.controller.task?.taskId)
|
||||
telemetryService.captureButtonClick("codeAction_addToChat", activeWebview?.controller.task?.taskId)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -476,8 +423,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 visible after focusing
|
||||
await pWaitFor(() => !!WebviewProvider.getVisibleInstance())
|
||||
// Wait for a webview instance to become available after focusing
|
||||
await pWaitFor(() => !!WebviewProvider.getLastActiveInstance())
|
||||
const editor = vscode.window.activeTextEditor
|
||||
if (!editor) {
|
||||
return
|
||||
@@ -487,17 +434,17 @@ export async function activate(context: vscode.ExtensionContext) {
|
||||
const filePath = editor.document.uri.fsPath
|
||||
const languageId = editor.document.languageId
|
||||
|
||||
// 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)
|
||||
// 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)
|
||||
}),
|
||||
)
|
||||
|
||||
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.getVisibleInstance())
|
||||
await pWaitFor(() => !!WebviewProvider.getLastActiveInstance())
|
||||
const editor = vscode.window.activeTextEditor
|
||||
if (!editor) {
|
||||
return
|
||||
@@ -511,18 +458,18 @@ export async function activate(context: vscode.ExtensionContext) {
|
||||
return
|
||||
}
|
||||
const filePath = editor.document.uri.fsPath
|
||||
const visibleWebview = WebviewProvider.getVisibleInstance()
|
||||
const fileMention = visibleWebview?.controller.getFileMentionFromPath(filePath) || filePath
|
||||
const activeWebview = WebviewProvider.getLastActiveInstance()
|
||||
const fileMention = activeWebview?.controller.getFileMentionFromPath(filePath) || filePath
|
||||
const prompt = `Explain the following code from ${fileMention}:\n\`\`\`${editor.document.languageId}\n${selectedText}\n\`\`\``
|
||||
await visibleWebview?.controller.initTask(prompt)
|
||||
telemetryService.captureButtonClick("codeAction_explainCode", visibleWebview?.controller.task?.taskId)
|
||||
await activeWebview?.controller.initTask(prompt)
|
||||
telemetryService.captureButtonClick("codeAction_explainCode", activeWebview?.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.getVisibleInstance())
|
||||
await pWaitFor(() => !!WebviewProvider.getLastActiveInstance())
|
||||
const editor = vscode.window.activeTextEditor
|
||||
if (!editor) {
|
||||
return
|
||||
@@ -536,73 +483,62 @@ export async function activate(context: vscode.ExtensionContext) {
|
||||
return
|
||||
}
|
||||
const filePath = editor.document.uri.fsPath
|
||||
const visibleWebview = WebviewProvider.getVisibleInstance()
|
||||
const fileMention = visibleWebview?.controller.getFileMentionFromPath(filePath) || filePath
|
||||
const activeWebview = WebviewProvider.getLastActiveInstance()
|
||||
const fileMention = activeWebview?.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 visibleWebview?.controller.initTask(prompt)
|
||||
telemetryService.captureButtonClick("codeAction_improveCode", visibleWebview?.controller.task?.taskId)
|
||||
await activeWebview?.controller.initTask(prompt)
|
||||
telemetryService.captureButtonClick("codeAction_improveCode", activeWebview?.controller.task?.taskId)
|
||||
}),
|
||||
)
|
||||
|
||||
// Register the focusChatInput command handler
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand("cline.focusChatInput", async () => {
|
||||
let activeWebviewProvider: WebviewProvider | undefined = WebviewProvider.getVisibleInstance()
|
||||
// Fast path: check for existing active instance
|
||||
let activeWebview = WebviewProvider.getLastActiveInstance()
|
||||
|
||||
// If a tab is visible and active, ensure it's fully revealed (might be redundant but safe)
|
||||
if (activeWebviewProvider?.getWebview() && Object.hasOwn(activeWebviewProvider.getWebview(), "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() && Object.hasOwn(potentialTabInstance.getWebview(), "reveal")) {
|
||||
const panelView = potentialTabInstance.getWebview() as vscode.WebviewPanel
|
||||
panelView.reveal(panelView.viewColumn)
|
||||
activeWebviewProvider = potentialTabInstance
|
||||
}
|
||||
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()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// No active instance - need to find or create one
|
||||
WebviewProvider.setLastActiveControllerId(null)
|
||||
|
||||
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() && Object.hasOwn(visibleInstance.getWebview(), "reveal"))
|
||||
},
|
||||
{ timeout: 2000 },
|
||||
)
|
||||
activeWebviewProvider = WebviewProvider.getVisibleInstance()
|
||||
// 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())
|
||||
}
|
||||
}
|
||||
// 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 {
|
||||
|
||||
// Send focus event
|
||||
const clientId = activeWebview?.getClientId()
|
||||
if (!clientId) {
|
||||
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
|
||||
}
|
||||
telemetryService.captureButtonClick("command_focusChatInput", activeWebviewProvider?.controller.task?.taskId)
|
||||
|
||||
sendFocusChatInputEvent(clientId)
|
||||
telemetryService.captureButtonClick("command_focusChatInput", activeWebview.controller?.task?.taskId)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -647,28 +583,21 @@ export async function activate(context: vscode.ExtensionContext) {
|
||||
return createClineAPI(sidebarWebview.controller)
|
||||
}
|
||||
|
||||
function maybeSetupHostProviders(context: ExtensionContext) {
|
||||
if (!HostProvider.isInitialized()) {
|
||||
console.log("Setting up vscode host providers...")
|
||||
function setupHostProvider(context: ExtensionContext) {
|
||||
console.log("Setting up vscode host providers...")
|
||||
|
||||
const createWebview = (type: WebviewProviderType) => new VscodeWebviewProvider(context, type)
|
||||
const createDiffView = () => new VscodeDiffViewProvider()
|
||||
const outputChannel = vscode.window.createOutputChannel("Cline")
|
||||
context.subscriptions.push(outputChannel)
|
||||
const createWebview = (type: WebviewProviderType) => new VscodeWebviewProvider(context, type)
|
||||
const createDiffView = () => new VscodeDiffViewProvider()
|
||||
const outputChannel = vscode.window.createOutputChannel("Cline")
|
||||
context.subscriptions.push(outputChannel)
|
||||
|
||||
const getCallbackUri = async function () {
|
||||
return `${vscode.env.uriScheme || "vscode"}://saoudrizwan.claude-dev`
|
||||
}
|
||||
HostProvider.initialize(createWebview, createDiffView, vscodeHostBridgeClient, outputChannel.appendLine, getCallbackUri)
|
||||
}
|
||||
const getCallbackUri = async () => `${vscode.env.uriScheme || "vscode"}://saoudrizwan.claude-dev`
|
||||
HostProvider.initialize(createWebview, createDiffView, vscodeHostBridgeClient, outputChannel.appendLine, getCallbackUri)
|
||||
}
|
||||
|
||||
// This method is called when your extension is deactivated
|
||||
export async function deactivate() {
|
||||
PostHogClientProvider.getInstance().dispose()
|
||||
|
||||
// Dispose all webview instances
|
||||
await WebviewProvider.disposeAllInstances()
|
||||
tearDown()
|
||||
|
||||
// Clean up test mode
|
||||
cleanupTestMode()
|
||||
|
||||
@@ -77,11 +77,6 @@ export class ExternalDiffViewProvider extends DiffViewProvider {
|
||||
return (await HostProvider.diff.getDocumentText({ diffId: this.activeDiffEditorId })).content
|
||||
}
|
||||
|
||||
protected override async getNewDiagnosticProblems(): Promise<string> {
|
||||
console.log(`Called ExternalDiffViewProvider.getNewDiagnosticProblems() stub`)
|
||||
return ""
|
||||
}
|
||||
|
||||
protected override async closeDiffView(): Promise<void> {
|
||||
if (!this.activeDiffEditorId) {
|
||||
return
|
||||
|
||||
+1
-6
@@ -1,8 +1,7 @@
|
||||
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.
|
||||
@@ -21,10 +20,6 @@ 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,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"
|
||||
|
||||
@@ -3,13 +3,11 @@ 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 "@/integrations/diagnostics"
|
||||
|
||||
export const DIFF_VIEW_URI_SCHEME = "cline-diff"
|
||||
|
||||
export class VscodeDiffViewProvider extends DiffViewProvider {
|
||||
private activeDiffEditor?: vscode.TextEditor
|
||||
private preDiagnostics: [vscode.Uri, vscode.Diagnostic[]][] = []
|
||||
|
||||
private fadedOverlayController?: DecorationController
|
||||
private activeLineController?: DecorationController
|
||||
@@ -18,8 +16,6 @@ export class VscodeDiffViewProvider extends DiffViewProvider {
|
||||
if (!this.absolutePath) {
|
||||
throw new Error("No file path set")
|
||||
}
|
||||
// get diagnostics before editing the file, we'll compare to diagnostics after editing to see if cline needs to fix anything
|
||||
this.preDiagnostics = vscode.languages.getDiagnostics()
|
||||
|
||||
// if the file was already open, close it (must happen after showing the diff view since if it's the only tab the column will close)
|
||||
this.documentWasOpen = false
|
||||
@@ -164,16 +160,6 @@ export class VscodeDiffViewProvider extends DiffViewProvider {
|
||||
return this.activeDiffEditor.document.getText()
|
||||
}
|
||||
|
||||
protected override async getNewDiagnosticProblems(): Promise<string> {
|
||||
// Get the diagnostics after changing the document.
|
||||
const postDiagnostics = vscode.languages.getDiagnostics()
|
||||
const newProblems = getNewDiagnostics(this.preDiagnostics, postDiagnostics)
|
||||
// Only including errors since warnings can be distracting (if user wants to fix warnings they can use the @problems mention)
|
||||
// will be empty string if no errors
|
||||
const problems = await diagnosticsToProblemsString(newProblems, [vscode.DiagnosticSeverity.Error])
|
||||
return problems
|
||||
}
|
||||
|
||||
protected override async saveDocument(): Promise<Boolean> {
|
||||
if (!this.activeDiffEditor) {
|
||||
return false
|
||||
@@ -206,6 +192,5 @@ export class VscodeDiffViewProvider extends DiffViewProvider {
|
||||
this.activeDiffEditor = undefined
|
||||
this.fadedOverlayController = undefined
|
||||
this.activeLineController = undefined
|
||||
this.preDiagnostics = []
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,9 +4,11 @@ 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 { HostProvider } from "@/hosts/host-provider"
|
||||
import { WebviewMessage } from "@/shared/WebviewMessage"
|
||||
import { handleGrpcRequest, handleGrpcRequestCancel } from "@/core/controller/grpc-handler"
|
||||
|
||||
/*
|
||||
https://github.com/microsoft/vscode-webview-ui-toolkit-samples/blob/main/default/weather-webview/src/providers/WeatherViewProvider.ts
|
||||
@@ -33,9 +35,6 @@ 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
|
||||
}
|
||||
@@ -72,6 +71,7 @@ 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,6 +84,7 @@ 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)
|
||||
}
|
||||
},
|
||||
@@ -96,6 +97,9 @@ 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,
|
||||
@@ -156,13 +160,51 @@ export class VscodeWebviewProvider extends WebviewProvider implements vscode.Web
|
||||
private setWebviewMessageListener(webview: vscode.Webview) {
|
||||
webview.onDidReceiveMessage(
|
||||
(message) => {
|
||||
this.controller.handleWebviewMessage(message)
|
||||
this.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()
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { GetWebviewHtmlRequest, GetWebviewHtmlResponse } from "@/shared/proto/index.host"
|
||||
|
||||
export async function getWebviewHtml(_: GetWebviewHtmlRequest): Promise<GetWebviewHtmlResponse> {
|
||||
throw new Error("Unimplemented")
|
||||
}
|
||||
@@ -63,7 +63,7 @@ describe("Hostbridge - Window - getOpenTabs", () => {
|
||||
return response.paths.length === 2
|
||||
},
|
||||
{
|
||||
timeout: 2000,
|
||||
timeout: 4000,
|
||||
interval: 50,
|
||||
},
|
||||
)
|
||||
@@ -93,7 +93,7 @@ describe("Hostbridge - Window - getOpenTabs", () => {
|
||||
return response.paths.length === 3
|
||||
},
|
||||
{
|
||||
timeout: 2000,
|
||||
timeout: 4000,
|
||||
interval: 50,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
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,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,464 @@
|
||||
import { describe, it, beforeEach } from "mocha"
|
||||
import { expect } from "chai"
|
||||
import { getNewDiagnostics, diagnosticsToProblemsString } from "../"
|
||||
import { DiagnosticSeverity, FileDiagnostics } from "@shared/proto/index.host"
|
||||
import * as sinon from "sinon"
|
||||
import * as pathUtils from "@/utils/path"
|
||||
|
||||
describe("Diagnostics Tests", () => {
|
||||
describe("getNewDiagnostics", () => {
|
||||
it("should return empty array when both old and new diagnostics are empty", () => {
|
||||
const oldDiagnostics: FileDiagnostics[] = []
|
||||
const newDiagnostics: FileDiagnostics[] = []
|
||||
|
||||
const result = getNewDiagnostics(oldDiagnostics, newDiagnostics)
|
||||
|
||||
expect(result).to.deep.equal([])
|
||||
})
|
||||
|
||||
it("should return all diagnostics when old diagnostics is empty", () => {
|
||||
const oldDiagnostics: FileDiagnostics[] = []
|
||||
const newDiagnostics: FileDiagnostics[] = [
|
||||
{
|
||||
filePath: "/path/to/file1.ts",
|
||||
diagnostics: [
|
||||
{
|
||||
severity: DiagnosticSeverity.DIAGNOSTIC_ERROR,
|
||||
message: "Error in file1",
|
||||
range: {
|
||||
start: { line: 0, character: 0 },
|
||||
end: { line: 0, character: 10 },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const result = getNewDiagnostics(oldDiagnostics, newDiagnostics)
|
||||
|
||||
expect(result).to.deep.equal(newDiagnostics)
|
||||
})
|
||||
|
||||
it("should return empty array when new diagnostics is empty", () => {
|
||||
const oldDiagnostics: FileDiagnostics[] = [
|
||||
{
|
||||
filePath: "/path/to/file1.ts",
|
||||
diagnostics: [
|
||||
{
|
||||
severity: DiagnosticSeverity.DIAGNOSTIC_ERROR,
|
||||
message: "Error in file1",
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
const newDiagnostics: FileDiagnostics[] = []
|
||||
|
||||
const result = getNewDiagnostics(oldDiagnostics, newDiagnostics)
|
||||
|
||||
expect(result).to.deep.equal([])
|
||||
})
|
||||
|
||||
it("should return only new diagnostics not present in old diagnostics", () => {
|
||||
const oldDiagnostics: FileDiagnostics[] = [
|
||||
{
|
||||
filePath: "/path/to/file1.ts",
|
||||
diagnostics: [
|
||||
{
|
||||
severity: DiagnosticSeverity.DIAGNOSTIC_ERROR,
|
||||
message: "Old error",
|
||||
range: {
|
||||
start: { line: 0, character: 0 },
|
||||
end: { line: 0, character: 10 },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
const newDiagnostics: FileDiagnostics[] = [
|
||||
{
|
||||
filePath: "/path/to/file1.ts",
|
||||
diagnostics: [
|
||||
{
|
||||
severity: DiagnosticSeverity.DIAGNOSTIC_ERROR,
|
||||
message: "Old error",
|
||||
range: {
|
||||
start: { line: 0, character: 0 },
|
||||
end: { line: 0, character: 10 },
|
||||
},
|
||||
},
|
||||
{
|
||||
severity: DiagnosticSeverity.DIAGNOSTIC_WARNING,
|
||||
message: "New warning",
|
||||
range: {
|
||||
start: { line: 5, character: 5 },
|
||||
end: { line: 5, character: 15 },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const result = getNewDiagnostics(oldDiagnostics, newDiagnostics)
|
||||
|
||||
expect(result).to.have.lengthOf(1)
|
||||
expect(result[0].filePath).to.equal("/path/to/file1.ts")
|
||||
expect(result[0].diagnostics).to.have.lengthOf(1)
|
||||
expect(result[0].diagnostics[0].message).to.equal("New warning")
|
||||
})
|
||||
|
||||
it("should handle multiple files correctly", () => {
|
||||
const oldDiagnostics: FileDiagnostics[] = [
|
||||
{
|
||||
filePath: "/path/to/file1.ts",
|
||||
diagnostics: [
|
||||
{
|
||||
severity: DiagnosticSeverity.DIAGNOSTIC_ERROR,
|
||||
message: "Error in file1",
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
const newDiagnostics: FileDiagnostics[] = [
|
||||
{
|
||||
filePath: "/path/to/file1.ts",
|
||||
diagnostics: [
|
||||
{
|
||||
severity: DiagnosticSeverity.DIAGNOSTIC_ERROR,
|
||||
message: "Error in file1",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
filePath: "/path/to/file2.ts",
|
||||
diagnostics: [
|
||||
{
|
||||
severity: DiagnosticSeverity.DIAGNOSTIC_ERROR,
|
||||
message: "Error in file2",
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const result = getNewDiagnostics(oldDiagnostics, newDiagnostics)
|
||||
|
||||
expect(result).to.have.lengthOf(1)
|
||||
expect(result[0].filePath).to.equal("/path/to/file2.ts")
|
||||
})
|
||||
|
||||
it("should handle diagnostics with source and code properties", () => {
|
||||
const oldDiagnostics: FileDiagnostics[] = []
|
||||
const newDiagnostics: FileDiagnostics[] = [
|
||||
{
|
||||
filePath: "/path/to/file1.ts",
|
||||
diagnostics: [
|
||||
{
|
||||
severity: DiagnosticSeverity.DIAGNOSTIC_ERROR,
|
||||
message: "Type error",
|
||||
source: "typescript",
|
||||
range: {
|
||||
start: { line: 10, character: 5 },
|
||||
end: { line: 10, character: 20 },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const result = getNewDiagnostics(oldDiagnostics, newDiagnostics)
|
||||
|
||||
expect(result).to.deep.equal(newDiagnostics)
|
||||
})
|
||||
})
|
||||
|
||||
describe("diagnosticsToProblemsString", () => {
|
||||
let getCwdStub: sinon.SinonStub
|
||||
|
||||
beforeEach(() => {
|
||||
getCwdStub = sinon.stub(pathUtils, "getCwd").resolves("/workspace")
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
sinon.restore()
|
||||
})
|
||||
|
||||
it("should return empty string when diagnostics array is empty", async () => {
|
||||
const diagnostics: FileDiagnostics[] = []
|
||||
const severities = [DiagnosticSeverity.DIAGNOSTIC_ERROR]
|
||||
|
||||
const result = await diagnosticsToProblemsString(diagnostics, severities)
|
||||
|
||||
expect(result).to.equal("")
|
||||
})
|
||||
|
||||
it("should return empty string when no diagnostics match the severity filter", async () => {
|
||||
const diagnostics: FileDiagnostics[] = [
|
||||
{
|
||||
filePath: "/workspace/src/file1.ts",
|
||||
diagnostics: [
|
||||
{
|
||||
severity: DiagnosticSeverity.DIAGNOSTIC_WARNING,
|
||||
message: "Warning message",
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
const severities = [DiagnosticSeverity.DIAGNOSTIC_ERROR]
|
||||
|
||||
const result = await diagnosticsToProblemsString(diagnostics, severities)
|
||||
|
||||
expect(result).to.equal("")
|
||||
})
|
||||
|
||||
it("should format error diagnostics correctly with line numbers", async () => {
|
||||
const diagnostics: FileDiagnostics[] = [
|
||||
{
|
||||
filePath: "/workspace/src/file1.ts",
|
||||
diagnostics: [
|
||||
{
|
||||
severity: DiagnosticSeverity.DIAGNOSTIC_ERROR,
|
||||
message: "Type error",
|
||||
range: {
|
||||
start: { line: 9, character: 5 },
|
||||
end: { line: 9, character: 20 },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
const severities = [DiagnosticSeverity.DIAGNOSTIC_ERROR]
|
||||
|
||||
const result = await diagnosticsToProblemsString(diagnostics, severities)
|
||||
|
||||
expect(result).to.equal("src/file1.ts\n- [Error] Line 10: Type error")
|
||||
})
|
||||
|
||||
it("should handle diagnostics without range information", async () => {
|
||||
const diagnostics: FileDiagnostics[] = [
|
||||
{
|
||||
filePath: "/workspace/src/file1.ts",
|
||||
diagnostics: [
|
||||
{
|
||||
severity: DiagnosticSeverity.DIAGNOSTIC_ERROR,
|
||||
message: "File-level error",
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
const severities = [DiagnosticSeverity.DIAGNOSTIC_ERROR]
|
||||
|
||||
const result = await diagnosticsToProblemsString(diagnostics, severities)
|
||||
|
||||
expect(result).to.equal("src/file1.ts\n- [Error] Line : File-level error")
|
||||
})
|
||||
|
||||
it("should handle diagnostics with missing start property in range", async () => {
|
||||
const diagnostics: FileDiagnostics[] = [
|
||||
{
|
||||
filePath: "/workspace/src/file1.ts",
|
||||
diagnostics: [
|
||||
{
|
||||
severity: DiagnosticSeverity.DIAGNOSTIC_ERROR,
|
||||
message: "Error with partial range",
|
||||
range: {} as any, // Simulating missing start property
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
const severities = [DiagnosticSeverity.DIAGNOSTIC_ERROR]
|
||||
|
||||
const result = await diagnosticsToProblemsString(diagnostics, severities)
|
||||
|
||||
expect(result).to.equal("src/file1.ts\n- [Error] Line : Error with partial range")
|
||||
})
|
||||
|
||||
it("should include source information when available", async () => {
|
||||
const diagnostics: FileDiagnostics[] = [
|
||||
{
|
||||
filePath: "/workspace/src/file1.ts",
|
||||
diagnostics: [
|
||||
{
|
||||
severity: DiagnosticSeverity.DIAGNOSTIC_ERROR,
|
||||
message: "Type error",
|
||||
source: "typescript",
|
||||
range: {
|
||||
start: { line: 0, character: 0 },
|
||||
end: { line: 0, character: 10 },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
const severities = [DiagnosticSeverity.DIAGNOSTIC_ERROR]
|
||||
|
||||
const result = await diagnosticsToProblemsString(diagnostics, severities)
|
||||
|
||||
expect(result).to.equal("src/file1.ts\n- [typescript Error] Line 1: Type error")
|
||||
})
|
||||
|
||||
it("should handle multiple severities", async () => {
|
||||
const diagnostics: FileDiagnostics[] = [
|
||||
{
|
||||
filePath: "/workspace/src/file1.ts",
|
||||
diagnostics: [
|
||||
{
|
||||
severity: DiagnosticSeverity.DIAGNOSTIC_ERROR,
|
||||
message: "Error message",
|
||||
range: {
|
||||
start: { line: 0, character: 0 },
|
||||
end: { line: 0, character: 10 },
|
||||
},
|
||||
},
|
||||
{
|
||||
severity: DiagnosticSeverity.DIAGNOSTIC_WARNING,
|
||||
message: "Warning message",
|
||||
range: {
|
||||
start: { line: 5, character: 0 },
|
||||
end: { line: 5, character: 10 },
|
||||
},
|
||||
},
|
||||
{
|
||||
severity: DiagnosticSeverity.DIAGNOSTIC_INFORMATION,
|
||||
message: "Info message",
|
||||
range: {
|
||||
start: { line: 10, character: 0 },
|
||||
end: { line: 10, character: 10 },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
const severities = [DiagnosticSeverity.DIAGNOSTIC_ERROR, DiagnosticSeverity.DIAGNOSTIC_WARNING]
|
||||
|
||||
const result = await diagnosticsToProblemsString(diagnostics, severities)
|
||||
|
||||
expect(result).to.equal("src/file1.ts\n- [Error] Line 1: Error message\n- [Warning] Line 6: Warning message")
|
||||
})
|
||||
|
||||
it("should handle multiple files", async () => {
|
||||
const diagnostics: FileDiagnostics[] = [
|
||||
{
|
||||
filePath: "/workspace/src/file1.ts",
|
||||
diagnostics: [
|
||||
{
|
||||
severity: DiagnosticSeverity.DIAGNOSTIC_ERROR,
|
||||
message: "Error in file1",
|
||||
range: {
|
||||
start: { line: 0, character: 0 },
|
||||
end: { line: 0, character: 10 },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
filePath: "/workspace/src/file2.ts",
|
||||
diagnostics: [
|
||||
{
|
||||
severity: DiagnosticSeverity.DIAGNOSTIC_ERROR,
|
||||
message: "Error in file2",
|
||||
range: {
|
||||
start: { line: 5, character: 0 },
|
||||
end: { line: 5, character: 10 },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
const severities = [DiagnosticSeverity.DIAGNOSTIC_ERROR]
|
||||
|
||||
const result = await diagnosticsToProblemsString(diagnostics, severities)
|
||||
|
||||
expect(result).to.equal(
|
||||
"src/file1.ts\n- [Error] Line 1: Error in file1\n\nsrc/file2.ts\n- [Error] Line 6: Error in file2",
|
||||
)
|
||||
})
|
||||
|
||||
it("should handle absolute paths outside workspace", async () => {
|
||||
const diagnostics: FileDiagnostics[] = [
|
||||
{
|
||||
filePath: "/other/path/file1.ts",
|
||||
diagnostics: [
|
||||
{
|
||||
severity: DiagnosticSeverity.DIAGNOSTIC_ERROR,
|
||||
message: "Error outside workspace",
|
||||
range: {
|
||||
start: { line: 0, character: 0 },
|
||||
end: { line: 0, character: 10 },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
const severities = [DiagnosticSeverity.DIAGNOSTIC_ERROR]
|
||||
|
||||
const result = await diagnosticsToProblemsString(diagnostics, severities)
|
||||
|
||||
expect(result).to.equal("../other/path/file1.ts\n- [Error] Line 1: Error outside workspace")
|
||||
})
|
||||
|
||||
it("should handle all diagnostic severity types", async () => {
|
||||
const diagnostics: FileDiagnostics[] = [
|
||||
{
|
||||
filePath: "/workspace/src/file1.ts",
|
||||
diagnostics: [
|
||||
{
|
||||
severity: DiagnosticSeverity.DIAGNOSTIC_ERROR,
|
||||
message: "Error",
|
||||
range: { start: { line: 0, character: 0 }, end: { line: 0, character: 10 } },
|
||||
},
|
||||
{
|
||||
severity: DiagnosticSeverity.DIAGNOSTIC_WARNING,
|
||||
message: "Warning",
|
||||
range: { start: { line: 1, character: 0 }, end: { line: 1, character: 10 } },
|
||||
},
|
||||
{
|
||||
severity: DiagnosticSeverity.DIAGNOSTIC_INFORMATION,
|
||||
message: "Information",
|
||||
range: { start: { line: 2, character: 0 }, end: { line: 2, character: 10 } },
|
||||
},
|
||||
{
|
||||
severity: DiagnosticSeverity.DIAGNOSTIC_HINT,
|
||||
message: "Hint",
|
||||
range: { start: { line: 3, character: 0 }, end: { line: 3, character: 10 } },
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
const severities = [
|
||||
DiagnosticSeverity.DIAGNOSTIC_ERROR,
|
||||
DiagnosticSeverity.DIAGNOSTIC_WARNING,
|
||||
DiagnosticSeverity.DIAGNOSTIC_INFORMATION,
|
||||
DiagnosticSeverity.DIAGNOSTIC_HINT,
|
||||
]
|
||||
|
||||
const result = await diagnosticsToProblemsString(diagnostics, severities)
|
||||
|
||||
expect(result).to.equal(
|
||||
"src/file1.ts\n- [Error] Line 1: Error\n- [Warning] Line 2: Warning\n- [Information] Line 3: Information\n- [Hint] Line 4: Hint",
|
||||
)
|
||||
})
|
||||
|
||||
it("should handle edge case with line number 0", async () => {
|
||||
const diagnostics: FileDiagnostics[] = [
|
||||
{
|
||||
filePath: "/workspace/src/file1.ts",
|
||||
diagnostics: [
|
||||
{
|
||||
severity: DiagnosticSeverity.DIAGNOSTIC_ERROR,
|
||||
message: "Error on first line",
|
||||
range: {
|
||||
start: { line: 0, character: 0 },
|
||||
end: { line: 0, character: 10 },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
const severities = [DiagnosticSeverity.DIAGNOSTIC_ERROR]
|
||||
|
||||
const result = await diagnosticsToProblemsString(diagnostics, severities)
|
||||
|
||||
// Line 0 should be displayed as Line 1 (1-indexed)
|
||||
expect(result).to.equal("src/file1.ts\n- [Error] Line 1: Error on first line")
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,105 +1,48 @@
|
||||
import * as vscode from "vscode"
|
||||
import * as path from "path"
|
||||
import deepEqual from "fast-deep-equal"
|
||||
import { getCwd } from "@/utils/path"
|
||||
import { Diagnostic, DiagnosticSeverity, FileDiagnostics } from "@/shared/proto/index.host"
|
||||
|
||||
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)
|
||||
export function getNewDiagnostics(oldDiagnostics: FileDiagnostics[], newDiagnostics: FileDiagnostics[]): FileDiagnostics[] {
|
||||
const oldMap = new Map<string, Diagnostic[]>()
|
||||
for (const diag of oldDiagnostics) {
|
||||
oldMap.set(diag.filePath, diag.diagnostics)
|
||||
}
|
||||
|
||||
for (const [uri, newDiags] of newDiagnostics) {
|
||||
const oldDiags = oldMap.get(uri) || []
|
||||
const newProblemsForUri = newDiags.filter((newDiag) => !oldDiags.some((oldDiag) => deepEqual(oldDiag, newDiag)))
|
||||
const newProblems: FileDiagnostics[] = []
|
||||
for (const newDiags of newDiagnostics) {
|
||||
const oldDiags = oldMap.get(newDiags.filePath) || []
|
||||
const newProblemsForFile = newDiags.diagnostics.filter(
|
||||
(newDiag) => !oldDiags.some((oldDiag) => deepEqual(oldDiag, newDiag)),
|
||||
)
|
||||
|
||||
if (newProblemsForUri.length > 0) {
|
||||
newProblems.push([uri, newProblemsForUri])
|
||||
if (newProblemsForFile.length > 0) {
|
||||
newProblems.push({ filePath: newDiags.filePath, diagnostics: newProblemsForFile })
|
||||
}
|
||||
}
|
||||
|
||||
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[],
|
||||
diagnostics: FileDiagnostics[],
|
||||
severities: DiagnosticSeverity[],
|
||||
): Promise<string> {
|
||||
const cwd = await getCwd()
|
||||
let result = ""
|
||||
for (const [uri, fileDiagnostics] of diagnostics) {
|
||||
const problems = fileDiagnostics.filter((d) => severities.includes(d.severity))
|
||||
for (const fileDiagnostics of diagnostics) {
|
||||
const problems = fileDiagnostics.diagnostics.filter((d) => severities.includes(d.severity))
|
||||
|
||||
if (problems.length > 0) {
|
||||
result += `\n\n${path.relative(cwd, uri.fsPath).toPosix()}`
|
||||
const filePath = path.relative(cwd, fileDiagnostics.filePath).toPosix()
|
||||
result += `\n\n${filePath}`
|
||||
|
||||
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 label = severityToString(diagnostic.severity)
|
||||
// Lines are 0-indexed
|
||||
const line = diagnostic.range?.start ? `${diagnostic.range.start.line + 1}` : ""
|
||||
|
||||
const source = diagnostic.source ? `${diagnostic.source} ` : ""
|
||||
result += `\n- [${source}${label}] Line ${line}: ${diagnostic.message}`
|
||||
}
|
||||
@@ -107,3 +50,19 @@ export async function diagnosticsToProblemsString(
|
||||
}
|
||||
return result.trim()
|
||||
}
|
||||
|
||||
function severityToString(severity: DiagnosticSeverity): string {
|
||||
switch (severity) {
|
||||
case DiagnosticSeverity.DIAGNOSTIC_ERROR:
|
||||
return "Error"
|
||||
case DiagnosticSeverity.DIAGNOSTIC_WARNING:
|
||||
return "Warning"
|
||||
case DiagnosticSeverity.DIAGNOSTIC_INFORMATION:
|
||||
return "Information"
|
||||
case DiagnosticSeverity.DIAGNOSTIC_HINT:
|
||||
return "Hint"
|
||||
default:
|
||||
console.warn("Unhandled diagnostic severity level:", severity)
|
||||
return "Diagnostic"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import * as path from "path"
|
||||
import * as fs from "fs/promises"
|
||||
import { createDirectoriesForFile } from "@utils/fs"
|
||||
import { arePathsEqual, getCwd } from "@utils/path"
|
||||
import { getCwd } from "@utils/path"
|
||||
import { formatResponse } from "@core/prompts/responses"
|
||||
import * as diff from "diff"
|
||||
import { detectEncoding } from "../misc/extract-text"
|
||||
import * as iconv from "iconv-lite"
|
||||
import { HostProvider } from "@/hosts/host-provider"
|
||||
import { DiagnosticSeverity, FileDiagnostics } from "@/shared/proto/index.host"
|
||||
import { diagnosticsToProblemsString, getNewDiagnostics } from "@/integrations/diagnostics"
|
||||
|
||||
export abstract class DiffViewProvider {
|
||||
editType?: "create" | "modify"
|
||||
@@ -14,6 +16,7 @@ export abstract class DiffViewProvider {
|
||||
originalContent: string | undefined
|
||||
private createdDirs: string[] = []
|
||||
protected documentWasOpen = false
|
||||
private preDiagnostics: FileDiagnostics[] = []
|
||||
protected relPath?: string
|
||||
protected absolutePath?: string
|
||||
protected fileEncoding: string = "utf8"
|
||||
@@ -47,6 +50,8 @@ export abstract class DiffViewProvider {
|
||||
if (!fileExists) {
|
||||
await fs.writeFile(this.absolutePath, "")
|
||||
}
|
||||
// get diagnostics before editing the file, we'll compare to diagnostics after editing to see if cline needs to fix anything
|
||||
this.preDiagnostics = (await HostProvider.workspace.getDiagnostics({})).fileDiagnostics
|
||||
await this.openDiffEditor()
|
||||
await this.scrollEditorToLine(0)
|
||||
this.streamedLines = []
|
||||
@@ -114,7 +119,16 @@ export abstract class DiffViewProvider {
|
||||
* applying a fix, Cline won't be notified, which is generally fine since the
|
||||
* initial fix is usually correct and it may just take time for linters to catch up.
|
||||
*/
|
||||
protected abstract getNewDiagnosticProblems(): Promise<string>
|
||||
private async getNewDiagnosticProblems(): Promise<string> {
|
||||
// Get the diagnostics after changing the document.
|
||||
const postDiagnostics = (await HostProvider.workspace.getDiagnostics({})).fileDiagnostics
|
||||
|
||||
const newProblems = getNewDiagnostics(this.preDiagnostics, postDiagnostics)
|
||||
// Only including errors since warnings can be distracting (if user wants to fix warnings they can use the @problems mention)
|
||||
// will be empty string if no errors
|
||||
const problems = await diagnosticsToProblemsString(newProblems, [DiagnosticSeverity.DIAGNOSTIC_ERROR])
|
||||
return problems
|
||||
}
|
||||
|
||||
/**
|
||||
* Save the contents of the diff editor UI to the file.
|
||||
@@ -359,12 +373,19 @@ export abstract class DiffViewProvider {
|
||||
|
||||
// close editor if open?
|
||||
async reset() {
|
||||
this.editType = undefined
|
||||
this.isEditing = false
|
||||
this.editType = undefined
|
||||
this.absolutePath = undefined
|
||||
this.relPath = undefined
|
||||
this.preDiagnostics = []
|
||||
|
||||
this.originalContent = undefined
|
||||
this.createdDirs = []
|
||||
this.fileEncoding = "utf8"
|
||||
this.documentWasOpen = false
|
||||
|
||||
this.streamedLines = []
|
||||
this.createdDirs = []
|
||||
this.newContent = undefined
|
||||
|
||||
await this.resetDiffView()
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { EventEmitter } from "events"
|
||||
import { stripAnsi } from "./ansiUtils"
|
||||
import * as vscode from "vscode"
|
||||
import { stripAnsi } from "./ansiUtils"
|
||||
import { getLatestTerminalOutput } from "./get-latest-output"
|
||||
|
||||
export interface TerminalProcessEvents {
|
||||
@@ -23,447 +23,217 @@ export class TerminalProcess extends EventEmitter<TerminalProcessEvents> {
|
||||
private lastRetrievedIndex: number = 0
|
||||
isHot: boolean = false
|
||||
private hotTimer: NodeJS.Timeout | null = null
|
||||
private command: string = ""
|
||||
private gracePeriodTimer: NodeJS.Timeout | null = null
|
||||
private hasEmittedCompleted: boolean = false
|
||||
|
||||
private async emitCurrentTerminalContents(): Promise<void> {
|
||||
try {
|
||||
const terminalSnapshot = await getLatestTerminalOutput()
|
||||
if (terminalSnapshot && terminalSnapshot.trim()) {
|
||||
const fallbackMessage = `The command's output could not be captured due to some technical issue, however it has been executed successfully. Here's the current terminal's content to help you get the command's output:\n\n${terminalSnapshot}`
|
||||
this.emit("line", fallbackMessage)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error capturing terminal output:", error)
|
||||
}
|
||||
}
|
||||
|
||||
async run(terminal: vscode.Terminal, command: string) {
|
||||
// Initialize state for new command
|
||||
await this.initializeForNewCommand(command)
|
||||
|
||||
console.log(`[TerminalProcess] Starting command: "${command}"`)
|
||||
console.log(`[TerminalProcess] Shell integration available: ${!!terminal.shellIntegration?.executeCommand}`)
|
||||
console.log(`[TerminalProcess] Terminal ID: ${terminal.name}`)
|
||||
|
||||
if (terminal.shellIntegration?.executeCommand) {
|
||||
await this.runWithShellIntegration(terminal, command)
|
||||
} else {
|
||||
// no shell integration detected, we'll fallback to running the command and capturing the terminal's output after some time
|
||||
await this.runWithoutShellIntegration(terminal, command)
|
||||
}
|
||||
}
|
||||
|
||||
private async initializeForNewCommand(command: string): Promise<void> {
|
||||
// Clear any existing grace period timer from previous commands
|
||||
if (this.gracePeriodTimer) {
|
||||
clearTimeout(this.gracePeriodTimer)
|
||||
this.gracePeriodTimer = null
|
||||
console.log(`[TerminalProcess] Cleared existing grace period timer before starting new command`)
|
||||
}
|
||||
|
||||
// Clear any existing hot timer
|
||||
if (this.hotTimer) {
|
||||
clearTimeout(this.hotTimer)
|
||||
this.hotTimer = null
|
||||
}
|
||||
|
||||
// Reset state for new command
|
||||
this.hasEmittedCompleted = false
|
||||
this.buffer = ""
|
||||
this.fullOutput = ""
|
||||
this.lastRetrievedIndex = 0
|
||||
this.isListening = true
|
||||
this.isHot = false
|
||||
this.command = command
|
||||
}
|
||||
|
||||
private async runWithShellIntegration(terminal: vscode.Terminal, command: string): Promise<void> {
|
||||
// Execute command and get stream
|
||||
const stream = await this.executeCommandWithShellIntegration(terminal, command)
|
||||
if (!stream) {
|
||||
return
|
||||
}
|
||||
|
||||
// Initialize state for stream processing
|
||||
const streamState = this.initializeStreamState()
|
||||
|
||||
// Set up timeout for commands with no output
|
||||
const firstChunkTimeout = this.setupNoOutputTimeout(command, streamState)
|
||||
|
||||
// Process the output stream
|
||||
await this.processOutputStream(stream, command, streamState, firstChunkTimeout)
|
||||
|
||||
// Handle post-stream cleanup
|
||||
this.cleanupAfterStream(streamState, firstChunkTimeout)
|
||||
|
||||
// Determine command completion behavior
|
||||
await this.handleCommandCompletion(command, streamState)
|
||||
}
|
||||
|
||||
private async executeCommandWithShellIntegration(terminal: vscode.Terminal, command: string): Promise<any> {
|
||||
try {
|
||||
if (!terminal.shellIntegration || !terminal.shellIntegration.executeCommand) {
|
||||
throw new Error("Shell integration not available")
|
||||
// When command does not produce any output, we can assume the shell integration API failed and as a fallback return the current terminal contents
|
||||
const returnCurrentTerminalContents = async () => {
|
||||
try {
|
||||
const terminalSnapshot = await getLatestTerminalOutput()
|
||||
if (terminalSnapshot && terminalSnapshot.trim()) {
|
||||
const fallbackMessage = `The command's output could not be captured due to some technical issue, however it has been executed successfully. Here's the current terminal's content to help you get the command's output:\n\n${terminalSnapshot}`
|
||||
this.emit("line", fallbackMessage)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error capturing terminal output:", error)
|
||||
}
|
||||
}
|
||||
|
||||
if (terminal.shellIntegration && terminal.shellIntegration.executeCommand) {
|
||||
const execution = terminal.shellIntegration.executeCommand(command)
|
||||
return execution.read()
|
||||
} catch (error) {
|
||||
console.error(`[TerminalProcess] Failed to execute command: ${error}`)
|
||||
this.emit("error", error as Error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
const stream = execution.read()
|
||||
// todo: need to handle errors
|
||||
let isFirstChunk = true
|
||||
let didOutputNonCommand = false
|
||||
let didEmitEmptyLine = false
|
||||
|
||||
private initializeStreamState() {
|
||||
return {
|
||||
isFirstChunk: true,
|
||||
didOutputNonCommand: false,
|
||||
didEmitEmptyLine: false,
|
||||
receivedFirstChunk: false,
|
||||
}
|
||||
}
|
||||
for await (let data of stream) {
|
||||
// 1. Process chunk and remove artifacts
|
||||
if (isFirstChunk) {
|
||||
/*
|
||||
The first chunk we get from this stream needs to be processed to be more human readable, ie remove vscode's custom escape sequences and identifiers, removing duplicate first char bug, etc.
|
||||
*/
|
||||
|
||||
private setupNoOutputTimeout(command: string, streamState: any): NodeJS.Timeout {
|
||||
// Set up a 3-second timeout to handle commands with no/delayed output.
|
||||
// This ensures the UI remains responsive by:
|
||||
// 1. Showing the "proceed while running" button after 3 seconds
|
||||
// 2. Capturing current terminal contents in case shell integration missed output
|
||||
// 3. Informing the user that the command is still running
|
||||
return setTimeout(async () => {
|
||||
if (!streamState.receivedFirstChunk && !streamState.didEmitEmptyLine) {
|
||||
console.log(`[TerminalProcess] First chunk timeout fired - no output received within 3s for: "${command}"`)
|
||||
this.emit("line", "") // empty line to show proceed button
|
||||
streamState.didEmitEmptyLine = true
|
||||
// bug where sometimes the command output makes its way into vscode shell integration metadata
|
||||
/*
|
||||
]633 is a custom sequence number used by VSCode shell integration:
|
||||
- OSC 633 ; A ST - Mark prompt start
|
||||
- OSC 633 ; B ST - Mark prompt end
|
||||
- OSC 633 ; C ST - Mark pre-execution (start of command output)
|
||||
- OSC 633 ; D [; <exitcode>] ST - Mark execution finished with optional exit code
|
||||
- OSC 633 ; E ; <commandline> [; <nonce>] ST - Explicitly set command line with optional nonce
|
||||
*/
|
||||
// if you print this data you might see something like "eecho hello worldo hello world;5ba85d14-e92a-40c4-b2fd-71525581eeb0]633;C" but this is actually just a bunch of escape sequences, ignore up to the first ;C
|
||||
/* ddateb15026-6a64-40db-b21f-2a621a9830f0]633;CTue Sep 17 06:37:04 EDT 2024 % ]633;D;0]633;P;Cwd=/Users/saoud/Repositories/test */
|
||||
// Gets output between ]633;C (command start) and ]633;D (command end)
|
||||
const outputBetweenSequences = this.removeLastLineArtifacts(
|
||||
data.match(/\]633;C([\s\S]*?)\]633;D/)?.[1] || "",
|
||||
).trim()
|
||||
|
||||
// Capture terminal contents as fallback for shell integration issues
|
||||
await this.emitCurrentTerminalContents()
|
||||
}
|
||||
}, 3000)
|
||||
}
|
||||
// Once we've retrieved any potential output between sequences, we can remove everything up to end of the last sequence
|
||||
// https://code.visualstudio.com/docs/terminal/shell-integration#_vs-code-custom-sequences-osc-633-st
|
||||
const vscodeSequenceRegex = /\x1b\]633;.[^\x07]*\x07/g
|
||||
const lastMatch = [...data.matchAll(vscodeSequenceRegex)].pop()
|
||||
if (lastMatch && lastMatch.index !== undefined) {
|
||||
data = data.slice(lastMatch.index + lastMatch[0].length)
|
||||
}
|
||||
// Place output back after removing vscode sequences
|
||||
if (outputBetweenSequences) {
|
||||
data = outputBetweenSequences + "\n" + data
|
||||
}
|
||||
// remove ansi
|
||||
data = stripAnsi(data)
|
||||
// Split data by newlines
|
||||
const lines = data ? data.split("\n") : []
|
||||
// Remove non-human readable characters from the first line
|
||||
if (lines.length > 0) {
|
||||
lines[0] = lines[0].replace(/[^\x20-\x7E]/g, "")
|
||||
}
|
||||
// Check for duplicated first character that might be a terminal artifact
|
||||
// But skip this check for known syntax characters like {, [, ", etc.
|
||||
if (
|
||||
lines.length > 0 &&
|
||||
lines[0].length >= 2 &&
|
||||
lines[0][0] === lines[0][1] &&
|
||||
!["[", "{", '"', "'", "<", "("].includes(lines[0][0])
|
||||
) {
|
||||
lines[0] = lines[0].slice(1)
|
||||
}
|
||||
// Only remove specific terminal artifacts from line beginnings while preserving JSON syntax
|
||||
if (lines.length > 0) {
|
||||
// This regex only removes common terminal artifacts (%, $, >, #) and invisible control chars
|
||||
// but preserves important syntax chars like {, [, ", etc.
|
||||
lines[0] = lines[0].replace(/^[\x00-\x1F%$>#\s]*/, "")
|
||||
}
|
||||
if (lines.length > 1) {
|
||||
lines[1] = lines[1].replace(/^[\x00-\x1F%$>#\s]*/, "")
|
||||
}
|
||||
// Join lines back
|
||||
data = lines.join("\n")
|
||||
isFirstChunk = false
|
||||
} else {
|
||||
data = stripAnsi(data)
|
||||
}
|
||||
|
||||
private async processOutputStream(
|
||||
stream: any,
|
||||
command: string,
|
||||
streamState: any,
|
||||
firstChunkTimeout: NodeJS.Timeout,
|
||||
): Promise<void> {
|
||||
for await (let data of stream) {
|
||||
// Handle first chunk received
|
||||
if (!streamState.receivedFirstChunk) {
|
||||
clearTimeout(firstChunkTimeout)
|
||||
streamState.receivedFirstChunk = true
|
||||
console.log(`[TerminalProcess] First chunk received for command: "${command}"`)
|
||||
// Ctrl+C detection: if user presses Ctrl+C, treat as command terminated
|
||||
if (data.includes("^C") || data.includes("\u0003")) {
|
||||
if (this.hotTimer) {
|
||||
clearTimeout(this.hotTimer)
|
||||
}
|
||||
this.isHot = false
|
||||
break
|
||||
}
|
||||
|
||||
// first few chunks could be the command being echoed back, so we must ignore
|
||||
// note this means that 'echo' commands won't work
|
||||
if (!didOutputNonCommand) {
|
||||
const lines = data.split("\n")
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (command.includes(lines[i].trim())) {
|
||||
lines.splice(i, 1)
|
||||
i-- // Adjust index after removal
|
||||
} else {
|
||||
didOutputNonCommand = true
|
||||
break
|
||||
}
|
||||
}
|
||||
data = lines.join("\n")
|
||||
}
|
||||
|
||||
// 2. Set isHot depending on the command
|
||||
// Set to hot to stall API requests until terminal is cool again
|
||||
this.isHot = true
|
||||
if (this.hotTimer) {
|
||||
clearTimeout(this.hotTimer)
|
||||
}
|
||||
// these markers indicate the command is some kind of local dev server recompiling the app, which we want to wait for output of before sending request to cline
|
||||
const compilingMarkers = ["compiling", "building", "bundling", "transpiling", "generating", "starting"]
|
||||
const markerNullifiers = [
|
||||
"compiled",
|
||||
"success",
|
||||
"finish",
|
||||
"complete",
|
||||
"succeed",
|
||||
"done",
|
||||
"end",
|
||||
"stop",
|
||||
"exit",
|
||||
"terminate",
|
||||
"error",
|
||||
"fail",
|
||||
]
|
||||
const isCompiling =
|
||||
compilingMarkers.some((marker) => data.toLowerCase().includes(marker.toLowerCase())) &&
|
||||
!markerNullifiers.some((nullifier) => data.toLowerCase().includes(nullifier.toLowerCase()))
|
||||
this.hotTimer = setTimeout(
|
||||
() => {
|
||||
this.isHot = false
|
||||
},
|
||||
isCompiling ? PROCESS_HOT_TIMEOUT_COMPILING : PROCESS_HOT_TIMEOUT_NORMAL,
|
||||
)
|
||||
|
||||
// For non-immediately returning commands we want to show loading spinner right away but this wouldn't happen until it emits a line break, so as soon as we get any output we emit "" to let webview know to show spinner
|
||||
// This is only done for the sake of unblocking the UI, in case there may be some time before the command emits a full line
|
||||
if (!didEmitEmptyLine && !this.fullOutput && data) {
|
||||
this.emit("line", "") // empty line to indicate start of command output stream
|
||||
didEmitEmptyLine = true
|
||||
}
|
||||
|
||||
this.fullOutput += data
|
||||
if (this.isListening) {
|
||||
this.emitIfEol(data)
|
||||
this.lastRetrievedIndex = this.fullOutput.length - this.buffer.length
|
||||
}
|
||||
}
|
||||
|
||||
// Log chunk info
|
||||
console.log(`[TerminalProcess] Raw data chunk received: ${data.length} chars`)
|
||||
if (!data || data.trim() === "") {
|
||||
console.log(`[TerminalProcess] WARNING: Received empty or whitespace-only chunk`)
|
||||
this.emitRemainingBufferIfListening()
|
||||
|
||||
// the command process is finished, let's check the output to see if we need to use the terminal capture fallback
|
||||
if (!this.fullOutput.trim()) {
|
||||
await returnCurrentTerminalContents()
|
||||
}
|
||||
|
||||
// Process the chunk
|
||||
data = await this.processChunk(data, command, streamState)
|
||||
|
||||
// Check for Ctrl+C interruption
|
||||
if (this.isCommandInterrupted(data)) {
|
||||
this.handleInterruption()
|
||||
break
|
||||
// for now we don't want this delaying requests since we don't send diagnostics automatically anymore (previous: "even though the command is finished, we still want to consider it 'hot' in case so that api request stalls to let diagnostics catch up")
|
||||
// to explain this further, before we would send workspace diagnostics automatically with each request, but now we only send new diagnostics after file edits, so there's no need to wait for a bit after commands run to let diagnostics catch up
|
||||
if (this.hotTimer) {
|
||||
clearTimeout(this.hotTimer)
|
||||
}
|
||||
|
||||
// Update hot/cool state based on output
|
||||
this.updateHotState(data)
|
||||
|
||||
// Handle output emission
|
||||
this.handleChunkOutput(data, streamState)
|
||||
}
|
||||
|
||||
// Emit any remaining buffer content
|
||||
this.emitRemainingBufferIfListening()
|
||||
}
|
||||
|
||||
private async processChunk(data: string, command: string, streamState: any): Promise<string> {
|
||||
if (streamState.isFirstChunk) {
|
||||
data = this.cleanFirstChunk(data)
|
||||
streamState.isFirstChunk = false
|
||||
} else {
|
||||
data = stripAnsi(data)
|
||||
}
|
||||
|
||||
// Remove command echo if not yet seen real output
|
||||
if (!streamState.didOutputNonCommand) {
|
||||
data = this.removeCommandEcho(data, command, streamState)
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
private cleanFirstChunk(data: string): string {
|
||||
/*
|
||||
The first chunk we get from this stream needs to be processed to be more human readable,
|
||||
ie remove vscode's custom escape sequences and identifiers, removing duplicate first char bug, etc.
|
||||
*/
|
||||
|
||||
// bug where sometimes the command output makes its way into vscode shell integration metadata
|
||||
/*
|
||||
]633 is a custom sequence number used by VSCode shell integration:
|
||||
- OSC 633 ; A ST - Mark prompt start
|
||||
- OSC 633 ; B ST - Mark prompt end
|
||||
- OSC 633 ; C ST - Mark pre-execution (start of command output)
|
||||
- OSC 633 ; D [; <exitcode>] ST - Mark execution finished with optional exit code
|
||||
- OSC 633 ; E ; <commandline> [; <nonce>] ST - Explicitly set command line with optional nonce
|
||||
*/
|
||||
// Gets output between ]633;C (command start) and ]633;D (command end)
|
||||
const outputBetweenSequences = this.removeLastLineArtifacts(data.match(/\]633;C([\s\S]*?)\]633;D/)?.[1] || "").trim()
|
||||
|
||||
// Once we've retrieved any potential output between sequences, we can remove everything up to end of the last sequence
|
||||
// https://code.visualstudio.com/docs/terminal/shell-integration#_vs-code-custom-sequences-osc-633-st
|
||||
const vscodeSequenceRegex = /\x1b\]633;.[^\x07]*\x07/g
|
||||
const lastMatch = [...data.matchAll(vscodeSequenceRegex)].pop()
|
||||
if (lastMatch && lastMatch.index !== undefined) {
|
||||
data = data.slice(lastMatch.index + lastMatch[0].length)
|
||||
}
|
||||
// Place output back after removing vscode sequences
|
||||
if (outputBetweenSequences) {
|
||||
data = outputBetweenSequences + "\n" + data
|
||||
}
|
||||
|
||||
// remove ansi codes
|
||||
data = stripAnsi(data)
|
||||
|
||||
// Split data by newlines for line-by-line processing
|
||||
const lines = data ? data.split("\n") : []
|
||||
|
||||
// Remove non-human readable characters from the first line
|
||||
if (lines.length > 0) {
|
||||
lines[0] = lines[0].replace(/[^\x20-\x7E]/g, "")
|
||||
}
|
||||
|
||||
// Check for duplicated first character that might be a terminal artifact
|
||||
// But skip this check for known syntax characters like {, [, ", etc.
|
||||
if (
|
||||
lines.length > 0 &&
|
||||
lines[0].length >= 2 &&
|
||||
lines[0][0] === lines[0][1] &&
|
||||
!["[", "{", '"', "'", "<", "("].includes(lines[0][0])
|
||||
) {
|
||||
lines[0] = lines[0].slice(1)
|
||||
}
|
||||
|
||||
// Remove specific terminal artifacts from line beginnings while preserving JSON syntax
|
||||
if (lines.length > 0) {
|
||||
// This regex only removes common terminal artifacts (%, $, >, #) and invisible control chars
|
||||
// but preserves important syntax chars like {, [, ", etc.
|
||||
lines[0] = lines[0].replace(/^[\x00-\x1F%$>#\s]*/, "")
|
||||
}
|
||||
if (lines.length > 1) {
|
||||
lines[1] = lines[1].replace(/^[\x00-\x1F%$>#\s]*/, "")
|
||||
}
|
||||
|
||||
// Join lines back
|
||||
return lines.join("\n")
|
||||
}
|
||||
|
||||
private removeCommandEcho(data: string, command: string, streamState: any): string {
|
||||
// first few chunks could be the command being echoed back, so we must ignore
|
||||
// note this means that 'echo' commands won't work properly
|
||||
const lines = data.split("\n")
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const trimmedLine = lines[i].trim()
|
||||
// Check if the line is the command being echoed back
|
||||
if (trimmedLine && trimmedLine === command.trim()) {
|
||||
lines.splice(i, 1)
|
||||
i-- // Adjust index after removal
|
||||
} else if (trimmedLine) {
|
||||
// We've hit actual output, not just the command echo
|
||||
streamState.didOutputNonCommand = true
|
||||
break
|
||||
}
|
||||
}
|
||||
return lines.join("\n")
|
||||
}
|
||||
|
||||
private isCommandInterrupted(data: string): boolean {
|
||||
// Ctrl+C detection: if user presses Ctrl+C, treat as command terminated
|
||||
return data.includes("^C") || data.includes("\u0003")
|
||||
}
|
||||
|
||||
private handleInterruption(): void {
|
||||
if (this.hotTimer) {
|
||||
clearTimeout(this.hotTimer)
|
||||
}
|
||||
this.isHot = false
|
||||
}
|
||||
|
||||
private updateHotState(data: string): void {
|
||||
// Set to hot to stall API requests until terminal is cool again
|
||||
this.isHot = true
|
||||
if (this.hotTimer) {
|
||||
clearTimeout(this.hotTimer)
|
||||
}
|
||||
|
||||
const isCompiling = this.detectCompilationStatus(data)
|
||||
const timeout = isCompiling ? PROCESS_HOT_TIMEOUT_COMPILING : PROCESS_HOT_TIMEOUT_NORMAL
|
||||
|
||||
this.hotTimer = setTimeout(() => {
|
||||
this.isHot = false
|
||||
}, timeout)
|
||||
}
|
||||
|
||||
private detectCompilationStatus(data: string): boolean {
|
||||
// these markers indicate the command is some kind of local dev server recompiling the app
|
||||
const compilingMarkers = ["compiling", "building", "bundling", "transpiling", "generating", "starting"]
|
||||
const markerNullifiers = [
|
||||
"compiled",
|
||||
"success",
|
||||
"finish",
|
||||
"complete",
|
||||
"succeed",
|
||||
"done",
|
||||
"end",
|
||||
"stop",
|
||||
"exit",
|
||||
"terminate",
|
||||
"error",
|
||||
"fail",
|
||||
]
|
||||
|
||||
return (
|
||||
compilingMarkers.some((marker) => data.toLowerCase().includes(marker.toLowerCase())) &&
|
||||
!markerNullifiers.some((nullifier) => data.toLowerCase().includes(nullifier.toLowerCase()))
|
||||
)
|
||||
}
|
||||
|
||||
private handleChunkOutput(data: string, streamState: any): void {
|
||||
// For non-immediately returning commands we want to show loading spinner right away
|
||||
if (!streamState.didEmitEmptyLine && !this.fullOutput && data) {
|
||||
this.emit("line", "") // empty line to indicate start of command output stream
|
||||
streamState.didEmitEmptyLine = true
|
||||
}
|
||||
|
||||
this.fullOutput += data
|
||||
if (this.isListening) {
|
||||
this.emitIfEol(data)
|
||||
this.lastRetrievedIndex = this.fullOutput.length - this.buffer.length
|
||||
}
|
||||
}
|
||||
|
||||
private cleanupAfterStream(streamState: any, firstChunkTimeout: NodeJS.Timeout): void {
|
||||
// Clean up the first chunk timeout if it's still active
|
||||
if (!streamState.receivedFirstChunk) {
|
||||
clearTimeout(firstChunkTimeout)
|
||||
console.log(`[TerminalProcess] WARNING: Stream ended without receiving any chunks for command: "${this.command}"`)
|
||||
}
|
||||
|
||||
// Clear hot timer
|
||||
if (this.hotTimer) {
|
||||
clearTimeout(this.hotTimer)
|
||||
}
|
||||
this.isHot = false
|
||||
|
||||
console.log(`[TerminalProcess] Stream ended for command: "${this.command}"`)
|
||||
console.log(`[TerminalProcess] Final output length: ${this.fullOutput.length} characters`)
|
||||
}
|
||||
|
||||
private async handleCommandCompletion(command: string, streamState: any): Promise<void> {
|
||||
const commandType = this.analyzeCommandType(command)
|
||||
|
||||
// Handle commands with no output
|
||||
if (this.fullOutput.length === 0) {
|
||||
console.log(`[TerminalProcess] WARNING: Process completed but no output was captured`)
|
||||
if (!streamState.didEmitEmptyLine) {
|
||||
await this.emitCurrentTerminalContents()
|
||||
}
|
||||
}
|
||||
|
||||
// Determine if we should skip grace period
|
||||
if (commandType.isQuickCommand && !commandType.isLongRunning) {
|
||||
console.log(`[TerminalProcess] Command appears to have completed immediately, skipping grace period`)
|
||||
this.emit("completed")
|
||||
this.emit("continue")
|
||||
} else {
|
||||
console.log(`[TerminalProcess] Command may still be running (longRunningCommand: ${commandType.isLongRunning})`)
|
||||
console.log(`[TerminalProcess] Starting grace period to detect true completion...`)
|
||||
this.startGracePeriod()
|
||||
// no shell integration detected, we'll fallback to running the command and capturing the terminal's output after some time
|
||||
terminal.sendText(command, true)
|
||||
|
||||
// wait 3 seconds for the command to run
|
||||
await new Promise((resolve) => setTimeout(resolve, 3000))
|
||||
|
||||
// For terminals without shell integration, also try to capture terminal content
|
||||
await returnCurrentTerminalContents()
|
||||
// For terminals without shell integration, we can't know when the command completes
|
||||
// So we'll just emit the continue event after a delay
|
||||
this.emit("completed")
|
||||
this.emit("continue")
|
||||
this.emit("no_shell_integration")
|
||||
// setTimeout(() => {
|
||||
// console.log(`Emitting continue after delay for terminal`)
|
||||
// // can't emit completed since we don't if the command actually completed, it could still be running server
|
||||
// }, 500) // Adjust this delay as needed
|
||||
}
|
||||
}
|
||||
|
||||
private analyzeCommandType(command: string): { isQuickCommand: boolean; isLongRunning: boolean } {
|
||||
// Check if this looks like a command that completed vs one that's still running
|
||||
const quickCommands = ["cd ", "pwd", "ls ", "echo ", "mkdir ", "touch ", "rm ", "cp ", "mv "]
|
||||
const isQuickCommand = quickCommands.some((cmd) => command.startsWith(cmd) || command.includes(" && " + cmd))
|
||||
|
||||
// Check if output suggests a long-running process
|
||||
const longRunningIndicators = [
|
||||
"listening on",
|
||||
"server running",
|
||||
"started on",
|
||||
"watching for",
|
||||
"compiled successfully",
|
||||
"webpack",
|
||||
"vite",
|
||||
"nodemon",
|
||||
"dev server",
|
||||
"press ctrl",
|
||||
"to quit",
|
||||
"to exit",
|
||||
"to stop",
|
||||
]
|
||||
const hasLongRunningOutput = longRunningIndicators.some((indicator) => this.fullOutput.toLowerCase().includes(indicator))
|
||||
|
||||
// Check if this is likely a command that starts a server or long-running process
|
||||
const longRunningCommands = ["npm run", "npm start", "yarn", "node ", "python ", "serve", "dev", "watch"]
|
||||
const isLongRunningCommand = longRunningCommands.some((cmd) => command.includes(cmd))
|
||||
|
||||
return {
|
||||
isQuickCommand: this.fullOutput.length === 0 || isQuickCommand,
|
||||
isLongRunning: isLongRunningCommand || hasLongRunningOutput,
|
||||
}
|
||||
}
|
||||
|
||||
private async runWithoutShellIntegration(terminal: vscode.Terminal, command: string): Promise<void> {
|
||||
// Send command to terminal
|
||||
terminal.sendText(command, true)
|
||||
|
||||
// wait 3 seconds for the command to run
|
||||
await new Promise((resolve) => setTimeout(resolve, 3000))
|
||||
|
||||
// For terminals without shell integration, also try to capture terminal content
|
||||
await this.emitCurrentTerminalContents()
|
||||
// For terminals without shell integration, we can't know when the command completes
|
||||
// So we'll just emit the continue event after a delay
|
||||
this.emit("completed")
|
||||
this.emit("continue")
|
||||
this.emit("no_shell_integration")
|
||||
// setTimeout(() => {
|
||||
// console.log(`Emitting continue after delay for terminal`)
|
||||
// // can't emit completed since we don't if the command actually completed, it could still be running server
|
||||
// }, 500) // Adjust this delay as needed
|
||||
}
|
||||
|
||||
// Inspired by https://github.com/sindresorhus/execa/blob/main/lib/transform/split.js
|
||||
private emitIfEol(chunk: string) {
|
||||
this.buffer += chunk
|
||||
let lineEndIndex: number
|
||||
let lineCount = 0
|
||||
while ((lineEndIndex = this.buffer.indexOf("\n")) !== -1) {
|
||||
let line = this.buffer.slice(0, lineEndIndex).trimEnd() // removes trailing \r
|
||||
// Remove \r if present (for Windows-style line endings)
|
||||
// if (line.endsWith("\r")) {
|
||||
// line = line.slice(0, -1)
|
||||
// }
|
||||
if (!line || line.trim() === "") {
|
||||
console.log(`[TerminalProcess] Emitting empty line`)
|
||||
} else {
|
||||
console.log(`[TerminalProcess] Emitting line: ${line.substring(0, 100)}${line.length > 100 ? "..." : ""}`)
|
||||
}
|
||||
this.emit("line", line)
|
||||
this.buffer = this.buffer.slice(lineEndIndex + 1)
|
||||
lineCount++
|
||||
}
|
||||
if (lineCount === 0 && chunk.length > 0) {
|
||||
console.log(`[TerminalProcess] Buffering partial line, buffer size: ${this.buffer.length}`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -478,41 +248,7 @@ export class TerminalProcess extends EventEmitter<TerminalProcessEvents> {
|
||||
}
|
||||
}
|
||||
|
||||
private startGracePeriod() {
|
||||
// Clear any existing grace period timer
|
||||
if (this.gracePeriodTimer) {
|
||||
clearTimeout(this.gracePeriodTimer)
|
||||
}
|
||||
|
||||
// Emit completed event for UI to show "proceed while running" button
|
||||
console.log(`[TerminalProcess] Emitting completed event for UI (grace period active)`)
|
||||
this.emit("completed")
|
||||
|
||||
// Wait 2.5 seconds to see if the command is truly finished
|
||||
this.gracePeriodTimer = setTimeout(() => {
|
||||
// Double-check the timer hasn't been cleared
|
||||
if (this.gracePeriodTimer && !this.hasEmittedCompleted) {
|
||||
console.log(`[TerminalProcess] Grace period completed - command appears truly finished: "${this.command}"`)
|
||||
console.log(`[TerminalProcess] Auto-continuing without user intervention`)
|
||||
this.hasEmittedCompleted = true
|
||||
this.gracePeriodTimer = null
|
||||
// Only emit continue after the grace period, not immediately
|
||||
this.emit("continue")
|
||||
}
|
||||
}, 2500) // 2.5 second grace period
|
||||
}
|
||||
|
||||
continue() {
|
||||
console.log(`[TerminalProcess] Manual continue() called for: "${this.command}"`)
|
||||
|
||||
// Clear grace period since user manually continued
|
||||
if (this.gracePeriodTimer) {
|
||||
console.log(`[TerminalProcess] Clearing grace period timer due to manual continue`)
|
||||
clearTimeout(this.gracePeriodTimer)
|
||||
this.gracePeriodTimer = null
|
||||
}
|
||||
|
||||
this.hasEmittedCompleted = true
|
||||
this.emitRemainingBufferIfListening()
|
||||
this.isListening = false
|
||||
this.removeAllListeners("line")
|
||||
|
||||
@@ -1,139 +0,0 @@
|
||||
import * as vscode from "vscode"
|
||||
import * as path from "path"
|
||||
import { listFiles } from "@services/glob/list-files"
|
||||
import { sendWorkspaceUpdateEvent } from "@core/controller/file/subscribeToWorkspaceUpdates"
|
||||
import { getCwd } from "@/utils/path"
|
||||
import { isDirectory } from "@/utils/fs"
|
||||
|
||||
// Note: this is not a drop-in replacement for listFiles at the start of tasks, since that will be done for Desktops when there is no workspace selected
|
||||
class WorkspaceTracker {
|
||||
private disposables: vscode.Disposable[] = []
|
||||
private filePaths: Set<string> = new Set()
|
||||
private cwd: string = ""
|
||||
|
||||
constructor() {
|
||||
this.initializeCwd()
|
||||
this.registerListeners()
|
||||
}
|
||||
|
||||
private async initializeCwd() {
|
||||
this.cwd = await getCwd()
|
||||
}
|
||||
|
||||
private get activeFiles() {
|
||||
return new Set(
|
||||
vscode.window.tabGroups.activeTabGroup.tabs
|
||||
.filter((tab) => tab.input instanceof vscode.TabInputText)
|
||||
.map((tab) => (tab.input as vscode.TabInputText).uri.fsPath),
|
||||
)
|
||||
}
|
||||
|
||||
async populateFilePaths() {
|
||||
// should not auto get filepaths for desktop since it would immediately show permission popup before cline ever creates a file
|
||||
if (!this.cwd) {
|
||||
return
|
||||
}
|
||||
const [files, _] = await listFiles(this.cwd, true, 1_000)
|
||||
files.forEach((file) => this.filePaths.add(this.normalizeFilePath(file)))
|
||||
this.workspaceDidUpdate()
|
||||
}
|
||||
|
||||
private registerListeners() {
|
||||
// Listen for file creation
|
||||
// .bind(this) ensures the callback refers to class instance when using this, not necessary when using arrow function
|
||||
this.disposables.push(vscode.workspace.onDidCreateFiles(this.onFilesCreated.bind(this)))
|
||||
|
||||
// Listen for file deletion
|
||||
this.disposables.push(vscode.workspace.onDidDeleteFiles(this.onFilesDeleted.bind(this)))
|
||||
|
||||
// Listen for file renaming
|
||||
this.disposables.push(vscode.workspace.onDidRenameFiles(this.onFilesRenamed.bind(this)))
|
||||
|
||||
// Listen for tab groups changes
|
||||
this.disposables.push(vscode.window.tabGroups.onDidChangeTabs(this.workspaceDidUpdate.bind(this)))
|
||||
|
||||
/*
|
||||
An event that is emitted when a workspace folder is added or removed.
|
||||
**Note:** this event will not fire if the first workspace folder is added, removed or changed,
|
||||
because in that case the currently executing extensions (including the one that listens to this
|
||||
event) will be terminated and restarted so that the (deprecated) `rootPath` property is updated
|
||||
to point to the first workspace folder.
|
||||
*/
|
||||
// In other words, we don't have to worry about the root workspace folder ([0]) changing since the extension will be restarted and our cwd will be updated to reflect the new workspace folder. (We don't care about non root workspace folders, since cline will only be working within the root folder cwd)
|
||||
// this.disposables.push(vscode.workspace.onDidChangeWorkspaceFolders(this.onWorkspaceFoldersChanged.bind(this)))
|
||||
}
|
||||
|
||||
private async onFilesCreated(event: vscode.FileCreateEvent) {
|
||||
await Promise.all(
|
||||
event.files.map(async (file) => {
|
||||
await this.addFilePath(file.fsPath)
|
||||
}),
|
||||
)
|
||||
this.workspaceDidUpdate()
|
||||
}
|
||||
|
||||
private async onFilesDeleted(event: vscode.FileDeleteEvent) {
|
||||
let updated = false
|
||||
await Promise.all(
|
||||
event.files.map(async (file) => {
|
||||
if (await this.removeFilePath(file.fsPath)) {
|
||||
updated = true
|
||||
}
|
||||
}),
|
||||
)
|
||||
if (updated) {
|
||||
this.workspaceDidUpdate()
|
||||
}
|
||||
}
|
||||
|
||||
private async onFilesRenamed(event: vscode.FileRenameEvent) {
|
||||
await Promise.all(
|
||||
event.files.map(async (file) => {
|
||||
await this.removeFilePath(file.oldUri.fsPath)
|
||||
await this.addFilePath(file.newUri.fsPath)
|
||||
}),
|
||||
)
|
||||
this.workspaceDidUpdate()
|
||||
}
|
||||
|
||||
private async workspaceDidUpdate() {
|
||||
if (!this.cwd) {
|
||||
return
|
||||
}
|
||||
const filePaths = Array.from(new Set([...this.activeFiles, ...this.filePaths])).map((file) => {
|
||||
const relativePath = path.relative(this.cwd, file).toPosix()
|
||||
return file.endsWith("/") ? relativePath + "/" : relativePath
|
||||
})
|
||||
await sendWorkspaceUpdateEvent(filePaths)
|
||||
}
|
||||
|
||||
private normalizeFilePath(filePath: string): string {
|
||||
const resolvedPath = this.cwd ? path.resolve(this.cwd, filePath) : path.resolve(filePath)
|
||||
return filePath.endsWith("/") ? resolvedPath + "/" : resolvedPath
|
||||
}
|
||||
|
||||
private async addFilePath(filePath: string): Promise<string> {
|
||||
const normalizedPath = this.normalizeFilePath(filePath)
|
||||
try {
|
||||
const isDir = await isDirectory(normalizedPath)
|
||||
const pathWithSlash = isDir && !normalizedPath.endsWith("/") ? normalizedPath + "/" : normalizedPath
|
||||
this.filePaths.add(pathWithSlash)
|
||||
return pathWithSlash
|
||||
} catch {
|
||||
// If stat fails, assume it's a file (this can happen for newly created files)
|
||||
this.filePaths.add(normalizedPath)
|
||||
return normalizedPath
|
||||
}
|
||||
}
|
||||
|
||||
private async removeFilePath(filePath: string): Promise<boolean> {
|
||||
const normalizedPath = this.normalizeFilePath(filePath)
|
||||
return this.filePaths.delete(normalizedPath) || this.filePaths.delete(normalizedPath + "/")
|
||||
}
|
||||
|
||||
public dispose() {
|
||||
this.disposables.forEach((d) => d.dispose())
|
||||
}
|
||||
}
|
||||
|
||||
export default WorkspaceTracker
|
||||
@@ -129,7 +129,7 @@ export class ClineError extends Error {
|
||||
const { code, status, details } = err._error
|
||||
|
||||
// Check balance error first (most specific)
|
||||
if (code === "insufficient_credits" && typeof details?.current_balance === "number") {
|
||||
if (status === 402 || (code === "insufficient_credits" && typeof details?.current_balance === "number")) {
|
||||
return ClineErrorType.Balance
|
||||
}
|
||||
|
||||
|
||||
+12
-13
@@ -13,7 +13,6 @@ import {
|
||||
ListToolsResultSchema,
|
||||
ReadResourceResultSchema,
|
||||
} from "@modelcontextprotocol/sdk/types.js"
|
||||
import { ExtensionMessage } from "@shared/ExtensionMessage"
|
||||
import {
|
||||
DEFAULT_MCP_TIMEOUT_SECONDS,
|
||||
McpResource,
|
||||
@@ -134,7 +133,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,
|
||||
@@ -190,7 +189,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: {
|
||||
@@ -345,7 +344,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)
|
||||
|
||||
@@ -369,25 +368,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,
|
||||
@@ -396,11 +395,11 @@ export class McpHub {
|
||||
})
|
||||
}
|
||||
})
|
||||
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({
|
||||
@@ -408,7 +407,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)
|
||||
}
|
||||
@@ -1102,7 +1101,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")
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1110,7 +1109,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> {
|
||||
|
||||
@@ -41,7 +41,6 @@ export class PostHogClientProvider {
|
||||
// Initialize PostHog client
|
||||
this.client = new PostHog(posthogConfig.apiKey, {
|
||||
host: posthogConfig.host,
|
||||
enableExceptionAutocapture: true,
|
||||
})
|
||||
|
||||
vscode.env.onDidChangeTelemetryEnabled((isTelemetryEnabled) => {
|
||||
|
||||
@@ -181,10 +181,10 @@ export class TelemetryService {
|
||||
* @param taskId Unique identifier for the new task
|
||||
* @param apiProvider Optional API provider
|
||||
*/
|
||||
public captureTaskCreated(taskId: string, apiProvider?: string) {
|
||||
public captureTaskCreated(taskId: string, ulid: string, apiProvider?: string) {
|
||||
this.capture({
|
||||
event: TelemetryService.EVENTS.TASK.CREATED,
|
||||
properties: { taskId, apiProvider },
|
||||
properties: { taskId, ulid, apiProvider },
|
||||
})
|
||||
}
|
||||
|
||||
@@ -193,10 +193,10 @@ export class TelemetryService {
|
||||
* @param taskId Unique identifier for the new task
|
||||
* @param apiProvider Optional API provider
|
||||
*/
|
||||
public captureTaskRestarted(taskId: string, apiProvider?: string) {
|
||||
public captureTaskRestarted(taskId: string, ulid: string, apiProvider?: string) {
|
||||
this.capture({
|
||||
event: TelemetryService.EVENTS.TASK.RESTARTED,
|
||||
properties: { taskId, apiProvider },
|
||||
properties: { taskId, ulid, apiProvider },
|
||||
})
|
||||
}
|
||||
|
||||
@@ -204,10 +204,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) {
|
||||
public captureTaskCompleted(taskId: string, ulid: string) {
|
||||
this.capture({
|
||||
event: TelemetryService.EVENTS.TASK.COMPLETED,
|
||||
properties: { taskId },
|
||||
properties: { taskId, ulid },
|
||||
})
|
||||
}
|
||||
|
||||
@@ -221,6 +221,7 @@ export class TelemetryService {
|
||||
*/
|
||||
public captureConversationTurnEvent(
|
||||
taskId: string,
|
||||
ulid: string,
|
||||
provider: string = "unknown",
|
||||
model: string = "unknown",
|
||||
source: "user" | "assistant",
|
||||
@@ -233,13 +234,14 @@ export class TelemetryService {
|
||||
} = {},
|
||||
) {
|
||||
// Ensure required parameters are provided
|
||||
if (!taskId || !provider || !model || !source) {
|
||||
if (!taskId || !ulid || !provider || !model || !source) {
|
||||
console.warn("TelemetryService: Missing required parameters for message capture")
|
||||
return
|
||||
}
|
||||
|
||||
const properties: Record<string, unknown> = {
|
||||
taskId,
|
||||
ulid,
|
||||
provider,
|
||||
model,
|
||||
source,
|
||||
@@ -598,6 +600,7 @@ export class TelemetryService {
|
||||
*/
|
||||
public captureProviderApiError(args: {
|
||||
taskId: string
|
||||
ulid: string
|
||||
model: string
|
||||
errorMessage: string
|
||||
errorStatus?: number | undefined
|
||||
|
||||
@@ -5,6 +5,9 @@ import * as childProcess from "child_process"
|
||||
import * as readline from "readline"
|
||||
import { getBinPath } from "../ripgrep"
|
||||
import type { Fzf, FzfResultItem } from "fzf"
|
||||
import { HostProvider } from "@/hosts/host-provider"
|
||||
import { GetOpenTabsRequest } from "@/shared/proto/host/window"
|
||||
import { isLocatedInWorkspace, asRelativePath } from "@/utils/path"
|
||||
|
||||
// Wrapper function for childProcess.spawn
|
||||
export type SpawnFunction = typeof childProcess.spawn
|
||||
@@ -90,10 +93,18 @@ export async function executeRipgrepForFiles(
|
||||
})
|
||||
}
|
||||
|
||||
// Get currently active/open files from VSCode tabs using hostbridge
|
||||
async function getActiveFiles(): Promise<Set<string>> {
|
||||
const request = GetOpenTabsRequest.create({})
|
||||
const response = await HostProvider.window.getOpenTabs(request)
|
||||
return new Set(response.paths)
|
||||
}
|
||||
|
||||
export async function searchWorkspaceFiles(
|
||||
query: string,
|
||||
workspacePath: string,
|
||||
limit: number = 20,
|
||||
selectedType?: "file" | "folder",
|
||||
): Promise<{ path: string; type: "file" | "folder"; label?: string }[]> {
|
||||
try {
|
||||
const rgPath = await getBinPath(vscode.env.appRoot)
|
||||
@@ -102,34 +113,54 @@ export async function searchWorkspaceFiles(
|
||||
throw new Error("Could not find ripgrep binary")
|
||||
}
|
||||
|
||||
// Get currently active files and convert to search format
|
||||
const activeFilePaths = await getActiveFiles()
|
||||
const activeFiles: { path: string; type: "file" | "folder"; label?: string }[] = []
|
||||
|
||||
for (const filePath of activeFilePaths) {
|
||||
if (await isLocatedInWorkspace(filePath)) {
|
||||
const relativePath = await asRelativePath(filePath)
|
||||
const normalizedPath = relativePath.toPosix()
|
||||
activeFiles.push({
|
||||
path: normalizedPath,
|
||||
type: "file",
|
||||
label: path.basename(normalizedPath),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Get all files and directories
|
||||
const allItems = await executeRipgrepForFiles(rgPath, workspacePath, 5000)
|
||||
|
||||
// If no query, just return the top items
|
||||
// Combine active files with all items, removing duplicates (like the old WorkspaceTracker)
|
||||
const combinedItems = [...activeFiles]
|
||||
for (const item of allItems) {
|
||||
if (!activeFiles.some((activeFile) => activeFile.path === item.path)) {
|
||||
combinedItems.push(item)
|
||||
}
|
||||
}
|
||||
|
||||
// If no query, return the combined items
|
||||
if (!query.trim()) {
|
||||
return allItems.slice(0, limit)
|
||||
if (selectedType === "file") {
|
||||
return combinedItems.filter((item) => item.type === "file").slice(0, limit)
|
||||
} else if (selectedType === "folder") {
|
||||
return combinedItems.filter((item) => item.type === "folder").slice(0, limit)
|
||||
}
|
||||
return combinedItems.slice(0, limit)
|
||||
}
|
||||
|
||||
// Match Scoring - Prioritize the label (filename) by including it twice in the search string
|
||||
// Use multiple tiebreakers in order of importance: Match score, then length of match (shorter=better)
|
||||
// Get more (2x) results than needed for filtering, we pick the top half after sorting
|
||||
const fzfModule = await import("fzf")
|
||||
const fzf = new fzfModule.Fzf(allItems, {
|
||||
const fzf = new fzfModule.Fzf(combinedItems, {
|
||||
selector: (item: { label?: string; path: string }) => `${item.label || ""} ${item.label || ""} ${item.path}`,
|
||||
tiebreakers: [OrderbyMatchScore, fzfModule.byLengthAsc],
|
||||
limit: limit * 2,
|
||||
})
|
||||
|
||||
// The min threshold value will require some testing and tuning as the scores are exponential, and exaggerated
|
||||
const MIN_SCORE_THRESHOLD = 100
|
||||
|
||||
// Filter results by score and map to original items
|
||||
// Use exponential scaling for normalization
|
||||
// This gives a more dramatic difference between good and bad matches
|
||||
const filteredResults = fzf
|
||||
.find(query)
|
||||
.filter(({ score }: { score: number }) => Math.exp(score / 20) >= MIN_SCORE_THRESHOLD)
|
||||
.slice(0, limit)
|
||||
const filteredResults = fzf.find(query).slice(0, limit)
|
||||
|
||||
// Verify if the path exists and is actually a directory
|
||||
const verifiedResultsPromises = filteredResults.map(
|
||||
|
||||
+23
-222
@@ -1,26 +1,23 @@
|
||||
import * as http from "http"
|
||||
import * as vscode from "vscode"
|
||||
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"
|
||||
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"
|
||||
|
||||
/**
|
||||
* 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(webviewProvider: WebviewProvider): {
|
||||
function createToolCallTracker(): {
|
||||
toolCalls: Record<string, number>
|
||||
toolFailures: Record<string, number>
|
||||
} {
|
||||
@@ -28,36 +25,6 @@ function createToolCallTracker(webviewProvider: WebviewProvider): {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -72,24 +39,15 @@ 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 provider The webview provider instance
|
||||
* @param controller The webview provider instance
|
||||
*/
|
||||
async function updateAutoApprovalSettings(context: vscode.ExtensionContext, provider?: WebviewProvider) {
|
||||
async function updateAutoApprovalSettings(context: vscode.ExtensionContext, controller?: Controller) {
|
||||
try {
|
||||
const { autoApprovalSettings } = await getAllExtensionState(context)
|
||||
|
||||
@@ -114,8 +72,8 @@ async function updateAutoApprovalSettings(context: vscode.ExtensionContext, prov
|
||||
Logger.log("Auto approval settings updated for test mode")
|
||||
|
||||
// Update the webview with the new state
|
||||
if (provider?.controller) {
|
||||
await provider.controller.postStateToWebview()
|
||||
if (controller) {
|
||||
await controller.postStateToWebview()
|
||||
}
|
||||
} catch (error) {
|
||||
Logger.log(`Error updating auto approval settings: ${error}`)
|
||||
@@ -127,7 +85,7 @@ async function updateAutoApprovalSettings(context: vscode.ExtensionContext, prov
|
||||
* @param webviewProvider The webview provider instance to use for message catching
|
||||
* @returns The created HTTP server instance
|
||||
*/
|
||||
export function createTestServer(webviewProvider?: WebviewProvider): http.Server {
|
||||
export function createTestServer(controller: Controller): http.Server {
|
||||
// Try to show the Cline sidebar
|
||||
Logger.log("[createTestServer] Opening Cline in sidebar...")
|
||||
vscode.commands.executeCommand("workbench.view.claude-dev-ActivityBar")
|
||||
@@ -135,10 +93,9 @@ export function createTestServer(webviewProvider?: WebviewProvider): http.Server
|
||||
// Then ensure the webview is focused/loaded
|
||||
vscode.commands.executeCommand("claude-dev.SidebarProvider.focus")
|
||||
|
||||
// Update auto approval settings if webviewProvider is available
|
||||
if (webviewProvider?.controller?.context) {
|
||||
updateAutoApprovalSettings(webviewProvider.controller.context, webviewProvider)
|
||||
}
|
||||
// Update auto approval settings is available
|
||||
updateAutoApprovalSettings(controller.context, controller)
|
||||
|
||||
const PORT = 9876
|
||||
|
||||
testServer = http.createServer((req, res) => {
|
||||
@@ -286,7 +243,7 @@ export function createTestServer(webviewProvider?: WebviewProvider): http.Server
|
||||
}
|
||||
|
||||
// Initialize tool call tracker
|
||||
const toolTracker = createToolCallTracker(visibleWebview)
|
||||
const toolTracker = createToolCallTracker()
|
||||
|
||||
// Record task start time
|
||||
const taskStartTime = Date.now()
|
||||
@@ -478,165 +435,9 @@ export function createTestServer(webviewProvider?: WebviewProvider): 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
|
||||
*/
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export type HistoryItem = {
|
||||
id: string
|
||||
ulid?: string // ULID for better tracking and metrics
|
||||
ts: number
|
||||
task: string
|
||||
tokensIn: number
|
||||
|
||||
+75
-4
@@ -62,6 +62,7 @@ export interface ApiHandlerOptions {
|
||||
openAiBaseUrl?: string
|
||||
openAiApiKey?: string
|
||||
ollamaBaseUrl?: string
|
||||
ollamaApiKey?: string
|
||||
ollamaApiOptionsCtxNum?: string
|
||||
lmStudioBaseUrl?: string
|
||||
geminiApiKey?: string
|
||||
@@ -508,6 +509,16 @@ export const vertexModels = {
|
||||
cacheWritesPrice: 3.75,
|
||||
cacheReadsPrice: 0.3,
|
||||
},
|
||||
"claude-opus-4-1@20250805": {
|
||||
maxTokens: 8192,
|
||||
contextWindow: 200_000,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: true,
|
||||
inputPrice: 15.0,
|
||||
outputPrice: 75.0,
|
||||
cacheWritesPrice: 18.75,
|
||||
cacheReadsPrice: 1.5,
|
||||
},
|
||||
"claude-opus-4@20250514": {
|
||||
maxTokens: 8192,
|
||||
contextWindow: 200_000,
|
||||
@@ -991,8 +1002,44 @@ export const geminiModels = {
|
||||
// OpenAI Native
|
||||
// https://openai.com/api/pricing/
|
||||
export type OpenAiNativeModelId = keyof typeof openAiNativeModels
|
||||
export const openAiNativeDefaultModelId: OpenAiNativeModelId = "gpt-4.1"
|
||||
export const openAiNativeDefaultModelId: OpenAiNativeModelId = "gpt-5-2025-08-07"
|
||||
export const openAiNativeModels = {
|
||||
"gpt-5-2025-08-07": {
|
||||
maxTokens: 128000,
|
||||
contextWindow: 272000,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: true,
|
||||
inputPrice: 1.25,
|
||||
outputPrice: 10.0,
|
||||
cacheReadsPrice: 0.125,
|
||||
},
|
||||
"gpt-5-mini-2025-08-07": {
|
||||
maxTokens: 128000,
|
||||
contextWindow: 272000,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: true,
|
||||
inputPrice: 0.25,
|
||||
outputPrice: 2.0,
|
||||
cacheReadsPrice: 0.025,
|
||||
},
|
||||
"gpt-5-nano-2025-08-07": {
|
||||
maxTokens: 128000,
|
||||
contextWindow: 272000,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: true,
|
||||
inputPrice: 0.05,
|
||||
outputPrice: 0.4,
|
||||
cacheReadsPrice: 0.005,
|
||||
},
|
||||
"nectarine-alpha-new-reasoning-effort-2025-07-25": {
|
||||
maxTokens: 128000,
|
||||
contextWindow: 256000,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: true,
|
||||
inputPrice: 0,
|
||||
outputPrice: 0,
|
||||
cacheReadsPrice: 0,
|
||||
},
|
||||
o3: {
|
||||
maxTokens: 100_000,
|
||||
contextWindow: 200_000,
|
||||
@@ -2735,21 +2782,21 @@ export const sapAiCoreModels = {
|
||||
maxTokens: 8192,
|
||||
contextWindow: 200_000,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: false,
|
||||
supportsPromptCache: true,
|
||||
description: sapAiCoreModelDescription,
|
||||
},
|
||||
"anthropic--claude-4-opus": {
|
||||
maxTokens: 8192,
|
||||
contextWindow: 200_000,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: false,
|
||||
supportsPromptCache: true,
|
||||
description: sapAiCoreModelDescription,
|
||||
},
|
||||
"anthropic--claude-3.7-sonnet": {
|
||||
maxTokens: 64_000,
|
||||
contextWindow: 200_000,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: false,
|
||||
supportsPromptCache: true,
|
||||
description: sapAiCoreModelDescription,
|
||||
},
|
||||
"anthropic--claude-3.5-sonnet": {
|
||||
@@ -2785,6 +2832,9 @@ export const sapAiCoreModels = {
|
||||
contextWindow: 1_048_576,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: true,
|
||||
thinkingConfig: {
|
||||
maxBudget: 32767,
|
||||
},
|
||||
description: sapAiCoreModelDescription,
|
||||
},
|
||||
"gemini-2.5-flash": {
|
||||
@@ -2832,6 +2882,27 @@ export const sapAiCoreModels = {
|
||||
supportsPromptCache: true,
|
||||
description: sapAiCoreModelDescription,
|
||||
},
|
||||
"gpt-5": {
|
||||
maxTokens: 128_000,
|
||||
contextWindow: 272_000,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: true,
|
||||
description: sapAiCoreModelDescription,
|
||||
},
|
||||
"gpt-5-nano": {
|
||||
maxTokens: 128_000,
|
||||
contextWindow: 272_000,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: true,
|
||||
description: sapAiCoreModelDescription,
|
||||
},
|
||||
"gpt-5-mini": {
|
||||
maxTokens: 128_000,
|
||||
contextWindow: 272_000,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: true,
|
||||
description: sapAiCoreModelDescription,
|
||||
},
|
||||
o1: {
|
||||
maxTokens: 4096,
|
||||
contextWindow: 200_000,
|
||||
|
||||
@@ -354,6 +354,7 @@ export function convertApiConfigurationToProto(config: ApiConfiguration): ProtoA
|
||||
openAiBaseUrl: config.openAiBaseUrl,
|
||||
openAiApiKey: config.openAiApiKey,
|
||||
ollamaBaseUrl: config.ollamaBaseUrl,
|
||||
ollamaApiKey: config.ollamaApiKey,
|
||||
ollamaApiOptionsCtxNum: config.ollamaApiOptionsCtxNum,
|
||||
lmStudioBaseUrl: config.lmStudioBaseUrl,
|
||||
geminiApiKey: config.geminiApiKey,
|
||||
@@ -485,6 +486,7 @@ export function convertProtoToApiConfiguration(protoConfig: ProtoApiConfiguratio
|
||||
openAiBaseUrl: protoConfig.openAiBaseUrl,
|
||||
openAiApiKey: protoConfig.openAiApiKey,
|
||||
ollamaBaseUrl: protoConfig.ollamaBaseUrl,
|
||||
ollamaApiKey: protoConfig.ollamaApiKey,
|
||||
ollamaApiOptionsCtxNum: protoConfig.ollamaApiOptionsCtxNum,
|
||||
lmStudioBaseUrl: protoConfig.lmStudioBaseUrl,
|
||||
geminiApiKey: protoConfig.geminiApiKey,
|
||||
|
||||
@@ -34,6 +34,7 @@ export function convertApiConfigurationToProtoApiConfiguration(config: ApiConfig
|
||||
openaiBaseUrl: config.openAiBaseUrl,
|
||||
openaiApiKey: config.openAiApiKey,
|
||||
ollamaBaseUrl: config.ollamaBaseUrl,
|
||||
ollamaApiKey: config.ollamaApiKey,
|
||||
ollamaApiOptionsCtxNum: config.ollamaApiOptionsCtxNum,
|
||||
lmStudioBaseUrl: config.lmStudioBaseUrl,
|
||||
geminiApiKey: config.geminiApiKey,
|
||||
@@ -159,6 +160,7 @@ export function convertProtoApiConfigurationToApiConfiguration(protoConfig: Prot
|
||||
openAiBaseUrl: protoConfig.openaiBaseUrl,
|
||||
openAiApiKey: protoConfig.openaiApiKey,
|
||||
ollamaBaseUrl: protoConfig.ollamaBaseUrl,
|
||||
ollamaApiKey: protoConfig.ollamaApiKey,
|
||||
ollamaApiOptionsCtxNum: protoConfig.ollamaApiOptionsCtxNum,
|
||||
lmStudioBaseUrl: protoConfig.lmStudioBaseUrl,
|
||||
geminiApiKey: protoConfig.geminiApiKey,
|
||||
|
||||
@@ -1,35 +1,29 @@
|
||||
import { activate } from "@/extension"
|
||||
import { Controller } from "@core/controller"
|
||||
import { CacheService } from "@core/storage/CacheService"
|
||||
import { initialize, tearDown } from "@/common"
|
||||
import { WebviewProvider } from "@/core/webview"
|
||||
import { AuthHandler } from "@/hosts/external/AuthHandler"
|
||||
import { HostProvider } from "@/hosts/host-provider"
|
||||
import { DiffViewProvider } from "@/integrations/editor/DiffViewProvider"
|
||||
import { ExternalDiffViewProvider } from "@hosts/external/ExternalDiffviewProvider"
|
||||
import { ExternalWebviewProvider } from "@hosts/external/ExternalWebviewProvider"
|
||||
import { ExternalHostBridgeClientManager } from "@hosts/external/host-bridge-client-manager"
|
||||
import { HostProvider } from "@/hosts/host-provider"
|
||||
import { WebviewProviderType } from "@shared/webview/types"
|
||||
import { v4 as uuidv4 } from "uuid"
|
||||
import { log } from "./utils"
|
||||
import { extensionContext, postMessage } from "./vscode-context"
|
||||
import { startProtobusService } from "./protobus-service"
|
||||
import { AuthHandler } from "@/hosts/external/AuthHandler"
|
||||
import { WebviewProvider } from "@/core/webview"
|
||||
import { DiffViewProvider } from "@/integrations/editor/DiffViewProvider"
|
||||
import { log } from "./utils"
|
||||
import { extensionContext } from "./vscode-context"
|
||||
|
||||
async function main() {
|
||||
log("\n\n\nStarting cline-core service...\n\n\n")
|
||||
|
||||
AuthHandler.getInstance().setEnabled(true)
|
||||
|
||||
setupHostProvider()
|
||||
|
||||
// Set up global error handlers to prevent process crashes
|
||||
setupGlobalErrorHandlers()
|
||||
|
||||
activate(extensionContext)
|
||||
// Create and initialize cache service
|
||||
const webviewProvider = await initialize(extensionContext)
|
||||
|
||||
// Create controller with cache service
|
||||
const controller = new Controller(extensionContext, postMessage, uuidv4())
|
||||
startProtobusService(controller)
|
||||
AuthHandler.getInstance().setEnabled(true)
|
||||
|
||||
startProtobusService(webviewProvider.controller)
|
||||
}
|
||||
|
||||
function setupHostProvider() {
|
||||
@@ -80,6 +74,8 @@ function setupGlobalErrorHandlers() {
|
||||
|
||||
process.on("SIGTERM", () => {
|
||||
log("Received SIGTERM, shutting down gracefully...")
|
||||
tearDown()
|
||||
|
||||
process.exit(0)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
// @ts-nocheck
|
||||
import * as vscode from "vscode"
|
||||
|
||||
import { log } from "./utils"
|
||||
|
||||
function postMessage(message: ExtensionMessage): Promise<boolean> {
|
||||
log("postMessage stub called:", JSON.stringify(message).slice(0, 200))
|
||||
return Promise.resolve(true)
|
||||
}
|
||||
|
||||
export { postMessage }
|
||||
@@ -5,7 +5,6 @@ import path, { join } from "path"
|
||||
import type { Extension, ExtensionContext } from "vscode"
|
||||
import { ExtensionKind, ExtensionMode } from "vscode"
|
||||
import { log } from "./utils"
|
||||
import { postMessage } from "./vscode-context-stubs"
|
||||
import { EnvironmentVariableCollection, MementoStore, readJson, SecretStore } from "./vscode-context-utils"
|
||||
|
||||
const VERSION = getPackageVersion()
|
||||
@@ -13,10 +12,11 @@ log("Running standalone cline ", VERSION)
|
||||
|
||||
const CLINE_DIR = process.env.CLINE_DIR || `${os.homedir()}/.cline`
|
||||
const DATA_DIR = path.join(CLINE_DIR, "data")
|
||||
const INSTALL_DIR = process.env.INSTALL_DIR || path.join(CLINE_DIR, "core", VERSION)
|
||||
mkdirSync(DATA_DIR, { recursive: true })
|
||||
log("Using settings dir:", DATA_DIR)
|
||||
|
||||
const EXTENSION_DIR = path.join(CLINE_DIR, "core", VERSION, "extension")
|
||||
const EXTENSION_DIR = path.join(INSTALL_DIR, "extension")
|
||||
const EXTENSION_MODE = process.env.IS_DEV === "true" ? ExtensionMode.Development : ExtensionMode.Production
|
||||
|
||||
const extension: Extension<void> = {
|
||||
@@ -66,4 +66,4 @@ function getPackageVersion(): string {
|
||||
|
||||
console.log("Finished loading vscode context...")
|
||||
|
||||
export { extensionContext, postMessage }
|
||||
export { extensionContext }
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { expect } from "@playwright/test"
|
||||
import { addSelectedCodeToClineWebview, getClineEditorWebviewFrame, openTab, toggleNotifications } from "./utils/common"
|
||||
import { e2e } from "./utils/helpers"
|
||||
|
||||
e2e("code actions and editor panel", async ({ page, sidebar }) => {
|
||||
await toggleNotifications(page)
|
||||
|
||||
await sidebar.getByRole("button", { name: "Get Started for Free" }).click({ delay: 100 })
|
||||
|
||||
// Verify the help improve banner is visible and can be closed.
|
||||
await sidebar.getByRole("button", { name: "Close banner and enable" }).click()
|
||||
|
||||
// Verify the release banner is visible for new installs and can be closed.
|
||||
await sidebar.getByTestId("close-button").locator("span").first().click()
|
||||
|
||||
// Sidebar - input should start empty
|
||||
const sidebarInput = sidebar.getByTestId("chat-input")
|
||||
await expect(sidebarInput).toBeEmpty()
|
||||
|
||||
// Open file tree and select code from file
|
||||
await openTab(page, "Explorer ")
|
||||
await page.getByRole("treeitem", { name: "index.html" }).locator("a").click()
|
||||
await expect(sidebarInput).not.toBeFocused()
|
||||
|
||||
// Sidebar should be opened and visible after adding code to Cline
|
||||
await addSelectedCodeToClineWebview(page)
|
||||
await expect(sidebarInput).not.toBeEmpty()
|
||||
await expect(sidebarInput).toBeFocused()
|
||||
|
||||
await page.getByRole("button", { name: "Open in Editor" }).click()
|
||||
await page.waitForLoadState("load")
|
||||
const clineEditorTab = page.getByRole("tab", { name: "Cline, Editor Group" })
|
||||
await expect(clineEditorTab).toBeVisible()
|
||||
|
||||
// Editor Panel
|
||||
const clineEditorWebview = await getClineEditorWebviewFrame(page)
|
||||
|
||||
await clineEditorWebview.getByTestId("chat-input").click()
|
||||
await expect(clineEditorWebview.getByTestId("chat-input")).toBeEmpty()
|
||||
await addSelectedCodeToClineWebview(page)
|
||||
await expect(clineEditorWebview.getByTestId("chat-input")).not.toBeEmpty()
|
||||
})
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { Page } from "@playwright/test"
|
||||
|
||||
export const openTab = async (_page: Page, tabName: string) => {
|
||||
await _page
|
||||
.getByRole("tab", { name: new RegExp(`${tabName}`) })
|
||||
.locator("a")
|
||||
.click()
|
||||
}
|
||||
|
||||
export const addSelectedCodeToClineWebview = async (_page: Page) => {
|
||||
await _page.locator("div:nth-child(4) > span > span").first().click()
|
||||
await _page.getByRole("textbox", { name: "The editor is not accessible" }).press("ControlOrMeta+a")
|
||||
|
||||
await _page.getByRole("listbox", { name: /Show Code Actions / }).click()
|
||||
await _page.keyboard.press("Enter", { delay: 100 }) // First action - "Add to Cline"
|
||||
}
|
||||
|
||||
export const getClineEditorWebviewFrame = async (_page: Page) => {
|
||||
return _page.frameLocator("iframe.webview").last().frameLocator("iframe")
|
||||
}
|
||||
|
||||
export const toggleNotifications = async (_page: Page) => {
|
||||
await _page.keyboard.press("ControlOrMeta+Shift+p")
|
||||
const editorSearchBar = _page.getByRole("textbox", { name: "Type the name of a command to" })
|
||||
await editorSearchBar.click({ delay: 100 }) // Ensure focus
|
||||
await editorSearchBar.fill("Toggle Do Not Disturb Mode")
|
||||
await _page.keyboard.press("Enter")
|
||||
}
|
||||
|
||||
export const closeBanners = async (sidebar: Page) => {
|
||||
const banners = ["Get Started for Free", "Close banner and enable"]
|
||||
|
||||
for (const banner of banners) {
|
||||
await sidebar.getByRole("button", { name: banner }).click({ delay: 100 })
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import { ClineApiServerMock } from "../fixtures/server"
|
||||
import { getResultsDir, rmForRetries } from "./helpers"
|
||||
|
||||
teardown("cleanup test environment", async () => {
|
||||
await ClineApiServerMock.stopGlobalServer()
|
||||
ClineApiServerMock.stopGlobalServer()
|
||||
.then(() => console.log("ClineApiServerMock stopped successfully."))
|
||||
.catch((error) => console.error("Error stopping ClineApiServerMock:", error))
|
||||
|
||||
|
||||
@@ -20,10 +20,6 @@ export class E2ETestHelper {
|
||||
// Instance properties for caching
|
||||
private cachedFrame: Frame | null = null
|
||||
|
||||
constructor() {
|
||||
// Initialize any instance-specific state if needed
|
||||
}
|
||||
|
||||
// Path utilities
|
||||
public static escapeToPath(text: string): string {
|
||||
return text.trim().toLowerCase().replaceAll(/\W/g, "_")
|
||||
|
||||
@@ -20,6 +20,16 @@ export function isGrok4ModelFamily(api: ApiHandler): boolean {
|
||||
return modelId.includes("grok-4")
|
||||
}
|
||||
|
||||
export function isGPT5ModelFamily(api: ApiHandler): boolean {
|
||||
const model = api.getModel()
|
||||
const modelId = model.id.toLowerCase()
|
||||
return modelId.includes("gpt-5") || modelId.includes("gpt5") || modelId.includes("nectarine")
|
||||
}
|
||||
|
||||
export function isNextGenModelFamily(api: ApiHandler): boolean {
|
||||
return isClaude4ModelFamily(api) || isGemini2dot5ModelFamily(api) || isGrok4ModelFamily(api) || isGPT5ModelFamily(api)
|
||||
}
|
||||
|
||||
export function modelDoesntSupportWebp(api: ApiHandler): boolean {
|
||||
const model = api.getModel()
|
||||
const modelId = model.id.toLowerCase()
|
||||
|
||||
@@ -46,23 +46,16 @@ const Announcement = ({ version, hideAnnouncement }: AnnouncementProps) => {
|
||||
</h3>
|
||||
<ul style={ulStyle}>
|
||||
<li>
|
||||
<b>Cerebras Provider Support:</b> Enhanced performance with updated model selection (Qwen and Llama 3.3 70B
|
||||
only) and increased context window for Qwen 3 32B from 16K to 64K tokens.
|
||||
<b>GPT-5 Model Support:</b> Added support for the new GPT-5 model family including GPT-5, GPT-5 Mini, and
|
||||
GPT-5 Nano with prompt caching support. GPT-5 is now the default model for new users.
|
||||
</li>
|
||||
<li>
|
||||
<b>Claude Code for Windows:</b> Improved system prompt handling to fix E2BIG errors and better error messages
|
||||
with guidance for common setup issues.
|
||||
<b>Improved Onboarding:</b> New users now see a "Take a Tour" button that opens the VSCode walkthrough to help
|
||||
them get started with Cline more easily.
|
||||
</li>
|
||||
<li>
|
||||
<b>Hugging Face Provider:</b> Added as a new API provider with support for their inference API models.
|
||||
</li>
|
||||
<li>
|
||||
<b>Moonshot Chinese Endpoints:</b> Added ability to choose Chinese endpoint for Moonshot provider and added
|
||||
Moonshot AI as a new provider.
|
||||
</li>
|
||||
<li>
|
||||
<b>Enhanced Stability:</b> Robust checkpoint timeout handling, fixed MCP servers starting when disabled, and
|
||||
improved authentication sync across multiple VSCode windows.
|
||||
<b>Enhanced Plan Mode:</b> Better exploration parameter support in plan mode for more thorough planning before
|
||||
execution.
|
||||
</li>
|
||||
</ul>
|
||||
<Accordion isCompact className="pl-0">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { mentionRegex, mentionRegexGlobal } from "@shared/context-mentions"
|
||||
import { EmptyRequest, StringRequest } from "@shared/proto/cline/common"
|
||||
import { FileSearchRequest, RelativePathsRequest } from "@shared/proto/cline/file"
|
||||
import { FileSearchRequest, FileSearchType, RelativePathsRequest } from "@shared/proto/cline/file"
|
||||
import { UpdateApiConfigurationRequest } from "@shared/proto/cline/models"
|
||||
import { PlanActMode, TogglePlanActModeRequest } from "@shared/proto/cline/state"
|
||||
import { convertApiConfigurationToProto } from "@shared/proto-conversions/models/api-configuration-conversion"
|
||||
@@ -44,6 +44,7 @@ import { validateApiConfiguration, validateModelId } from "@/utils/validate"
|
||||
import ClineRulesToggleModal from "../cline-rules/ClineRulesToggleModal"
|
||||
import ServersToggleModal from "./ServersToggleModal"
|
||||
import { Mode } from "@shared/storage/types"
|
||||
import { isSafari } from "@/utils/platformUtils"
|
||||
|
||||
const { MAX_IMAGES_AND_FILES_PER_MESSAGE } = CHAT_CONSTANTS
|
||||
|
||||
@@ -92,7 +93,8 @@ interface GitCommit {
|
||||
description: string
|
||||
}
|
||||
|
||||
const PLAN_MODE_COLOR = "var(--vscode-inputValidation-warningBorder)"
|
||||
const PLAN_MODE_COLOR = "var(--vscode-activityWarningBadge-background)"
|
||||
const ACT_MODE_COLOR = "var(--vscode-focusBorder)"
|
||||
|
||||
const SwitchOption = styled.div.withConfig({
|
||||
shouldForwardProp: (prop) => !["isActive"].includes(prop),
|
||||
@@ -131,7 +133,7 @@ const Slider = styled.div.withConfig({
|
||||
position: absolute;
|
||||
height: 100%;
|
||||
width: 50%;
|
||||
background-color: ${(props) => (props.isPlan ? PLAN_MODE_COLOR : "var(--vscode-focusBorder)")};
|
||||
background-color: ${(props) => (props.isPlan ? PLAN_MODE_COLOR : ACT_MODE_COLOR)};
|
||||
transition: transform 0.2s ease;
|
||||
transform: translateX(${(props) => (props.isAct ? "100%" : "0%")});
|
||||
`
|
||||
@@ -277,7 +279,7 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const { filePaths, mode, apiConfiguration, openRouterModels, platform, localWorkflowToggles, globalWorkflowToggles } =
|
||||
const { mode, apiConfiguration, openRouterModels, platform, localWorkflowToggles, globalWorkflowToggles } =
|
||||
useExtensionState()
|
||||
const [isTextAreaFocused, setIsTextAreaFocused] = useState(false)
|
||||
const [isDraggingOver, setIsDraggingOver] = useState(false)
|
||||
@@ -351,14 +353,8 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
|
||||
{ type: ContextMenuOptionType.Problems, value: "problems" },
|
||||
{ type: ContextMenuOptionType.Terminal, value: "terminal" },
|
||||
...gitCommits,
|
||||
...filePaths
|
||||
.map((file) => "/" + file)
|
||||
.map((path) => ({
|
||||
type: path.endsWith("/") ? ContextMenuOptionType.Folder : ContextMenuOptionType.File,
|
||||
value: path,
|
||||
})),
|
||||
]
|
||||
}, [filePaths, gitCommits])
|
||||
}, [gitCommits])
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
@@ -410,6 +406,36 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
|
||||
setSelectedType(type)
|
||||
setSearchQuery("")
|
||||
setSelectedMenuIndex(0)
|
||||
|
||||
// Trigger search with the selected type
|
||||
if (type === ContextMenuOptionType.File || type === ContextMenuOptionType.Folder) {
|
||||
setSearchLoading(true)
|
||||
|
||||
// Map ContextMenuOptionType to FileSearchType enum
|
||||
let searchType = undefined
|
||||
if (type === ContextMenuOptionType.File) {
|
||||
searchType = FileSearchType.FILE
|
||||
} else if (type === ContextMenuOptionType.Folder) {
|
||||
searchType = FileSearchType.FOLDER
|
||||
}
|
||||
|
||||
FileServiceClient.searchFiles(
|
||||
FileSearchRequest.create({
|
||||
query: "",
|
||||
mentionsRequestId: "",
|
||||
selectedType: searchType,
|
||||
}),
|
||||
)
|
||||
.then((results) => {
|
||||
setFileSearchResults((results.results || []) as SearchResult[])
|
||||
setSearchLoading(false)
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Error searching files:", error)
|
||||
setFileSearchResults([])
|
||||
setSearchLoading(false)
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -567,7 +593,8 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
|
||||
}
|
||||
}
|
||||
|
||||
const isComposing = event.nativeEvent?.isComposing ?? false
|
||||
// Safari does not support InputEvent.isComposing (always false), so we need to fallback to keyCode === 229 for it
|
||||
const isComposing = isSafari ? event.nativeEvent.keyCode === 229 : (event.nativeEvent?.isComposing ?? false)
|
||||
if (event.key === "Enter" && !event.shiftKey && !isComposing) {
|
||||
event.preventDefault()
|
||||
|
||||
@@ -746,6 +773,7 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
|
||||
FileSearchRequest.create({
|
||||
query: query,
|
||||
mentionsRequestId: query,
|
||||
selectedType: undefined, // No type filter for general search
|
||||
}),
|
||||
)
|
||||
.then((results) => {
|
||||
|
||||
@@ -39,6 +39,7 @@ interface ChatViewProps {
|
||||
|
||||
// Use constants from the imported module
|
||||
const MAX_IMAGES_AND_FILES_PER_MESSAGE = CHAT_CONSTANTS.MAX_IMAGES_AND_FILES_PER_MESSAGE
|
||||
const QUICK_WINS_HISTORY_THRESHOLD = 3
|
||||
|
||||
const IS_STANDALONE = window?.__is_standalone__ ?? false
|
||||
|
||||
@@ -51,8 +52,11 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
|
||||
telemetrySetting,
|
||||
navigateToChat,
|
||||
mode,
|
||||
userInfo,
|
||||
} = useExtensionState()
|
||||
const shouldShowQuickWins = false // !taskHistory || taskHistory.length < QUICK_WINS_HISTORY_THRESHOLD
|
||||
const isProdHostedApp = userInfo?.apiBaseUrl === "https://app.cline.bot"
|
||||
const shouldShowQuickWins = isProdHostedApp && (!taskHistory || taskHistory.length < QUICK_WINS_HISTORY_THRESHOLD)
|
||||
|
||||
//const task = messages.length > 0 ? (messages[0].say === "task" ? messages[0] : undefined) : undefined) : undefined
|
||||
const task = useMemo(() => messages.at(0), [messages]) // leaving this less safe version here since if the first message is not a task, then the extension is in a bad state and needs to be debugged (see Cline.abort)
|
||||
const modifiedMessages = useMemo(() => combineApiRequests(combineCommandSequences(messages.slice(1))), [messages])
|
||||
@@ -259,7 +263,14 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
|
||||
|
||||
// Set up addToInput subscription
|
||||
useEffect(() => {
|
||||
const cleanup = UiServiceClient.subscribeToAddToInput(EmptyRequest.create({}), {
|
||||
const clientId = (window as { clineClientId?: string }).clineClientId
|
||||
if (!clientId) {
|
||||
console.error("Client ID not found in window object for addToInput subscription")
|
||||
return
|
||||
}
|
||||
|
||||
const request = StringRequest.create({ value: clientId })
|
||||
const cleanup = UiServiceClient.subscribeToAddToInput(request, {
|
||||
onResponse: (event) => {
|
||||
if (event.value) {
|
||||
setInputValue((prevValue) => {
|
||||
|
||||
@@ -6,23 +6,30 @@ import { VSCodeButton } from "@vscode/webview-ui-toolkit/react"
|
||||
import { useExtensionState } from "@/context/ExtensionStateContext"
|
||||
|
||||
import React from "react"
|
||||
import { useClineAuth } from "@/context/ClineAuthContext"
|
||||
|
||||
interface CreditLimitErrorProps {
|
||||
currentBalance: number
|
||||
totalSpent?: number
|
||||
totalPromotions?: number
|
||||
message: string
|
||||
buyCreditsUrl?: string
|
||||
// buyCreditsUrl?: string
|
||||
}
|
||||
|
||||
const CreditLimitError: React.FC<CreditLimitErrorProps> = ({
|
||||
currentBalance = 0,
|
||||
totalSpent = 0,
|
||||
totalPromotions = 0,
|
||||
message = "You have run out of credit.",
|
||||
buyCreditsUrl = "https://app.cline.bot/dashboard/account?tab=credits&redirect=true",
|
||||
message = "You have run out of credits.",
|
||||
// buyCreditsUrl = "https://app.cline.bot/dashboard/account?tab=credits&redirect=true",
|
||||
}) => {
|
||||
const { uriScheme } = useExtensionState()
|
||||
const { activeOrganization } = useClineAuth()
|
||||
|
||||
const isPersonal = !activeOrganization?.organizationId
|
||||
const buyCreditsUrl = isPersonal
|
||||
? "https://app.cline.bot/dashboard/account?tab=credits&redirect=true"
|
||||
: "https://app.cline.bot/dashboard/organization?tab=credits&redirect=true"
|
||||
|
||||
const callbackUrl = `${uriScheme || "vscode"}://saoudrizwan.claude-dev`
|
||||
const fullPurchaseUrl = new URL(buyCreditsUrl)
|
||||
@@ -33,13 +40,13 @@ const CreditLimitError: React.FC<CreditLimitErrorProps> = ({
|
||||
<div className="p-2 border-none rounded-md mb-2 bg-[var(--vscode-textBlockQuote-background)]">
|
||||
<div className="mb-3 font-azeret-mono">
|
||||
<div style={{ color: "var(--vscode-errorForeground)", marginBottom: "8px" }}>{message}</div>
|
||||
<div style={{ marginBottom: "12px" }}>
|
||||
{/* <div style={{ marginBottom: "12px" }}>
|
||||
<div style={{ color: "var(--vscode-foreground)" }}>
|
||||
Current Balance: <span style={{ fontWeight: "bold" }}>{currentBalance.toFixed(2)}</span>
|
||||
</div>
|
||||
<div style={{ color: "var(--vscode-foreground)" }}>Total Spent: {totalSpent.toFixed(2)}</div>
|
||||
<div style={{ color: "var(--vscode-foreground)" }}>Total Promotions: {totalPromotions.toFixed(2)}</div>
|
||||
</div>
|
||||
</div> */}
|
||||
</div>
|
||||
|
||||
<VSCodeButtonLink
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user