Compare commits

...
Author SHA1 Message Date
arafatkatze 924e28c6b5 Adding local fix 2025-07-18 13:13:52 -07:00
Sarah Fortune 309e3bd85c Fix import in McpHub (#5021) 2025-07-18 11:59:07 -07:00
Daniel Campos Olivares eb6eb371e4 fix: Add documentation to nav menu (#5014) 2025-07-18 11:25:40 -07:00
Mohan Raj Rajamanickam 419e3e4677 fix: mcp servers are not started when disabled (#4501)
* fix: disabled servers should not be started

* refactor to dedupe

* add changeset

* Update moody-crabs-relate.md

* minor comment tweaks

* use unknown instead of any
2025-07-18 10:35:19 -07:00
Sarah Fortune e95eecd65f Just shut up already (#5011)
Don't display a notification when the MCP servers have updated.
2025-07-17 23:32:10 -07:00
Sarah Fortune e83e71cc6d Remove class that is unused, and other class that is totally commented out. (#5010) 2025-07-17 23:00:29 -07:00
Sarah Fortuneandellipsis-dev[bot] a9e526e99b Move save() and getDocument() to the platform specific diff classes (#5008)
* Move logic to save the diff document and get the diff content to the platform specific diff view providers.

Add getDocumentText to the diff service.

The ExternalDiffViewProvider should be using its activeDiffEditorId (not the activeDiffEditor, which is the vscode Editor object)

* Check activeDiffEditorId

* Update src/integrations/editor/DiffViewProvider.ts

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

* Use await with this.saveDocument()

* Remove test file

---------

Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>
2025-07-17 18:06:54 -10:00
Sarah Fortune 72f16a8c30 Don't need to use Message.create({...}) with the host bridge (#4999)
* Use await when calling async function showMessage.

Use await when calling `windowClient.showMessage`, otherwise the caller doesn't wait for the request to complete and any exceptions are lost.

For the host bridge RPCs, you don't need to do `ShowMessageRequest.create({...})`, you can just pass the request directly like: `{...}. The Message.create() was needed for the ProtoBus because of the way it was implemented, it couldn't be type-checked by the compiled. It's not needed elsewhere.

* Dont use await, because it blocks until the message is dismissed

* Dont need `?.selectedOption`, the response cannot be null

* Dont use await
2025-07-17 20:15:42 -07:00
ToshiiandBee 1a3ec9f024 move environment setting to launch & publish (#5002)
* move env setting to launch & publish

* change env name

* Update webview-ui/vite.config.ts

Co-authored-by: Bee <68532117+abeatrix@users.noreply.github.com>

* update env location

---------

Co-authored-by: Bee <68532117+abeatrix@users.noreply.github.com>
2025-07-17 18:49:36 -07:00
Sarah Fortune b3fa3ad0d3 Add new RPCs to the host bridge diff service (#4997)
* Add truncate document to the hostbridge diff service.

# Conflicts:
#	src/standalone/ExternalDiffviewProvider.ts

* Fix typo

* Add host bridge rpcs for the diff service

* Add saveDocument and closeDiff to the host bridge.

* Formatting

* Use the host bridge in ExternalDiffviewProvider

* Make comments less verbose
2025-07-17 18:36:43 -07:00
45241fcccf v3.19.7 Release Notes (#4983)
* changeset version bump

* Updating CHANGELOG.md format

* Update CHANGELOG.md for version 3.19.7

---------

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-07-17 18:19:36 -07:00
pashpashpashandarafatkatze 2a3f0e9418 Adding huggingface provider (#4952)
Co-authored-by: arafatkatze <arafat.da.khan@gmail.com>
2025-07-17 18:00:28 -07:00
Toshii 4334764903 remove warning (#5003) 2025-07-17 17:53:25 -07:00
Bee b3a10243b8 Unify error handling and display logic (#4984)
* Unify error handling and display

Several improvements to error handling and reporting within the Cline extension, focusing on providing more informative error messages to the user and improving telemetry data.

- **Error Handling:**
  - Introduces a `ClineError` class to encapsulate Cline-specific errors, providing structured data about the error (status code, request ID, etc.).
  - The `ErrorService` now creates and logs `ClineError` instances, improving error reporting to Sentry and telemetry.
  - Removes redundant error formatting logic from `src/core/task/utils.ts`, relying on the `ClineError` class for consistent error representation.
  - The Cline API handler now throws the raw error, allowing the `ClineError` class to handle the error formatting.

- **UI Improvements:**
  - Introduces new UI components (`ErrorRow`, `ErrorBlockTitle`) to display error messages in a more user-friendly format within the chat interface.
  - Displays credit limit errors with detailed information and a link to purchase credits.
  - Improves the display of rate limit errors and authentication errors.
  - Adds specific handling for PowerShell-related errors, providing a link to a troubleshooting guide.

- **Telemetry:**
  - Captures provider API errors using `ClineError` data, providing more detailed information about the error in telemetry reports.

- **Other Changes:**
  - Fixes an import path in `src/services/posthog/PostHogClientProvider.ts`.
  - Adds tests for the new UI components.
  - Adds `error` field to `ClineMessage` to transport `ClineError` instances to the webview.

These changes aim to provide a better user experience by displaying more informative error messages and improving the overall reliability of the Cline extension.

* include provider id

* includes model and provider id

* fix test

* return gracefully for empty response
2025-07-17 16:21:18 -07:00
Bee 8e95c136a6 Handle auth state changes in all extension windows (#4987)
* Handle auth state changes in all extension windows

Addresses an issue where authentication state changes (login/logout) were not being properly propagated across all extension windows.

The `onDidChange` event handler for `clineAccountId` secret now checks if the secret was added/updated (login) or removed (logout).

- If the secret exists, `restoreRefreshTokenAndRetrieveAuthInfo` is called to restore auth info (login from another window).
- If the secret is removed, `handleDeauth` is called to handle logout for all windows.

This ensures that all extension windows are kept in sync with the current authentication state.

* changeset
2025-07-17 15:40:30 -07:00
Daniel Campos Olivares 0b66faa1dd chore: Introduce SAP AI Core documentation (#4961)
* feat: Introduce SAP AI Core documentation

* fix: Markdown lint

Signed-off-by: Daniel Campos Olivares <dacamposol@gmail.com>

* fix: Typo

Signed-off-by: Daniel Campos Olivares <dacamposol@gmail.com>

---------

Signed-off-by: Daniel Campos Olivares <dacamposol@gmail.com>
2025-07-17 14:10:30 -07:00
Sarah Fortune 3d2dc1c5c4 Move platform specific code out of the DiffViewProvider and into the VscodeDiffViewProvider (#4980)
* Add a way to reset state for the platform specific diff view providers.

Add the abstract method `resetDiffView()` to the DiffViewProvider.

On Vscode it reset the active diff editor and decorations.

On External diff view providers, it reset the diff editor ID.

* Add a way to truncate the document to the platform specific diff view providers.

This is used when the last update is recieved to remove any content from the bottom of the document.

* Move `closeDiffView` to the platform specific diff view classes.

* Update comment

* Use await for async function

* Move setting the cursor position into replaceText
2025-07-17 12:35:48 -07:00
Andrei EternalandAndrei Edell 028412579b Standalone Terminal Manager & remove other vscode-impls (now covered by host bridge) (#4993)
* Revert "experimental vscode impls & build-proto cleanup (#4493)"

This reverts commit f00c5f4ecc.

* leave terminal impls

* terminal manager switch for standalone

* Export standaloneTerminalManager to global

* clean up standalone terminal switch

---------

Co-authored-by: Andrei Edell <andrei@nugbase.com>
2025-07-17 12:11:45 -07:00
Sarah Fortune f04788c2ec Move the logic to scroll the diff editor window into the platform specific classes (#4977)
* Move the logic to scroll the diff editor window into the platform specific classes.

Move the current logic into the VscodeDiffViewProvider.

The ExternalDiffViewProvider will use the host bridge to scroll the editor tab. But for now it doesn't do anything.

* Add comment

* Remove log messages
2025-07-17 11:26:40 -07:00
73 changed files with 2080 additions and 1790 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
fix: mcp servers are not started when disabled
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
Improve the Claude Code error messages
+1
View File
@@ -94,6 +94,7 @@ jobs:
env:
VSCE_PAT: ${{ secrets.VSCE_PAT }}
OVSX_PAT: ${{ secrets.OVSX_PAT }}
CLINE_ENVIRONMENT: production
run: |
# Required to generate the .vsix
vsce package --out "cline-${{ steps.get_version.outputs.version }}.vsix"
+4 -2
View File
@@ -14,7 +14,8 @@
"preLaunchTask": "${defaultBuildTask}",
"env": {
"IS_DEV": "true",
"DEV_WORKSPACE_FOLDER": "${workspaceFolder}"
"DEV_WORKSPACE_FOLDER": "${workspaceFolder}",
"CLINE_ENVIRONMENT": "local"
}
},
{
@@ -37,7 +38,8 @@
"env": {
"IS_DEV": "true",
"TEMP_PROFILE": "true",
"DEV_WORKSPACE_FOLDER": "${workspaceFolder}"
"DEV_WORKSPACE_FOLDER": "${workspaceFolder}",
"CLINE_ENVIRONMENT": "local"
}
},
{
+6
View File
@@ -1,5 +1,11 @@
# Changelog
## [3.19.7]
- Add Hugging Face as a new API provider with support for their inference API models
- Improve Claude Code error messages with better guidance for common setup issues (Thanks @BarreiroT!)
- Fix authentication sync issues when using multiple VSCode windows
## [3.19.6]
- Improve Kimi K2 model provider routing with additional provider options for better availability and performance
+2 -1
View File
@@ -159,7 +159,8 @@
"provider-config/openai",
"provider-config/openai-compatible",
"provider-config/openrouter",
"provider-config/requesty"
"provider-config/requesty",
"provider-config/sap-aicore"
]
},
{
+39
View File
@@ -0,0 +1,39 @@
---
title: "SAP AI Core"
description: "Learn how to configure and use LLM models from Generative AI Hub in SAP AI Core with Cline."
---
SAP AI Core and the generative AI hub help you to integrate LLMs and AI into new business processes in a cost-efficient manner.
**Website:** [SAP Help Portal](https://help.sap.com/docs/sap-ai-core/sap-ai-core-service-guide/what-is-sap-ai-core)
### Getting a Service Binding
> 💡 **Information**
>
> SAP AI Core, and Generative AI Hub, are offerings from SAP BTP.
> You need an active SAP BTP contract and a existing subaccount with a SAP AI Core instance to perform these steps.
1. **Access:** Go to your subaccount via [BTP Cloud Cockpit](cockpit.btp.cloud.sap/cockpit)
2. **Create a Service Binding:** Go to "Instances and Subscriptions", select your SAP AI Core service instance and click on Service Bindings > Create.
3. **Copy the Service Binding:** Copy the service binding values.
### Supported Models
SAP AI Core supports a large and growing number of models.
Refer to the [Generative AI Hub Supported Models page](https://me.sap.com/notes/3437766) for the complete and up-to-date list.
### Configuration in Cline
1. **Open Cline Settings:** Click the settings icon (⚙️) in the Cline panel.
2. **Select Provider:** Choose "SAP AI Core" from the "API Provider" dropdown.
3. **Enter Client Id:** Add the `.clientid` field from the service binding into the "AI Core Client Id" field.
4. **Enter Client Secret:** Add the `.clientsecret` field from the service binding into the "AI Core Client Secret" field.
5. **Enter Base URL:** Add the `.serviceurls.AI_API_URL` field from the service binding into the "AI Core Base URL" field.
6. **Enter Auth URL:** Add the `.url` field from the service binding into the "AI Core Auth URL" field.
7. **Enter Resource Group:** Add the resource group where you have your model deployments. See [Create a Deployment for a Generative AI Model](https://help.sap.com/docs/sap-ai-core/sap-ai-core-service-guide/create-deployment-for-generative-ai-model-in-sap-ai-core).
8. **Select Model:** Choose your desired model from the "Model" dropdown.
### Tips and Notes
- **Model Selection:** SAP AI Core offers a wide range of models. You won't be able to use the model, even if selected, if a deployment doesn't exist in the provided resource group.
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "claude-dev",
"version": "3.19.6",
"version": "3.19.7",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "claude-dev",
"version": "3.19.6",
"version": "3.19.7",
"license": "Apache-2.0",
"dependencies": {
"@anthropic-ai/sdk": "^0.37.0",
+1 -1
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.19.6",
"version": "3.19.7",
"icon": "assets/icons/icon.png",
"engines": {
"vscode": "^1.84.0"
+40 -2
View File
@@ -10,7 +10,16 @@ import "common.proto";
service DiffService {
// Open the diff view/editor.
rpc openDiff(OpenDiffRequest) returns (OpenDiffResponse);
// Get the contents of the diff view.
rpc getDocumentText(GetDocumentTextRequest) returns (GetDocumentTextResponse);
// Replace a text selection in the diff.
rpc replaceText(ReplaceTextRequest) returns (ReplaceTextResponse);
// Truncate the diff document.
rpc truncateDocument(TruncateDocumentRequest) returns (TruncateDocumentResponse);
// Save the diff document.
rpc saveDocument(SaveDocumentRequest) returns (SaveDocumentResponse);
// Close the diff editor UI.
rpc closeDiff(CloseDiffRequest) returns (CloseDiffResponse);
}
message OpenDiffRequest {
@@ -26,6 +35,15 @@ message OpenDiffResponse {
optional string diff_id = 1;
}
message GetDocumentTextRequest {
optional cline.Metadata metadata = 1;
optional string diff_id = 2;
}
message GetDocumentTextResponse {
optional string content = 1;
}
message ReplaceTextRequest {
optional cline.Metadata metadata = 1;
optional string diff_id = 2;
@@ -34,6 +52,26 @@ message ReplaceTextRequest {
optional int32 end_line = 5;
}
message ReplaceTextResponse {
// TBD
message ReplaceTextResponse {}
message TruncateDocumentRequest {
optional cline.Metadata metadata = 1;
optional string diff_id = 2;
optional int32 end_line = 5;
}
message TruncateDocumentResponse {}
message CloseDiffRequest {
optional cline.Metadata metadata = 1;
optional string diff_id = 2;
}
message CloseDiffResponse {}
message SaveDocumentRequest {
optional cline.Metadata metadata = 1;
optional string diff_id = 2;
}
message SaveDocumentResponse {}
+4
View File
@@ -6,6 +6,10 @@ option java_multiple_files = true;
import "common.proto";
/**
* The watch service is only here as example of a streaming rpc in the host bridge.
* This being replaced with a native JS file watcher.
*/
// WatchService provides methods for watching files in the IDE
service WatchService {
// Subscribe to file changes
+6
View File
@@ -15,6 +15,8 @@ service ModelsService {
rpc getVsCodeLmModels(EmptyRequest) returns (VsCodeLmModelsArray);
// Refreshes and returns OpenRouter models
rpc refreshOpenRouterModels(EmptyRequest) returns (OpenRouterCompatibleModelInfo);
// Refreshes and returns Hugging Face models
rpc refreshHuggingFaceModels(EmptyRequest) returns (OpenRouterCompatibleModelInfo);
// Refreshes and returns OpenAI models
rpc refreshOpenAiModels(OpenAiModelsRequest) returns (StringArray);
// Refreshes and returns Requesty models
@@ -126,6 +128,7 @@ enum ApiProvider {
SAPAICORE = 25;
CLAUDE_CODE = 26;
MOONSHOT = 27;
HUGGINGFACE = 28;
}
// Model info for OpenAI-compatible models
@@ -246,4 +249,7 @@ message ModelsApiConfiguration {
optional string groq_api_key = 78;
optional string groq_model_id = 79;
optional OpenRouterModelInfo groq_model_info = 80;
optional string hugging_face_api_key = 81;
optional string hugging_face_model_id = 82;
optional OpenRouterModelInfo hugging_face_model_info = 83;
}
+6
View File
@@ -29,6 +29,7 @@ import { SapAiCoreHandler } from "./providers/sapaicore"
import { ClaudeCodeHandler } from "./providers/claude-code"
import { MoonshotHandler } from "./providers/moonshot"
import { GroqHandler } from "./providers/groq"
import { HuggingFaceHandler } from "./providers/huggingface"
export interface ApiHandler {
createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream
@@ -195,6 +196,11 @@ function createHandlerForProvider(apiProvider: string | undefined, options: Omit
moonshotApiLine: options.moonshotApiLine,
apiModelId: options.apiModelId,
})
case "huggingface":
return new HuggingFaceHandler({
huggingFaceApiKey: options.huggingFaceApiKey,
apiModelId: options.apiModelId,
})
case "nebius":
return new NebiusHandler({
nebiusApiKey: options.nebiusApiKey,
+1 -11
View File
@@ -177,17 +177,7 @@ export class ClineHandler implements ApiHandler {
}
} catch (error) {
console.error("Cline API Error:", error)
const requestId = error?.request_id ? `\n | Request ID: ${error.request_id}` : ""
if (error.code === "ERR_BAD_REQUEST" || error.status === 401) {
throw new Error(CLINE_ACCOUNT_AUTH_ERROR_MESSAGE + requestId)
} else if (error.code === "insufficient_credits" || error.status === 402) {
if (error.error) {
throw new Error(JSON.stringify(error.error))
}
}
const _error = error instanceof Error ? error : new Error(String(error))
_error.message = _error.message + requestId
throw _error
throw error
}
}
+141
View File
@@ -0,0 +1,141 @@
import { Anthropic } from "@anthropic-ai/sdk"
import OpenAI from "openai"
import { withRetry } from "../retry"
import { ApiHandler } from "../"
import { ApiHandlerOptions, HuggingFaceModelId, ModelInfo, huggingFaceDefaultModelId, huggingFaceModels } from "@shared/api"
import { calculateApiCostOpenAI } from "../../utils/cost"
import { convertToOpenAiMessages } from "../transform/openai-format"
import { ApiStream } from "../transform/stream"
interface HuggingFaceHandlerOptions {
huggingFaceApiKey?: string
apiModelId?: string
}
export class HuggingFaceHandler implements ApiHandler {
private options: HuggingFaceHandlerOptions
private client: OpenAI | undefined
private cachedModel: { id: HuggingFaceModelId; info: ModelInfo } | undefined
constructor(options: HuggingFaceHandlerOptions) {
this.options = options
}
private ensureClient(): OpenAI {
if (!this.client) {
if (!this.options.huggingFaceApiKey) {
throw new Error("Hugging Face API key is required")
}
try {
this.client = new OpenAI({
baseURL: "https://router.huggingface.co/v1",
apiKey: this.options.huggingFaceApiKey,
defaultHeaders: {
"User-Agent": "Cline/1.0",
},
})
} catch (error: any) {
throw new Error(`Error creating Hugging Face client: ${error.message}`)
}
}
return this.client
}
private async *yieldUsage(info: ModelInfo, usage: OpenAI.Completions.CompletionUsage | undefined): ApiStream {
if (!usage) {
return
}
const inputTokens = usage.prompt_tokens || 0
const outputTokens = usage.completion_tokens || 0
const totalCost = calculateApiCostOpenAI(info, inputTokens, outputTokens)
const usageData = {
type: "usage" as const,
inputTokens: inputTokens,
outputTokens: outputTokens,
cacheWriteTokens: 0,
cacheReadTokens: 0,
totalCost: totalCost,
}
yield usageData
}
@withRetry()
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
try {
const client = this.ensureClient()
const model = this.getModel()
const openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [
{ role: "system", content: systemPrompt },
...convertToOpenAiMessages(messages),
]
const requestParams = {
model: model.id,
max_tokens: model.info.maxTokens,
messages: openAiMessages,
stream: true,
stream_options: { include_usage: true },
temperature: 0,
}
const stream = (await client.chat.completions.create(requestParams)) as any
let chunkCount = 0
let totalContent = ""
for await (const chunk of stream) {
chunkCount++
const delta = chunk.choices[0]?.delta
if (delta?.content) {
totalContent += delta.content
yield {
type: "text",
text: delta.content,
}
}
if (chunk.usage) {
yield* this.yieldUsage(model.info, chunk.usage)
}
}
} catch (error: any) {
throw error
}
}
getModel(): { id: HuggingFaceModelId; info: ModelInfo } {
// Return cached model if available
if (this.cachedModel) {
return this.cachedModel
}
const modelId = this.options.apiModelId
// List all available models for debugging
const availableModels = Object.keys(huggingFaceModels)
let result: { id: HuggingFaceModelId; info: ModelInfo }
if (modelId && modelId in huggingFaceModels) {
const id = modelId as HuggingFaceModelId
const modelInfo = huggingFaceModels[id]
result = { id, info: modelInfo }
} else {
const defaultInfo = huggingFaceModels[huggingFaceDefaultModelId]
result = {
id: huggingFaceDefaultModelId,
info: defaultInfo,
}
}
// Cache the result for future calls
this.cachedModel = result
return result
}
}
+2 -2
View File
@@ -1,6 +1,6 @@
export type Environment = "production" | "staging" | "local"
const CURRENT_ENVIRONMENT: Environment = "production"
const CLINE_ENVIRONMENT: Environment = (process.env.CLINE_ENVIRONMENT as Environment) || "production"
interface EnvironmentConfig {
appBaseUrl: string
@@ -55,4 +55,4 @@ const configs: Record<Environment, EnvironmentConfig> = {
},
}
export const clineEnvConfig = configs[CURRENT_ENVIRONMENT]
export const clineEnvConfig = configs[CLINE_ENVIRONMENT]
+8 -12
View File
@@ -44,12 +44,10 @@ export const createRuleFile: FileMethodHandler = async (controller: Controller,
if (fileExists) {
const message = `${fileTypeName} file "${request.filename}" already exists.`
getHostBridgeProvider().windowClient.showMessage(
ShowMessageRequest.create({
type: ShowMessageType.WARNING,
message,
}),
)
getHostBridgeProvider().windowClient.showMessage({
type: ShowMessageType.WARNING,
message,
})
// Still open it for editing
await handleFileServiceRequest(controller, "openFile", { value: filePath })
} else {
@@ -63,12 +61,10 @@ export const createRuleFile: FileMethodHandler = async (controller: Controller,
await handleFileServiceRequest(controller, "openFile", { value: filePath })
const message = `Created new ${request.isGlobal ? "global" : "workspace"} ${fileTypeName} file: ${request.filename}`
getHostBridgeProvider().windowClient.showMessage(
ShowMessageRequest.create({
type: ShowMessageType.INFORMATION,
message,
}),
)
getHostBridgeProvider().windowClient.showMessage({
type: ShowMessageType.INFORMATION,
message,
})
}
return RuleFile.create({
+4 -6
View File
@@ -46,12 +46,10 @@ export const deleteRuleFile: FileMethodHandler = async (controller: Controller,
const fileTypeName = request.type === "workflow" ? "workflow" : "rule"
const message = `${fileTypeName} file "${fileName}" deleted successfully`
getHostBridgeProvider().windowClient.showMessage(
ShowMessageRequest.create({
type: ShowMessageType.INFORMATION,
message,
}),
)
getHostBridgeProvider().windowClient.showMessage({
type: ShowMessageType.INFORMATION,
message,
})
return RuleFile.create({
filePath: request.rulePath,
+52 -78
View File
@@ -114,19 +114,15 @@ export class Controller {
await updateGlobalState(this.context, "userInfo", undefined)
await updateGlobalState(this.context, "apiProvider", "openrouter")
await this.postStateToWebview()
getHostBridgeProvider().windowClient.showMessage(
ShowMessageRequest.create({
type: ShowMessageType.INFORMATION,
message: "Successfully logged out of Cline",
}),
)
getHostBridgeProvider().windowClient.showMessage({
type: ShowMessageType.INFORMATION,
message: "Successfully logged out of Cline",
})
} catch (error) {
getHostBridgeProvider().windowClient.showMessage(
ShowMessageRequest.create({
type: ShowMessageType.INFORMATION,
message: "Logout failed",
}),
)
getHostBridgeProvider().windowClient.showMessage({
type: ShowMessageType.INFORMATION,
message: "Logout failed",
})
}
}
@@ -485,12 +481,10 @@ export class Controller {
await this.postStateToWebview()
} catch (error) {
console.error("Failed to handle auth callback:", error)
getHostBridgeProvider().windowClient.showMessage(
ShowMessageRequest.create({
type: ShowMessageType.ERROR,
message: "Failed to log in to Cline",
}),
)
getHostBridgeProvider().windowClient.showMessage({
type: ShowMessageType.ERROR,
message: "Failed to log in to Cline",
})
// Even on login failure, we preserve any existing tokens
// Only clear tokens on explicit logout
}
@@ -525,12 +519,10 @@ export class Controller {
console.error("Failed to fetch MCP marketplace:", error)
if (!silent) {
const errorMessage = error instanceof Error ? error.message : "Failed to fetch MCP marketplace"
getHostBridgeProvider().windowClient.showMessage(
ShowMessageRequest.create({
type: ShowMessageType.ERROR,
message: errorMessage,
}),
)
getHostBridgeProvider().windowClient.showMessage({
type: ShowMessageType.ERROR,
message: errorMessage,
})
}
return undefined
}
@@ -614,12 +606,10 @@ export class Controller {
} catch (error) {
console.error("Failed to handle cached MCP marketplace:", error)
const errorMessage = error instanceof Error ? error.message : "Failed to handle cached MCP marketplace"
getHostBridgeProvider().windowClient.showMessage(
ShowMessageRequest.create({
type: ShowMessageType.ERROR,
message: errorMessage,
}),
)
getHostBridgeProvider().windowClient.showMessage({
type: ShowMessageType.ERROR,
message: errorMessage,
})
}
}
@@ -982,24 +972,20 @@ export class Controller {
// Check if there's a workspace folder open
const cwd = await getCwd()
if (!cwd) {
getHostBridgeProvider().windowClient.showMessage(
ShowMessageRequest.create({
type: ShowMessageType.ERROR,
message: "No workspace folder open",
}),
)
getHostBridgeProvider().windowClient.showMessage({
type: ShowMessageType.ERROR,
message: "No workspace folder open",
})
return
}
// Get the git diff
const gitDiff = await getWorkingState(cwd)
if (gitDiff === "No changes in working directory") {
getHostBridgeProvider().windowClient.showMessage(
ShowMessageRequest.create({
type: ShowMessageType.INFORMATION,
message: "No changes in workspace for commit message",
}),
)
getHostBridgeProvider().windowClient.showMessage({
type: ShowMessageType.INFORMATION,
message: "No changes in workspace for commit message",
})
return
}
@@ -1067,58 +1053,46 @@ Commit message:`
const repo = api.repositories[0]
repo.inputBox.value = commitMessage
const message = "Commit message generated and applied"
getHostBridgeProvider().windowClient.showMessage(
ShowMessageRequest.create({
type: ShowMessageType.INFORMATION,
message,
}),
)
getHostBridgeProvider().windowClient.showMessage({
type: ShowMessageType.INFORMATION,
message,
})
} else {
const message = "No Git repositories found"
getHostBridgeProvider().windowClient.showMessage(
ShowMessageRequest.create({
type: ShowMessageType.ERROR,
message,
}),
)
getHostBridgeProvider().windowClient.showMessage({
type: ShowMessageType.ERROR,
message,
})
}
} else {
const message = "Git extension not found"
getHostBridgeProvider().windowClient.showMessage(
ShowMessageRequest.create({
type: ShowMessageType.ERROR,
message,
}),
)
getHostBridgeProvider().windowClient.showMessage({
type: ShowMessageType.ERROR,
message,
})
}
} else {
const message = "Failed to generate commit message"
getHostBridgeProvider().windowClient.showMessage(
ShowMessageRequest.create({
type: ShowMessageType.ERROR,
message,
}),
)
getHostBridgeProvider().windowClient.showMessage({
type: ShowMessageType.ERROR,
message,
})
}
} catch (innerError) {
const innerErrorMessage = innerError instanceof Error ? innerError.message : String(innerError)
getHostBridgeProvider().windowClient.showMessage(
ShowMessageRequest.create({
type: ShowMessageType.ERROR,
message: `Failed to generate commit message: ${innerErrorMessage}`,
}),
)
getHostBridgeProvider().windowClient.showMessage({
type: ShowMessageType.ERROR,
message: `Failed to generate commit message: ${innerErrorMessage}`,
})
}
},
)
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error)
getHostBridgeProvider().windowClient.showMessage(
ShowMessageRequest.create({
type: ShowMessageType.ERROR,
message: `Failed to generate commit message: ${errorMessage}`,
}),
)
getHostBridgeProvider().windowClient.showMessage({
type: ShowMessageType.ERROR,
message: `Failed to generate commit message: ${errorMessage}`,
})
}
}
}
@@ -0,0 +1,112 @@
import { Controller } from ".."
import { EmptyRequest } from "../../../shared/proto/common"
import { OpenRouterCompatibleModelInfo, OpenRouterModelInfo } from "../../../shared/proto/models"
import axios from "axios"
import path from "path"
import fs from "fs/promises"
import { fileExistsAtPath } from "@utils/fs"
import { GlobalFileNames } from "@core/storage/disk"
import { huggingFaceModels } from "@shared/api"
/**
* Ensures the cache directory exists and returns its path
*/
async function ensureCacheDirectoryExists(controller: Controller): Promise<string> {
const cacheDir = path.join(controller.context.globalStorageUri.fsPath, "cache")
try {
await fs.mkdir(cacheDir, { recursive: true })
} catch (error) {
// Directory might already exist
}
return cacheDir
}
/**
* Refreshes the Hugging Face models and returns the updated model list
* @param controller The controller instance
* @param request Empty request object
* @returns Response containing the Hugging Face models
*/
export async function refreshHuggingFaceModels(
controller: Controller,
_request: EmptyRequest,
): Promise<OpenRouterCompatibleModelInfo> {
const huggingFaceModelsFilePath = path.join(await ensureCacheDirectoryExists(controller), "huggingface_models.json")
let models: Record<string, OpenRouterModelInfo> = {}
try {
// Fetch models from Hugging Face API
const response = await axios.get("https://router.huggingface.co/v1/models", {
timeout: 10000,
})
if (response.data?.data) {
const rawModels = response.data.data
// Transform HF models to OpenRouter-compatible format
for (const rawModel of rawModels) {
const modelInfo = OpenRouterModelInfo.create({
maxTokens: 8192, // HF doesn't provide max_tokens, use default
contextWindow: 128_000, // FIXME: HF doesn't provide context window, use default
supportsImages: false, // Most models don't support images
supportsPromptCache: false,
inputPrice: 0, // Will be set based on providers
outputPrice: 0, // Will be set based on providers
cacheWritesPrice: 0,
cacheReadsPrice: 0,
description: `Available on providers: ${rawModel.providers?.join(", ") || "unknown"}`,
})
// Add model-specific configurations if we have them in our static models
if (rawModel.id in huggingFaceModels) {
const staticModel = huggingFaceModels[rawModel.id as keyof typeof huggingFaceModels]
modelInfo.maxTokens = staticModel.maxTokens
modelInfo.contextWindow = staticModel.contextWindow
modelInfo.supportsImages = staticModel.supportsImages
modelInfo.supportsPromptCache = staticModel.supportsPromptCache
modelInfo.inputPrice = staticModel.inputPrice
modelInfo.outputPrice = staticModel.outputPrice
modelInfo.description = staticModel.description || modelInfo.description
}
models[rawModel.id] = modelInfo
}
// Save to cache
await fs.writeFile(huggingFaceModelsFilePath, JSON.stringify(models, null, 2))
}
} catch (error) {
console.error("Error fetching Hugging Face models:", error)
// Try to load from cache
try {
if (await fileExistsAtPath(huggingFaceModelsFilePath)) {
const cachedModels = await fs.readFile(huggingFaceModelsFilePath, "utf-8")
const parsedModels = JSON.parse(cachedModels)
models = parsedModels
}
} catch (cacheError) {
console.error("Error loading cached Hugging Face models:", cacheError)
}
// If no cache available, use static models as fallback
if (Object.keys(models).length === 0) {
for (const [modelId, modelInfo] of Object.entries(huggingFaceModels)) {
models[modelId] = OpenRouterModelInfo.create({
maxTokens: modelInfo.maxTokens,
contextWindow: modelInfo.contextWindow,
supportsImages: modelInfo.supportsImages,
supportsPromptCache: modelInfo.supportsPromptCache,
inputPrice: modelInfo.inputPrice,
outputPrice: modelInfo.outputPrice,
cacheWritesPrice: (modelInfo as any).cacheWritesPrice || 0,
cacheReadsPrice: (modelInfo as any).cacheReadsPrice || 0,
description: modelInfo.description || "",
})
}
}
}
return OpenRouterCompatibleModelInfo.create({ models })
}
+16 -24
View File
@@ -15,20 +15,16 @@ import { getHostBridgeProvider } from "@/hosts/host-providers"
export async function resetState(controller: Controller, request: ResetStateRequest): Promise<Empty> {
try {
if (request.global) {
getHostBridgeProvider().windowClient.showMessage(
ShowMessageRequest.create({
type: ShowMessageType.INFORMATION,
message: "Resetting global state...",
}),
)
getHostBridgeProvider().windowClient.showMessage({
type: ShowMessageType.INFORMATION,
message: "Resetting global state...",
})
await resetGlobalState(controller.context)
} else {
getHostBridgeProvider().windowClient.showMessage(
ShowMessageRequest.create({
type: ShowMessageType.INFORMATION,
message: "Resetting workspace state...",
}),
)
getHostBridgeProvider().windowClient.showMessage({
type: ShowMessageType.INFORMATION,
message: "Resetting workspace state...",
})
await resetWorkspaceState(controller.context)
}
@@ -37,12 +33,10 @@ export async function resetState(controller: Controller, request: ResetStateRequ
controller.task = undefined
}
getHostBridgeProvider().windowClient.showMessage(
ShowMessageRequest.create({
type: ShowMessageType.INFORMATION,
message: "State reset",
}),
)
getHostBridgeProvider().windowClient.showMessage({
type: ShowMessageType.INFORMATION,
message: "State reset",
})
await controller.postStateToWebview()
await sendChatButtonClickedEvent(controller.id)
@@ -50,12 +44,10 @@ export async function resetState(controller: Controller, request: ResetStateRequ
return Empty.create()
} catch (error) {
console.error("Error resetting state:", error)
getHostBridgeProvider().windowClient.showMessage(
ShowMessageRequest.create({
type: ShowMessageType.ERROR,
message: `Failed to reset state: ${error instanceof Error ? error.message : String(error)}`,
}),
)
getHostBridgeProvider().windowClient.showMessage({
type: ShowMessageType.ERROR,
message: `Failed to reset state: ${error instanceof Error ? error.message : String(error)}`,
})
throw error
}
}
@@ -27,12 +27,10 @@ export async function updateDefaultTerminalProfile(
// Show information message if terminals were closed
if (closedCount > 0) {
const message = `Closed ${closedCount} ${closedCount === 1 ? "terminal" : "terminals"} with different profile.`
getHostBridgeProvider().windowClient.showMessage(
ShowMessageRequest.create({
type: ShowMessageType.INFORMATION,
message,
}),
)
getHostBridgeProvider().windowClient.showMessage({
type: ShowMessageType.INFORMATION,
message,
})
}
// Show warning if there are busy terminals that couldn't be closed
@@ -40,12 +38,10 @@ export async function updateDefaultTerminalProfile(
const message =
`${busyTerminals.length} busy ${busyTerminals.length === 1 ? "terminal has" : "terminals have"} a different profile. ` +
`Close ${busyTerminals.length === 1 ? "it" : "them"} to use the new profile for all commands.`
getHostBridgeProvider().windowClient.showMessage(
ShowMessageRequest.create({
type: ShowMessageType.WARNING,
message,
}),
)
getHostBridgeProvider().windowClient.showMessage({
type: ShowMessageType.WARNING,
message,
})
}
}
@@ -33,7 +33,7 @@ export async function deleteAllTaskHistory(controller: Controller): Promise<Dele
},
}),
)
)?.selectedOption
).selectedOption
// Default VS Code Cancel button returns `undefined` - don't delete anything
if (userChoice === undefined) {
@@ -67,17 +67,15 @@ export async function deleteAllTaskHistory(controller: Controller): Promise<Dele
} else {
// No favorited tasks found - show warning and ask user what to do
const answer = (
await getHostBridgeProvider().windowClient.showMessage(
ShowMessageRequest.create({
type: ShowMessageType.WARNING,
message: "No favorited tasks found. Would you like to delete all tasks anyway?",
options: {
modal: true,
items: ["Delete All Tasks"],
},
}),
)
)?.selectedOption
await getHostBridgeProvider().windowClient.showMessage({
type: ShowMessageType.WARNING,
message: "No favorited tasks found. Would you like to delete all tasks anyway?",
options: {
modal: true,
items: ["Delete All Tasks"],
},
})
).selectedOption
// User cancelled - don't delete anything
if (answer === undefined) {
@@ -105,12 +103,10 @@ export async function deleteAllTaskHistory(controller: Controller): Promise<Dele
await fs.rm(checkpointsDirPath, { recursive: true, force: true })
}
} catch (error) {
getHostBridgeProvider().windowClient.showMessage(
ShowMessageRequest.create({
type: ShowMessageType.ERROR,
message: `Encountered error while deleting task history, there may be some files left behind. Error: ${error instanceof Error ? error.message : String(error)}`,
}),
)
getHostBridgeProvider().windowClient.showMessage({
type: ShowMessageType.ERROR,
message: `Encountered error while deleting task history, there may be some files left behind. Error: ${error instanceof Error ? error.message : String(error)}`,
})
}
// Update webview
@@ -28,13 +28,11 @@ export const deleteTasksWithIds: TaskMethodHandler = async (
? "Are you sure you want to delete this task? This action cannot be undone."
: `Are you sure you want to delete these ${taskCount} tasks? This action cannot be undone.`
const userChoice = await getHostBridgeProvider().windowClient.showMessage(
ShowMessageRequest.create({
type: ShowMessageType.WARNING,
message,
options: { modal: true, items: ["Delete"] },
}),
)
const userChoice = await getHostBridgeProvider().windowClient.showMessage({
type: ShowMessageType.WARNING,
message,
options: { modal: true, items: ["Delete"] },
})
if (userChoice === undefined) {
return Empty.create()
+22
View File
@@ -0,0 +1,22 @@
import * as vscode from "vscode"
import type { Controller } from "../index"
import type { EmptyRequest } from "../../../shared/proto/common"
import { Empty } from "../../../shared/proto/common"
import { telemetryService } from "../../../services/posthog/telemetry/TelemetryService"
/**
* Opens the Cline walkthrough in VSCode
* @param controller The controller instance
* @param request Empty request
* @returns Empty response
*/
export async function openWalkthrough(controller: Controller, request: EmptyRequest): Promise<Empty> {
try {
await vscode.commands.executeCommand("workbench.action.openWalkthrough", "saoudrizwan.claude-dev#ClineWalkthrough")
telemetryService.captureButtonClick("webview_openWalkthrough")
return Empty.create({})
} catch (error) {
console.error(`Failed to open walkthrough: ${error}`)
throw error
}
}
+8 -12
View File
@@ -78,12 +78,10 @@ export async function parseMentions(
await urlContentFetcher.launchBrowser()
} catch (error) {
launchBrowserError = error
getHostBridgeProvider().windowClient.showMessage(
ShowMessageRequest.create({
type: ShowMessageType.ERROR,
message: `Error fetching content for ${urlMention}: ${error.message}`,
}),
)
getHostBridgeProvider().windowClient.showMessage({
type: ShowMessageType.ERROR,
message: `Error fetching content for ${urlMention}: ${error.message}`,
})
}
}
@@ -100,12 +98,10 @@ export async function parseMentions(
const markdown = await urlContentFetcher.urlToMarkdown(mention)
result = markdown
} catch (error) {
getHostBridgeProvider().windowClient.showMessage(
ShowMessageRequest.create({
type: ShowMessageType.ERROR,
message: `Error fetching content for ${mention}: ${error.message}`,
}),
)
getHostBridgeProvider().windowClient.showMessage({
type: ShowMessageType.ERROR,
message: `Error fetching content for ${mention}: ${error.message}`,
})
result = `Error fetching content: ${error.message}`
}
}
+3
View File
@@ -21,6 +21,7 @@ export type SecretKey =
| "asksageApiKey"
| "xaiApiKey"
| "moonshotApiKey"
| "huggingFaceApiKey"
| "nebiusApiKey"
| "sambanovaApiKey"
| "cerebrasApiKey"
@@ -107,6 +108,8 @@ export type GlobalStateKey =
| "requestyModelInfo"
| "togetherModelId"
| "fireworksModelId"
| "huggingFaceModelId"
| "huggingFaceModelInfo"
| "sapAiCoreModelId"
// Previous mode saved configurations (per workspace)
| "previousModeApiProvider"
+16
View File
@@ -170,6 +170,7 @@ export async function getAllExtensionState(context: vscode.ExtensionContext) {
groqApiKey,
moonshotApiKey,
nebiusApiKey,
huggingFaceApiKey,
planActSeparateModelsSettingRaw,
favoritedModelIds,
globalClineRulesToggles,
@@ -250,6 +251,7 @@ export async function getAllExtensionState(context: vscode.ExtensionContext) {
getSecret(context, "groqApiKey") as Promise<string | undefined>,
getSecret(context, "moonshotApiKey") as Promise<string | undefined>,
getSecret(context, "nebiusApiKey") as Promise<string | undefined>,
getSecret(context, "huggingFaceApiKey") as Promise<string | undefined>,
getGlobalState(context, "planActSeparateModelsSetting") as Promise<boolean | undefined>,
getGlobalState(context, "favoritedModelIds") as Promise<string[] | undefined>,
getGlobalState(context, "globalClineRulesToggles") as Promise<ClineRulesToggles | undefined>,
@@ -308,6 +310,8 @@ export async function getAllExtensionState(context: vscode.ExtensionContext) {
previousModeAwsBedrockCustomModelBaseId,
previousModeSapAiCoreModelId,
sapAiCoreModelId,
huggingFaceModelId,
huggingFaceModelInfo,
] = await Promise.all([
getGlobalState(context, "chatSettings") as Promise<StoredChatSettings | undefined>,
getGlobalState(context, "mode") as Promise<"plan" | "act" | undefined>,
@@ -340,6 +344,8 @@ export async function getAllExtensionState(context: vscode.ExtensionContext) {
getGlobalState(context, "previousModeAwsBedrockCustomModelBaseId") as Promise<BedrockModelId | undefined>,
getGlobalState(context, "previousModeSapAiCoreModelId") as Promise<string | undefined>,
getGlobalState(context, "sapAiCoreModelId") as Promise<string | undefined>,
getGlobalState(context, "huggingFaceModelId") as Promise<string | undefined>,
getGlobalState(context, "huggingFaceModelInfo") as Promise<ModelInfo | undefined>,
])
const processingStart = performance.now()
@@ -462,6 +468,9 @@ export async function getAllExtensionState(context: vscode.ExtensionContext) {
sapAiCoreTokenUrl,
sapAiResourceGroup,
sapAiCoreModelId,
huggingFaceApiKey,
huggingFaceModelId,
huggingFaceModelInfo,
},
isNewUser: isNewUser ?? true,
welcomeViewCompleted,
@@ -581,6 +590,9 @@ export async function updateApiConfiguration(context: vscode.ExtensionContext, a
sapAiResourceGroup,
sapAiCoreModelId,
claudeCodePath,
huggingFaceApiKey,
huggingFaceModelId,
huggingFaceModelInfo,
} = apiConfiguration
// OPTIMIZED: Batch all global state updates into 2 operations instead of 47
@@ -608,6 +620,8 @@ export async function updateApiConfiguration(context: vscode.ExtensionContext, a
groqModelId,
groqModelInfo,
sapAiCoreModelId,
huggingFaceModelId,
huggingFaceModelInfo,
// Global state updates (27 keys)
awsRegion,
@@ -672,6 +686,7 @@ export async function updateApiConfiguration(context: vscode.ExtensionContext, a
nebiusApiKey,
sapAiCoreClientId,
sapAiCoreClientSecret,
huggingFaceApiKey,
}
// Execute batched operations in parallel for maximum performance
@@ -715,6 +730,7 @@ export async function resetGlobalState(context: vscode.ExtensionContext) {
"groqApiKey",
"moonshotApiKey",
"nebiusApiKey",
"huggingFaceApiKey",
]
for (const key of secretKeys) {
await storeSecret(context, key, undefined)
+1 -1
View File
@@ -634,7 +634,7 @@ export class ToolExecutor {
}
await this.diffViewProvider.update(newContent, true)
await setTimeoutPromise(300) // wait for diff view to update
this.diffViewProvider.scrollToFirstDiff()
await this.diffViewProvider.scrollToFirstDiff()
// showOmissionWarning(this.diffViewProvider.originalContent || "", newContent)
const completeMessage = JSON.stringify({
+61 -46
View File
@@ -81,9 +81,10 @@ import { refreshWorkflowToggles } from "../context/instructions/user-instruction
import { MessageStateHandler } from "./message-state"
import { TaskState } from "./TaskState"
import { ToolExecutor } from "./ToolExecutor"
import { extractErrorDetails, formatErrorWithStatusCode, updateApiReqMsg } from "./utils"
import { createDiffViewProvider, getHostBridgeProvider } from "@/hosts/host-providers"
import { updateApiReqMsg } from "./utils"
import { createDiffViewProvider } from "@/hosts/host-providers"
import { ErrorService } from "@/services/error/ErrorService"
import { ClineErrorType } from "@/services/error/ClineError"
export const USE_EXPERIMENTAL_CLAUDE4_FEATURES = false
export type ToolResponse = string | Array<Anthropic.TextBlockParam | Anthropic.ImageBlockParam>
@@ -165,8 +166,19 @@ export class Task {
this.reinitExistingTaskFromId = reinitExistingTaskFromId
this.cancelTask = cancelTask
this.clineIgnoreController = new ClineIgnoreController(cwd)
// Initialization moved to startTask/resumeTaskFromHistory
this.terminalManager = new TerminalManager()
// TODO(ae) this is a hack to replace the terminal manager for standalone,
// until we have proper host bridge support for terminal execution. The
// standaloneTerminalManager is defined in the vscode-impls and injected
// during compilation of the standalone manager only, so this variable only
// exists in that case
if ((global as any).standaloneTerminalManager) {
console.log("[DEBUG] Using vscode-impls.js terminal manager")
this.terminalManager = (global as any).standaloneTerminalManager
} else {
console.log("[DEBUG] Using built in terminal manager")
this.terminalManager = new TerminalManager()
}
this.terminalManager.setShellIntegrationTimeout(shellIntegrationTimeout)
this.terminalManager.setTerminalReuseEnabled(terminalReuseEnabled ?? true)
this.terminalManager.setTerminalOutputLineLimit(terminalOutputLineLimit)
@@ -1577,6 +1589,12 @@ export class Task {
}
}
private async getCurrentProviderInfo(): Promise<{ modelId: string; providerId: string }> {
const modelId = this.api.getModel()?.id
const providerId = (await getGlobalState(this.getContext(), "apiProvider")) as string
return { modelId, providerId }
}
async *attemptApiRequest(previousApiReqIndex: number): ApiStream {
// Wait for MCP servers to be connected before generating system prompt
await pWaitFor(() => this.mcpHub.isConnecting !== true, { timeout: 10_000 }).catch(() => {
@@ -1671,16 +1689,17 @@ export class Task {
const isAnthropic = this.api instanceof AnthropicHandler
const isOpenRouterContextWindowError = checkIsOpenRouterContextWindowError(error) && isOpenRouter
const isAnthropicContextWindowError = checkIsAnthropicContextWindowError(error) && isAnthropic
const { modelId, providerId } = await this.getCurrentProviderInfo()
const clineError = ErrorService.toClineError(error, modelId, providerId)
const { statusCode, message, requestId } = extractErrorDetails(error)
// Capture provider failure telemetry
// Capture provider failure telemetry using clineError
// TODO: Move into ErrorService
telemetryService.captureProviderApiError({
taskId: this.taskId,
model: modelInfo.id,
errorMessage: message,
errorStatus: statusCode,
requestId,
errorMessage: clineError.message,
errorStatus: clineError._error?.status,
requestId: clineError._error?.request_id,
})
if (isAnthropic && isAnthropicContextWindowError && !this.taskState.didAutomaticallyRetryFailedApiRequest) {
@@ -1726,12 +1745,12 @@ export class Task {
// If the conversation has more than 3 messages, we can truncate again. If not, then the conversation is bricked.
// ToDo: Allow the user to change their input if this is the case.
if (truncatedConversationHistory.length > 3) {
error = new Error("Context window exceeded. Click retry to truncate the conversation and try again.")
clineError.message = "Context window exceeded. Click retry to truncate the conversation and try again."
this.taskState.didAutomaticallyRetryFailedApiRequest = false
}
}
const errorMessage = formatErrorWithStatusCode(error)
const streamingFailedMessage = clineError.serialize()
// Update the 'api_req_started' message to reflect final failure before asking user to manually retry
const lastApiReqStartedIndex = findLastIndex(
@@ -1747,19 +1766,24 @@ export class Task {
text: JSON.stringify({
...currentApiReqInfo, // Spread the modified info (with retryStatus removed)
// cancelReason: "retries_exhausted", // Indicate that automatic retries failed
streamingFailedMessage: errorMessage,
streamingFailedMessage,
} satisfies ClineApiReqInfo),
})
// this.ask will trigger postStateToWebview, so this change should be picked up.
}
const { response } = await this.ask("api_req_failed", errorMessage)
const { response } = await this.ask("api_req_failed", streamingFailedMessage)
if (response !== "yesButtonClicked") {
// this will never happen since if noButtonClicked, we will clear current task, aborting this instance
throw new Error("API request failed")
}
// Do not retry automatically again if currently unauthenticated
if (clineError.isErrorType(ClineErrorType.Auth)) {
return
}
await this.say("api_req_retried")
}
// delegate generator output from the recursive call
@@ -1895,10 +1919,10 @@ export class Task {
}
// Used to know what models were used in the task if user wants to export metadata for error reporting purposes
const currentProviderId = (await getGlobalState(this.getContext(), "apiProvider")) as string
if (currentProviderId && this.api.getModel().id) {
const { modelId, providerId } = await this.getCurrentProviderInfo()
if (providerId && modelId) {
try {
await this.modelContextTracker.recordModelUsage(currentProviderId, this.api.getModel().id, this.chatSettings.mode)
await this.modelContextTracker.recordModelUsage(providerId, modelId, this.chatSettings.mode)
} catch {}
}
@@ -2076,7 +2100,7 @@ export class Task {
content: userContent,
})
telemetryService.captureConversationTurnEvent(this.taskId, currentProviderId, this.api.getModel().id, "user")
telemetryService.captureConversationTurnEvent(this.taskId, providerId, modelId, "user")
// since we sent off a placeholder api_req_started message to update the webview while waiting to actually start the API request (to load potential details for example), we need to update the text of that message
const lastApiReqIndex = findLastIndex(this.messageStateHandler.getClineMessages(), (m) => m.say === "api_req_started")
@@ -2141,19 +2165,13 @@ export class Task {
})
await this.messageStateHandler.saveClineMessagesAndUpdateHistory()
telemetryService.captureConversationTurnEvent(
this.taskId,
currentProviderId,
this.api.getModel().id,
"assistant",
{
tokensIn: inputTokens,
tokensOut: outputTokens,
cacheWriteTokens,
cacheReadTokens,
totalCost,
},
)
telemetryService.captureConversationTurnEvent(this.taskId, providerId, this.api.getModel().id, "assistant", {
tokensIn: inputTokens,
tokensOut: outputTokens,
cacheWriteTokens,
cacheReadTokens,
totalCost,
})
// signals to provider that it can retrieve the saved messages from disk, as abortTask can not be awaited on in nature
this.taskState.didFinishAbortingStream = true
@@ -2250,7 +2268,8 @@ export class Task {
// abandoned happens when extension is no longer waiting for the cline instance to finish aborting (error is thrown here when any function in the for loop throws due to this.abort)
if (!this.taskState.abandoned) {
this.abortTask() // if the stream failed, there's various states the task could be in (i.e. could have streamed some tools the user may have executed), so we just resort to replicating a cancel task
const errorMessage = formatErrorWithStatusCode(error)
const clineError = ErrorService.toClineError(error, this.api.getModel().id)
const errorMessage = clineError.serialize()
await abortStream("streaming_failed", errorMessage)
await this.reinitExistingTaskFromId(this.taskId)
@@ -2320,19 +2339,13 @@ 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,
currentProviderId,
this.api.getModel().id,
"assistant",
{
tokensIn: inputTokens,
tokensOut: outputTokens,
cacheWriteTokens,
cacheReadTokens,
totalCost,
},
)
telemetryService.captureConversationTurnEvent(this.taskId, providerId, modelId, "assistant", {
tokensIn: inputTokens,
tokensOut: outputTokens,
cacheWriteTokens,
cacheReadTokens,
totalCost,
})
await this.messageStateHandler.addToApiConversationHistory({
role: "assistant",
@@ -2378,6 +2391,8 @@ export class Task {
},
],
})
// Returns early to avoid retry since no assistant message was received
return true
}
return didEndLoop // will always be false for now
-16
View File
@@ -1,25 +1,9 @@
import { showSystemNotification } from "@/integrations/notifications"
import { ClineApiReqCancelReason, ClineApiReqInfo } from "@/shared/ExtensionMessage"
import { serializeError } from "serialize-error"
import { MessageStateHandler } from "./message-state"
import { calculateApiCostAnthropic } from "@/utils/cost"
import { ApiHandler } from "@/api"
export function formatErrorWithStatusCode(error: any): string {
const { statusCode, message } = extractErrorDetails(error)
// Only prepend the statusCode if it's not already part of the message
return statusCode && !message.includes(statusCode.toString()) ? `${statusCode} - ${message}` : message
}
export function extractErrorDetails(error: any): { message: string; statusCode?: number; requestId?: string } {
const statusCode = error.status || error.statusCode || (error.response && error.response?.status)
const message = error.message ?? JSON.stringify(serializeError(error), null, 2)
const requestId = error.request_id || error.response?.request_id || undefined
return { message, statusCode, requestId }
}
export const showNotificationForApprovalIfAutoApprovalEnabled = (
message: string,
autoApprovalSettingsEnabled: boolean,
+5 -7
View File
@@ -264,13 +264,11 @@ export abstract class WebviewProvider {
} catch (error) {
// Only show the error message if not in development mode.
if (!process.env.IS_DEV) {
getHostBridgeProvider().windowClient.showMessage(
ShowMessageRequest.create({
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.",
}),
)
getHostBridgeProvider().windowClient.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()
+4 -6
View File
@@ -99,12 +99,10 @@ export function registerTaskCommands(context: vscode.ExtensionContext, controlle
await controller.postStateToWebview()
const message = `Created ${tasksCount} test tasks`
getHostBridgeProvider().windowClient.showMessage(
ShowMessageRequest.create({
type: ShowMessageType.INFORMATION,
message,
}),
)
getHostBridgeProvider().windowClient.showMessage({
type: ShowMessageType.INFORMATION,
message,
})
},
)
}),
+31 -32
View File
@@ -106,12 +106,10 @@ export async function activate(context: vscode.ExtensionContext) {
const message = `Cline has been updated to v${currentVersion}`
await vscode.commands.executeCommand("claude-dev.SidebarProvider.focus")
await new Promise((resolve) => setTimeout(resolve, 200))
getHostBridgeProvider().windowClient.showMessage(
ShowMessageRequest.create({
type: ShowMessageType.INFORMATION,
message,
}),
)
getHostBridgeProvider().windowClient.showMessage({
type: ShowMessageType.INFORMATION,
message,
})
// Record that we've shown the popup for this version.
await context.globalState.update("clineLastPopupNotificationVersion", currentVersion)
}
@@ -413,12 +411,10 @@ export async function activate(context: vscode.ExtensionContext) {
// Ensure clipboard is restored even if an error occurs
await writeTextToClipboard(tempCopyBuffer)
console.error("Error getting terminal contents:", error)
getHostBridgeProvider().windowClient.showMessage(
ShowMessageRequest.create({
type: ShowMessageType.ERROR,
message: "Failed to get terminal contents",
}),
)
getHostBridgeProvider().windowClient.showMessage({
type: ShowMessageType.ERROR,
message: "Failed to get terminal contents",
})
}
}),
)
@@ -554,12 +550,10 @@ export async function activate(context: vscode.ExtensionContext) {
}
const selectedText = editor.document.getText(range)
if (!selectedText.trim()) {
getHostBridgeProvider().windowClient.showMessage(
ShowMessageRequest.create({
type: ShowMessageType.INFORMATION,
message: "Please select some code to explain.",
}),
)
getHostBridgeProvider().windowClient.showMessage({
type: ShowMessageType.INFORMATION,
message: "Please select some code to explain.",
})
return
}
const filePath = editor.document.uri.fsPath
@@ -581,12 +575,10 @@ export async function activate(context: vscode.ExtensionContext) {
}
const selectedText = editor.document.getText(range)
if (!selectedText.trim()) {
getHostBridgeProvider().windowClient.showMessage(
ShowMessageRequest.create({
type: ShowMessageType.INFORMATION,
message: "Please select some code to improve.",
}),
)
getHostBridgeProvider().windowClient.showMessage({
type: ShowMessageType.INFORMATION,
message: "Please select some code to improve.",
})
return
}
const filePath = editor.document.uri.fsPath
@@ -651,12 +643,10 @@ export async function activate(context: vscode.ExtensionContext) {
sendFocusChatInputEvent(clientId)
} else {
console.error("FocusChatInput: Could not find or activate a Cline webview to focus.")
getHostBridgeProvider().windowClient.showMessage(
ShowMessageRequest.create({
type: ShowMessageType.ERROR,
message: "Could not activate Cline view. Please try opening it manually from the Activity Bar.",
}),
)
getHostBridgeProvider().windowClient.showMessage({
type: ShowMessageType.ERROR,
message: "Could not activate Cline view. Please try opening it manually from the Activity Bar.",
})
}
telemetryService.captureButtonClick("command_focusChatInput", activeWebviewProvider?.controller.task?.taskId)
}),
@@ -691,9 +681,18 @@ export async function activate(context: vscode.ExtensionContext) {
)
context.subscriptions.push(
context.secrets.onDidChange((event) => {
context.secrets.onDidChange(async (event) => {
if (event.key === "clineAccountId") {
AuthService.getInstance(context)?.restoreRefreshTokenAndRetrieveAuthInfo()
// Check if the secret was removed (logout) or added/updated (login)
const secretValue = await context.secrets.get("clineAccountId")
const authService = AuthService.getInstance(context)
if (secretValue) {
// Secret was added or updated - restore auth info (login from another window)
authService?.restoreRefreshTokenAndRetrieveAuthInfo()
} else {
// Secret was removed - handle logout for all windows
authService?.handleDeauth()
}
}
}),
)
+89 -6
View File
@@ -5,6 +5,9 @@ import { DecorationController } from "@integrations/editor/DecorationController"
import { DIFF_VIEW_URI_SCHEME, DiffViewProvider } from "@integrations/editor/DiffViewProvider"
export class VscodeDiffViewProvider extends DiffViewProvider {
private fadedOverlayController?: DecorationController
private activeLineController?: DecorationController
override async openDiffEditor(): Promise<void> {
if (!this.absolutePath) {
throw new Error("No file path set")
@@ -81,20 +84,100 @@ export class VscodeDiffViewProvider extends DiffViewProvider {
override async replaceText(
content: string,
rangeToReplace: { startLine: number; endLine: number },
currentLine: number,
currentLine: number | undefined,
): Promise<void> {
const document = this.activeDiffEditor?.document
if (!document) {
if (!this.activeDiffEditor || !this.activeDiffEditor.document) {
throw new Error("User closed text editor, unable to edit file...")
}
// Place cursor at the beginning of the diff editor to keep it out of the way of the stream animation
const beginningOfDocument = new vscode.Position(0, 0)
this.activeDiffEditor.selection = new vscode.Selection(beginningOfDocument, beginningOfDocument)
// Replace the text in the diff editor document.
const document = this.activeDiffEditor?.document
const edit = new vscode.WorkspaceEdit()
const range = new vscode.Range(rangeToReplace.startLine, 0, rangeToReplace.endLine, 0)
edit.replace(document.uri, range, content)
await vscode.workspace.applyEdit(edit)
// Update decorations for the entire changed section
this.activeLineController?.setActiveLine(currentLine)
this.fadedOverlayController?.updateOverlayAfterLine(currentLine, document.lineCount)
if (currentLine !== undefined) {
// Update decorations for the entire changed section
this.activeLineController?.setActiveLine(currentLine)
this.fadedOverlayController?.updateOverlayAfterLine(currentLine, document.lineCount)
}
}
override async scrollEditorToLine(line: number): Promise<void> {
if (!this.activeDiffEditor) {
return
}
const scrollLine = line + 4
this.activeDiffEditor.revealRange(new vscode.Range(scrollLine, 0, scrollLine, 0), vscode.TextEditorRevealType.InCenter)
}
override async scrollAnimation(startLine: number, endLine: number): Promise<void> {
if (!this.activeDiffEditor) {
return
}
const totalLines = endLine - startLine
const numSteps = 10 // Adjust this number to control animation speed
const stepSize = Math.max(1, Math.floor(totalLines / numSteps))
// Create and await the smooth scrolling animation
for (let line = startLine; line <= endLine; line += stepSize) {
this.activeDiffEditor.revealRange(new vscode.Range(line, 0, line, 0), vscode.TextEditorRevealType.InCenter)
await new Promise((resolve) => setTimeout(resolve, 16)) // ~60fps
}
}
override async truncateDocument(lineNumber: number): Promise<void> {
if (!this.activeDiffEditor) {
return
}
const document = this.activeDiffEditor.document
if (lineNumber < document.lineCount) {
const edit = new vscode.WorkspaceEdit()
edit.delete(document.uri, new vscode.Range(lineNumber, 0, document.lineCount, 0))
await vscode.workspace.applyEdit(edit)
}
// Clear all decorations at the end (before applying final edit)
this.fadedOverlayController?.clear()
this.activeLineController?.clear()
}
protected override async getDocumentText(): Promise<string | undefined> {
if (!this.activeDiffEditor || !this.activeDiffEditor.document) {
return undefined
}
return this.activeDiffEditor.document.getText()
}
protected override async saveDocument(): Promise<void> {
if (!this.activeDiffEditor) {
return
}
if (this.activeDiffEditor.document.isDirty) {
await this.activeDiffEditor.document.save()
}
}
protected async closeDiffView(): Promise<void> {
// Close all the cline diff views.
const tabs = vscode.window.tabGroups.all
.flatMap((tg) => tg.tabs)
.filter((tab) => tab.input instanceof vscode.TabInputTextDiff && tab.input?.original?.scheme === DIFF_VIEW_URI_SCHEME)
for (const tab of tabs) {
// trying to close dirty views results in save popup
if (!tab.isDirty) {
await vscode.window.tabGroups.close(tab)
}
}
}
protected override async resetDiffView(): Promise<void> {
this.activeDiffEditor = undefined
this.fadedOverlayController = undefined
this.activeLineController = undefined
this.preDiagnostics = []
}
}
@@ -0,0 +1,5 @@
import { CloseDiffRequest, CloseDiffResponse } from "@/shared/proto/index.host"
export async function closeDiff(_request: CloseDiffRequest): Promise<CloseDiffResponse> {
throw new Error("diffService is not supported. Use the VscodeDiffViewProvider.")
}
@@ -0,0 +1,5 @@
import { GetDocumentTextRequest, GetDocumentTextResponse } from "@/shared/proto/index.host"
export async function getDocumentText(_request: GetDocumentTextRequest): Promise<GetDocumentTextResponse> {
throw new Error("diffService is not supported. Use the VscodeDiffViewProvider.")
}
+1 -1
View File
@@ -1,5 +1,5 @@
import { OpenDiffRequest, OpenDiffResponse } from "@/shared/proto/index.host"
export async function openDiff(_request: OpenDiffRequest): Promise<OpenDiffResponse> {
throw new Error("diffService.openDiff is not supported. Use the VscodeDiffViewProvider.")
throw new Error("diffService is not supported. Use the VscodeDiffViewProvider.")
}
@@ -1,5 +1,5 @@
import { ReplaceTextRequest, ReplaceTextResponse } from "@/shared/proto/index.host"
export async function replaceText(_request: ReplaceTextRequest): Promise<ReplaceTextResponse> {
throw new Error("diffService.replaceText is not supported. Use the VscodeDiffViewProvider.")
throw new Error("diffService is not supported. Use the VscodeDiffViewProvider.")
}
@@ -0,0 +1,5 @@
import { SaveDocumentRequest, SaveDocumentResponse } from "@/shared/proto/index.host"
export async function saveDocument(_request: SaveDocumentRequest): Promise<SaveDocumentResponse> {
throw new Error("diffService is not supported. Use the VscodeDiffViewProvider.")
}
@@ -0,0 +1,5 @@
import { TruncateDocumentRequest, TruncateDocumentResponse } from "@/shared/proto/index.host"
export async function truncateDocument(_request: TruncateDocumentRequest): Promise<TruncateDocumentResponse> {
throw new Error("diffService is not supported. Use the VscodeDiffViewProvider.")
}
@@ -3,7 +3,7 @@ import { SelectedResponse, ShowMessageRequest, ShowMessageType } from "@/shared/
const DEFAULT_OPTIONS = { modal: false, items: [] } as const
export async function showMessage(request: ShowMessageRequest): Promise<SelectedResponse | undefined> {
export async function showMessage(request: ShowMessageRequest): Promise<SelectedResponse> {
const { message, type, options } = request
const { modal, detail, items } = { ...DEFAULT_OPTIONS, ...options }
const option = { modal, detail }
@@ -1,75 +0,0 @@
import * as vscode from "vscode"
interface DebugSession {
id: string
name: string
output: string[]
lastRetrievedIndex: number
}
export class DebugConsoleManager {
private sessions: Map<string, DebugSession> = new Map()
private disposables: vscode.Disposable[] = []
constructor() {
// Listen for debug session start events
this.disposables.push(
vscode.debug.onDidStartDebugSession((session) => {
this.sessions.set(session.id, {
id: session.id,
name: session.name,
output: [],
lastRetrievedIndex: -1,
})
}),
)
// Listen for debug session end events
this.disposables.push(
vscode.debug.onDidTerminateDebugSession((session) => {
this.sessions.delete(session.id)
}),
)
// Listen for debug console output
this.disposables.push(
vscode.debug.onDidReceiveDebugSessionCustomEvent((e: vscode.DebugSessionCustomEvent) => {
if (e.event === "output" && e.body?.output) {
const session = this.sessions.get(e.session.id)
if (session) {
session.output.push(e.body.output)
}
}
}),
)
}
/**
* Get all active debug sessions
*/
getActiveSessions(): { id: string; name: string }[] {
return Array.from(this.sessions.values()).map(({ id, name }) => ({ id, name }))
}
/**
* Get any new output since the last retrieval for a specific debug session
*/
getUnretrievedOutput(sessionId: string): string | undefined {
const session = this.sessions.get(sessionId)
if (!session) {
return undefined
}
const newOutput = session.output.slice(session.lastRetrievedIndex + 1).join("")
session.lastRetrievedIndex = session.output.length - 1
return newOutput || undefined
}
/**
* Clean up resources
*/
dispose() {
this.disposables.forEach((d) => d.dispose())
this.sessions.clear()
}
}
@@ -1,123 +0,0 @@
/*
import * as vscode from "vscode"
import deepEqual from "fast-deep-equal"
type FileDiagnostics = [vscode.Uri, vscode.Diagnostic[]][]
About Diagnostics:
The Problems tab shows diagnostics that have been reported for your project. These diagnostics are categorized into:
Errors: Critical issues that usually prevent your code from compiling or running correctly.
Warnings: Potential problems in the code that may not prevent it from running but could cause issues (e.g., bad practices, unused variables).
Information: Non-critical suggestions or tips (e.g., formatting issues or notes from linters).
The Problems tab displays diagnostics from various sources:
1. Language Servers:
- TypeScript: Type errors, missing imports, syntax issues
- Python: Syntax errors, invalid type hints, undefined variables
- JavaScript/Node.js: Parsing and execution errors
2. Linters:
- ESLint: Code style, best practices, potential bugs
- Pylint: Unused imports, naming conventions
- TSLint: Style and correctness issues in TypeScript
3. Build Tools:
- Webpack: Module resolution failures, build errors
- Gulp: Build errors during task execution
4. Custom Validators:
- Extensions can generate custom diagnostics for specific languages or tools
Each problem typically indicates its source (e.g., language server, linter, build tool).
Diagnostics update in real-time as you edit code, helping identify issues quickly. For example, if you introduce a syntax error in a TypeScript file, the Problems tab will immediately display the new error.
Notes on diagnostics:
- linter diagnostics are only captured for open editors
- this works great for us since when cline edits/creates files its through vscode's textedit api's and we get those diagnostics for free
- some tools might require you to save the file or manually refresh to clear the problem from the list.
System Prompt
- You will automatically receive workspace error diagnostics in environment_details. Be mindful that this may include issues beyond the scope of your task or the user's request. Only address errors relevant to your work, and avoid fixing pre-existing or unrelated issues unless the user specifically instructs you to do so.
- If you are unable to resolve errors provided in environment_details after two attempts, consider using ask_followup_question to ask the user for additional information, such as the latest documentation related to a problematic framework, to help you make progress on the task. If the error remains unresolved after this step, proceed with your task while disregarding the error.
class DiagnosticsMonitor {
private diagnosticsChangeEmitter: vscode.EventEmitter<void> = new vscode.EventEmitter<void>()
private disposables: vscode.Disposable[] = []
private lastDiagnostics: FileDiagnostics = []
constructor() {
this.disposables.push(
vscode.languages.onDidChangeDiagnostics(() => {
this.diagnosticsChangeEmitter.fire()
})
)
}
public async getCurrentDiagnostics(shouldWaitForChanges: boolean): Promise<FileDiagnostics> {
const currentDiagnostics = this.getDiagnostics()
if (!shouldWaitForChanges) {
this.lastDiagnostics = currentDiagnostics
return currentDiagnostics
}
if (!deepEqual(this.lastDiagnostics, currentDiagnostics)) {
this.lastDiagnostics = currentDiagnostics
return currentDiagnostics
}
let timeout = 300 // only way this happens is if there's no errors
// if diagnostics contain existing errors (since the check above didn't trigger) then it's likely cline just did something that should have fixed the error, so we'll give a longer grace period for diagnostics to catch up
const hasErrors = currentDiagnostics.some(([_, diagnostics]) =>
diagnostics.some((d) => d.severity === vscode.DiagnosticSeverity.Error)
)
if (hasErrors) {
console.log("Existing errors detected, extending timeout", currentDiagnostics)
timeout = 10_000
}
return this.waitForUpdatedDiagnostics(timeout)
}
private async waitForUpdatedDiagnostics(timeout: number): Promise<FileDiagnostics> {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
cleanup()
const finalDiagnostics = this.getDiagnostics()
this.lastDiagnostics = finalDiagnostics
resolve(finalDiagnostics)
}, timeout)
const disposable = this.diagnosticsChangeEmitter.event(() => {
const updatedDiagnostics = this.getDiagnostics() // I thought this would only trigger when diagnostics changed, but that's not the case.
if (deepEqual(this.lastDiagnostics, updatedDiagnostics)) {
// diagnostics have not changed, ignoring...
return
}
cleanup()
this.lastDiagnostics = updatedDiagnostics
resolve(updatedDiagnostics)
})
const cleanup = () => {
clearTimeout(timer)
disposable.dispose()
}
})
}
private getDiagnostics(): FileDiagnostics {
const allDiagnostics = vscode.languages.getDiagnostics()
return allDiagnostics
.filter(([_, diagnostics]) => diagnostics.some((d) => d.severity === vscode.DiagnosticSeverity.Error))
.map(([uri, diagnostics]) => [
uri,
diagnostics.filter((d) => d.severity === vscode.DiagnosticSeverity.Error),
])
}
public dispose() {
this.disposables.forEach((d) => d.dispose())
this.disposables = []
this.diagnosticsChangeEmitter.dispose()
}
}
export default DiagnosticsMonitor
*/
+90 -113
View File
@@ -4,13 +4,11 @@ import * as fs from "fs/promises"
import { createDirectoriesForFile } from "@utils/fs"
import { arePathsEqual, getCwd } from "@utils/path"
import { formatResponse } from "@core/prompts/responses"
import { DecorationController } from "./DecorationController"
import * as diff from "diff"
import { diagnosticsToProblemsString, getNewDiagnostics } from "../diagnostics"
import { detectEncoding } from "../misc/extract-text"
import * as iconv from "iconv-lite"
import { getHostBridgeProvider } from "@/hosts/host-providers"
import { ShowTextDocumentRequest, ShowTextDocumentOptions } from "@/shared/proto/host/window"
export const DIFF_VIEW_URI_SCHEME = "cline-diff"
@@ -27,8 +25,6 @@ export abstract class DiffViewProvider {
private newContent?: string
protected activeDiffEditor?: vscode.TextEditor
protected fadedOverlayController?: DecorationController
protected activeLineController?: DecorationController
protected preDiagnostics: [vscode.Uri, vscode.Diagnostic[]][] = []
constructor() {}
@@ -62,17 +58,13 @@ export abstract class DiffViewProvider {
await fs.writeFile(this.absolutePath, "")
}
await this.openDiffEditor()
this.scrollEditorToLine(0) // will this crash for new files?
await this.scrollEditorToLine(0)
this.streamedLines = []
}
/**
* Opens a diff editor or viewer for the current file.
*
* This abstract method must be implemented by subclasses to create and display
* a diff editor or viewer that shows the difference between the original and
* modified content.
*
* Called automatically by the `open` method after ensuring the file exists and
* creating any necessary directories.
*
@@ -80,13 +72,61 @@ export abstract class DiffViewProvider {
*/
protected abstract openDiffEditor(): Promise<void>
/**
* Scrolls the diff editor to reveal a specific line.
*
* It's used during streaming updates to keep the user's view focused on the changing content.
*
* @param line The 0-based line number to scroll to
*/
protected abstract scrollEditorToLine(line: number): Promise<void>
/**
* Creates a smooth scrolling animation between two lines in the diff editor.
*
* It's typically used when updates contain many lines, to help the user visually track the flow
* of significant changes in the document.
*
* @param startLine The 0-based line number to begin the animation from
* @param endLine The 0-based line number to animate to
*/
protected abstract scrollAnimation(startLine: number, endLine: number): Promise<void>
/**
* Removes content from the specified line to the end of the document.
* Called after the final update is received.
*/
protected abstract truncateDocument(lineNumber: number): Promise<void>
/**
* Get the contents of the diff editor document.
*
* Returns undefined if the diff editor was closed.
*/
protected abstract getDocumentText(): Promise<string | undefined>
/**
* Save the contents of the diff editor UI to the file.
*/
protected abstract saveDocument(): Promise<void>
/**
* Closes the diff editor tab or window.
*/
protected abstract closeDiffView(): Promise<void>
/**
* Cleans up the diff view resources and resets internal state.
*/
protected abstract resetDiffView(): Promise<void>
async update(
accumulatedContent: string,
isFinal: boolean,
changeLocation?: { startLine: number; endLine: number; startChar: number; endChar: number },
) {
if (!this.relPath || !this.activeLineController || !this.fadedOverlayController) {
throw new Error("Required values not set")
if (!this.relPath) {
throw new Error("Required value relPath not set")
}
// --- Fix to prevent duplicate BOM ---
@@ -104,16 +144,6 @@ export abstract class DiffViewProvider {
}
const diffLines = accumulatedLines.slice(this.streamedLines.length)
const diffEditor = this.activeDiffEditor
const document = diffEditor?.document
if (!diffEditor || !document) {
throw new Error("User closed text editor, unable to edit file...")
}
// Place cursor at the beginning of the diff editor to keep it out of the way of the stream animation
const beginningOfDocument = new vscode.Position(0, 0)
diffEditor.selection = new vscode.Selection(beginningOfDocument, beginningOfDocument)
// Instead of animating each line, we'll update in larger chunks
const currentLine = this.streamedLines.length + diffLines.length - 1
if (currentLine >= 0) {
@@ -129,30 +159,19 @@ export abstract class DiffViewProvider {
if (changeLocation) {
// We have the actual location of the change, scroll to it
const targetLine = changeLocation.startLine
this.scrollEditorToLine(targetLine)
await this.scrollEditorToLine(targetLine)
} else {
// Fallback to the old logic for non-replacement updates
if (diffLines.length <= 5) {
// For small changes, just jump directly to the line
this.scrollEditorToLine(currentLine)
await this.scrollEditorToLine(currentLine)
} else {
// For larger changes, create a quick scrolling animation
const startLine = this.streamedLines.length
const endLine = currentLine
const totalLines = endLine - startLine
const numSteps = 10 // Adjust this number to control animation speed
const stepSize = Math.max(1, Math.floor(totalLines / numSteps))
// Create and await the smooth scrolling animation
for (let line = startLine; line <= endLine; line += stepSize) {
this.activeDiffEditor?.revealRange(
new vscode.Range(line, 0, line, 0),
vscode.TextEditorRevealType.InCenter,
)
await new Promise((resolve) => setTimeout(resolve, 16)) // ~60fps
}
await this.scrollAnimation(startLine, endLine)
// Ensure we end at the final line
this.scrollEditorToLine(currentLine)
await this.scrollEditorToLine(currentLine)
}
}
}
@@ -161,11 +180,8 @@ export abstract class DiffViewProvider {
this.streamedLines = accumulatedLines
if (isFinal) {
// Handle any remaining lines if the new content is shorter than the original
if (this.streamedLines.length < document.lineCount) {
const edit = new vscode.WorkspaceEdit()
edit.delete(document.uri, new vscode.Range(this.streamedLines.length, 0, document.lineCount, 0))
await vscode.workspace.applyEdit(edit)
}
await this.truncateDocument(this.streamedLines.length)
// Add empty last line if original content had one
const hasEmptyLastLine = this.originalContent?.endsWith("\n")
if (hasEmptyLastLine) {
@@ -174,9 +190,6 @@ export abstract class DiffViewProvider {
accumulatedContent += "\n"
}
}
// Clear all decorations at the end (before applying final edit)
this.fadedOverlayController.clear()
this.activeLineController.clear()
}
}
@@ -195,7 +208,7 @@ export abstract class DiffViewProvider {
abstract replaceText(
content: string,
rangeToReplace: { startLine: number; endLine: number },
currentLine: number,
currentLine: number | undefined,
): Promise<void>
async saveChanges(): Promise<{
@@ -204,7 +217,10 @@ export abstract class DiffViewProvider {
autoFormattingEdits: string | undefined
finalContent: string | undefined
}> {
if (!this.relPath || !this.newContent || !this.activeDiffEditor) {
// get the contents before save operation which may do auto-formatting
const preSaveContent = await this.getDocumentText()
if (!this.relPath || !this.absolutePath || !this.newContent || preSaveContent === undefined) {
return {
newProblemsMessage: undefined,
userEdits: undefined,
@@ -212,28 +228,19 @@ export abstract class DiffViewProvider {
finalContent: undefined,
}
}
const updatedDocument = this.activeDiffEditor.document
// get the contents before save operation which may do auto-formatting
const preSaveContent = updatedDocument.getText()
if (updatedDocument.isDirty) {
await updatedDocument.save()
}
await this.saveDocument()
// get text after save in case there is any auto-formatting done by the editor
const postSaveContent = updatedDocument.getText()
const postSaveContent = (await this.getDocumentText()) || ""
await getHostBridgeProvider().windowClient.showTextDocument(
ShowTextDocumentRequest.create({
path: this.absolutePath,
options: ShowTextDocumentOptions.create({
preview: false,
preserveFocus: true,
}),
}),
)
await this.closeAllDiffViews()
await getHostBridgeProvider().windowClient.showTextDocument({
path: this.absolutePath,
options: {
preview: false,
preserveFocus: true,
},
})
await this.closeDiffView()
/*
Getting diagnostics before and after the file edit is a better approach than
@@ -299,12 +306,10 @@ export abstract class DiffViewProvider {
return
}
const fileExists = this.editType === "modify"
const updatedDocument = this.activeDiffEditor.document
if (!fileExists) {
if (updatedDocument.isDirty) {
await updatedDocument.save()
}
await this.closeAllDiffViews()
await this.saveDocument()
await this.closeDiffView()
await fs.unlink(this.absolutePath)
// Remove only the directories we created, in reverse order
for (let i = this.createdDirs.length - 1; i >= 0; i--) {
@@ -314,6 +319,7 @@ export abstract class DiffViewProvider {
console.log(`File ${this.absolutePath} has been deleted.`)
} else {
// revert document
const updatedDocument = this.activeDiffEditor.document
const edit = new vscode.WorkspaceEdit()
const fullRange = new vscode.Range(
updatedDocument.positionAt(0),
@@ -325,59 +331,32 @@ export abstract class DiffViewProvider {
await updatedDocument.save()
console.log(`File ${this.absolutePath} has been reverted to its original content.`)
if (this.documentWasOpen) {
await getHostBridgeProvider().windowClient.showTextDocument(
ShowTextDocumentRequest.create({
path: this.absolutePath,
options: ShowTextDocumentOptions.create({
preview: false,
preserveFocus: true,
}),
}),
)
await getHostBridgeProvider().windowClient.showTextDocument({
path: this.absolutePath,
options: {
preview: false,
preserveFocus: true,
},
})
}
await this.closeAllDiffViews()
await this.closeDiffView()
}
// edit is done
await this.reset()
}
private async closeAllDiffViews() {
const tabs = vscode.window.tabGroups.all
.flatMap((tg) => tg.tabs)
.filter((tab) => tab.input instanceof vscode.TabInputTextDiff && tab.input?.original?.scheme === DIFF_VIEW_URI_SCHEME)
for (const tab of tabs) {
// trying to close dirty views results in save popup
if (!tab.isDirty) {
await vscode.window.tabGroups.close(tab)
}
}
}
private scrollEditorToLine(line: number) {
if (this.activeDiffEditor) {
const scrollLine = line + 4
this.activeDiffEditor.revealRange(
new vscode.Range(scrollLine, 0, scrollLine, 0),
vscode.TextEditorRevealType.InCenter,
)
}
}
scrollToFirstDiff() {
if (!this.activeDiffEditor) {
async scrollToFirstDiff() {
if (!this.isEditing) {
return
}
const currentContent = this.activeDiffEditor.document.getText()
const currentContent = (await this.getDocumentText()) || ""
const diffs = diff.diffLines(this.originalContent || "", currentContent)
let lineCount = 0
for (const part of diffs) {
if (part.added || part.removed) {
// Found the first diff, scroll to it
this.activeDiffEditor.revealRange(
new vscode.Range(lineCount, 0, lineCount, 0),
vscode.TextEditorRevealType.InCenter,
)
this.scrollEditorToLine(lineCount)
return
}
if (!part.removed) {
@@ -393,10 +372,8 @@ export abstract class DiffViewProvider {
this.originalContent = undefined
this.createdDirs = []
this.documentWasOpen = false
this.activeDiffEditor = undefined
this.fadedOverlayController = undefined
this.activeLineController = undefined
this.streamedLines = []
this.preDiagnostics = []
await this.resetDiffView()
}
}
@@ -59,12 +59,10 @@ export function extractCommitMessage(aiResponse: string): string {
*/
export async function copyCommitMessageToClipboard(message: string): Promise<void> {
await writeTextToClipboard(message)
getHostBridgeProvider().windowClient.showMessage(
ShowMessageRequest.create({
type: ShowMessageType.INFORMATION,
message: "Commit message copied to clipboard",
}),
)
getHostBridgeProvider().windowClient.showMessage({
type: ShowMessageType.INFORMATION,
message: "Commit message copied to clipboard",
})
}
/**
@@ -77,18 +75,16 @@ export async function showCommitMessageOptions(message: string): Promise<void> {
const editAction = "Edit Message"
const selectedAction = (
await getHostBridgeProvider().windowClient.showMessage(
ShowMessageRequest.create({
type: ShowMessageType.INFORMATION,
message: "Commit message generated",
options: {
modal: false,
detail: message,
items: [copyAction, applyAction, editAction],
},
}),
)
)?.selectedOption
await getHostBridgeProvider().windowClient.showMessage({
type: ShowMessageType.INFORMATION,
message: "Commit message generated",
options: {
modal: false,
detail: message,
items: [copyAction, applyAction, editAction],
},
})
).selectedOption
// Handle user dismissing the dialog (selectedAction is undefined)
if (!selectedAction) {
@@ -120,28 +116,22 @@ async function applyCommitMessageToGitInput(message: string): Promise<void> {
if (api && api.repositories.length > 0) {
const repo = api.repositories[0]
repo.inputBox.value = message
getHostBridgeProvider().windowClient.showMessage(
ShowMessageRequest.create({
type: ShowMessageType.INFORMATION,
message: "Commit message applied to Git input",
}),
)
getHostBridgeProvider().windowClient.showMessage({
type: ShowMessageType.INFORMATION,
message: "Commit message applied to Git input",
})
} else {
getHostBridgeProvider().windowClient.showMessage(
ShowMessageRequest.create({
type: ShowMessageType.ERROR,
message: "No Git repositories found",
}),
)
getHostBridgeProvider().windowClient.showMessage({
type: ShowMessageType.ERROR,
message: "No Git repositories found",
})
await copyCommitMessageToClipboard(message)
}
} else {
getHostBridgeProvider().windowClient.showMessage(
ShowMessageRequest.create({
type: ShowMessageType.ERROR,
message: "Git extension not found",
}),
)
getHostBridgeProvider().windowClient.showMessage({
type: ShowMessageType.ERROR,
message: "Git extension not found",
})
await copyCommitMessageToClipboard(message)
}
}
@@ -161,10 +151,8 @@ async function editCommitMessage(message: string): Promise<void> {
path: document.uri.fsPath,
}),
)
getHostBridgeProvider().windowClient.showMessage(
ShowMessageRequest.create({
type: ShowMessageType.INFORMATION,
message: "Edit the commit message and copy when ready",
}),
)
getHostBridgeProvider().windowClient.showMessage({
type: ShowMessageType.INFORMATION,
message: "Edit the commit message and copy when ready",
})
}
+4 -6
View File
@@ -48,12 +48,10 @@ export async function downloadTask(dateTs: number, conversationHistory: Anthropi
}),
)
} catch (error) {
getHostBridgeProvider().windowClient.showMessage(
ShowMessageRequest.create({
type: ShowMessageType.ERROR,
message: `Failed to save markdown file: ${error instanceof Error ? error.message : String(error)}`,
}),
)
getHostBridgeProvider().windowClient.showMessage({
type: ShowMessageType.ERROR,
message: `Failed to save markdown file: ${error instanceof Error ? error.message : String(error)}`,
})
}
}
}
+9 -13
View File
@@ -3,18 +3,16 @@ import * as os from "os"
import * as vscode from "vscode"
import { arePathsEqual } from "@utils/path"
import { getHostBridgeProvider } from "@/hosts/host-providers"
import { ShowTextDocumentRequest, ShowTextDocumentOptions, ShowMessageRequest, ShowMessageType } from "@/shared/proto/host/window"
import { ShowMessageRequest, ShowMessageType } from "@/shared/proto/host/window"
import { writeFile } from "@utils/fs"
export async function openImage(dataUri: string) {
const matches = dataUri.match(/^data:image\/([a-zA-Z]+);base64,(.+)$/)
if (!matches) {
getHostBridgeProvider().windowClient.showMessage(
ShowMessageRequest.create({
type: ShowMessageType.ERROR,
message: "Invalid data URI format",
}),
)
getHostBridgeProvider().windowClient.showMessage({
type: ShowMessageType.ERROR,
message: "Invalid data URI format",
})
return
}
const [, format, base64Data] = matches
@@ -24,12 +22,10 @@ export async function openImage(dataUri: string) {
await writeFile(tempFilePath, new Uint8Array(imageBuffer))
await vscode.commands.executeCommand("vscode.open", vscode.Uri.file(tempFilePath))
} catch (error) {
getHostBridgeProvider().windowClient.showMessage(
ShowMessageRequest.create({
type: ShowMessageType.ERROR,
message: `Error opening image: ${error}`,
}),
)
getHostBridgeProvider().windowClient.showMessage({
type: ShowMessageType.ERROR,
message: `Error opening image: ${error}`,
})
}
}
+16 -24
View File
@@ -46,22 +46,18 @@ export async function selectFiles(imagesAllowed: boolean): Promise<{ images: str
const dimensions = sizeOf(uint8Array) // Get dimensions from Uint8Array
if (dimensions.width! > 7500 || dimensions.height! > 7500) {
console.warn(`Image dimensions exceed 7500px, skipping: ${filePath}`)
getHostBridgeProvider().windowClient.showMessage(
ShowMessageRequest.create({
type: ShowMessageType.ERROR,
message: `Image too large: ${path.basename(filePath)} was skipped (dimensions exceed 7500px).`,
}),
)
getHostBridgeProvider().windowClient.showMessage({
type: ShowMessageType.ERROR,
message: `Image too large: ${path.basename(filePath)} was skipped (dimensions exceed 7500px).`,
})
return null
}
} catch (error) {
console.error(`Error reading file or getting dimensions for ${filePath}:`, error)
getHostBridgeProvider().windowClient.showMessage(
ShowMessageRequest.create({
type: ShowMessageType.ERROR,
message: `Could not read dimensions for ${path.basename(filePath)}, skipping.`,
}),
)
getHostBridgeProvider().windowClient.showMessage({
type: ShowMessageType.ERROR,
message: `Could not read dimensions for ${path.basename(filePath)}, skipping.`,
})
return null
}
@@ -76,22 +72,18 @@ export async function selectFiles(imagesAllowed: boolean): Promise<{ images: str
const stats = await fs.stat(filePath)
if (stats.size > 20 * 1000 * 1024) {
console.warn(`File too large, skipping: ${filePath}`)
getHostBridgeProvider().windowClient.showMessage(
ShowMessageRequest.create({
type: ShowMessageType.ERROR,
message: `File too large: ${path.basename(filePath)} was skipped (size exceeds 20MB).`,
}),
)
getHostBridgeProvider().windowClient.showMessage({
type: ShowMessageType.ERROR,
message: `File too large: ${path.basename(filePath)} was skipped (size exceeds 20MB).`,
})
return null
}
} catch (error) {
console.error(`Error checking file size for ${filePath}:`, error)
getHostBridgeProvider().windowClient.showMessage(
ShowMessageRequest.create({
type: ShowMessageType.ERROR,
message: `Could not check file size for ${path.basename(filePath)}, skipping.`,
}),
)
getHostBridgeProvider().windowClient.showMessage({
type: ShowMessageType.ERROR,
message: `Could not check file size for ${path.basename(filePath)}, skipping.`,
})
return null
}
return { type: "file", data: filePath }
+3 -1
View File
@@ -86,7 +86,9 @@ export class TerminalProcess extends EventEmitter<TerminalProcessEvents> {
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
if (!stream) {
return
}
// Initialize state for stream processing
const streamState = this.initializeStreamState()
+157
View File
@@ -0,0 +1,157 @@
import { CLINE_ACCOUNT_AUTH_ERROR_MESSAGE } from "../../shared/ClineAccount"
import { serializeError } from "serialize-error"
export enum ClineErrorType {
Auth = "auth",
Network = "network",
RateLimit = "rateLimit",
Balance = "balance",
}
interface ErrorDetails {
/**
* The HTTP status code of the error, if applicable.
*/
status?: number
/**
* The request ID associated with the error, if available.
* This can be useful for debugging and support.
*/
request_id?: string
/**
* Specific error code provided by the API or service.
*/
code?: string
/**
* The model ID associated with the error, if applicable.
* This is useful for identifying which model the error relates to.
*/
modelId?: string
/**
* The provider ID associated with the error, if applicable.
* This is useful for identifying which provider the error relates to.
*/
providerId?: string
/**
* The error message associated with the error, if applicable.
*/
message?: string
// Additional details that might be present in the error
// This can include things like current balance, error messages, etc.
details?: any
}
const RATE_LIMIT_PATTERNS = [/status code 429/i, /rate limit/i, /too many requests/i, /quota exceeded/i, /resource exhausted/i]
export class ClineError extends Error {
readonly title = "ClineError"
readonly _error: ErrorDetails
// Error details per providers:
// Cline: error?.error
// Ollama: error?.cause
// tbc
constructor(
raw: any,
public readonly modelId?: string,
public readonly providerId?: string,
) {
const error = serializeError(raw)
const message = error.message || String(error) || error?.cause?.means
super(message)
// Extract status from multiple possible locations
const status = error.status || error.statusCode || error.response?.status
// Construct the error details object to includes relevant information
// And ensure it has a consistent structure
this._error = {
message: raw.message || message,
status,
request_id: error.request_id || error.response?.request_id,
code: error.code || error?.cause?.code,
modelId,
providerId,
details: error.details || error.error, // Additional details provided by the server
...error,
stack: undefined, // Avoid serializing stack trace to keep the error object clean
}
}
/**
* Serializes the error to a JSON string that allows for easy transmission and storage.
* This is useful for logging or sending error details to a webviews.
*/
public serialize(): string {
return JSON.stringify({
message: this.message,
status: this._error.status,
request_id: this._error.request_id,
code: this._error.code,
modelId: this.modelId,
providerId: this.providerId,
details: this._error.details,
})
}
/**
* Parses a stringified error into a ClineError instance.
*/
static parse(errorStr?: string, modelId?: string): ClineError | undefined {
if (!errorStr || typeof errorStr !== "string") {
return undefined
}
return ClineError.transform(errorStr, modelId)
}
/**
* Transforms any object into a ClineError instance.
* Always returns a ClineError, even if the input is not a valid error object.
*/
static transform(error: any, modelId?: string, providerId?: string): ClineError {
try {
return new ClineError(JSON.parse(error), modelId, providerId)
} catch {
return new ClineError(error, modelId, providerId)
}
}
public isErrorType(type: ClineErrorType): boolean {
return ClineError.getErrorType(this) === type
}
/**
* Is known error type based on the error code, status, and details.
* This is useful for determining how to handle the error in the UI or logic.
*/
static getErrorType(err: ClineError): ClineErrorType | undefined {
const { code, status, details } = err._error
// Check balance error first (most specific)
if (code === "insufficient_credits" && typeof details?.current_balance === "number") {
return ClineErrorType.Balance
}
// Check auth errors
if (code === "ERR_BAD_REQUEST" || status === 401) {
return ClineErrorType.Auth
}
// Check for auth message (only if message exists)
const message = err.message
if (message?.includes(CLINE_ACCOUNT_AUTH_ERROR_MESSAGE)) {
return ClineErrorType.Auth
}
// Check rate limit patterns
if (message) {
const lowerMessage = message.toLowerCase()
if (RATE_LIMIT_PATTERNS.some((pattern) => pattern.test(lowerMessage))) {
return ClineErrorType.RateLimit
}
}
return undefined
}
}
+10 -3
View File
@@ -1,7 +1,8 @@
import * as Sentry from "@sentry/browser"
import * as vscode from "vscode"
import { telemetryService } from "@/services/posthog/telemetry/TelemetryService"
import { telemetryService } from "../posthog/telemetry/TelemetryService"
import * as pkg from "../../../package.json"
import { ClineError } from "./ClineError"
let telemetryLevel = vscode.workspace.getConfiguration("telemetry").get<string>("telemetryLevel", "all")
let isTelemetryEnabled = ["all", "error"].includes(telemetryLevel)
@@ -15,6 +16,8 @@ vscode.workspace.onDidChangeConfiguration(() => {
}
})
const isDev = process.env.IS_DEV === "true"
export class ErrorService {
private static serviceEnabled: boolean
private static serviceLevel: string
@@ -29,7 +32,7 @@ export class ErrorService {
beforeSend(event) {
// TelemetryService keeps track of whether the user has opted in to telemetry/error reporting
const isUserManuallyOptedIn = telemetryService.isTelemetryEnabled()
if (isUserManuallyOptedIn && ErrorService.isEnabled()) {
if (isUserManuallyOptedIn && ErrorService.isEnabled() && !isDev) {
return event
}
return null
@@ -65,7 +68,7 @@ export class ErrorService {
}
}
static logException(error: Error): void {
static logException(error: Error | ClineError): void {
// Don't log if telemetry is off
const isUserManuallyOptedIn = telemetryService.isTelemetryEnabled()
if (!isUserManuallyOptedIn || !ErrorService.isEnabled()) {
@@ -93,4 +96,8 @@ export class ErrorService {
static isEnabled(): boolean {
return ErrorService.serviceEnabled
}
static toClineError(rawError: any, modelId?: string, providerId?: string): ClineError {
return ClineError.transform(rawError, modelId, providerId)
}
}
+90 -71
View File
@@ -35,10 +35,10 @@ import { secondsToMs } from "@utils/time"
import { GlobalFileNames } from "@core/storage/disk"
import { ExtensionMessage } from "@shared/ExtensionMessage"
import { DEFAULT_REQUEST_TIMEOUT_MS } from "./constants"
import { McpConnection, McpServerConfig } from "./types"
import { McpConnection, McpServerConfig, Transport } from "./types"
import { BaseConfigSchema, ServerConfigSchema, McpSettingsSchema } from "./schemas"
import { getHostBridgeProvider } from "@/hosts/host-providers"
import { ShowMessageRequest, ShowMessageType } from "@/shared/proto/host/window"
import { ShowMessageType } from "@/shared/proto/host/window"
export class McpHub {
getMcpServersPath: () => Promise<string>
private getSettingsDirectoryPath: () => Promise<string>
@@ -109,24 +109,20 @@ export class McpHub {
try {
config = JSON.parse(content)
} catch (error) {
getHostBridgeProvider().windowClient.showMessage(
ShowMessageRequest.create({
type: ShowMessageType.ERROR,
message: "Invalid MCP settings format. Please ensure your settings follow the correct JSON format.",
}),
)
getHostBridgeProvider().windowClient.showMessage({
type: ShowMessageType.ERROR,
message: "Invalid MCP settings format. Please ensure your settings follow the correct JSON format.",
})
return undefined
}
// Validate against schema
const result = McpSettingsSchema.safeParse(config)
if (!result.success) {
getHostBridgeProvider().windowClient.showMessage(
ShowMessageRequest.create({
type: ShowMessageType.ERROR,
message: "Invalid MCP settings schema.",
}),
)
getHostBridgeProvider().windowClient.showMessage({
type: ShowMessageType.ERROR,
message: "Invalid MCP settings schema.",
})
return undefined
}
@@ -158,12 +154,6 @@ export class McpHub {
if (settings) {
try {
await this.updateServerConnections(settings.mcpServers)
getHostBridgeProvider().windowClient.showMessage(
ShowMessageRequest.create({
type: ShowMessageType.INFORMATION,
message: "MCP servers updated",
}),
)
} catch (error) {
console.error("Failed to process MCP settings change:", error)
}
@@ -202,6 +192,23 @@ export class McpHub {
// Remove existing connection if it exists (should never happen, the connection should be deleted beforehand)
this.connections = this.connections.filter((conn) => conn.server.name !== name)
if (config.disabled) {
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: {
name,
config: JSON.stringify(config),
status: "disconnected",
disabled: true,
},
client: null as unknown as Client,
transport: null as unknown as Transport,
}
this.connections.push(disabledConnection)
return
}
try {
// Each MCP server requires its own transport connection and has unique capabilities, configurations, and error handling. Having separate clients also allows proper scoping of resources/tools and independent server management like reconnection.
const client = new Client(
@@ -413,12 +420,10 @@ export class McpHub {
console.log(`[MCP Fallback Notification] ${name}:`, JSON.stringify(notification, null, 2))
// Show in VS Code for visibility
getHostBridgeProvider().windowClient.showMessage(
ShowMessageRequest.create({
type: ShowMessageType.INFORMATION,
message: `MCP ${name}: ${notification.method || "unknown"} - ${JSON.stringify(notification.params || {})}`,
}),
)
getHostBridgeProvider().windowClient.showMessage({
type: ShowMessageType.INFORMATION,
message: `MCP ${name}: ${notification.method || "unknown"} - ${JSON.stringify(notification.params || {})}`,
})
}
console.log(`[MCP Debug] Successfully set fallback notification handler for ${name}`)
} catch (error) {
@@ -453,6 +458,11 @@ export class McpHub {
throw new Error(`No connection found for server: ${serverName}`)
}
// Disabled servers don't have clients, so return empty tools list
if (connection.server.disabled || !connection.client) {
return []
}
const response = await connection.client.request({ method: "tools/list" }, ListToolsResultSchema, {
timeout: DEFAULT_REQUEST_TIMEOUT_MS,
})
@@ -478,9 +488,16 @@ export class McpHub {
private async fetchResourcesList(serverName: string): Promise<McpResource[]> {
try {
const response = await this.connections
.find((conn) => conn.server.name === serverName)
?.client.request({ method: "resources/list" }, ListResourcesResultSchema, { timeout: DEFAULT_REQUEST_TIMEOUT_MS })
const connection = this.connections.find((conn) => conn.server.name === serverName)
// Disabled servers don't have clients, so return empty resources list
if (!connection || connection.server.disabled || !connection.client) {
return []
}
const response = await connection.client.request({ method: "resources/list" }, ListResourcesResultSchema, {
timeout: DEFAULT_REQUEST_TIMEOUT_MS,
})
return response?.resources || []
} catch (error) {
// console.error(`Failed to fetch resources for ${serverName}:`, error)
@@ -490,11 +507,20 @@ export class McpHub {
private async fetchResourceTemplatesList(serverName: string): Promise<McpResourceTemplate[]> {
try {
const response = await this.connections
.find((conn) => conn.server.name === serverName)
?.client.request({ method: "resources/templates/list" }, ListResourceTemplatesResultSchema, {
const connection = this.connections.find((conn) => conn.server.name === serverName)
// Disabled servers don't have clients, so return empty resource templates list
if (!connection || connection.server.disabled || !connection.client) {
return []
}
const response = await connection.client.request(
{ method: "resources/templates/list" },
ListResourceTemplatesResultSchema,
{
timeout: DEFAULT_REQUEST_TIMEOUT_MS,
})
},
)
return response?.resourceTemplates || []
} catch (error) {
@@ -507,8 +533,13 @@ export class McpHub {
const connection = this.connections.find((conn) => conn.server.name === name)
if (connection) {
try {
await connection.transport.close()
await connection.client.close()
// Only close transport and client if they exist (disabled servers don't have them)
if (connection.transport) {
await connection.transport.close()
}
if (connection.client) {
await connection.client.close()
}
} catch (error) {
console.error(`Failed to close transport for ${name}:`, error)
}
@@ -671,12 +702,10 @@ export class McpHub {
const connection = this.connections.find((conn) => conn.server.name === serverName)
const config = connection?.server.config
if (config) {
getHostBridgeProvider().windowClient.showMessage(
ShowMessageRequest.create({
type: ShowMessageType.INFORMATION,
message: `Restarting ${serverName} MCP server...`,
}),
)
getHostBridgeProvider().windowClient.showMessage({
type: ShowMessageType.INFORMATION,
message: `Restarting ${serverName} MCP server...`,
})
connection.server.status = "connecting"
connection.server.error = ""
await this.notifyWebviewOfServerChanges()
@@ -685,20 +714,16 @@ export class McpHub {
await this.deleteConnection(serverName)
// Try to connect again using existing config
await this.connectToServer(serverName, JSON.parse(config), "internal")
getHostBridgeProvider().windowClient.showMessage(
ShowMessageRequest.create({
type: ShowMessageType.INFORMATION,
message: `${serverName} MCP server connected`,
}),
)
getHostBridgeProvider().windowClient.showMessage({
type: ShowMessageType.INFORMATION,
message: `${serverName} MCP server connected`,
})
} catch (error) {
console.error(`Failed to restart connection for ${serverName}:`, error)
getHostBridgeProvider().windowClient.showMessage(
ShowMessageRequest.create({
type: ShowMessageType.ERROR,
message: `Failed to connect to ${serverName} MCP server`,
}),
)
getHostBridgeProvider().windowClient.showMessage({
type: ShowMessageType.ERROR,
message: `Failed to connect to ${serverName} MCP server`,
})
}
}
@@ -784,12 +809,10 @@ export class McpHub {
if (error instanceof Error) {
console.error("Error details:", error.message, error.stack)
}
getHostBridgeProvider().windowClient.showMessage(
ShowMessageRequest.create({
type: ShowMessageType.ERROR,
message: `Failed to update server state: ${error instanceof Error ? error.message : String(error)}`,
}),
)
getHostBridgeProvider().windowClient.showMessage({
type: ShowMessageType.ERROR,
message: `Failed to update server state: ${error instanceof Error ? error.message : String(error)}`,
})
throw error
}
}
@@ -946,12 +969,10 @@ export class McpHub {
}
} catch (error) {
console.error("Failed to update autoApprove settings:", error)
getHostBridgeProvider().windowClient.showMessage(
ShowMessageRequest.create({
type: ShowMessageType.ERROR,
message: "Failed to update autoApprove settings",
}),
)
getHostBridgeProvider().windowClient.showMessage({
type: ShowMessageType.ERROR,
message: "Failed to update autoApprove settings",
})
throw error // Re-throw to ensure the error is properly handled
}
}
@@ -1069,12 +1090,10 @@ export class McpHub {
if (error instanceof Error) {
console.error("Error details:", error.message, error.stack)
}
getHostBridgeProvider().windowClient.showMessage(
ShowMessageRequest.create({
type: ShowMessageType.ERROR,
message: `Failed to update server timeout: ${error instanceof Error ? error.message : String(error)}`,
}),
)
getHostBridgeProvider().windowClient.showMessage({
type: ShowMessageType.ERROR,
message: `Failed to update server timeout: ${error instanceof Error ? error.message : String(error)}`,
})
throw error
}
}
@@ -1,5 +1,5 @@
import { PostHog } from "posthog-node"
import { posthogConfig } from "@/shared/services/config/posthog-config"
import { posthogConfig } from "../../shared/services/config/posthog-config"
class PostHogClientProvider {
private static instance: PostHogClientProvider
+1 -1
View File
@@ -7,7 +7,7 @@ import { HistoryItem } from "./HistoryItem"
import { TelemetrySetting } from "./TelemetrySetting"
import { ClineRulesToggles } from "./cline-rules"
import { UserInfo } from "./UserInfo"
import { McpDisplayMode, DEFAULT_MCP_DISPLAY_MODE } from "./McpDisplayMode"
import { McpDisplayMode } from "./McpDisplayMode"
// webview will hold state
export interface ExtensionMessage {
+47
View File
@@ -29,6 +29,7 @@ export type ApiProvider =
| "cerebras"
| "sapaicore"
| "groq"
| "huggingface"
export interface ApiHandlerOptions {
apiModelId?: string
@@ -92,6 +93,9 @@ export interface ApiHandlerOptions {
qwenApiLine?: string
moonshotApiLine?: string
moonshotApiKey?: string
huggingFaceApiKey?: string
huggingFaceModelId?: string
huggingFaceModelInfo?: ModelInfo
nebiusApiKey?: string
asksageApiUrl?: string
asksageApiKey?: string
@@ -1074,6 +1078,49 @@ export const deepSeekModels = {
},
} as const satisfies Record<string, ModelInfo>
// Hugging Face Inference Providers
// https://huggingface.co/docs/inference-providers/en/index
export type HuggingFaceModelId = keyof typeof huggingFaceModels
export const huggingFaceDefaultModelId: HuggingFaceModelId = "moonshotai/Kimi-K2-Instruct"
export const huggingFaceModels = {
"moonshotai/Kimi-K2-Instruct": {
maxTokens: 131_072,
contextWindow: 131_072,
supportsImages: false,
supportsPromptCache: false,
inputPrice: 0,
outputPrice: 0,
description: "Advanced reasoning model with superior performance across coding, math, and general capabilities.",
},
"deepseek-ai/DeepSeek-V3-0324": {
maxTokens: 8192,
contextWindow: 64_000,
supportsImages: false,
supportsPromptCache: false,
inputPrice: 0,
outputPrice: 0,
description: "Advanced reasoning model with superior performance across coding, math, and general capabilities.",
},
"deepseek-ai/DeepSeek-R1": {
maxTokens: 8192,
contextWindow: 64_000,
supportsImages: false,
supportsPromptCache: false,
inputPrice: 0,
outputPrice: 0,
description: "DeepSeek's reasoning model with step-by-step thinking capabilities.",
},
"meta-llama/Llama-3.1-8B-Instruct": {
maxTokens: 8192,
contextWindow: 128_000,
supportsImages: false,
supportsPromptCache: false,
inputPrice: 0,
outputPrice: 0,
description: "Efficient 8B parameter Llama model for general-purpose tasks.",
},
} as const satisfies Record<string, ModelInfo>
// Qwen
// https://bailian.console.aliyun.com/
export type MainlandQwenModelId = keyof typeof mainlandQwenModels
@@ -224,6 +224,8 @@ function convertApiProviderToProto(provider: string | undefined): ProtoApiProvid
return ProtoApiProvider.LITELLM
case "moonshot":
return ProtoApiProvider.MOONSHOT
case "huggingface":
return ProtoApiProvider.HUGGINGFACE
case "nebius":
return ProtoApiProvider.NEBIUS
case "fireworks":
@@ -288,6 +290,8 @@ function convertProtoToApiProvider(provider: ProtoApiProvider): ApiProvider {
return "litellm"
case ProtoApiProvider.MOONSHOT:
return "moonshot"
case ProtoApiProvider.HUGGINGFACE:
return "huggingface"
case ProtoApiProvider.NEBIUS:
return "nebius"
case ProtoApiProvider.FIREWORKS:
@@ -374,6 +378,9 @@ export function convertApiConfigurationToProto(config: ApiConfiguration): ProtoA
qwenApiLine: config.qwenApiLine,
moonshotApiLine: config.moonshotApiLine,
moonshotApiKey: config.moonshotApiKey,
huggingFaceApiKey: config.huggingFaceApiKey,
huggingFaceModelId: config.huggingFaceModelId,
huggingFaceModelInfo: convertModelInfoToProtoOpenRouter(config.huggingFaceModelInfo),
nebiusApiKey: config.nebiusApiKey,
asksageApiUrl: config.asksageApiUrl,
asksageApiKey: config.asksageApiKey,
@@ -460,6 +467,9 @@ export function convertProtoToApiConfiguration(protoConfig: ProtoApiConfiguratio
qwenApiLine: protoConfig.qwenApiLine,
moonshotApiLine: protoConfig.moonshotApiLine,
moonshotApiKey: protoConfig.moonshotApiKey,
huggingFaceApiKey: protoConfig.huggingFaceApiKey,
huggingFaceModelId: protoConfig.huggingFaceModelId,
huggingFaceModelInfo: convertProtoToModelInfo(protoConfig.huggingFaceModelInfo),
nebiusApiKey: protoConfig.nebiusApiKey,
asksageApiUrl: protoConfig.asksageApiUrl,
asksageApiKey: protoConfig.asksageApiKey,
+47 -1
View File
@@ -3,6 +3,7 @@ import { DiffViewProvider } from "@/integrations/editor/DiffViewProvider"
export class ExternalDiffViewProvider extends DiffViewProvider {
private activeDiffEditorId: string | undefined
override async openDiffEditor(): Promise<void> {
if (!this.absolutePath) {
return
@@ -13,10 +14,11 @@ export class ExternalDiffViewProvider extends DiffViewProvider {
})
this.activeDiffEditorId = response.diffId
}
override async replaceText(
content: string,
rangeToReplace: { startLine: number; endLine: number },
_currentLine: number,
_currentLine: number | undefined,
): Promise<void> {
await getHostBridgeProvider().diffClient.replaceText({
diffId: this.activeDiffEditorId,
@@ -25,4 +27,48 @@ export class ExternalDiffViewProvider extends DiffViewProvider {
endLine: rangeToReplace.endLine,
})
}
protected override async truncateDocument(lineNumber: number): Promise<void> {
if (!this.activeDiffEditorId) {
return
}
await getHostBridgeProvider().diffClient.truncateDocument({
diffId: this.activeDiffEditorId,
endLine: lineNumber,
})
}
protected async saveDocument(): Promise<void> {
if (!this.activeDiffEditorId) {
return
}
await getHostBridgeProvider().diffClient.saveDocument({ diffId: this.activeDiffEditorId })
}
protected override async scrollEditorToLine(line: number): Promise<void> {
console.log(`Called ExternalDiffViewProvider.scrollEditorToLine(${line}) stub`)
}
override async scrollAnimation(startLine: number, endLine: number): Promise<void> {
console.log(`Called ExternalDiffViewProvider.scrollAnimation(${startLine}, ${endLine}) stub`)
}
protected override async getDocumentText(): Promise<string | undefined> {
if (!this.activeDiffEditorId) {
return undefined
}
return (await getHostBridgeProvider().diffClient.getDocumentText({ diffId: this.activeDiffEditorId })).content
}
protected override async closeDiffView(): Promise<void> {
if (!this.activeDiffEditorId) {
return
}
await getHostBridgeProvider().diffClient.closeDiff({ diffId: this.activeDiffEditorId })
this.activeDiffEditorId = undefined
}
protected override async resetDiffView(): Promise<void> {
this.activeDiffEditorId = undefined
}
}
@@ -4,11 +4,6 @@ import * as vscode from "vscode"
import { URI } from "vscode-uri"
import { WebviewProvider } from "@core/webview"
/*
https://github.com/microsoft/vscode-webview-ui-toolkit-samples/blob/main/default/weather-webview/src/providers/WeatherViewProvider.ts
https://github.com/KumarVariable/vscode-extension-sidebar-html/blob/master/src/customSidebarViewProvider.ts
*/
export class ExternalWebviewProvider extends WebviewProvider {
// This hostname cannot be changed without updating the external webview handler.
private RESOURCE_HOSTNAME: string = "internal.resources"
+18 -654
View File
@@ -39,210 +39,9 @@ vscode.window = {
console.log("Stubbed showSaveDialog:", options)
return undefined
},
showTextDocument: async (uri, options) => {
console.log("Stubbed showTextDocument:", uri, options)
// Extract file path from URI
let filePath = uri.path || uri.fsPath || uri
if (typeof filePath !== "string") {
filePath = uri.toString()
}
// Remove file:// prefix if present
if (filePath.startsWith("file://")) {
filePath = filePath.substring(7)
}
// Create a function that always reads the current file content
const getCurrentFileContent = async () => {
try {
const content = await fs.promises.readFile(filePath, "utf8")
console.log(`getCurrentFileContent: Read file ${filePath} (${content.length} chars)`)
return content
} catch (error) {
console.log(`getCurrentFileContent: Could not read file ${filePath}:`, error.message)
return ""
}
}
// Try to read the initial file content
let fileContent = await getCurrentFileContent()
let lineCount = fileContent.split("\n").length
// Check if we already have an active editor for this file path
const existingEditor = vscode.window._documentEditors && vscode.window._documentEditors[filePath]
if (existingEditor) {
console.log(`showTextDocument: Updating existing editor for ${filePath}`)
// Update the existing editor's content
fileContent = await getCurrentFileContent()
lineCount = fileContent.split("\n").length
// Update the document's getText method to return current content
existingEditor.document.getText = (range) => {
// Always read fresh content for getText calls
const currentContent = require("fs").readFileSync(filePath, "utf8")
if (!range) {
return currentContent
}
// Handle range-based getText with current content
const lines = currentContent.split("\n")
const startLine = Math.max(0, range.start.line)
const endLine = Math.min(lines.length - 1, range.end.line)
if (startLine === endLine) {
// Single line
const line = lines[startLine] || ""
const startChar = Math.max(0, range.start.character)
const endChar = Math.min(line.length, range.end.character)
return line.substring(startChar, endChar)
} else {
// Multiple lines
const result = []
for (let i = startLine; i <= endLine; i++) {
const line = lines[i] || ""
if (i === startLine) {
result.push(line.substring(range.start.character))
} else if (i === endLine) {
result.push(line.substring(0, range.end.character))
} else {
result.push(line)
}
}
return result.join("\n")
}
}
// Update other properties
existingEditor.document.lineCount = lineCount
existingEditor.document.fileName = filePath
// Update the active text editor reference
vscode.window.activeTextEditor = existingEditor
return existingEditor
}
// Create a new mock text editor that always reads current file content
const mockEditor = {
document: {
uri: uri,
fileName: filePath,
isDirty: false,
lineCount: lineCount,
getText: (range) => {
// Always read fresh content for getText calls
try {
const currentContent = require("fs").readFileSync(filePath, "utf8")
console.log(`document.getText: Read fresh content (${currentContent.length} chars)`)
if (!range) {
return currentContent
}
// Handle range-based getText with current content
const lines = currentContent.split("\n")
const startLine = Math.max(0, range.start.line)
const endLine = Math.min(lines.length - 1, range.end.line)
if (startLine === endLine) {
// Single line
const line = lines[startLine] || ""
const startChar = Math.max(0, range.start.character)
const endChar = Math.min(line.length, range.end.character)
return line.substring(startChar, endChar)
} else {
// Multiple lines
const result = []
for (let i = startLine; i <= endLine; i++) {
const line = lines[i] || ""
if (i === startLine) {
result.push(line.substring(range.start.character))
} else if (i === endLine) {
result.push(line.substring(0, range.end.character))
} else {
result.push(line)
}
}
return result.join("\n")
}
} catch (error) {
console.error(`Error reading file in getText: ${error.message}`)
return ""
}
},
save: async () => {
console.log("Called mock textDocument.save")
return true
},
positionAt: (offset) => {
try {
const currentContent = require("fs").readFileSync(filePath, "utf8")
const lines = currentContent.split("\n")
let currentOffset = 0
for (let line = 0; line < lines.length; line++) {
const lineLength = lines[line].length + 1 // +1 for newline
if (currentOffset + lineLength > offset) {
return { line: line, character: offset - currentOffset }
}
currentOffset += lineLength
}
return { line: lines.length - 1, character: lines[lines.length - 1]?.length || 0 }
} catch (error) {
return { line: 0, character: 0 }
}
},
offsetAt: (position) => {
try {
const currentContent = require("fs").readFileSync(filePath, "utf8")
const lines = currentContent.split("\n")
let offset = 0
for (let i = 0; i < position.line && i < lines.length; i++) {
offset += lines[i].length + 1 // +1 for newline
}
offset += Math.min(position.character, lines[position.line]?.length || 0)
return offset
} catch (error) {
return 0
}
},
},
selection: { start: { line: 0, character: 0 }, end: { line: 0, character: 0 } },
selections: [],
visibleRanges: [{ start: { line: 0, character: 0 }, end: { line: 0, character: 0 } }],
options: {},
viewColumn: 1,
edit: async (callback) => {
console.log("Called mock textEditor.edit")
return true
},
insertSnippet: async () => true,
setDecorations: () => {},
revealRange: () => {},
show: () => {},
hide: () => {},
}
// Store the editor by file path for future reference
if (!vscode.window._documentEditors) {
vscode.window._documentEditors = {}
}
vscode.window._documentEditors[filePath] = mockEditor
// Update the active text editor
vscode.window.activeTextEditor = mockEditor
// Trigger onDidChangeActiveTextEditor listeners
if (vscode.window._activeTextEditorListeners) {
setTimeout(() => {
vscode.window._activeTextEditorListeners.forEach((listener) => {
try {
listener(mockEditor)
} catch (error) {
console.error("Error calling onDidChangeActiveTextEditor listener:", error)
}
})
}, 10) // Small delay to simulate async behavior
}
return mockEditor
showTextDocument: async (...args) => {
console.log("Stubbed showTextDocument:", ...args)
return {}
},
createOutputChannel: (name) => {
console.log("Stubbed createOutputChannel:", name)
@@ -290,23 +89,10 @@ vscode.window = {
activeTextEditor: undefined,
visibleTextEditors: [],
tabGroups: {
all: [
{
tabs: [],
isActive: true,
viewColumn: 1,
},
],
activeTabGroup: {
tabs: [],
isActive: true,
viewColumn: 1,
},
close: async (tab) => {
console.log("Stubbed tabGroups.close:", tab)
return true
},
onDidChangeTabs: createStub("vscode.window.tabGroups.onDidChangeTabs"),
all: [],
close: async () => {},
onDidChangeTabs: createStub("vscode.env.tabGroups.onDidChangeTabs"),
activeTabGroup: { tabs: [] },
},
withProgress: async (_options, task) => {
console.log("Stubbed withProgress")
@@ -314,56 +100,14 @@ vscode.window = {
},
registerUriHandler: () => ({ dispose: () => {} }),
registerWebviewViewProvider: () => ({ dispose: () => {} }),
onDidChangeActiveTextEditor: (listener) => {
console.log("Called vscode.window.onDidChangeActiveTextEditor")
// Store the listener so we can call it when showTextDocument is called
vscode.window._activeTextEditorListeners = vscode.window._activeTextEditorListeners || []
vscode.window._activeTextEditorListeners.push(listener)
return {
dispose: () => {
console.log("Disposed onDidChangeActiveTextEditor listener")
const index = vscode.window._activeTextEditorListeners.indexOf(listener)
if (index > -1) {
vscode.window._activeTextEditorListeners.splice(index, 1)
}
},
}
},
onDidChangeActiveTextEditor: () => ({ dispose: () => {} }),
createTextEditorDecorationType: () => ({ dispose: () => {} }),
createWebviewPanel: (...args) => {
console.log("Stubbed createWebviewPanel:", ...args)
return {
webview: {},
reveal: () => {},
dispose: () => {},
}
createWebviewPanel: (..._args) => {
throw new Error("WebviewPanel is not supported in standalone app.")
},
onDidChangeTerminalState: (listener) => {
console.log("Called vscode.window.onDidChangeTerminalState")
return {
dispose: () => {
console.log("Disposed onDidChangeTerminalState listener")
},
}
},
onDidChangeTextEditorVisibleRanges: (listener) => {
console.log("Called vscode.window.onDidChangeTextEditorVisibleRanges")
return {
dispose: () => {
console.log("Disposed onDidChangeTextEditorVisibleRanges listener")
},
}
},
terminals: [],
activeTerminal: null,
}
// Initialize env object if it doesn't exist, then extend it
if (!vscode.env) {
vscode.env = {}
}
Object.assign(vscode.env, {
vscode.env = {
uriScheme: "vscode",
appName: "Visual Studio Code",
appRoot: "/tmp/vscode/appRoot",
@@ -373,26 +117,17 @@ Object.assign(vscode.env, {
sessionId: "stub-session-id",
shell: "/bin/bash",
// Add the stub functions that were missing
clipboard: createStub("vscode.env.clipboard"),
openExternal: createStub("vscode.env.openExternal"),
getQueryParameter: createStub("vscode.env.getQueryParameter"),
onDidChangeTelemetryEnabled: createStub("vscode.env.onDidChangeTelemetryEnabled"),
isTelemetryEnabled: createStub("vscode.env.isTelemetryEnabled"),
telemetryConfiguration: createStub("vscode.env.telemetryConfiguration"),
onDidChangeTelemetryConfiguration: createStub("vscode.env.onDidChangeTelemetryConfiguration"),
createTelemetryLogger: createStub("vscode.env.createTelemetryLogger"),
})
// Override the openExternal function with actual implementation
vscode.env.openExternal = async (uri) => {
const url = typeof uri === "string" ? uri : (uri.toString?.() ?? "")
console.log("Opening browser:", url)
await open(url)
return true
}
// Extend Uri object with improved implementations
Object.assign(vscode.Uri, {
vscode.Uri = {
parse: (uriString) => {
const url = new URL(uriString)
return {
@@ -437,387 +172,16 @@ Object.assign(vscode.Uri, {
const joined = segments.map((s) => (typeof s === "string" ? s : s.path)).join("/")
return vscode.Uri.file("/" + joined.replace(/\/+/g, "/"))
},
})
// Extend workspace object with file system operations
Object.assign(vscode.workspace, {
fs: {
readFile: async function (uri) {
console.log(`Called vscode.workspace.fs.readFile with uri:`, uri)
try {
// Extract file path from URI
let filePath = uri.path || uri.fsPath || uri
if (typeof filePath !== "string") {
filePath = uri.toString()
}
// Remove file:// prefix if present
if (filePath.startsWith("file://")) {
filePath = filePath.substring(7)
}
console.log(`Reading file: ${filePath}`)
const content = await fs.promises.readFile(filePath, "utf8")
console.log(
`File content read (${content.length} chars):`,
content.substring(0, 100) + (content.length > 100 ? "..." : ""),
)
return new Uint8Array(Buffer.from(content, "utf8"))
} catch (error) {
console.error(`Error reading file:`, error)
throw error
}
},
writeFile: async function (uri, content) {
console.log(`Called vscode.workspace.fs.writeFile with uri:`, uri)
try {
// Extract file path from URI
let filePath = uri.path || uri.fsPath || uri
if (typeof filePath !== "string") {
filePath = uri.toString()
}
// Remove file:// prefix if present
if (filePath.startsWith("file://")) {
filePath = filePath.substring(7)
}
console.log(`Writing file: ${filePath}`)
// Ensure directory exists
const dir = path.dirname(filePath)
await fs.promises.mkdir(dir, { recursive: true })
// Write the file
await fs.promises.writeFile(filePath, content)
} catch (error) {
console.error(`Error writing file:`, error)
throw error
}
},
},
rootPath: process.cwd(),
name: path.basename(process.cwd()),
// Add other workspace methods as stubs
getConfiguration: () => ({
get: () => undefined,
update: () => Promise.resolve(),
has: () => false,
}),
createFileSystemWatcher: () => ({
onDidChange: () => ({ dispose: () => {} }),
onDidCreate: () => ({ dispose: () => {} }),
onDidDelete: () => ({ dispose: () => {} }),
dispose: () => {},
}),
onDidChangeConfiguration: () => ({ dispose: () => {} }),
onDidCreateFiles: createStub("vscode.workspace.onDidCreateFiles"),
onDidDeleteFiles: createStub("vscode.workspace.onDidDeleteFiles"),
onDidRenameFiles: createStub("vscode.workspace.onDidRenameFiles"),
onWillCreateFiles: createStub("vscode.workspace.onWillCreateFiles"),
onWillDeleteFiles: createStub("vscode.workspace.onWillDeleteFiles"),
onWillRenameFiles: createStub("vscode.workspace.onWillRenameFiles"),
textDocuments: {
find: (predicate) => {
console.log("Called vscode.workspace.textDocuments.find")
// Return a mock text document that behaves like VSCode expects
return {
uri: { fsPath: "/tmp/mock-document" },
fileName: "/tmp/mock-document",
isDirty: false,
save: async () => {
console.log("Called mock textDocument.save")
return true
},
getText: () => "",
lineCount: 0,
}
},
forEach: (callback) => {
console.log("Called vscode.workspace.textDocuments.forEach")
// No documents to iterate over in standalone mode
},
length: 0,
[Symbol.iterator]: function* () {
// Empty iterator for standalone mode
},
},
// Add the crucial applyEdit method
applyEdit: async (workspaceEdit) => {
console.log("Called vscode.workspace.applyEdit", workspaceEdit)
// For standalone mode, we'll simulate applying the edit by actually writing to files
try {
// WorkspaceEdit can contain multiple types of edits
if (workspaceEdit._edits) {
for (const edit of workspaceEdit._edits) {
if (edit._type === 1) {
// TextEdit
const uri = edit._uri
const edits = edit._edits
let filePath = uri.path || uri.fsPath
if (filePath.startsWith("file://")) {
filePath = filePath.substring(7)
}
console.log(`Applying text edits to: ${filePath}`)
// Read current content if file exists
let currentContent = ""
try {
currentContent = await fs.promises.readFile(filePath, "utf8")
} catch (e) {
// File doesn't exist, start with empty content
console.log(`File ${filePath} doesn't exist, starting with empty content`)
}
// Apply edits in reverse order (from end to beginning) to maintain positions
const sortedEdits = edits.sort((a, b) => {
const aStart = a.range.start.line * 1000000 + a.range.start.character
const bStart = b.range.start.line * 1000000 + b.range.start.character
return bStart - aStart
})
let lines = currentContent.split("\n")
for (const edit of sortedEdits) {
const startLine = edit.range.start.line
const startChar = edit.range.start.character
const endLine = edit.range.end.line
const endChar = edit.range.end.character
const newText = edit.newText
console.log(`Applying edit: ${startLine}:${startChar} - ${endLine}:${endChar} -> "${newText}"`)
// Handle the edit
if (startLine === endLine) {
// Single line edit
const line = lines[startLine] || ""
lines[startLine] = line.substring(0, startChar) + newText + line.substring(endChar)
} else {
// Multi-line edit
const firstLine = lines[startLine] || ""
const lastLine = lines[endLine] || ""
const newFirstLine = firstLine.substring(0, startChar) + newText + lastLine.substring(endChar)
// Replace the range with the new content
lines.splice(startLine, endLine - startLine + 1, newFirstLine)
}
}
const newContent = lines.join("\n")
// Ensure directory exists
const dir = path.dirname(filePath)
await fs.promises.mkdir(dir, { recursive: true })
// Write the updated content
await fs.promises.writeFile(filePath, newContent, "utf8")
console.log(`Successfully applied edits to: ${filePath}`)
}
}
}
return true
} catch (error) {
console.error("Error applying workspace edit:", error)
return false
}
},
})
// Fix CodeActionKind to have static properties instead of being a class
vscode.CodeActionKind = {
Empty: "",
QuickFix: "quickfix",
Refactor: "refactor",
RefactorExtract: "refactor.extract",
RefactorInline: "refactor.inline",
RefactorRewrite: "refactor.rewrite",
Source: "source",
SourceOrganizeImports: "source.organizeImports",
SourceFixAll: "source.fixAll",
}
// Add missing commands implementation
if (!vscode.commands) {
vscode.commands = {}
vscode.env.openExternal = async (uri) => {
const url = typeof uri === "string" ? uri : (uri.toString?.() ?? "")
console.log("Opening browser:", url)
await open(url)
return true
}
Object.assign(vscode.commands, {
executeCommand: async (command, ...args) => {
console.log(`Called vscode.commands.executeCommand: ${command}`, args)
// Handle the vscode.diff command specifically
if (command === "vscode.diff") {
const [originalUri, modifiedUri, title, options] = args
console.log("Opening diff view:", { originalUri, modifiedUri, title })
// For standalone mode, just open the modified file directly
// since we can't show a proper diff view
const editor = await vscode.window.showTextDocument(modifiedUri, {
preserveFocus: options?.preserveFocus || false,
preview: false,
})
// Ensure the onDidChangeActiveTextEditor event fires with a slight delay
// This is crucial for DiffViewProvider.openDiffEditor() to work properly
setTimeout(() => {
if (vscode.window._activeTextEditorListeners) {
vscode.window._activeTextEditorListeners.forEach((listener) => {
try {
listener(editor)
} catch (error) {
console.error("Error calling onDidChangeActiveTextEditor listener in vscode.diff:", error)
}
})
}
}, 50) // Slightly longer delay to ensure proper event ordering
return editor
}
// For other commands, just return a resolved promise
return Promise.resolve()
},
registerCommand: (command, callback) => {
console.log(`Registered command: ${command}`)
return { dispose: () => {} }
},
getCommands: async () => {
return []
},
})
// Add missing TabInput classes
vscode.TabInputText = class TabInputText {
constructor(uri) {
this.uri = uri
}
}
vscode.TabInputTextDiff = class TabInputTextDiff {
constructor(original, modified) {
this.original = original
this.modified = modified
}
}
// Add missing WorkspaceEdit and related classes
vscode.WorkspaceEdit = class WorkspaceEdit {
constructor() {
this._edits = []
}
replace(uri, range, newText) {
console.log("WorkspaceEdit.replace:", uri, range, newText)
this._edits.push({
_type: 1, // TextEdit
_uri: uri,
_edits: [
{
range: range,
newText: newText,
},
],
})
}
insert(uri, position, newText) {
console.log("WorkspaceEdit.insert:", uri, position, newText)
this.replace(uri, new vscode.Range(position, position), newText)
}
delete(uri, range) {
console.log("WorkspaceEdit.delete:", uri, range)
this.replace(uri, range, "")
}
}
vscode.Range = class Range {
constructor(startLine, startCharacter, endLine, endCharacter) {
if (typeof startLine === "object") {
// Called with Position objects
this.start = startLine
this.end = startCharacter
} else {
// Called with line/character numbers
this.start = new vscode.Position(startLine, startCharacter)
this.end = new vscode.Position(endLine, endCharacter)
}
}
}
vscode.Position = class Position {
constructor(line, character) {
this.line = line
this.character = character
}
}
vscode.Selection = class Selection extends vscode.Range {
constructor(anchorLine, anchorCharacter, activeLine, activeCharacter) {
if (typeof anchorLine === "object") {
// Called with Position objects
super(anchorLine, anchorCharacter)
this.anchor = anchorLine
this.active = anchorCharacter
} else {
// Called with line/character numbers
super(anchorLine, anchorCharacter, activeLine, activeCharacter)
this.anchor = new vscode.Position(anchorLine, anchorCharacter)
this.active = new vscode.Position(activeLine, activeCharacter)
}
}
}
// Add TextEditorRevealType enum
vscode.TextEditorRevealType = {
Default: 0,
InCenter: 1,
InCenterIfOutsideViewport: 2,
AtTop: 3,
}
// Add missing languages API
if (!vscode.languages) {
vscode.languages = {}
}
Object.assign(vscode.languages, {
getDiagnostics: (uri) => {
console.log("Called vscode.languages.getDiagnostics")
// Return empty diagnostics for standalone mode
if (uri) {
return []
} else {
// Return all diagnostics as empty array
return []
}
},
registerCodeActionsProvider: () => ({ dispose: () => {} }),
createDiagnosticCollection: () => ({
set: () => {},
delete: () => {},
clear: () => {},
dispose: () => {},
}),
})
// Export the terminal manager globally for Cline core to use
global.standaloneTerminalManager = globalTerminalManager
// Override the TerminalManager to use our standalone implementation
if (typeof global !== "undefined") {
// Replace the TerminalManager class with our standalone implementation
global.StandaloneTerminalManagerClass = require("./enhanced-terminal").StandaloneTerminalManager
}
module.exports = vscode
console.log("Finished loading stub impls...")
+21 -331
View File
@@ -1,10 +1,9 @@
import { VSCodeBadge, VSCodeButton, VSCodeProgressRing } from "@vscode/webview-ui-toolkit/react"
import { VSCodeBadge, VSCodeProgressRing } from "@vscode/webview-ui-toolkit/react"
import deepEqual from "fast-deep-equal"
import React, { memo, MouseEvent, useCallback, useEffect, useMemo, useRef, useState } from "react"
import styled from "styled-components"
import { useSize } from "react-use"
import CreditLimitError from "@/components/chat/CreditLimitError"
import { OptionsButtons } from "@/components/chat/OptionsButtons"
import TaskFeedbackButtons from "@/components/chat/TaskFeedbackButtons"
import { CheckmarkControl } from "@/components/common/CheckmarkControl"
@@ -36,8 +35,8 @@ import NewTaskPreview from "./NewTaskPreview"
import ReportBugPreview from "./ReportBugPreview"
import UserMessage from "./UserMessage"
import QuoteButton from "./QuoteButton"
import { useClineAuth } from "@/context/ClineAuthContext"
import { CLINE_ACCOUNT_AUTH_ERROR_MESSAGE } from "@shared/ClineAccount"
import ErrorRow from "./ErrorRow"
import { ErrorBlockTitle } from "./ErrorBlockTitle"
const normalColor = "var(--vscode-foreground)"
const errorColor = "var(--vscode-errorForeground)"
@@ -104,42 +103,6 @@ const Markdown = memo(({ markdown }: { markdown?: string }) => {
)
})
const RetryMessage = memo(
({ seconds, attempt, retryOperations }: { retryOperations: number; attempt: number; seconds?: number }) => {
const [remainingSeconds, setRemainingSeconds] = useState(seconds || 0)
useEffect(() => {
if (seconds && seconds > 0) {
setRemainingSeconds(seconds)
const interval = setInterval(() => {
setRemainingSeconds((prev) => {
if (prev <= 1) {
clearInterval(interval)
return 0
}
return prev - 1
})
}, 1000)
return () => clearInterval(interval)
}
}, [seconds])
return (
<span
style={{
color: normalColor,
fontWeight: "bold",
}}>
{`API Request (Retrying failed attempt ${attempt}/${retryOperations}`}
{remainingSeconds > 0 && ` in ${remainingSeconds} seconds`}
)...
</span>
)
},
)
const ChatRow = memo(
(props: ChatRowProps) => {
const { isLast, onHeightChange, message, lastModifiedMessage, inputValue } = props
@@ -186,7 +149,6 @@ export const ChatRowContent = memo(
sendMessageFromChatRow,
onSetQuote,
}: ChatRowContentProps) => {
const { handleSignIn, clineUser } = useClineAuth()
const { mcpServers, mcpMarketplaceCatalog, onRelinquishControl, apiConfiguration } = useExtensionState()
const [seeNewChangesDisabled, setSeeNewChangesDisabled] = useState(false)
const [quoteButtonState, setQuoteButtonState] = useState<QuoteButtonState>({
@@ -201,7 +163,7 @@ export const ChatRowContent = memo(
const info: ClineApiReqInfo = JSON.parse(message.text)
return [info.cost, info.cancelReason, info.streamingFailedMessage, info.retryStatus]
}
return [undefined, undefined, undefined, undefined]
return [undefined, undefined, undefined, undefined, undefined]
}, [message.text, message.say])
// when resuming task last won't be api_req_failed but a resume_task message so api_req_started will show loading spinner. that's why we just remove the last api_req_started that failed without streaming anything
@@ -381,73 +343,12 @@ export const ChatRowContent = memo(
<span style={{ color: successColor, fontWeight: "bold" }}>Task Completed</span>,
]
case "api_req_started":
const getIconSpan = (iconName: string, color: string) => (
<div
style={{
width: 16,
height: 16,
display: "flex",
alignItems: "center",
justifyContent: "center",
}}>
<span
className={`codicon codicon-${iconName}`}
style={{
color,
fontSize: 16,
marginBottom: "-1.5px",
}}></span>
</div>
)
return [
apiReqCancelReason != null ? (
apiReqCancelReason === "user_cancelled" ? (
getIconSpan("error", cancelledColor)
) : (
getIconSpan("error", errorColor)
)
) : cost != null ? (
getIconSpan("check", successColor)
) : apiRequestFailedMessage ? (
getIconSpan("error", errorColor)
) : (
<ProgressIndicator />
),
(() => {
if (apiReqCancelReason != null) {
return apiReqCancelReason === "user_cancelled" ? (
<span style={{ color: normalColor, fontWeight: "bold" }}>API Request Cancelled</span>
) : (
<span style={{ color: errorColor, fontWeight: "bold" }}>API Streaming Failed</span>
)
}
if (cost != null) {
return <span style={{ color: normalColor, fontWeight: "bold" }}>API Request</span>
}
if (apiRequestFailedMessage) {
const errorData = parseErrorText(apiRequestFailedMessage)
if (errorData?.code === "insufficient_credits") {
return <span style={{ color: errorColor, fontWeight: "bold" }}>Credit Limit Reached</span>
}
return <span style={{ color: errorColor, fontWeight: "bold" }}>API Request Failed</span>
}
// New: Check for retryStatus to modify the title
if (retryStatus && cost == null && !apiReqCancelReason) {
const retryOperations = retryStatus.maxAttempts > 0 ? retryStatus.maxAttempts - 1 : 0
return (
<RetryMessage
seconds={retryStatus.delaySec}
attempt={retryStatus.attempt}
retryOperations={retryOperations}
/>
)
}
return <span style={{ color: normalColor, fontWeight: "bold" }}>API Request...</span>
})(),
]
return ErrorBlockTitle({
cost,
apiReqCancelReason,
apiRequestFailedMessage,
retryStatus,
})
case "followup":
return [
<span
@@ -946,92 +847,12 @@ export const ChatRowContent = memo(
<span className={`codicon codicon-chevron-${isExpanded ? "up" : "down"}`}></span>
</div>
{((cost == null && apiRequestFailedMessage) || apiReqStreamingFailedMessage) && (
<>
{(() => {
// Try to parse the error message as JSON for credit limit error
const errorData = parseErrorText(
apiRequestFailedMessage || apiReqStreamingFailedMessage,
)
if (errorData) {
if (
errorData.code === "insufficient_credits" &&
typeof errorData.current_balance === "number"
) {
return (
<CreditLimitError
currentBalance={errorData.current_balance}
totalSpent={errorData.total_spent}
totalPromotions={errorData.total_promotions}
message={errorData.message}
buyCreditsUrl={errorData.buy_credits_url}
/>
)
}
}
// Check for rate limit errors (status code 429)
const isRateLimitError =
apiRequestFailedMessage?.includes("status code 429") ||
apiRequestFailedMessage?.toLowerCase().includes("rate limit") ||
apiRequestFailedMessage?.toLowerCase().includes("too many requests") ||
apiRequestFailedMessage?.toLowerCase().includes("quota exceeded") ||
apiRequestFailedMessage?.toLowerCase().includes("resource exhausted")
if (isRateLimitError) {
return (
<p
style={{
...pStyle,
color: "var(--vscode-errorForeground)",
}}>
{apiRequestFailedMessage || apiReqStreamingFailedMessage}
</p>
)
}
// Default error display
return (
<p
style={{
...pStyle,
color: "var(--vscode-errorForeground)",
}}>
{apiRequestFailedMessage || apiReqStreamingFailedMessage}
{apiRequestFailedMessage?.toLowerCase().includes("powershell") && (
<>
<br />
<br />
It seems like you're having Windows PowerShell issues, please see this{" "}
<a
href="https://github.com/cline/cline/wiki/TroubleShooting-%E2%80%90-%22PowerShell-is-not-recognized-as-an-internal-or-external-command%22"
style={{
color: "inherit",
textDecoration: "underline",
}}>
troubleshooting guide
</a>
.
</>
)}
{apiRequestFailedMessage?.includes(CLINE_ACCOUNT_AUTH_ERROR_MESSAGE) && (
<>
<br />
<br />
{clineUser ? (
<span style={{ color: "var(--vscode-descriptionForeground)" }}>
(Click "Retry" below)
</span>
) : (
<VSCodeButton onClick={handleSignIn} className="w-full mb-4">
Sign in to Cline
</VSCodeButton>
)}
</>
)}
</p>
)
})()}
</>
<ErrorRow
message={message}
errorType="error"
apiRequestFailedMessage={apiRequestFailedMessage}
apiReqStreamingFailedMessage={apiReqStreamingFailedMessage}
/>
)}
{isExpanded && (
@@ -1183,97 +1004,11 @@ export const ChatRowContent = memo(
</div>
)
case "error":
return (
<>
{title && (
<div style={headerStyle}>
{icon}
{title}
</div>
)}
<p
style={{
...pStyle,
color: "var(--vscode-errorForeground)",
}}>
{message.text}
</p>
</>
)
return <ErrorRow message={message} errorType="error" />
case "diff_error":
return (
<>
<div
style={{
display: "flex",
flexDirection: "column",
backgroundColor: "var(--vscode-textBlockQuote-background)",
padding: 8,
borderRadius: 3,
fontSize: 12,
color: "var(--vscode-foreground)",
opacity: 0.8,
}}>
<div
style={{
display: "flex",
alignItems: "center",
marginBottom: 4,
}}>
<i
className="codicon codicon-warning"
style={{
marginRight: 8,
fontSize: 14,
color: "var(--vscode-descriptionForeground)",
}}></i>
<span style={{ fontWeight: 500 }}>Diff Edit Mismatch</span>
</div>
<div>The model used search patterns that don't match anything in the file. Retrying...</div>
</div>
</>
)
return <ErrorRow message={message} errorType="diff_error" />
case "clineignore_error":
return (
<>
<div
style={{
display: "flex",
flexDirection: "column",
backgroundColor: "rgba(255, 191, 0, 0.1)",
padding: 8,
borderRadius: 3,
fontSize: 12,
}}>
<div
style={{
display: "flex",
alignItems: "center",
marginBottom: 4,
}}>
<i
className="codicon codicon-error"
style={{
marginRight: 8,
fontSize: 18,
color: "#FFA500",
}}></i>
<span
style={{
fontWeight: 500,
color: "#FFA500",
}}>
Access Denied
</span>
</div>
<div>
Cline tried to access <code>{message.text}</code> which is blocked by the{" "}
<code>.clineignore</code>
file.
</div>
</div>
</>
)
return <ErrorRow message={message} errorType="clineignore_error" />
case "checkpoint_created":
return (
<>
@@ -1433,37 +1168,9 @@ export const ChatRowContent = memo(
case "ask":
switch (message.ask) {
case "mistake_limit_reached":
return (
<>
<div style={headerStyle}>
{icon}
{title}
</div>
<p
style={{
...pStyle,
color: "var(--vscode-errorForeground)",
}}>
{message.text}
</p>
</>
)
return <ErrorRow message={message} errorType="mistake_limit_reached" />
case "auto_approval_max_req_reached":
return (
<>
<div style={headerStyle}>
{icon}
{title}
</div>
<p
style={{
...pStyle,
color: "var(--vscode-errorForeground)",
}}>
{message.text}
</p>
</>
)
return <ErrorRow message={message} errorType="auto_approval_max_req_reached" />
case "completion_result":
if (message.text) {
const hasChanges = message.text.endsWith(COMPLETION_RESULT_CHANGES_FLAG) ?? false
@@ -1686,20 +1393,3 @@ export const ChatRowContent = memo(
}
},
)
function parseErrorText(text: string | undefined) {
if (!text) {
return undefined
}
try {
const startIndex = text.indexOf("{")
const endIndex = text.lastIndexOf("}")
if (startIndex !== -1 && endIndex !== -1) {
const jsonStr = text.substring(startIndex, endIndex + 1)
const errorObject = JSON.parse(jsonStr)
return errorObject
}
} catch (e) {
// Not JSON or missing required fields
}
}
@@ -0,0 +1,106 @@
import React from "react"
import { ClineError, ClineErrorType } from "../../../../src/services/error/ClineError"
import { ProgressIndicator } from "./ChatRow"
const RetryMessage = React.memo(
({ seconds, attempt, retryOperations }: { retryOperations: number; attempt: number; seconds?: number }) => {
const [remainingSeconds, setRemainingSeconds] = React.useState(seconds || 0)
React.useEffect(() => {
if (seconds && seconds > 0) {
setRemainingSeconds(seconds)
const interval = setInterval(() => {
setRemainingSeconds((prev) => {
if (prev <= 1) {
clearInterval(interval)
return 0
}
return prev - 1
})
}, 1000)
return () => clearInterval(interval)
}
}, [seconds])
return (
<span className="font-bold text-[var(--vscode-foreground)]">
{`API Request (Retrying failed attempt ${attempt}/${retryOperations}`}
{remainingSeconds > 0 && ` in ${remainingSeconds} seconds`}
)...
</span>
)
},
)
interface ErrorBlockTitleProps {
cost?: number
apiReqCancelReason?: string
apiRequestFailedMessage?: string
retryStatus?: {
attempt: number
maxAttempts: number
delaySec?: number
errorSnippet?: string
}
}
export const ErrorBlockTitle = ({
cost,
apiReqCancelReason,
apiRequestFailedMessage,
retryStatus,
}: ErrorBlockTitleProps): [React.ReactElement, React.ReactElement] => {
const getIconSpan = (iconName: string, colorClass: string) => (
<div className="w-4 h-4 flex items-center justify-center">
<span className={`codicon codicon-${iconName} text-base -mb-0.5 ${colorClass}`}></span>
</div>
)
const icon =
apiReqCancelReason != null ? (
apiReqCancelReason === "user_cancelled" ? (
getIconSpan("error", "text-[var(--vscode-descriptionForeground)]")
) : (
getIconSpan("error", "text-[var(--vscode-errorForeground)]")
)
) : cost != null ? (
getIconSpan("check", "text-[var(--vscode-charts-green)]")
) : apiRequestFailedMessage ? (
getIconSpan("error", "text-[var(--vscode-errorForeground)]")
) : (
<ProgressIndicator />
)
const title = (() => {
// Default loading state
const details = { title: "API Request...", classNames: ["font-bold"] }
// Handle cancellation states first
if (apiReqCancelReason === "user_cancelled") {
details.title = "API Request Cancelled"
details.classNames.push("text-[var(--vscode-foreground)]")
} else if (apiReqCancelReason != null) {
details.title = "API Streaming Failed"
details.classNames.push("text-[var(--vscode-errorForeground)]")
} else if (cost != null) {
// Handle completed request
details.title = "API Request"
details.classNames.push("text-[var(--vscode-foreground)]")
} else if (apiRequestFailedMessage) {
// Handle failed request
const clineError = ClineError.parse(apiRequestFailedMessage)
const titleText = clineError?.isErrorType(ClineErrorType.Balance) ? "Credit Limit Reached" : "API Request Failed"
details.title = titleText
details.classNames.push("font-bold text-[var(--vscode-errorForeground)]")
} else if (retryStatus) {
// Handle retry state
const retryOperations = Math.max(0, retryStatus.maxAttempts - 1)
return <RetryMessage seconds={retryStatus.delaySec} attempt={retryStatus.attempt} retryOperations={retryOperations} />
}
return <span className={details.classNames.join(" ")}>{details.title}</span>
})()
return [icon, title]
}
@@ -0,0 +1,200 @@
import { render, screen } from "@testing-library/react"
import { describe, it, expect, vi } from "vitest"
import ErrorRow from "./ErrorRow"
import { ClineMessage } from "@shared/ExtensionMessage"
// Mock the auth context
const mockHandleSignIn = vi.fn()
vi.mock("@/context/ClineAuthContext", () => ({
useClineAuth: () => ({
handleSignIn: mockHandleSignIn,
clineUser: null,
}),
}))
// Mock CreditLimitError component
vi.mock("@/components/chat/CreditLimitError", () => ({
default: ({ message }: { message: string }) => <div data-testid="credit-limit-error">{message}</div>,
}))
// Mock ClineError
vi.mock("../../../../src/services/error/ClineError", () => ({
ClineError: {
parse: vi.fn(),
},
ClineErrorType: {
Balance: "balance",
RateLimit: "rateLimit",
Auth: "auth",
},
}))
describe("ErrorRow", () => {
const mockMessage: ClineMessage = {
ts: 123456789,
type: "say",
say: "error",
text: "Test error message",
}
beforeEach(() => {
vi.clearAllMocks()
})
it("renders basic error message", () => {
render(<ErrorRow message={mockMessage} errorType="error" />)
expect(screen.getByText("Test error message")).toBeInTheDocument()
})
it("renders mistake limit reached error", () => {
const mistakeMessage = { ...mockMessage, text: "Mistake limit reached" }
render(<ErrorRow message={mistakeMessage} errorType="mistake_limit_reached" />)
expect(screen.getByText("Mistake limit reached")).toBeInTheDocument()
})
it("renders auto approval max requests error", () => {
const maxReqMessage = { ...mockMessage, text: "Max requests reached" }
render(<ErrorRow message={maxReqMessage} errorType="auto_approval_max_req_reached" />)
expect(screen.getByText("Max requests reached")).toBeInTheDocument()
})
it("renders diff error", () => {
render(<ErrorRow message={mockMessage} errorType="diff_error" />)
expect(
screen.getByText("The model used search patterns that don't match anything in the file. Retrying..."),
).toBeInTheDocument()
})
it("renders clineignore error", () => {
const clineignoreMessage = { ...mockMessage, text: "/path/to/file.txt" }
render(<ErrorRow message={clineignoreMessage} errorType="clineignore_error" />)
expect(screen.getByText(/Cline tried to access/)).toBeInTheDocument()
expect(screen.getByText("/path/to/file.txt")).toBeInTheDocument()
})
describe("API error handling", () => {
it("renders credit limit error when balance error is detected", async () => {
const mockClineError = {
message: "Insufficient credits",
isErrorType: vi.fn((type) => type === "balance"),
_error: {
details: {
current_balance: 0,
total_spent: 10.5,
total_promotions: 5.0,
message: "You have run out of credit.",
buy_credits_url: "https://app.cline.bot/dashboard",
},
},
}
const { ClineError } = await import("../../../../src/services/error/ClineError")
vi.mocked(ClineError.parse).mockReturnValue(mockClineError as any)
render(<ErrorRow message={mockMessage} errorType="error" apiRequestFailedMessage="Insufficient credits error" />)
expect(screen.getByTestId("credit-limit-error")).toBeInTheDocument()
expect(screen.getByText("You have run out of credit.")).toBeInTheDocument()
})
it("renders rate limit error with request ID", async () => {
const mockClineError = {
message: "Rate limit exceeded",
isErrorType: vi.fn((type) => type === "rateLimit"),
_error: {
request_id: "req_123456",
},
}
const { ClineError } = await import("../../../../src/services/error/ClineError")
vi.mocked(ClineError.parse).mockReturnValue(mockClineError as any)
render(<ErrorRow message={mockMessage} errorType="error" apiRequestFailedMessage="Rate limit exceeded" />)
expect(screen.getByText("Rate limit exceeded")).toBeInTheDocument()
expect(screen.getByText("Request ID: req_123456")).toBeInTheDocument()
})
it("renders auth error with sign in button when user is not signed in", async () => {
const mockClineError = {
message: "Authentication failed",
isErrorType: vi.fn((type) => type === "auth"),
providerId: "cline",
_error: {},
}
const { ClineError } = await import("../../../../src/services/error/ClineError")
vi.mocked(ClineError.parse).mockReturnValue(mockClineError as any)
render(<ErrorRow message={mockMessage} errorType="error" apiRequestFailedMessage="Authentication failed" />)
expect(screen.getByText("Authentication failed")).toBeInTheDocument()
expect(screen.getByText("Sign in to Cline")).toBeInTheDocument()
})
it("renders PowerShell troubleshooting link when error mentions PowerShell", async () => {
const mockClineError = {
message: "PowerShell is not recognized as an internal or external command",
isErrorType: vi.fn(() => false),
_error: {},
}
const { ClineError } = await import("../../../../src/services/error/ClineError")
vi.mocked(ClineError.parse).mockReturnValue(mockClineError as any)
render(
<ErrorRow
message={mockMessage}
errorType="error"
apiRequestFailedMessage="PowerShell is not recognized as an internal or external command"
/>,
)
expect(screen.getByText(/PowerShell is not recognized/)).toBeInTheDocument()
expect(screen.getByText("troubleshooting guide")).toBeInTheDocument()
expect(screen.getByRole("link", { name: "troubleshooting guide" })).toHaveAttribute(
"href",
"https://github.com/cline/cline/wiki/TroubleShooting-%E2%80%90-%22PowerShell-is-not-recognized-as-an-internal-or-external-command%22",
)
})
it("handles apiReqStreamingFailedMessage instead of apiRequestFailedMessage", async () => {
const mockClineError = {
message: "Streaming failed",
isErrorType: vi.fn(() => false),
_error: {},
}
const { ClineError } = await import("../../../../src/services/error/ClineError")
vi.mocked(ClineError.parse).mockReturnValue(mockClineError as any)
render(<ErrorRow message={mockMessage} errorType="error" apiReqStreamingFailedMessage="Streaming failed" />)
expect(screen.getByText("Streaming failed")).toBeInTheDocument()
})
it("falls back to regular error message when ClineError.parse returns null", async () => {
const { ClineError } = await import("../../../../src/services/error/ClineError")
vi.mocked(ClineError.parse).mockReturnValue(undefined)
render(<ErrorRow message={mockMessage} errorType="error" apiRequestFailedMessage="Some API error" />)
// When ClineError.parse returns null, clineErrorMessage is undefined, so it renders an empty paragraph
// The fallback to message.text only happens when there's no apiRequestFailedMessage at all
const paragraph = screen.getByRole("paragraph")
expect(paragraph).toBeInTheDocument()
expect(paragraph).toBeEmptyDOMElement()
})
it("renders regular error message when no API error messages are provided", () => {
render(<ErrorRow message={mockMessage} errorType="error" />)
expect(screen.getByText("Test error message")).toBeInTheDocument()
})
})
})
+130
View File
@@ -0,0 +1,130 @@
import { memo } from "react"
import { VSCodeButton } from "@vscode/webview-ui-toolkit/react"
import { ClineMessage } from "@shared/ExtensionMessage"
import { ClineError, ClineErrorType } from "../../../../src/services/error/ClineError"
import CreditLimitError from "@/components/chat/CreditLimitError"
import { useClineAuth } from "@/context/ClineAuthContext"
const errorColor = "var(--vscode-errorForeground)"
interface ErrorRowProps {
message: ClineMessage
errorType: "error" | "mistake_limit_reached" | "auto_approval_max_req_reached" | "diff_error" | "clineignore_error"
apiRequestFailedMessage?: string
apiReqStreamingFailedMessage?: string
}
const ErrorRow = memo(({ message, errorType, apiRequestFailedMessage, apiReqStreamingFailedMessage }: ErrorRowProps) => {
const { handleSignIn, clineUser } = useClineAuth()
const renderErrorContent = () => {
switch (errorType) {
case "error":
case "mistake_limit_reached":
case "auto_approval_max_req_reached":
// Handle API request errors with special error parsing
if (apiRequestFailedMessage || apiReqStreamingFailedMessage) {
const clineError = ClineError.parse(apiRequestFailedMessage || apiReqStreamingFailedMessage)
const clineErrorMessage = clineError?.message
const requestId = clineError?._error?.request_id
const isClineProvider = clineError?.providerId === "cline"
if (clineError) {
if (clineError.isErrorType(ClineErrorType.Balance)) {
const errorDetails = clineError._error?.details
return (
<CreditLimitError
currentBalance={errorDetails?.current_balance}
totalSpent={errorDetails?.total_spent}
totalPromotions={errorDetails?.total_promotions}
message={errorDetails?.message}
buyCreditsUrl={errorDetails?.buy_credits_url}
/>
)
}
}
if (clineError?.isErrorType(ClineErrorType.RateLimit)) {
return (
<p className="m-0 whitespace-pre-wrap text-[var(--vscode-errorForeground)] wrap-anywhere">
{clineErrorMessage}
{requestId && <div>Request ID: {requestId}</div>}
</p>
)
}
// Default error display
return (
<p className="m-0 whitespace-pre-wrap text-[var(--vscode-errorForeground)] wrap-anywhere">
{clineErrorMessage}
{requestId && <div>Request ID: {requestId}</div>}
{clineErrorMessage?.toLowerCase()?.includes("powershell") && (
<>
<br />
<br />
It seems like you're having Windows PowerShell issues, please see this{" "}
<a
href="https://github.com/cline/cline/wiki/TroubleShooting-%E2%80%90-%22PowerShell-is-not-recognized-as-an-internal-or-external-command%22"
className="underline text-inherit">
troubleshooting guide
</a>
.
</>
)}
{clineError?.isErrorType(ClineErrorType.Auth) && (
<>
<br />
<br />
{/* The user is signed in or not using cline provider */}
{clineUser && !isClineProvider ? (
<span className="mb-4 text-[var(--vscode-descriptionForeground)]">
(Click "Retry" below)
</span>
) : (
<VSCodeButton onClick={handleSignIn} className="w-full mb-4">
Sign in to Cline
</VSCodeButton>
)}
</>
)}
</p>
)
}
// Regular error message
return (
<p className="m-0 whitespace-pre-wrap text-[var(--vscode-errorForeground)] wrap-anywhere">{message.text}</p>
)
case "diff_error":
return (
<div className="flex flex-col p-2 rounded text-xs opacity-80 bg-[var(--vscode-textBlockQuote-background)] text-[var(--vscode-foreground)]">
<div>The model used search patterns that don't match anything in the file. Retrying...</div>
</div>
)
case "clineignore_error":
return (
<div className="flex flex-col p-2 rounded text-xs bg-[var(--vscode-textBlockQuote-background)] text-[var(--vscode-foreground)] opacity-80">
<div>
Cline tried to access <code>{message.text}</code> which is blocked by the <code>.clineignore</code>
file.
</div>
</div>
)
default:
return null
}
}
// For diff_error and clineignore_error, we don't show the header separately
if (errorType === "diff_error" || errorType === "clineignore_error") {
return <>{renderErrorContent()}</>
}
// For other error types, show header + content
return <>{renderErrorContent()}</>
})
export default ErrorRow
@@ -0,0 +1,51 @@
import { describe, it, expect } from "vitest"
import { ErrorBlockTitle } from "../ErrorBlockTitle"
describe("ErrorBlockTitle", () => {
it("should return icon and title for API request cancelled", () => {
const [icon, title] = ErrorBlockTitle({
apiReqCancelReason: "user_cancelled",
})
expect(icon).toBeDefined()
expect(title).toBeDefined()
})
it("should return icon and title for completed API request", () => {
const [icon, title] = ErrorBlockTitle({
cost: 0.001,
})
expect(icon).toBeDefined()
expect(title).toBeDefined()
})
it("should return icon and title for failed API request", () => {
const [icon, title] = ErrorBlockTitle({
apiRequestFailedMessage: "Request failed",
})
expect(icon).toBeDefined()
expect(title).toBeDefined()
})
it("should return icon and title for retry status", () => {
const [icon, title] = ErrorBlockTitle({
retryStatus: {
attempt: 2,
maxAttempts: 3,
delaySec: 5,
},
})
expect(icon).toBeDefined()
expect(title).toBeDefined()
})
it("should return icon and title for default API request", () => {
const [icon, title] = ErrorBlockTitle({})
expect(icon).toBeDefined()
expect(title).toBeDefined()
})
})
@@ -30,6 +30,7 @@ import { ClaudeCodeProvider } from "./providers/ClaudeCodeProvider"
import { SapAiCoreProvider } from "./providers/SapAiCoreProvider"
import { BedrockProvider } from "./providers/BedrockProvider"
import { MoonshotProvider } from "./providers/MoonshotProvider"
import { HuggingFaceProvider } from "./providers/HuggingFaceProvider"
import { NebiusProvider } from "./providers/NebiusProvider"
import { LiteLlmProvider } from "./providers/LiteLlmProvider"
import { VSCodeLmProvider } from "./providers/VSCodeLmProvider"
@@ -159,6 +160,7 @@ const ApiOptions = ({ showModelOptions, apiErrorMessage, modelIdErrorMessage, is
<VSCodeOption value="ollama">Ollama</VSCodeOption>
<VSCodeOption value="litellm">LiteLLM</VSCodeOption>
<VSCodeOption value="moonshot">Moonshot AI</VSCodeOption>
<VSCodeOption value="huggingface">Hugging Face</VSCodeOption>
<VSCodeOption value="nebius">Nebius AI Studio</VSCodeOption>
<VSCodeOption value="asksage">AskSage</VSCodeOption>
<VSCodeOption value="xai">xAI</VSCodeOption>
@@ -261,6 +263,10 @@ const ApiOptions = ({ showModelOptions, apiErrorMessage, modelIdErrorMessage, is
<MoonshotProvider showModelOptions={showModelOptions} isPopup={isPopup} />
)}
{apiConfiguration && selectedProvider === "huggingface" && (
<HuggingFaceProvider showModelOptions={showModelOptions} isPopup={isPopup} />
)}
{apiConfiguration && selectedProvider === "nebius" && (
<NebiusProvider showModelOptions={showModelOptions} isPopup={isPopup} />
)}
@@ -0,0 +1,200 @@
import { EmptyRequest } from "@shared/proto/common"
import { VSCodeTextField } from "@vscode/webview-ui-toolkit/react"
import Fuse from "fuse.js"
import React, { KeyboardEvent, useEffect, useMemo, useRef, useState } from "react"
import { useMount } from "react-use"
import { huggingFaceDefaultModelId, huggingFaceModels } from "@shared/api"
import { useExtensionState } from "../../context/ExtensionStateContext"
import { ModelsServiceClient } from "../../services/grpc-client"
import { highlight } from "../history/HistoryView"
import { ModelInfoView } from "./common/ModelInfoView"
import { normalizeApiConfiguration } from "./utils/providerUtils"
import { useApiConfigurationHandlers } from "./utils/useApiConfigurationHandlers"
export interface HuggingFaceModelPickerProps {
isPopup?: boolean
}
const HuggingFaceModelPicker: React.FC<HuggingFaceModelPickerProps> = ({ isPopup }) => {
const { apiConfiguration, huggingFaceModels: dynamicModels, setHuggingFaceModels } = useExtensionState()
const { handleFieldsChange } = useApiConfigurationHandlers()
const [searchTerm, setSearchTerm] = useState(apiConfiguration?.huggingFaceModelId || huggingFaceDefaultModelId)
const [isDropdownVisible, setIsDropdownVisible] = useState(false)
const [selectedIndex, setSelectedIndex] = useState(-1)
const dropdownRef = useRef<HTMLDivElement>(null)
const itemRefs = useRef<(HTMLDivElement | null)[]>([])
const dropdownListRef = useRef<HTMLDivElement>(null)
const handleModelChange = (newModelId: string) => {
const allModels = { ...huggingFaceModels, ...dynamicModels }
handleFieldsChange({
huggingFaceModelId: newModelId,
huggingFaceModelInfo: allModels[newModelId as keyof typeof allModels],
})
setSearchTerm(newModelId)
}
const { selectedModelId, selectedModelInfo } = useMemo(() => {
return normalizeApiConfiguration(apiConfiguration)
}, [apiConfiguration])
useMount(() => {
ModelsServiceClient.refreshHuggingFaceModels(EmptyRequest.create({}))
.then((response) => {
setHuggingFaceModels({
[huggingFaceDefaultModelId]: huggingFaceModels[huggingFaceDefaultModelId],
...response.models,
})
})
.catch((err) => {
console.error("Failed to refresh Hugging Face models:", err)
})
})
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsDropdownVisible(false)
}
}
document.addEventListener("mousedown", handleClickOutside)
return () => {
document.removeEventListener("mousedown", handleClickOutside)
}
}, [])
const allModels = useMemo(() => {
return { ...huggingFaceModels, ...dynamicModels }
}, [dynamicModels])
const modelIds = useMemo(() => {
return Object.keys(allModels).sort((a, b) => a.localeCompare(b))
}, [allModels])
const searchableItems = useMemo(() => {
return modelIds.map((id) => ({
id,
html: id,
}))
}, [modelIds])
const fuse = useMemo(() => {
return new Fuse(searchableItems, {
keys: ["html"],
threshold: 0.6,
shouldSort: true,
isCaseSensitive: false,
ignoreLocation: false,
includeMatches: true,
minMatchCharLength: 1,
})
}, [searchableItems])
const modelSearchResults = useMemo(() => {
let results: { id: string; html: string }[] = searchTerm
? highlight(fuse.search(searchTerm), "model-item-highlight")
: searchableItems
return results
}, [searchTerm, fuse, searchableItems])
const handleKeyDown = (e: KeyboardEvent<HTMLElement>) => {
if (!isDropdownVisible) return
switch (e.key) {
case "ArrowDown":
e.preventDefault()
setSelectedIndex((prev) => (prev < modelSearchResults.length - 1 ? prev + 1 : 0))
break
case "ArrowUp":
e.preventDefault()
setSelectedIndex((prev) => (prev > 0 ? prev - 1 : modelSearchResults.length - 1))
break
case "Enter":
e.preventDefault()
if (selectedIndex >= 0 && selectedIndex < modelSearchResults.length) {
const selectedModelId = modelSearchResults[selectedIndex].id
handleModelChange(selectedModelId)
setIsDropdownVisible(false)
}
break
case "Escape":
e.preventDefault()
setIsDropdownVisible(false)
break
}
}
useEffect(() => {
if (selectedIndex >= 0 && itemRefs.current[selectedIndex] && dropdownListRef.current) {
const selectedItem = itemRefs.current[selectedIndex]
const dropdown = dropdownListRef.current
const itemOffsetTop = selectedItem.offsetTop
const itemHeight = selectedItem.offsetHeight
const dropdownScrollTop = dropdown.scrollTop
const dropdownHeight = dropdown.offsetHeight
if (itemOffsetTop < dropdownScrollTop) {
dropdown.scrollTop = itemOffsetTop
} else if (itemOffsetTop + itemHeight > dropdownScrollTop + dropdownHeight) {
dropdown.scrollTop = itemOffsetTop + itemHeight - dropdownHeight
}
}
}, [selectedIndex])
return (
<div className="w-full">
<div className="flex flex-col">
<label htmlFor="hf-model-search">
<span className="font-medium">Model</span>
</label>
<div ref={dropdownRef} className="relative w-full">
<VSCodeTextField
id="hf-model-search"
placeholder="Search models..."
value={searchTerm}
onInput={(e: any) => {
setSearchTerm(e.target.value)
setIsDropdownVisible(true)
setSelectedIndex(-1)
}}
onFocus={() => setIsDropdownVisible(true)}
onKeyDown={handleKeyDown}
className="w-full relative z-[1000]"
/>
{isDropdownVisible && (
<div
ref={dropdownListRef}
className={`absolute top-[calc(100%-3px)] left-0 w-[calc(100%-2px)] ${
isPopup ? "max-h-[90px]" : "max-h-[200px]"
} overflow-y-auto bg-[var(--vscode-dropdown-background)] border border-[var(--vscode-list-activeSelectionBackground)] z-[999] rounded-b-[3px]`}>
{modelSearchResults.map((result, index) => (
<div
key={result.id}
ref={(el: HTMLDivElement | null) => (itemRefs.current[index] = el)}
className={`p-[5px_10px] cursor-pointer break-all whitespace-normal ${
index === selectedIndex ? "bg-[var(--vscode-list-activeSelectionBackground)]" : ""
} hover:bg-[var(--vscode-list-activeSelectionBackground)]`}
onMouseEnter={() => setSelectedIndex(index)}
onClick={() => {
handleModelChange(result.id)
setIsDropdownVisible(false)
}}>
<div
dangerouslySetInnerHTML={{ __html: result.html }}
className="[&_.model-item-highlight]:bg-[var(--vscode-editor-findMatchHighlightBackground)] [&_.model-item-highlight]:text-inherit"
/>
</div>
))}
</div>
)}
</div>
</div>
<ModelInfoView selectedModelId={selectedModelId} modelInfo={selectedModelInfo} isPopup={isPopup} />
</div>
)
}
export { HuggingFaceModelPicker }
@@ -0,0 +1,58 @@
import { huggingFaceModels } from "@shared/api"
import { DebouncedTextField } from "../common/DebouncedTextField"
import { ModelSelector } from "../common/ModelSelector"
import { ModelInfoView } from "../common/ModelInfoView"
import { normalizeApiConfiguration } from "../utils/providerUtils"
import { useApiConfigurationHandlers } from "../utils/useApiConfigurationHandlers"
import { useExtensionState } from "@/context/ExtensionStateContext"
import { HuggingFaceModelPicker } from "../HuggingFaceModelPicker"
/**
* Props for the HuggingFaceProvider component
*/
interface HuggingFaceProviderProps {
showModelOptions: boolean
isPopup?: boolean
}
/**
* The Hugging Face provider configuration component
*/
export const HuggingFaceProvider = ({ showModelOptions, isPopup }: HuggingFaceProviderProps) => {
const { apiConfiguration } = useExtensionState()
const { handleFieldChange } = useApiConfigurationHandlers()
// Get the normalized configuration
const { selectedModelId, selectedModelInfo } = normalizeApiConfiguration(apiConfiguration)
return (
<div>
<DebouncedTextField
initialValue={apiConfiguration?.huggingFaceApiKey || ""}
onChange={(value) => handleFieldChange("huggingFaceApiKey", value)}
style={{ width: "100%" }}
type="password"
placeholder="Enter API Key...">
<span style={{ fontWeight: 500 }}>Hugging Face API Key</span>
</DebouncedTextField>
<p
style={{
fontSize: "12px",
marginTop: "5px",
color: "var(--vscode-descriptionForeground)",
}}>
This key is stored locally and only used to make API requests from this extension. We dont show pricing here
because it depends on your Hugging Face provider settings and isnt consistently available via their API{" "}
<a href="https://huggingface.co/settings/tokens" target="_blank" rel="noopener noreferrer">
Get your API key here
</a>
</p>
{showModelOptions && (
<>
<HuggingFaceModelPicker isPopup={isPopup} />
</>
)}
</div>
)
}
@@ -36,6 +36,8 @@ import {
liteLlmModelInfoSaneDefaults,
moonshotModels,
moonshotDefaultModelId,
huggingFaceModels,
huggingFaceDefaultModelId,
nebiusModels,
nebiusDefaultModelId,
cerebrasModels,
@@ -174,6 +176,12 @@ export function normalizeApiConfiguration(apiConfiguration?: ApiConfiguration):
return getProviderData(xaiModels, xaiDefaultModelId)
case "moonshot":
return getProviderData(moonshotModels, moonshotDefaultModelId)
case "huggingface":
return {
selectedProvider: provider,
selectedModelId: apiConfiguration?.huggingFaceModelId || huggingFaceDefaultModelId,
selectedModelInfo: apiConfiguration?.huggingFaceModelInfo || huggingFaceModels[huggingFaceDefaultModelId],
}
case "nebius":
return getProviderData(nebiusModels, nebiusDefaultModelId)
case "sambanova":
@@ -1,4 +1,4 @@
import { useState } from "react"
import { useState, useEffect } from "react"
import { useDebounceEffect } from "@/utils/useDebounceEffect"
/**
@@ -18,6 +18,11 @@ export function useDebouncedInput<T>(
// Local state to prevent jumpy input - initialize once
const [localValue, setLocalValue] = useState(initialValue)
// Update local value when initialValue changes (e.g., when component remounts with new data)
useEffect(() => {
setLocalValue(initialValue)
}, [initialValue])
// Debounced backend save - saves after user stops changing value
useDebounceEffect(
() => {
+4 -13
View File
@@ -1,21 +1,12 @@
export type Environment = "production" | "staging" | "local"
const CURRENT_ENVIRONMENT: Environment = "production"
// Use the injected global variable from vite.config.ts
declare const __APP_BASE_URL__: string
interface EnvironmentConfig {
appBaseUrl: string
}
const configs: Record<Environment, EnvironmentConfig> = {
production: {
appBaseUrl: "https://app.cline.bot",
},
staging: {
appBaseUrl: "https://staging-app.cline.bot",
},
local: {
appBaseUrl: "http://localhost:3000",
},
export const clineEnvConfig: EnvironmentConfig = {
appBaseUrl: typeof __APP_BASE_URL__ !== "undefined" ? __APP_BASE_URL__ : "https://app.cline.bot",
}
export const clineEnvConfig = configs[CURRENT_ENVIRONMENT]
@@ -26,6 +26,8 @@ import {
requestyDefaultModelInfo,
groqDefaultModelId,
groqModels,
huggingFaceDefaultModelId,
huggingFaceModels,
} from "../../../src/shared/api"
import { McpMarketplaceCatalog, McpServer, McpViewTab } from "../../../src/shared/mcp"
import { convertTextMateToHljs } from "../utils/textMateToHljs"
@@ -41,6 +43,7 @@ interface ExtensionStateContextType extends ExtensionState {
openAiModels: string[]
requestyModels: Record<string, ModelInfo>
groqModels: Record<string, ModelInfo>
huggingFaceModels: Record<string, ModelInfo>
mcpServers: McpServer[]
mcpMarketplaceCatalog: McpMarketplaceCatalog
filePaths: string[]
@@ -62,6 +65,7 @@ interface ExtensionStateContextType extends ExtensionState {
setMcpServers: (value: McpServer[]) => void
setRequestyModels: (value: Record<string, ModelInfo>) => void
setGroqModels: (value: Record<string, ModelInfo>) => void
setHuggingFaceModels: (value: Record<string, ModelInfo>) => void
setGlobalClineRulesToggles: (toggles: Record<string, boolean>) => void
setLocalClineRulesToggles: (toggles: Record<string, boolean>) => void
setLocalCursorRulesToggles: (toggles: Record<string, boolean>) => void
@@ -212,6 +216,7 @@ export const ExtensionStateContextProvider: React.FC<{
const [groqModelsState, setGroqModels] = useState<Record<string, ModelInfo>>({
[groqDefaultModelId]: groqModels[groqDefaultModelId],
})
const [huggingFaceModels, setHuggingFaceModels] = useState<Record<string, ModelInfo>>({})
const [mcpServers, setMcpServers] = useState<McpServer[]>([])
const [mcpMarketplaceCatalog, setMcpMarketplaceCatalog] = useState<McpMarketplaceCatalog>({ items: [] })
@@ -638,6 +643,7 @@ export const ExtensionStateContextProvider: React.FC<{
openAiModels,
requestyModels,
groqModels: groqModelsState,
huggingFaceModels,
mcpServers,
mcpMarketplaceCatalog,
filePaths,
@@ -678,6 +684,7 @@ export const ExtensionStateContextProvider: React.FC<{
setMcpServers: (mcpServers: McpServer[]) => setMcpServers(mcpServers),
setRequestyModels: (models: Record<string, ModelInfo>) => setRequestyModels(models),
setGroqModels: (models: Record<string, ModelInfo>) => setGroqModels(models),
setHuggingFaceModels: (models: Record<string, ModelInfo>) => setHuggingFaceModels(models),
setMcpMarketplaceCatalog: (catalog: McpMarketplaceCatalog) => setMcpMarketplaceCatalog(catalog),
setShowMcp,
closeMcpView,
+8
View File
@@ -67,7 +67,15 @@ export default defineConfig({
NODE_ENV: JSON.stringify(process.env.IS_DEV ? "development" : "production"),
IS_DEV: JSON.stringify(process.env.IS_DEV),
IS_TEST: JSON.stringify(process.env.IS_TEST),
CLINE_ENVIRONMENT: JSON.stringify(process.env.CLINE_ENVIRONMENT ?? "production"),
},
__APP_BASE_URL__: JSON.stringify(
process.env.CLINE_ENVIRONMENT === "local"
? "http://localhost:3000"
: process.env.CLINE_ENVIRONMENT === "staging"
? "https://staging-app.cline.bot"
: "https://app.cline.bot",
),
},
resolve: {
alias: {