Compare commits

...
Author SHA1 Message Date
Saoud Rizwan 3d8eaa8eee Revert plan color change 2025-08-09 02:07:36 -07:00
Saoud Rizwan 61eac3614c Fix plan/act hover color and act mode color 2025-08-09 02:01:01 -07:00
Bee 5885a3cc1d improve mode switch background color (#5467)
* fix: mode switch styling

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

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

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

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

* remove: unused bedrock conversion functions

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

* add: changeset

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

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

* Updating CHANGELOG.md format

* Update CHANGELOG.md

---------

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

* revert unnecessary change

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

* formatting

---------

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

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

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

* add changeset

* remove max_tokens & temperature

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

---------

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

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

Move the WebviewProvider out of index.ts and into a file name `WebviewProvider`,
this follows best practises.
2025-08-08 13:50:28 +01:00
27 changed files with 879 additions and 549 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
improve visibility for mode switch background color on different themes.
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": minor
---
Add caching support for Bedrock inferences using SAP AI Core and minor refactor
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
Support sending context to active webview when editor panels are opened.
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
remove unused parseAssistantmessageV1
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
Fix credit error tests
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
Fix LiteLLM Proxy Provider Cost Tracking
+3
View File
@@ -219,6 +219,9 @@ EOF
## Basic PR Commands
```bash
# Get current PR number
gh pr view --json number -q .number
# List open PRs
gh pr list
+8
View File
@@ -1,5 +1,13 @@
# Changelog
## [3.22.0]
- Implemented a retry strategy for Cerebras to handle rate limit issues due to its generation speed
- Add support for GPT-5 models to SAP AI Core Provider
- Support sending context to active webview when editor panels are opened.
- Fix bug where running out of credits on Cline accounts would show '402 empty body' response instead of 'buy credits' component
- Fix LiteLLM Proxy Provider Cost Tracking
## [3.21.0]
- Add support for GPT-5 model family including GPT-5, GPT-5 Mini, and GPT-5 Nano with prompt caching support and set GPT-5 as the new default model
+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.21.0",
"version": "3.22.0",
"icon": "assets/icons/icon.png",
"engines": {
"vscode": "^1.84.0"
+2
View File
@@ -274,6 +274,8 @@ function createHandlerForProvider(
sapAiResourceGroup: options.sapAiResourceGroup,
sapAiCoreBaseUrl: options.sapAiCoreBaseUrl,
apiModelId: mode === "plan" ? options.planModeApiModelId : options.actModeApiModelId,
thinkingBudgetTokens:
mode === "plan" ? options.planModeThinkingBudgetTokens : options.actModeThinkingBudgetTokens,
})
case "claude-code":
return new ClaudeCodeHandler({
+53 -2
View File
@@ -39,7 +39,11 @@ export class CerebrasHandler implements ApiHandler {
return this.client
}
@withRetry()
@withRetry({
maxRetries: 6, // More retries to be patient with rate limits
baseDelay: 5000, // Start with 5 second delay
maxDelay: 60000, // Allow up to 60 second delays to respect rate limits
})
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
const client = this.ensureClient()
@@ -170,7 +174,25 @@ export class CerebrasHandler implements ApiHandler {
}
}
}
} catch (error) {
} catch (error: any) {
// Enhanced error handling for Cerebras API
if (error?.status === 429 || error?.code === "rate_limit_exceeded") {
// Rate limit error - will be handled by retry decorator with patient backoff
const limits = this.getRateLimits()
throw new Error(`Cerebras API rate limit exceeded.`)
} else if (error?.status === 401) {
throw new Error("Cerebras API authentication failed. Please check your API key.")
} else if (error?.status === 403) {
throw new Error("Cerebras API access denied. Please check your API key permissions.")
} else if (error?.status >= 500) {
// Server errors - retryable
throw new Error(`Cerebras API server error (${error.status}): ${error.message || "Unknown server error"}`)
} else if (error?.status === 400) {
// Client errors - not retryable
throw new Error(`Cerebras API bad request: ${error.message || "Invalid request parameters"}`)
}
// Re-throw original error for other cases
throw error
}
}
@@ -193,6 +215,35 @@ export class CerebrasHandler implements ApiHandler {
}
}
/**
* Get rate limit information for the current model
*
* These limits are used for informational purposes and to calculate appropriate
* retry delays. Since Cerebras inference is extremely fast, users hit these limits
* quickly, so we need to be patient with retries to maximize usage efficiency.
*
* @returns Rate limit configuration for the model
*/
private getRateLimits(): { requestsPerMinute: number; tokensPerMinute: number } {
const modelId = this.getModel().id
switch (modelId) {
case "qwen-3-coder-480b":
case "qwen-3-coder-480b-free":
return { requestsPerMinute: 10, tokensPerMinute: 150_000 }
case "qwen-3-235b-a22b-instruct-2507":
case "qwen-3-235b-a22b-thinking-2507":
return { requestsPerMinute: 30, tokensPerMinute: 60_000 }
case "llama-3.3-70b":
case "gpt-oss-120b":
case "qwen-3-32b":
return { requestsPerMinute: 30, tokensPerMinute: 64_000 }
default:
// Default rate limits for unknown models
return { requestsPerMinute: 30, tokensPerMinute: 60_000 }
}
}
private calculateCost({ inputTokens, outputTokens }: { inputTokens: number; outputTokens: number }): number {
const model = this.getModel()
const inputPrice = model.info.inputPrice || 0
+365 -144
View File
@@ -5,6 +5,11 @@ import { ApiHandler } from "../"
import { ModelInfo, sapAiCoreDefaultModelId, SapAiCoreModelId, sapAiCoreModels } from "../../shared/api"
import { convertToOpenAiMessages } from "../transform/openai-format"
import { ApiStream } from "../transform/stream"
import {
type Message as BedrockMessage,
type ContentBlock as BedrockContentBlock,
ConversationRole as BedrockConversationRole,
} from "@aws-sdk/client-bedrock-runtime"
interface SapAiCoreHandlerOptions {
sapAiCoreClientId?: string
@@ -13,6 +18,7 @@ interface SapAiCoreHandlerOptions {
sapAiResourceGroup?: string
sapAiCoreBaseUrl?: string
apiModelId?: string
thinkingBudgetTokens?: number
}
interface Deployment {
@@ -27,6 +33,307 @@ interface Token {
token_type: string
expires_at: number
}
// Bedrock namespace containing caching-related functions
namespace Bedrock {
// Define cache point type for AWS Bedrock
interface CachePointContentBlock {
cachePoint: {
type: "default"
}
}
// Define types for supported content types
type SupportedContentType = "text" | "image" | "thinking"
interface ContentItem {
type: SupportedContentType
text?: string
source?: {
data: string | Buffer | Uint8Array
media_type?: string
}
}
/**
* Prepares system messages with optional caching support
*/
export function prepareSystemMessages(systemPrompt: string, enableCaching: boolean): any[] | undefined {
if (!systemPrompt) {
return undefined
}
if (enableCaching) {
return [{ text: systemPrompt }, { cachePoint: { type: "default" } }]
}
return [{ text: systemPrompt }]
}
/**
* Applies cache control to messages for prompt caching using AWS Bedrock's cachePoint system
* AWS Bedrock uses cachePoint objects instead of Anthropic's cache_control approach
*/
export function applyCacheControlToMessages(
messages: BedrockMessage[],
lastUserMsgIndex: number,
secondLastMsgUserIndex: number,
): BedrockMessage[] {
return messages.map((message, index) => {
// Add cachePoint to the last user message and second-to-last user message
if (index === lastUserMsgIndex || index === secondLastMsgUserIndex) {
// Clone the message to avoid modifying the original
const messageWithCache = { ...message }
if (messageWithCache.content && Array.isArray(messageWithCache.content)) {
// Add cachePoint to the end of the content array
messageWithCache.content = [
...messageWithCache.content,
{
cachePoint: {
type: "default",
},
} as CachePointContentBlock, // Properly typed cache point for AWS SDK
]
}
return messageWithCache
}
return message
})
}
/**
* Formats messages for models using the Converse API specification
* Used by both Anthropic and Nova models to avoid code duplication
*/
export function formatMessagesForConverseAPI(messages: Anthropic.Messages.MessageParam[]): BedrockMessage[] {
return messages.map((message) => {
// Determine role (user or assistant)
const role = message.role === "user" ? BedrockConversationRole.USER : BedrockConversationRole.ASSISTANT
// Process content based on type
let content: BedrockContentBlock[] = []
if (typeof message.content === "string") {
// Simple text content
content = [{ text: message.content }]
} else if (Array.isArray(message.content)) {
// Convert Anthropic content format to Converse API content format
const processedContent = message.content
.map((item) => {
// Text content
if (item.type === "text") {
return { text: item.text }
}
// Image content
if (item.type === "image") {
return processImageContent(item)
}
// Log unsupported content types for debugging
console.warn(`Unsupported content type: ${(item as ContentItem).type}`)
return null
})
.filter((item): item is BedrockContentBlock => item !== null)
content = processedContent
}
// Return formatted message
return {
role,
content,
}
})
}
/**
* Processes image content with proper error handling and user notification
*/
function processImageContent(item: any): BedrockContentBlock | null {
let imageData: Uint8Array
let format: "png" | "jpeg" | "gif" | "webp" = "jpeg" // default format
// Extract format from media_type if available
if (item.source.media_type) {
// Extract format from media_type (e.g., "image/jpeg" -> "jpeg")
const formatMatch = item.source.media_type.match(/image\/(\w+)/)
if (formatMatch && formatMatch[1]) {
const extractedFormat = formatMatch[1]
// Ensure format is one of the allowed values
if (["png", "jpeg", "gif", "webp"].includes(extractedFormat)) {
format = extractedFormat as "png" | "jpeg" | "gif" | "webp"
}
}
}
// Get image data with improved error handling
try {
if (typeof item.source.data === "string") {
// Handle base64 encoded data
const base64Data = item.source.data.replace(/^data:image\/\w+;base64,/, "")
imageData = new Uint8Array(Buffer.from(base64Data, "base64"))
} else if (item.source.data && typeof item.source.data === "object") {
// Try to convert to Uint8Array
imageData = new Uint8Array(Buffer.from(item.source.data as Buffer | Uint8Array))
} else {
throw new Error("Unsupported image data format")
}
return {
image: {
format,
source: {
bytes: imageData,
},
},
}
} catch (error) {
console.error("Failed to process image content:", error)
// Return a text content indicating the error instead of null
// This ensures users are aware of the issue
return {
text: `[ERROR: Failed to process image - ${error instanceof Error ? error.message : "Unknown error"}]`,
}
}
}
}
// Gemini namespace containing caching-related functions and types
namespace Gemini {
/**
* Process Gemini streaming response with enhanced thinking content support and caching awareness
*/
export function processStreamChunk(data: any): {
text?: string
reasoning?: string
usageMetadata?: {
promptTokenCount?: number
candidatesTokenCount?: number
thoughtsTokenCount?: number
cachedContentTokenCount?: number
}
} {
const result: ReturnType<typeof processStreamChunk> = {}
// Handle thinking content from Gemini's response
const candidateForThoughts = data?.candidates?.[0]
const partsForThoughts = candidateForThoughts?.content?.parts
let thoughts = ""
if (partsForThoughts) {
for (const part of partsForThoughts) {
const { thought, text } = part
if (thought && text) {
thoughts += text + "\n"
}
}
}
if (thoughts.trim() !== "") {
result.reasoning = thoughts.trim()
}
// Handle regular text content
if (data.text) {
result.text = data.text
}
// Handle content parts for non-thought text
if (data.candidates && data.candidates[0]?.content?.parts) {
let nonThoughtText = ""
for (const part of data.candidates[0].content.parts) {
if (part.text && !part.thought) {
nonThoughtText += part.text
}
}
if (nonThoughtText && !result.text) {
result.text = nonThoughtText
}
}
// Handle usage metadata with caching support
if (data.usageMetadata) {
result.usageMetadata = {
promptTokenCount: data.usageMetadata.promptTokenCount,
candidatesTokenCount: data.usageMetadata.candidatesTokenCount,
thoughtsTokenCount: data.usageMetadata.thoughtsTokenCount,
cachedContentTokenCount: data.usageMetadata.cachedContentTokenCount,
}
}
return result
}
function convertAnthropicMessageToGemini(message: Anthropic.Messages.MessageParam) {
const role = message.role === "assistant" ? "model" : "user"
const parts = []
if (typeof message.content === "string") {
parts.push({ text: message.content })
} else if (Array.isArray(message.content)) {
for (const block of message.content) {
if (block.type === "text") {
parts.push({ text: block.text })
} else if (block.type === "image") {
parts.push({
inlineData: {
mimeType: block.source.media_type,
data: block.source.data,
},
})
}
}
}
return { role, parts }
}
/**
* Prepare Gemini request payload with thinking configuration and implicit caching support
*/
export function prepareRequestPayload(
systemPrompt: string,
messages: Anthropic.Messages.MessageParam[],
model: { id: SapAiCoreModelId; info: ModelInfo },
thinkingBudgetTokens?: number,
): any {
const contents = messages.map(convertAnthropicMessageToGemini)
const payload = {
contents,
systemInstruction: {
parts: [
{
text: systemPrompt,
},
],
},
generationConfig: {
maxOutputTokens: model.info.maxTokens,
temperature: 0.0,
},
}
// Add thinking config if the model supports it and budget is provided
const thinkingBudget = thinkingBudgetTokens ?? 0
const maxBudget = model.info.thinkingConfig?.maxBudget ?? 0
if (thinkingBudget > 0 && model.info.thinkingConfig) {
// Add thinking configuration to the payload
;(payload as any).thinkingConfig = {
thinkingBudget: thinkingBudget,
includeThoughts: true,
}
}
return payload
}
}
export class SapAiCoreHandler implements ApiHandler {
private options: SapAiCoreHandlerOptions
private token?: Token
@@ -142,7 +449,20 @@ export class SapAiCoreHandler implements ApiHandler {
"anthropic--claude-3-opus",
]
const openAIModels = ["gpt-4o", "gpt-4", "gpt-4o-mini", "o1", "gpt-4.1", "gpt-4.1-nano", "o3-mini", "o3", "o4-mini"]
const openAIModels = [
"gpt-4o",
"gpt-4",
"gpt-4o-mini",
"o1",
"gpt-4.1",
"gpt-4.1-nano",
"gpt-5",
"gpt-5-nano",
"gpt-5-mini",
"o3-mini",
"o3",
"o4-mini",
]
const geminiModels = ["gemini-2.5-flash", "gemini-2.5-pro"]
@@ -151,21 +471,47 @@ export class SapAiCoreHandler implements ApiHandler {
if (anthropicModels.includes(model.id)) {
url = `${this.options.sapAiCoreBaseUrl}/v2/inference/deployments/${deploymentId}/invoke-with-response-stream`
// Format messages for Converse API. Note that the Invoke API has
// the same format for messages as the Converse API.
const formattedMessages = Bedrock.formatMessagesForConverseAPI(messages)
// Get message indices for caching
const userMsgIndices = messages.reduce(
(acc, msg, index) => (msg.role === "user" ? [...acc, index] : acc),
[] as number[],
)
const lastUserMsgIndex = userMsgIndices[userMsgIndices.length - 1] ?? -1
const secondLastMsgUserIndex = userMsgIndices[userMsgIndices.length - 2] ?? -1
if (
model.id === "anthropic--claude-4-sonnet" ||
model.id === "anthropic--claude-4-opus" ||
model.id === "anthropic--claude-3.7-sonnet"
) {
// Use converse-stream endpoint with caching support
url = `${this.options.sapAiCoreBaseUrl}/v2/inference/deployments/${deploymentId}/converse-stream`
// Apply caching controls to messages (enabled by default)
const messagesWithCache = Bedrock.applyCacheControlToMessages(
formattedMessages,
lastUserMsgIndex,
secondLastMsgUserIndex,
)
// Prepare system message with caching support (enabled by default)
const systemMessages = Bedrock.prepareSystemMessages(systemPrompt, true)
payload = {
inferenceConfig: {
maxTokens: model.info.maxTokens,
temperature: 0.0,
},
system: systemPrompt ? [{ text: systemPrompt }] : undefined,
messages: this.formatAnthropicMessages(messages),
system: systemMessages,
messages: messagesWithCache,
}
} else {
// Use invoke-with-response-stream endpoint
// TODO: add caching support using Anthropic-native cache_control blocks
payload = {
max_tokens: model.info.maxTokens,
system: systemPrompt,
@@ -191,7 +537,7 @@ export class SapAiCoreHandler implements ApiHandler {
stream_options: { include_usage: true },
}
if (["o1", "o3-mini", "o3", "o4-mini"].includes(model.id)) {
if (["o1", "o3-mini", "o3", "o4-mini", "gpt-5", "gpt-5-nano", "gpt-5-mini"].includes(model.id)) {
delete payload.max_tokens
delete payload.temperature
}
@@ -202,7 +548,7 @@ export class SapAiCoreHandler implements ApiHandler {
}
} else if (geminiModels.includes(model.id)) {
url = `${this.options.sapAiCoreBaseUrl}/v2/inference/deployments/${deploymentId}/models/${model.id}:streamGenerateContent`
payload = this.convertToGeminiFormat(systemPrompt, messages)
payload = Gemini.prepareRequestPayload(systemPrompt, messages, model, this.options.thinkingBudgetTokens)
} else {
throw new Error(`Unsupported model: ${model.id}`)
}
@@ -493,50 +839,31 @@ export class SapAiCoreHandler implements ApiHandler {
const jsonData = line.slice(6)
try {
const data = JSON.parse(jsonData)
const candidateForThoughts = data?.candidates?.[0]
const partsForThoughts = candidateForThoughts?.content?.parts
let thoughts = ""
if (partsForThoughts) {
for (const part of partsForThoughts) {
const { thought, text } = part
if (thought && text) {
thoughts += text + "\n"
}
}
}
// Use Gemini namespace to process the chunk
const processed = Gemini.processStreamChunk(data)
if (thoughts.trim() !== "") {
// Yield reasoning if present
if (processed.reasoning) {
yield {
type: "reasoning",
reasoning: thoughts.trim(),
reasoning: processed.reasoning,
}
}
if (data.text) {
// Yield text if present
if (processed.text) {
yield {
type: "text",
text: data.text,
text: processed.text,
}
}
if (data.candidates && data.candidates[0]?.content?.parts) {
for (const part of data.candidates[0].content.parts) {
if (part.text && !part.thought) {
// Only non-thought text
yield {
type: "text",
text: part.text,
}
}
}
}
if (data.usageMetadata) {
promptTokens = data.usageMetadata.promptTokenCount ?? promptTokens
outputTokens = data.usageMetadata.candidatesTokenCount ?? outputTokens
thoughtsTokenCount = data.usageMetadata.thoughtsTokenCount ?? thoughtsTokenCount
cacheReadTokens = data.usageMetadata.cachedContentTokenCount ?? cacheReadTokens
if (processed.usageMetadata) {
promptTokens = processed.usageMetadata.promptTokenCount ?? promptTokens
outputTokens = processed.usageMetadata.candidatesTokenCount ?? outputTokens
thoughtsTokenCount = processed.usageMetadata.thoughtsTokenCount ?? thoughtsTokenCount
cacheReadTokens = processed.usageMetadata.cachedContentTokenCount ?? cacheReadTokens
yield {
type: "usage",
@@ -544,6 +871,7 @@ export class SapAiCoreHandler implements ApiHandler {
outputTokens,
thoughtsTokenCount,
cacheReadTokens,
cacheWriteTokens: 0,
}
}
} catch (error) {
@@ -581,111 +909,4 @@ export class SapAiCoreHandler implements ApiHandler {
}
return { id: sapAiCoreDefaultModelId, info: sapAiCoreModels[sapAiCoreDefaultModelId] }
}
private getValidImageFormat(mediaType: string): string {
const format = mediaType.split("/")[1]?.toLowerCase()
const validFormats = ["png", "jpeg", "gif", "webp"]
if (validFormats.includes(format)) {
return format
}
throw new Error(`Unsupported image format: ${format}`)
}
private convertToGeminiFormat(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]) {
const contents = messages.map(this.convertAnthropicMessageToGemini)
const payload = {
contents,
systemInstruction: {
parts: [
{
text: systemPrompt,
},
],
},
generationConfig: {
maxOutputTokens: this.getModel().info.maxTokens,
temperature: 0.0,
},
}
return payload
}
private convertAnthropicMessageToGemini(message: Anthropic.Messages.MessageParam) {
const role = message.role === "assistant" ? "model" : "user"
const parts = []
if (typeof message.content === "string") {
parts.push({ text: message.content })
} else if (Array.isArray(message.content)) {
for (const block of message.content) {
if (block.type === "text") {
parts.push({ text: block.text })
} else if (block.type === "image") {
parts.push({
inlineData: {
mimeType: block.source.media_type,
data: block.source.data,
},
})
}
}
}
return { role, parts }
}
private formatAnthropicMessages(messages: Anthropic.Messages.MessageParam[]): any[] {
return messages.map((m) => {
const contentBlocks: any[] = []
if (typeof m.content === "string") {
contentBlocks.push({ text: m.content })
} else if (Array.isArray(m.content)) {
for (const block of m.content) {
if (block.type === "text") {
if (!block.text) {
throw new Error('Text block is missing the "text" field.')
}
contentBlocks.push({ text: block.text })
} else if (block.type === "image") {
if (!block.source) {
throw new Error('Image block is missing the "source" field.')
}
const { type, media_type, data } = block.source
if (!type || !media_type || !data) {
throw new Error('Image source must have "type", "media_type", and "data" fields.')
}
if (type !== "base64") {
throw new Error(`Unsupported image source type: ${type}. Only "base64" is supported.`)
}
const format = this.getValidImageFormat(media_type)
contentBlocks.push({
image: {
format,
source: {
bytes: data,
},
},
})
} else {
throw new Error(`Unsupported content block type: ${block.type}`)
}
}
} else {
throw new Error("Unsupported content format.")
}
return {
role: m.role,
content: contentBlocks,
}
})
}
}
@@ -2,8 +2,6 @@ import { Controller } from "../index"
import { AuthService } from "@/services/auth/AuthService"
import { EmptyRequest, String } from "@shared/proto/cline/common"
const authService = AuthService.getInstance()
/**
* Handles the user clicking the login link in the UI.
* Generates a secure nonce for state validation, stores it in secrets,
@@ -13,5 +11,5 @@ const authService = AuthService.getInstance()
* @returns The login URL as a string.
*/
export async function accountLoginClicked(_controller: Controller, _: EmptyRequest): Promise<String> {
return await authService.createAuthRequest()
return await AuthService.getInstance().createAuthRequest()
}
@@ -3,7 +3,6 @@ import { Empty } from "@shared/proto/cline/common"
import type { EmptyRequest } from "@shared/proto/cline/common"
import type { Controller } from "../index"
const authService = AuthService.getInstance()
/**
* Handles the account logout action
* @param controller The controller instance
@@ -12,6 +11,6 @@ const authService = AuthService.getInstance()
*/
export async function accountLogoutClicked(controller: Controller, _request: EmptyRequest): Promise<Empty> {
await controller.handleSignOut()
await authService.handleDeauth()
await AuthService.getInstance().handleDeauth()
return Empty.create({})
}
@@ -1,5 +1,13 @@
import { AuthService } from "../../../services/auth/AuthService"
import { AuthState, EmptyRequest } from "@/shared/proto/index.cline"
import { AuthService } from "@services/auth/AuthService"
import { Controller } from ".."
import { StreamingResponseHandler } from "../grpc-handler"
const authService = AuthService.getInstance()
export const subscribeToAuthStatusUpdate = authService.subscribeToAuthStatusUpdate.bind(authService)
export const sendAuthStatusUpdateEvent = authService.sendAuthStatusUpdate.bind(authService)
export async function subscribeToAuthStatusUpdate(
controller: Controller,
request: EmptyRequest,
responseStream: StreamingResponseHandler<AuthState>,
requestId?: string,
): Promise<void> {
return AuthService.getInstance().subscribeToAuthStatusUpdate(controller, request, responseStream, requestId)
}
+357
View File
@@ -0,0 +1,357 @@
import axios from "axios"
import * as vscode from "vscode"
import { getNonce } from "./getNonce"
import { WebviewProviderType } from "@/shared/webview/types"
import { Controller } from "@core/controller/index"
import { findLast } from "@shared/array"
import { readFile } from "fs/promises"
import path from "node:path"
import { v4 as uuidv4 } from "uuid"
import { Uri } from "vscode"
import { HostProvider } from "@/hosts/host-provider"
import { ShowMessageType } from "@/shared/proto/host/window"
export abstract class WebviewProvider {
public static readonly sideBarId = "claude-dev.SidebarProvider" // used in package.json as the view's id. This value cannot be changed due to how vscode caches views based on their id, and updating the id would break existing instances of the extension.
public static readonly tabPanelId = "claude-dev.TabPanelProvider"
private static activeInstances: Set<WebviewProvider> = new Set()
private static clientIdMap = new Map<WebviewProvider, string>()
controller: Controller
private clientId: string
private static lastActiveControllerId: string | null = null
constructor(
readonly context: vscode.ExtensionContext,
private readonly providerType: WebviewProviderType,
) {
WebviewProvider.activeInstances.add(this)
this.clientId = uuidv4()
WebviewProvider.clientIdMap.set(this, this.clientId)
// Create controller with cache service
this.controller = new Controller(context, this.clientId)
WebviewProvider.setLastActiveControllerId(this.controller.id)
}
// Add a method to get the client ID
public getClientId(): string {
return this.clientId
}
// Add a static method to get the client ID for a specific instance
public static getClientIdForInstance(instance: WebviewProvider): string | undefined {
return WebviewProvider.clientIdMap.get(instance)
}
async dispose() {
await this.controller.dispose()
WebviewProvider.activeInstances.delete(this)
// Remove from client ID map
WebviewProvider.clientIdMap.delete(this)
}
public static getVisibleInstance(): WebviewProvider | undefined {
return findLast(Array.from(WebviewProvider.activeInstances), (instance) => instance.isVisible() === true)
}
public static getActiveInstance(): WebviewProvider | undefined {
return Array.from(WebviewProvider.activeInstances).find((instance) => {
const webview = instance.getWebview()
if (webview && webview.viewType === "claude-dev.TabPanelProvider" && "active" in webview) {
return webview.active === true
}
return false
})
}
public static getAllInstances(): WebviewProvider[] {
return Array.from(WebviewProvider.activeInstances)
}
public static getSidebarInstance() {
return Array.from(WebviewProvider.activeInstances).find(
(instance) => instance.providerType === WebviewProviderType.SIDEBAR,
)
}
public static getTabInstances(): WebviewProvider[] {
return Array.from(WebviewProvider.activeInstances).filter((instance) => instance.providerType === WebviewProviderType.TAB)
}
public static getLastActiveInstance(): WebviewProvider | undefined {
const lastActiveId = WebviewProvider.getLastActiveControllerId()
if (!lastActiveId) {
return undefined
}
return Array.from(WebviewProvider.activeInstances).find((instance) => instance.controller.id === lastActiveId)
}
/**
* Gets the last active controller ID with performance optimization
* @returns The last active controller ID or null
*/
public static getLastActiveControllerId(): string | null {
return WebviewProvider.lastActiveControllerId || WebviewProvider.getSidebarInstance()?.controller.id || null
}
/**
* Sets the last active controller ID with validation and performance optimization
* @param controllerId The controller ID to set as last active
*/
public static setLastActiveControllerId(controllerId: string | null): void {
// Only update if the value is actually different to avoid unnecessary operations
if (WebviewProvider.lastActiveControllerId !== controllerId) {
WebviewProvider.lastActiveControllerId = controllerId
}
}
public static async disposeAllInstances() {
const instances = Array.from(WebviewProvider.activeInstances)
for (const instance of instances) {
await instance.dispose()
}
}
/**
* Initializes and sets up the webview when it's first created.
*
* @param webviewView - The webview view or panel instance to be resolved
* @returns A promise that resolves when the webview has been fully initialized
*/
abstract resolveWebviewView(webviewView: vscode.WebviewView | vscode.WebviewPanel): Promise<void>
/**
* Gets the current webview instance.
*
* @returns The webview instance (WebviewView, WebviewPanel, or similar)
*/
abstract getWebview(): any
/**
* Converts a local URI to a webview URI that can be used within the webview.
*
* @param uri - The local URI to convert
* @returns A URI that can be used within the webview
*/
abstract getWebviewUri(uri: Uri): Uri
/**
* Gets the Content Security Policy source for the webview.
*
* @returns The CSP source string to be used in the webview's Content-Security-Policy
*/
abstract getCspSource(): string
/**
* Checks if the webview is currently visible to the user.
*
* @returns True if the webview is visible, false otherwise
*/
abstract isVisible(): boolean
/**
* Defines and returns the HTML that should be rendered within the webview panel.
*
* @remarks This is also the place where references to the React webview build files
* are created and inserted into the webview HTML.
*
* @param webview A reference to the extension webview
* @param extensionUri The URI of the directory containing the extension
* @returns A template string literal containing the HTML that should be
* rendered within the webview panel
*/
public getHtmlContent(): string {
// Get the local path to main script run in the webview,
// then convert it to a uri we can use in the webview.
// The CSS file from the React build output
const stylesUri = this.getExtensionUri("webview-ui", "build", "assets", "index.css")
// The JS file from the React build output
const scriptUri = this.getExtensionUri("webview-ui", "build", "assets", "index.js")
// The codicon font from the React build output
// https://github.com/microsoft/vscode-extension-samples/blob/main/webview-codicons-sample/src/extension.ts
// we installed this package in the extension so that we can access it how its intended from the extension (the font file is likely bundled in vscode), and we just import the css fileinto our react app we don't have access to it
// don't forget to add font-src ${webview.cspSource};
const codiconsUri = this.getExtensionUri("node_modules", "@vscode", "codicons", "dist", "codicon.css")
// const scriptUri = webview.asWebviewUri(vscode.Uri.joinPath(this._extensionUri, "assets", "main.js"))
// const styleResetUri = webview.asWebviewUri(vscode.Uri.joinPath(this._extensionUri, "assets", "reset.css"))
// const styleVSCodeUri = webview.asWebviewUri(vscode.Uri.joinPath(this._extensionUri, "assets", "vscode.css"))
// // Same for stylesheet
// const stylesheetUri = webview.asWebviewUri(vscode.Uri.joinPath(this._extensionUri, "assets", "main.css"))
// Use a nonce to only allow a specific script to be run.
/*
content security policy of your webview to only allow scripts that have a specific nonce
create a content security policy meta tag so that only loading scripts with a nonce is allowed
As your extension grows you will likely want to add custom styles, fonts, and/or images to your webview. If you do, you will need to update the content security policy meta tag to explicitly allow for these resources. E.g.
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src ${webview.cspSource}; font-src ${webview.cspSource}; img-src ${webview.cspSource} https:; script-src 'nonce-${nonce}';">
- 'unsafe-inline' is required for styles due to vscode-webview-toolkit's dynamic style injection
- since we pass base64 images to the webview, we need to specify img-src ${webview.cspSource} data:;
in meta tag we add nonce attribute: A cryptographic nonce (only used once) to allow scripts. The server must generate a unique nonce value each time it transmits a policy. It is critical to provide a nonce that cannot be guessed as bypassing a resource's policy is otherwise trivial.
*/
const nonce = getNonce()
// Tip: Install the es6-string-html VS Code extension to enable code highlighting below
return /*html*/ `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,shrink-to-fit=no">
<meta name="theme-color" content="#000000">
<link rel="stylesheet" type="text/css" href="${stylesUri}">
<link href="${codiconsUri}" rel="stylesheet" />
<meta http-equiv="Content-Security-Policy" content="default-src 'none';
connect-src https://*.posthog.com https://*.cline.bot https://*.firebaseauth.com https://*.firebaseio.com https://*.googleapis.com https://*.firebase.com;
font-src ${this.getCspSource()} data:;
style-src ${this.getCspSource()} 'unsafe-inline';
img-src ${this.getCspSource()} https: data:;
script-src 'nonce-${nonce}' 'unsafe-eval';">
<title>Cline</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<script type="text/javascript" nonce="${nonce}">
// Inject the provider type
window.WEBVIEW_PROVIDER_TYPE = ${JSON.stringify(this.providerType)};
// Inject the client ID
window.clineClientId = "${this.clientId}";
</script>
<script type="module" nonce="${nonce}" src="${scriptUri}"></script>
<script src="http://localhost:8097"></script>
</body>
</html>
`
}
/**
* Reads the Vite dev server port from the generated port file to avoid conflicts
* Returns a Promise that resolves to the port number
* If the file doesn't exist or can't be read, it resolves to the default port
*/
private getDevServerPort(): Promise<number> {
const DEFAULT_PORT = 25463
const portFilePath = path.join(__dirname, "..", "webview-ui", ".vite-port")
return readFile(portFilePath, "utf8")
.then((portFile) => {
const port = parseInt(portFile.trim()) || DEFAULT_PORT
console.info(`[getDevServerPort] Using dev server port ${port} from .vite-port file`)
return port
})
.catch((err) => {
console.warn(
`[getDevServerPort] Port file not found or couldn't be read at ${portFilePath}, using default port: ${DEFAULT_PORT}`,
)
return DEFAULT_PORT
})
}
/**
* Connects to the local Vite dev server to allow HMR, with fallback to the bundled assets
*
* @param webview A reference to the extension webview
* @returns A template string literal containing the HTML that should be
* rendered within the webview panel
*/
protected async getHMRHtmlContent(): Promise<string> {
const localPort = await this.getDevServerPort()
const localServerUrl = `localhost:${localPort}`
// Check if local dev server is running.
try {
await axios.get(`http://${localServerUrl}`)
} catch (error) {
// Only show the error message when in development mode.
if (process.env.IS_DEV) {
HostProvider.window.showMessage({
type: ShowMessageType.ERROR,
message:
"Cline: Local webview dev server is not running, HMR will not work. Please run 'npm run dev:webview' before launching the extension to enable HMR. Using bundled assets.",
})
}
return this.getHtmlContent()
}
const nonce = getNonce()
const stylesUri = this.getExtensionUri("webview-ui", "build", "assets", "index.css")
const codiconsUri = this.getExtensionUri("node_modules", "@vscode", "codicons", "dist", "codicon.css")
const scriptEntrypoint = "src/main.tsx"
const scriptUri = `http://${localServerUrl}/${scriptEntrypoint}`
const reactRefresh = /*html*/ `
<script nonce="${nonce}" type="module">
import RefreshRuntime from "http://${localServerUrl}/@react-refresh"
RefreshRuntime.injectIntoGlobalHook(window)
window.$RefreshReg$ = () => {}
window.$RefreshSig$ = () => (type) => type
window.__vite_plugin_react_preamble_installed__ = true
</script>
`
const csp = [
"default-src 'none'",
`font-src ${this.getCspSource()}`,
`style-src ${this.getCspSource()} 'unsafe-inline' https://* http://${localServerUrl} http://0.0.0.0:${localPort}`,
`img-src ${this.getCspSource()} https: data:`,
`script-src 'unsafe-eval' https://* http://${localServerUrl} http://0.0.0.0:${localPort} 'nonce-${nonce}'`,
`connect-src https://* ws://${localServerUrl} ws://0.0.0.0:${localPort} http://${localServerUrl} http://0.0.0.0:${localPort}`,
]
return /*html*/ `
<!DOCTYPE html>
<html lang="en">
<head>
${process.env.IS_DEV ? '<script src="http://localhost:8097"></script>' : ""}
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,shrink-to-fit=no">
<meta http-equiv="Content-Security-Policy" content="${csp.join("; ")}">
<link rel="stylesheet" type="text/css" href="${stylesUri}">
<link href="${codiconsUri}" rel="stylesheet" />
<title>Cline</title>
</head>
<body>
<div id="root"></div>
<script type="text/javascript" nonce="${nonce}">
// Inject the provider type
window.WEBVIEW_PROVIDER_TYPE = ${JSON.stringify(this.providerType)};
// Inject the client ID
window.clineClientId = "${this.clientId}";
</script>
${reactRefresh}
<script type="module" src="${scriptUri}"></script>
</body>
</html>
`
}
/**
* A helper function which will get the webview URI of a given file or resource in the extension directory.
*
* @remarks This URI can be used within a webview's HTML as a link to the
* given file/resource.
*
* @param pathList An array of strings representing the path to a file/resource in the extension directory.
* @returns A URI pointing to the file/resource
*/
private getExtensionUri(...pathList: string[]): Uri {
if (!this.getWebview()) {
throw Error("webview is not initialized.")
}
return this.getWebviewUri(Uri.joinPath(this.context.extensionUri, ...pathList))
}
}
+1 -358
View File
@@ -1,358 +1 @@
import axios from "axios"
import * as vscode from "vscode"
import { getNonce } from "./getNonce"
import { WebviewProviderType } from "@/shared/webview/types"
import { Controller } from "@core/controller/index"
import { findLast } from "@shared/array"
import { readFile } from "fs/promises"
import path from "node:path"
import { v4 as uuidv4 } from "uuid"
import { Uri } from "vscode"
import { ExtensionMessage } from "@/shared/ExtensionMessage"
import { HostProvider } from "@/hosts/host-provider"
import { ShowMessageType } from "@/shared/proto/host/window"
export abstract class WebviewProvider {
public static readonly sideBarId = "claude-dev.SidebarProvider" // used in package.json as the view's id. This value cannot be changed due to how vscode caches views based on their id, and updating the id would break existing instances of the extension.
public static readonly tabPanelId = "claude-dev.TabPanelProvider"
private static activeInstances: Set<WebviewProvider> = new Set()
private static clientIdMap = new Map<WebviewProvider, string>()
controller: Controller
private clientId: string
private static lastActiveControllerId: string | null = null
constructor(
readonly context: vscode.ExtensionContext,
private readonly providerType: WebviewProviderType,
) {
WebviewProvider.activeInstances.add(this)
this.clientId = uuidv4()
WebviewProvider.clientIdMap.set(this, this.clientId)
// Create controller with cache service
this.controller = new Controller(context, this.clientId)
WebviewProvider.setLastActiveControllerId(this.controller.id)
}
// Add a method to get the client ID
public getClientId(): string {
return this.clientId
}
// Add a static method to get the client ID for a specific instance
public static getClientIdForInstance(instance: WebviewProvider): string | undefined {
return WebviewProvider.clientIdMap.get(instance)
}
async dispose() {
await this.controller.dispose()
WebviewProvider.activeInstances.delete(this)
// Remove from client ID map
WebviewProvider.clientIdMap.delete(this)
}
public static getVisibleInstance(): WebviewProvider | undefined {
return findLast(Array.from(WebviewProvider.activeInstances), (instance) => instance.isVisible() === true)
}
public static getActiveInstance(): WebviewProvider | undefined {
return Array.from(WebviewProvider.activeInstances).find((instance) => {
const webview = instance.getWebview()
if (webview && webview.viewType === "claude-dev.TabPanelProvider" && "active" in webview) {
return webview.active === true
}
return false
})
}
public static getAllInstances(): WebviewProvider[] {
return Array.from(WebviewProvider.activeInstances)
}
public static getSidebarInstance() {
return Array.from(WebviewProvider.activeInstances).find(
(instance) => instance.providerType === WebviewProviderType.SIDEBAR,
)
}
public static getTabInstances(): WebviewProvider[] {
return Array.from(WebviewProvider.activeInstances).filter((instance) => instance.providerType === WebviewProviderType.TAB)
}
public static getLastActiveInstance(): WebviewProvider | undefined {
const lastActiveId = WebviewProvider.getLastActiveControllerId()
if (!lastActiveId) {
return undefined
}
return Array.from(WebviewProvider.activeInstances).find((instance) => instance.controller.id === lastActiveId)
}
/**
* Gets the last active controller ID with performance optimization
* @returns The last active controller ID or null
*/
public static getLastActiveControllerId(): string | null {
return WebviewProvider.lastActiveControllerId || WebviewProvider.getSidebarInstance()?.controller.id || null
}
/**
* Sets the last active controller ID with validation and performance optimization
* @param controllerId The controller ID to set as last active
*/
public static setLastActiveControllerId(controllerId: string | null): void {
// Only update if the value is actually different to avoid unnecessary operations
if (WebviewProvider.lastActiveControllerId !== controllerId) {
WebviewProvider.lastActiveControllerId = controllerId
}
}
public static async disposeAllInstances() {
const instances = Array.from(WebviewProvider.activeInstances)
for (const instance of instances) {
await instance.dispose()
}
}
/**
* Initializes and sets up the webview when it's first created.
*
* @param webviewView - The webview view or panel instance to be resolved
* @returns A promise that resolves when the webview has been fully initialized
*/
abstract resolveWebviewView(webviewView: vscode.WebviewView | vscode.WebviewPanel): Promise<void>
/**
* Gets the current webview instance.
*
* @returns The webview instance (WebviewView, WebviewPanel, or similar)
*/
abstract getWebview(): vscode.WebviewPanel | vscode.WebviewView | undefined
/**
* Converts a local URI to a webview URI that can be used within the webview.
*
* @param uri - The local URI to convert
* @returns A URI that can be used within the webview
*/
abstract getWebviewUri(uri: Uri): Uri
/**
* Gets the Content Security Policy source for the webview.
*
* @returns The CSP source string to be used in the webview's Content-Security-Policy
*/
abstract getCspSource(): string
/**
* Checks if the webview is currently visible to the user.
*
* @returns True if the webview is visible, false otherwise
*/
abstract isVisible(): boolean
/**
* Defines and returns the HTML that should be rendered within the webview panel.
*
* @remarks This is also the place where references to the React webview build files
* are created and inserted into the webview HTML.
*
* @param webview A reference to the extension webview
* @param extensionUri The URI of the directory containing the extension
* @returns A template string literal containing the HTML that should be
* rendered within the webview panel
*/
public getHtmlContent(): string {
// Get the local path to main script run in the webview,
// then convert it to a uri we can use in the webview.
// The CSS file from the React build output
const stylesUri = this.getExtensionUri("webview-ui", "build", "assets", "index.css")
// The JS file from the React build output
const scriptUri = this.getExtensionUri("webview-ui", "build", "assets", "index.js")
// The codicon font from the React build output
// https://github.com/microsoft/vscode-extension-samples/blob/main/webview-codicons-sample/src/extension.ts
// we installed this package in the extension so that we can access it how its intended from the extension (the font file is likely bundled in vscode), and we just import the css fileinto our react app we don't have access to it
// don't forget to add font-src ${webview.cspSource};
const codiconsUri = this.getExtensionUri("node_modules", "@vscode", "codicons", "dist", "codicon.css")
// const scriptUri = webview.asWebviewUri(vscode.Uri.joinPath(this._extensionUri, "assets", "main.js"))
// const styleResetUri = webview.asWebviewUri(vscode.Uri.joinPath(this._extensionUri, "assets", "reset.css"))
// const styleVSCodeUri = webview.asWebviewUri(vscode.Uri.joinPath(this._extensionUri, "assets", "vscode.css"))
// // Same for stylesheet
// const stylesheetUri = webview.asWebviewUri(vscode.Uri.joinPath(this._extensionUri, "assets", "main.css"))
// Use a nonce to only allow a specific script to be run.
/*
content security policy of your webview to only allow scripts that have a specific nonce
create a content security policy meta tag so that only loading scripts with a nonce is allowed
As your extension grows you will likely want to add custom styles, fonts, and/or images to your webview. If you do, you will need to update the content security policy meta tag to explicitly allow for these resources. E.g.
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src ${webview.cspSource}; font-src ${webview.cspSource}; img-src ${webview.cspSource} https:; script-src 'nonce-${nonce}';">
- 'unsafe-inline' is required for styles due to vscode-webview-toolkit's dynamic style injection
- since we pass base64 images to the webview, we need to specify img-src ${webview.cspSource} data:;
in meta tag we add nonce attribute: A cryptographic nonce (only used once) to allow scripts. The server must generate a unique nonce value each time it transmits a policy. It is critical to provide a nonce that cannot be guessed as bypassing a resource's policy is otherwise trivial.
*/
const nonce = getNonce()
// Tip: Install the es6-string-html VS Code extension to enable code highlighting below
return /*html*/ `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,shrink-to-fit=no">
<meta name="theme-color" content="#000000">
<link rel="stylesheet" type="text/css" href="${stylesUri}">
<link href="${codiconsUri}" rel="stylesheet" />
<meta http-equiv="Content-Security-Policy" content="default-src 'none';
connect-src https://*.posthog.com https://*.cline.bot https://*.firebaseauth.com https://*.firebaseio.com https://*.googleapis.com https://*.firebase.com;
font-src ${this.getCspSource()} data:;
style-src ${this.getCspSource()} 'unsafe-inline';
img-src ${this.getCspSource()} https: data:;
script-src 'nonce-${nonce}' 'unsafe-eval';">
<title>Cline</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<script type="text/javascript" nonce="${nonce}">
// Inject the provider type
window.WEBVIEW_PROVIDER_TYPE = ${JSON.stringify(this.providerType)};
// Inject the client ID
window.clineClientId = "${this.clientId}";
</script>
<script type="module" nonce="${nonce}" src="${scriptUri}"></script>
<script src="http://localhost:8097"></script>
</body>
</html>
`
}
/**
* Reads the Vite dev server port from the generated port file to avoid conflicts
* Returns a Promise that resolves to the port number
* If the file doesn't exist or can't be read, it resolves to the default port
*/
private getDevServerPort(): Promise<number> {
const DEFAULT_PORT = 25463
const portFilePath = path.join(__dirname, "..", "webview-ui", ".vite-port")
return readFile(portFilePath, "utf8")
.then((portFile) => {
const port = parseInt(portFile.trim()) || DEFAULT_PORT
console.info(`[getDevServerPort] Using dev server port ${port} from .vite-port file`)
return port
})
.catch((err) => {
console.warn(
`[getDevServerPort] Port file not found or couldn't be read at ${portFilePath}, using default port: ${DEFAULT_PORT}`,
)
return DEFAULT_PORT
})
}
/**
* Connects to the local Vite dev server to allow HMR, with fallback to the bundled assets
*
* @param webview A reference to the extension webview
* @returns A template string literal containing the HTML that should be
* rendered within the webview panel
*/
protected async getHMRHtmlContent(): Promise<string> {
const localPort = await this.getDevServerPort()
const localServerUrl = `localhost:${localPort}`
// Check if local dev server is running.
try {
await axios.get(`http://${localServerUrl}`)
} catch (error) {
// Only show the error message when in development mode.
if (process.env.IS_DEV) {
HostProvider.window.showMessage({
type: ShowMessageType.ERROR,
message:
"Cline: Local webview dev server is not running, HMR will not work. Please run 'npm run dev:webview' before launching the extension to enable HMR. Using bundled assets.",
})
}
return this.getHtmlContent()
}
const nonce = getNonce()
const stylesUri = this.getExtensionUri("webview-ui", "build", "assets", "index.css")
const codiconsUri = this.getExtensionUri("node_modules", "@vscode", "codicons", "dist", "codicon.css")
const scriptEntrypoint = "src/main.tsx"
const scriptUri = `http://${localServerUrl}/${scriptEntrypoint}`
const reactRefresh = /*html*/ `
<script nonce="${nonce}" type="module">
import RefreshRuntime from "http://${localServerUrl}/@react-refresh"
RefreshRuntime.injectIntoGlobalHook(window)
window.$RefreshReg$ = () => {}
window.$RefreshSig$ = () => (type) => type
window.__vite_plugin_react_preamble_installed__ = true
</script>
`
const csp = [
"default-src 'none'",
`font-src ${this.getCspSource()}`,
`style-src ${this.getCspSource()} 'unsafe-inline' https://* http://${localServerUrl} http://0.0.0.0:${localPort}`,
`img-src ${this.getCspSource()} https: data:`,
`script-src 'unsafe-eval' https://* http://${localServerUrl} http://0.0.0.0:${localPort} 'nonce-${nonce}'`,
`connect-src https://* ws://${localServerUrl} ws://0.0.0.0:${localPort} http://${localServerUrl} http://0.0.0.0:${localPort}`,
]
return /*html*/ `
<!DOCTYPE html>
<html lang="en">
<head>
${process.env.IS_DEV ? '<script src="http://localhost:8097"></script>' : ""}
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,shrink-to-fit=no">
<meta http-equiv="Content-Security-Policy" content="${csp.join("; ")}">
<link rel="stylesheet" type="text/css" href="${stylesUri}">
<link href="${codiconsUri}" rel="stylesheet" />
<title>Cline</title>
</head>
<body>
<div id="root"></div>
<script type="text/javascript" nonce="${nonce}">
// Inject the provider type
window.WEBVIEW_PROVIDER_TYPE = ${JSON.stringify(this.providerType)};
// Inject the client ID
window.clineClientId = "${this.clientId}";
</script>
${reactRefresh}
<script type="module" src="${scriptUri}"></script>
</body>
</html>
`
}
/**
* A helper function which will get the webview URI of a given file or resource in the extension directory.
*
* @remarks This URI can be used within a webview's HTML as a link to the
* given file/resource.
*
* @param pathList An array of strings representing the path to a file/resource in the extension directory.
* @returns A URI pointing to the file/resource
*/
private getExtensionUri(...pathList: string[]): Uri {
if (!this.getWebview()) {
throw Error("webview is not initialized.")
}
return this.getWebviewUri(Uri.joinPath(this.context.extensionUri, ...pathList))
}
}
export { WebviewProvider } from "./WebviewProvider"
+2 -3
View File
@@ -1,8 +1,7 @@
import { ExtensionMessage } from "@/shared/ExtensionMessage"
import { WebviewProviderType } from "@/shared/webview/types"
import { WebviewProvider } from "@/core/webview"
import * as vscode from "vscode"
import { URI } from "vscode-uri"
import { WebviewProvider } from "@core/webview"
export class ExternalWebviewProvider extends WebviewProvider {
// This hostname cannot be changed without updating the external webview handler.
@@ -25,7 +24,7 @@ export class ExternalWebviewProvider extends WebviewProvider {
return true
}
override getWebview() {
return undefined
return {}
}
override resolveWebviewView(_: any): Promise<void> {
+1 -1
View File
@@ -1,4 +1,4 @@
import { WebviewProvider } from "@core/webview"
import { WebviewProvider } from "@/core/webview"
import { HostBridgeClientProvider } from "./host-provider-types"
import { WebviewProviderType } from "@/shared/webview/types"
import { DiffViewProvider } from "@/integrations/editor/DiffViewProvider"
+1 -1
View File
@@ -129,7 +129,7 @@ export class ClineError extends Error {
const { code, status, details } = err._error
// Check balance error first (most specific)
if (code === "insufficient_credits" && typeof details?.current_balance === "number") {
if (status === 402 || (code === "insufficient_credits" && typeof details?.current_balance === "number")) {
return ClineErrorType.Balance
}
+27 -3
View File
@@ -2782,21 +2782,21 @@ export const sapAiCoreModels = {
maxTokens: 8192,
contextWindow: 200_000,
supportsImages: true,
supportsPromptCache: false,
supportsPromptCache: true,
description: sapAiCoreModelDescription,
},
"anthropic--claude-4-opus": {
maxTokens: 8192,
contextWindow: 200_000,
supportsImages: true,
supportsPromptCache: false,
supportsPromptCache: true,
description: sapAiCoreModelDescription,
},
"anthropic--claude-3.7-sonnet": {
maxTokens: 64_000,
contextWindow: 200_000,
supportsImages: true,
supportsPromptCache: false,
supportsPromptCache: true,
description: sapAiCoreModelDescription,
},
"anthropic--claude-3.5-sonnet": {
@@ -2832,6 +2832,9 @@ export const sapAiCoreModels = {
contextWindow: 1_048_576,
supportsImages: true,
supportsPromptCache: true,
thinkingConfig: {
maxBudget: 32767,
},
description: sapAiCoreModelDescription,
},
"gemini-2.5-flash": {
@@ -2879,6 +2882,27 @@ export const sapAiCoreModels = {
supportsPromptCache: true,
description: sapAiCoreModelDescription,
},
"gpt-5": {
maxTokens: 128_000,
contextWindow: 272_000,
supportsImages: true,
supportsPromptCache: true,
description: sapAiCoreModelDescription,
},
"gpt-5-nano": {
maxTokens: 128_000,
contextWindow: 272_000,
supportsImages: true,
supportsPromptCache: true,
description: sapAiCoreModelDescription,
},
"gpt-5-mini": {
maxTokens: 128_000,
contextWindow: 272_000,
supportsImages: true,
supportsPromptCache: true,
description: sapAiCoreModelDescription,
},
o1: {
maxTokens: 4096,
contextWindow: 200_000,
+2 -1
View File
@@ -12,10 +12,11 @@ log("Running standalone cline ", VERSION)
const CLINE_DIR = process.env.CLINE_DIR || `${os.homedir()}/.cline`
const DATA_DIR = path.join(CLINE_DIR, "data")
const INSTALL_DIR = process.env.INSTALL_DIR || path.join(CLINE_DIR, "core", VERSION)
mkdirSync(DATA_DIR, { recursive: true })
log("Using settings dir:", DATA_DIR)
const EXTENSION_DIR = path.join(CLINE_DIR, "core", VERSION, "extension")
const EXTENSION_DIR = path.join(INSTALL_DIR, "extension")
const EXTENSION_MODE = process.env.IS_DEV === "true" ? ExtensionMode.Development : ExtensionMode.Production
const extension: Extension<void> = {
@@ -44,6 +44,7 @@ import { validateApiConfiguration, validateModelId } from "@/utils/validate"
import ClineRulesToggleModal from "../cline-rules/ClineRulesToggleModal"
import ServersToggleModal from "./ServersToggleModal"
import { Mode } from "@shared/storage/types"
import { isSafari } from "@/utils/platformUtils"
const { MAX_IMAGES_AND_FILES_PER_MESSAGE } = CHAT_CONSTANTS
@@ -92,7 +93,8 @@ interface GitCommit {
description: string
}
const PLAN_MODE_COLOR = "var(--vscode-inputValidation-warningBorder)"
const PLAN_MODE_COLOR = "var(--vscode-activityWarningBadge-background)"
const ACT_MODE_COLOR = "var(--vscode-focusBorder)"
const SwitchOption = styled.div.withConfig({
shouldForwardProp: (prop) => !["isActive"].includes(prop),
@@ -131,7 +133,7 @@ const Slider = styled.div.withConfig({
position: absolute;
height: 100%;
width: 50%;
background-color: ${(props) => (props.isPlan ? PLAN_MODE_COLOR : "var(--vscode-focusBorder)")};
background-color: ${(props) => (props.isPlan ? PLAN_MODE_COLOR : ACT_MODE_COLOR)};
transition: transform 0.2s ease;
transform: translateX(${(props) => (props.isAct ? "100%" : "0%")});
`
@@ -567,7 +569,8 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
}
}
const isComposing = event.nativeEvent?.isComposing ?? false
// Safari does not support InputEvent.isComposing (always false), so we need to fallback to keyCode === 229 for it
const isComposing = isSafari ? event.nativeEvent.keyCode === 229 : (event.nativeEvent?.isComposing ?? false)
if (event.key === "Enter" && !event.shiftKey && !isComposing) {
event.preventDefault()
@@ -6,23 +6,30 @@ import { VSCodeButton } from "@vscode/webview-ui-toolkit/react"
import { useExtensionState } from "@/context/ExtensionStateContext"
import React from "react"
import { useClineAuth } from "@/context/ClineAuthContext"
interface CreditLimitErrorProps {
currentBalance: number
totalSpent?: number
totalPromotions?: number
message: string
buyCreditsUrl?: string
// buyCreditsUrl?: string
}
const CreditLimitError: React.FC<CreditLimitErrorProps> = ({
currentBalance = 0,
totalSpent = 0,
totalPromotions = 0,
message = "You have run out of credit.",
buyCreditsUrl = "https://app.cline.bot/dashboard/account?tab=credits&redirect=true",
message = "You have run out of credits.",
// buyCreditsUrl = "https://app.cline.bot/dashboard/account?tab=credits&redirect=true",
}) => {
const { uriScheme } = useExtensionState()
const { activeOrganization } = useClineAuth()
const isPersonal = !activeOrganization?.organizationId
const buyCreditsUrl = isPersonal
? "https://app.cline.bot/dashboard/account?tab=credits&redirect=true"
: "https://app.cline.bot/dashboard/organization?tab=credits&redirect=true"
const callbackUrl = `${uriScheme || "vscode"}://saoudrizwan.claude-dev`
const fullPurchaseUrl = new URL(buyCreditsUrl)
@@ -33,13 +40,13 @@ const CreditLimitError: React.FC<CreditLimitErrorProps> = ({
<div className="p-2 border-none rounded-md mb-2 bg-[var(--vscode-textBlockQuote-background)]">
<div className="mb-3 font-azeret-mono">
<div style={{ color: "var(--vscode-errorForeground)", marginBottom: "8px" }}>{message}</div>
<div style={{ marginBottom: "12px" }}>
{/* <div style={{ marginBottom: "12px" }}>
<div style={{ color: "var(--vscode-foreground)" }}>
Current Balance: <span style={{ fontWeight: "bold" }}>{currentBalance.toFixed(2)}</span>
</div>
<div style={{ color: "var(--vscode-foreground)" }}>Total Spent: {totalSpent.toFixed(2)}</div>
<div style={{ color: "var(--vscode-foreground)" }}>Total Promotions: {totalPromotions.toFixed(2)}</div>
</div>
</div> */}
</div>
<VSCodeButtonLink
@@ -87,7 +87,7 @@ describe("ErrorRow", () => {
current_balance: 0,
total_spent: 10.5,
total_promotions: 5.0,
message: "You have run out of credit.",
message: "You have run out of credits.",
buy_credits_url: "https://app.cline.bot/dashboard",
},
},
@@ -99,7 +99,7 @@ describe("ErrorRow", () => {
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()
expect(screen.getByText("You have run out of credits.")).toBeInTheDocument()
})
it("renders rate limit error with request ID", async () => {
+1 -1
View File
@@ -38,7 +38,7 @@ const ErrorRow = memo(({ message, errorType, apiRequestFailedMessage, apiReqStre
totalSpent={errorDetails?.total_spent}
totalPromotions={errorDetails?.total_promotions}
message={errorDetails?.message}
buyCreditsUrl={errorDetails?.buy_credits_url}
// buyCreditsUrl={errorDetails?.buy_credits_url}
/>
)
}
+6
View File
@@ -34,3 +34,9 @@ export const detectMetaKeyChar = (platform: string) => {
return "CMD"
}
}
const userAgent = navigator?.userAgent || ""
export const isChrome = userAgent.indexOf("Chrome") >= 0
export const isSafari = !isChrome && userAgent.indexOf("Safari") >= 0