Compare commits

...
Author SHA1 Message Date
abeatrix c1651c5233 revert refactor 2025-08-11 20:01:47 -07:00
abeatrix 028fe70019 fix imports 2025-08-11 18:08:02 -07:00
abeatrix 41c5e809bd Improve type safety and error handling for ClineHandler
- Add explicit TypeScript types for OpenRouter interfaces
- Reorganize imports with proper type annotations
- Remove unused counter property and improve method naming
- Improve error handling and code readability
2025-08-11 15:01:20 -07:00
Sarah Fortune 2aa5156905 Update ExternalDiffViewProvider to return diagnostics before/after diff (#5500)
The diff view is supposed to return any new errors or warnings after the
file is edited. The ExternalDiffViewProvider was just returning *all*
the errors.

When the DiffViewProvider is being reset, reset *all* the properties.

Add unit tests for diagnostics functionality

Refactoring:
- Move diagnostics into the parent DiffViewProvider, remove duplicate implementations in VscodeDiffViewProvider and ExternalDiffViewProvider
- Move duplicated code for converting FileDiagnostics to string to `diagnosticsToProblemsString`.
- Use a single implementation of `getDiagnostics` and `diagnosticsToProblemsString` using the HostBridge protos.
2025-08-11 19:54:41 +01:00
celestial-vault 51b619e0d5 remove workspace tracker (#5346)
* remove workspace tracker

* remove console log

* fix search when clicking folder option

* create enum for searchType

* use hostbridge for active files

* use util function for relative path

* Fix into interests error where false security warning is being triggered
2025-08-11 09:51:47 -07:00
Sarah Fortune 85fb76a996 Call teardown() when cline-core is stopped. (#5496) 2025-08-11 17:29:27 +01:00
Dennise Bartlett d73a7cfd06 Fix package-lock version and update CODEOWNERS (#5490) 2025-08-11 01:43:08 -07:00
Ara 489dfbc932 Fixing Read of Workspace root by index.ts (#5482)
* Fixing Read of Workspace root by index.ts

* Fixing Read of Workspace root by index.ts
2025-08-09 20:50:50 -07:00
Bee 314c416788 remove PostHog exception autocapture (#5481)
Remove enableExceptionAutocapture option from PostHog client configuration.
2025-08-09 16:49:07 -07:00
Igor Tceglevskii 3b19c2ec95 Click from a file name in chat to editor (#5422) 2025-08-09 15:48:12 -07:00
Sarah Fortune e04cbea504 Add an endpoint to the HostBridge for integration testing (#5476)
Add an RPC to the HostBridge that returns the contents of the
webview, for use in integration tests.
2025-08-09 23:14:29 +01:00
Toshii affac119f5 stop double counting tokens (#5426)
* updated

* cleanup
2025-08-09 13:05:36 -07:00
Sarah Fortune 3847a2545c Test is still flaking, increase the timeout (#5473) 2025-08-09 16:25:02 +01:00
Sarah Fortune 15593bac2a Use the active webview in ProtoBus getWebviewHtml. (#5444) 2025-08-09 16:24:46 +01:00
Sarah Fortune 84267efb9e Don't use activate() in cline-core (#5448)
* Don't use activate() in cline-core

Have separate code paths to set up the extension and cline-core.

This means the cline-core is not running all the vscode setup and is
only using one `Controller` (the one from the WebviewProvider).

Move the shared logic into common.ts.

* Comments and logging
2025-08-09 14:49:23 +01:00
985ce56809 v3.23.0 Release Notes (#5466)
* changeset version bump

* Updating CHANGELOG.md format

* Update CHANGELOG.md

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2025-08-09 02:16:07 -07:00
xiongxiong cad28c4c0c fix: calibrate input token when using anthropic models of sap ai core provider (#5469) 2025-08-09 02:13:01 -07:00
Saoud Rizwan 4a22f7dbd2 Fix plan/act hover color and act mode color (#5470)
* Fix plan/act hover color and act mode color

* Revert plan color change
2025-08-09 02:10:49 -07:00
Saoud Rizwan a430226caa Revert unnecessary terminal command issue workarounds (#5463)
* Revert terminal process logic to pre-capture where we didn't use grace periods or fast command workarounds

* Create empty-pears-fail.md
2025-08-09 02:09:40 -07:00
Bee 5885a3cc1d improve mode switch background color (#5467)
* fix: mode switch styling

Replace the use of `--vscode-toolbar-hoverBackground` which is a `-hoverBackground` that tends to be transperant or opacity change on some themes. Replace it with `-background` which uses solid color instead. See https://code.visualstudio.com/api/references/theme-color

- Update Plan/Act mode switch colors var for better visibility across themes
- Remove hover effects from switch options
- Add background classes to active switch options

* changeset added
2025-08-09 00:30:07 -07:00
Oliver Schirmer 759ef873ae Feat: Prompt Caching in SAP AI Core (#5399)
* add: caching support for bedrock (claude)

* refactor: gemini message handling to adhere closer to original implementation (and make implicit caching clear)

* remove: unused bedrock conversion functions

* fix: payload for converse stream (older claude models)
remove: caching support flag for older claude models

* add: changeset

* Update package-lock.json
2025-08-09 00:12:26 -07:00
Saoud Rizwan 782e4ff6e0 Fix credit error tests (#5465)
* Fix credit error tests

* Create odd-tables-pump.md
2025-08-08 21:17:33 -07:00
4bb00241bf v3.22.0 Release Notes (#5424)
* changeset version bump

* Updating CHANGELOG.md format

* Update CHANGELOG.md

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2025-08-08 20:47:54 -07:00
Saoud Rizwan c325faf8db Fix bug where running out of credits on cline accounts would show '402 empty body' response instead of 'buy credits' component (#5464)
* fix: show credits purchase component when user runs out of credits and we receive 402 status from server

* revert unnecessary change

* Create many-adults-end.md
2025-08-08 20:43:43 -07:00
Igor Tceglevskii 20f8f9c9cf Request for requesting a current PR number for pr_review workflow (#5406) 2025-08-08 13:14:50 -07:00
Yechao LIand0xtoshii 5be163f49d Fix safari does not support isComposing of input event (#4118)
* fix: safari does not support isComposing of input event

* formatting

---------

Co-authored-by: 0xtoshii <94262432+0xToshii@users.noreply.github.com>
2025-08-08 12:28:46 -07:00
Kevin Taylor 7843ab937a Add cerebras rate limit handling (#5408)
* Update cerebras.ts

* Create friendly-geckos-accept.md
2025-08-08 12:28:34 -07:00
tjandy98 5ed4319d21 Add support for GPT-5 models to SAP AI Core Provider (#5428)
* add gpt-5 models

Signed-off-by: tjandy98 <3953059+tjandy98@users.noreply.github.com>

* add changeset

* remove max_tokens & temperature

Signed-off-by: tjandy98 <3953059+tjandy98@users.noreply.github.com>

---------

Signed-off-by: tjandy98 <3953059+tjandy98@users.noreply.github.com>
2025-08-08 10:16:45 -07:00
Igor Tceglevskii a8971b807a Possibility to install binaries to a separate folder (#5446) 2025-08-08 15:24:05 +01:00
Sarah Fortune 51c4e0aceb Don't instantiate the AuthService at the top level of the module. (#5443)
I am working separating the initialization for the extension and cline-core
and this is causing a circular dependency.
2025-08-08 06:51:43 -07:00
Sarah Fortune 1cf62941cd Fix webview on IntelliJ (#5439)
The ExternalWebviewProvider has to return /something/ for `getWebview()` or
the rest of the code thinks that is not set up and it won't generate the HTML
for IntelliJ.

The Vscode webview panel, `resolveWebview()` and other Vscode specific parts are
planned to be moved out of the WebviewProvider and into VscodeWebviewProvider,
but that depends other changes to how the webview is initialized in extension.ts
to need to happen first.

Move the WebviewProvider out of index.ts and into a file name `WebviewProvider`,
this follows best practises.
2025-08-08 13:50:28 +01:00
Sarah Fortune cc2472f500 Sssh McpHub (#5434) 2025-08-08 11:58:14 +01:00
Ara 677e544c51 Fix Gpt 5 context window (#5414)
* Fix Gpt 5 context window

* Fix Gpt 5 context window
2025-08-08 01:04:12 -07:00
akfoster d3c8fbbf1d chore: remove unused parseAssistantmessageV1 (#5425)
* chore: remove unused parseAssistantmessageV1

* chore: add PR number to comments

* fix: include full path to PR
2025-08-07 22:21:17 -07:00
Samandellipsis-dev[bot] 1b06633253 fix: LiteLLM provider cost calculations (#4990)
* fix: LiteLLM provider cost calculations

* fix: LiteLLM provider cost calculations

* Update src/api/providers/litellm.ts

Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>

---------

Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>
2025-08-07 22:06:04 -07:00
Bee 4ab8559fce feat: support sending context to active editor panels (#5239)
* feat: add client-specific targeting for addToInput events

- Add client-specific targeting for addToInput events
- Update subscribeToAddToInput to accept client ID parameter
- Replace global event broadcasting with targeted client messaging
- Remove automatic sidebar focus when adding code to chat
- Use last active webview instance for context menu actions
- Maintain backward compatibility with subscription management

* add changeset

* remove debug profiler

* e2e test

* add type

* Add e2e test

* update teardown
2025-08-07 19:32:40 -07:00
259368e0a3 v3.21.0 Release Notes (#5392)
* changeset version bump

* Updating CHANGELOG.md format

* release notes

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: pashpashpash <nik@cline.bot>
2025-08-07 10:03:39 -07:00
pashpashpashandSaoud Rizwan 9b7839efcd Pashpashpash/gpt 5 release (#5413)
* preparing for gpt5 release

* Update generic system prompt with needs_more_exploration param for plan mode

* changeset

---------

Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2025-08-07 09:47:04 -07:00
Ara 47a2ae83de Switch to ULID from UUID for tasks telemetry (#5407)
* Switch to ULID from UUID for tasks

* Switch to ULID from UUID for tasks

* Switch to ULID from UUID for tasks
2025-08-06 19:51:19 -07:00
Sarah Fortune 1b5590e26c Refactoring: move postMessageToWebview into vscode specific code (#5396) 2025-08-07 02:49:12 +01:00
ToshiiandEndoTheDev 9e493341d2 Add ollama key for cloud endpoint (#5400)
* base

Co-authored-by: EndoTheDev <endothedev@gmail.com>

* toggle showing key box

* typing

---------

Co-authored-by: EndoTheDev <endothedev@gmail.com>
2025-08-06 14:41:48 -07:00
kvyb 1d4cd3187b Hostbridge diff diagnostics (#5368)
* feat: migrate diff edit diagnostics to hostbridge; Migrate diagnostics functionality from direct VS Code API calls to the hostbridge layer to enable multi-host support (VS Code + IntelliJ).

* remove test logging

* refactor: migrate diagnostics to workspace service and host separation
2025-08-06 18:16:17 +03:00
Ara 32f0f9618c Adding UUID to task creation for tracking the metrics of a Task in telemetry (#5379) 2025-08-06 00:14:32 -07:00
Ara 3001f883c2 Add walkthrough button and enable quick wins for new users (#5047)
* Add walkthrough button and enable quick wins for new users

- Add openWalkthrough RPC method to ui.proto
- Enable quick wins display for users with <3 tasks in history
- Add "Take a Tour" button in HomeHeader when quick wins are shown
- Update WelcomeSection to pass shouldShowQuickWins prop to HomeHeader

* Adding Gpt-oss through groq

* Adding Gpt-oss through groq

* Support prompt caching and thinking for Opus 4.1

* Support prompt caching and thinking for Opus 4.1

* Support prompt caching and thinking for Opus 4.1
2025-08-06 00:13:31 -07:00
a64e60b8f6 v3.20.13 Release Notes (#5391)
* changeset version bump

* Updating CHANGELOG.md format

* Update CHANGELOG.md

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2025-08-06 00:12:50 -07:00
Saoud Rizwan 3a0e6a471b Add prompt caching for Opus 4.1 (#5389)
* Add prompt caching for Opus 4.1

* Create forty-poets-doubt.md
2025-08-06 00:10:50 -07:00
116 changed files with 3434 additions and 19434 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
Adding safety guard for workspace root
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
calibrate input token counts when using anthropic models of sap ai core provider
+3
View File
@@ -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
View File
@@ -1 +1,3 @@
* @saoudrizwan @ocasta181 @NightTrek @pashpashpash @dcbartlett @saito-sv @Garoth
/docs/
/.github/ @saoudrizwan @dcbartlett
/README.md @saoudrizwan @nickbaumann98
+24
View File
@@ -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!)
+1
View File
@@ -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)
+1
View File
@@ -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`
-2
View File
@@ -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**
+236 -16826
View File
File diff suppressed because it is too large Load Diff
+6 -3
View File
@@ -2,7 +2,7 @@
"name": "claude-dev",
"displayName": "Cline",
"description": "Autonomous coding agent right in your IDE, capable of creating/editing files, running commands, using the browser, and more with your permission every step of the way.",
"version": "3.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"
+5 -4
View File
@@ -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/,
},
],
})
+5
View File
@@ -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
View File
@@ -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
+1
View File
@@ -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;
+1
View File
@@ -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;
+4 -1
View File
@@ -227,7 +227,7 @@ service UiService {
rpc onDidShowAnnouncement(EmptyRequest) returns (Boolean);
// Subscribe to addToInput events (when user adds content via context menu)
rpc subscribeToAddToInput(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);
}
+17
View File
@@ -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;
}
+41
View File
@@ -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;
}
+3
View File
@@ -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({
+53 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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,
+14 -2
View File
@@ -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}`)
}
+27
View File
@@ -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,
+16 -42
View File
@@ -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
+4 -1
View File
@@ -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
View File
@@ -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,
}
})
}
}
+1
View File
@@ -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":
+3
View File
@@ -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":
+92
View File
@@ -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()
}
+2 -1
View File
@@ -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()
}
+10 -1
View File
@@ -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)
}
+42 -42
View File
@@ -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)
})
})
})
+26 -11
View File
@@ -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 },
+16 -65
View File
@@ -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)
+10 -9
View File
@@ -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)
}
}
+16 -5
View File
@@ -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 {
+5 -3
View File
@@ -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
+7 -4
View File
@@ -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.
+5
View File
@@ -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"),
+1
View File
@@ -7,6 +7,7 @@ export type SecretKey =
| "awsSessionToken"
| "awsBedrockApiKey"
| "openAiApiKey"
| "ollamaApiKey"
| "geminiApiKey"
| "openAiNativeApiKey"
| "deepSeekApiKey"
+1
View File
@@ -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,
+4
View File
@@ -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",
+24 -16
View File
@@ -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
View File
@@ -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,
+4
View File
@@ -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,
+357
View File
@@ -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
View File
@@ -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
View File
@@ -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()
-5
View File
@@ -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
View File
@@ -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 -1
View File
@@ -1,4 +1,4 @@
import { WebviewProvider } from "@core/webview"
import { WebviewProvider } from "@/core/webview"
import { HostBridgeClientProvider } from "./host-provider-types"
import { WebviewProviderType } from "@/shared/webview/types"
import { DiffViewProvider } from "@/integrations/editor/DiffViewProvider"
@@ -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 = []
}
}
+47 -5
View File
@@ -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")
})
})
})
+42 -83
View File
@@ -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"
}
}
+25 -4
View File
@@ -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()
}
+179 -443
View File
@@ -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
+1 -1
View File
@@ -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
View File
@@ -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
+44 -13
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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,
+13 -17
View File
@@ -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)
})
}
-11
View File
@@ -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 }
+3 -3
View File
@@ -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 }
+42
View File
@@ -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()
})
+36
View File
@@ -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 })
}
}
+1 -1
View File
@@ -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))
-4
View File
@@ -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, "_")
+10
View File
@@ -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">
+40 -12
View File
@@ -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) => {
+13 -2
View File
@@ -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