mirror of
https://github.com/cline/cline.git
synced 2026-09-01 23:19:18 +08:00
Compare commits
76 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b092284f63 | |||
| 19c4439877 | |||
| ec01e1f19d | |||
| 4a0a40ead1 | |||
| 124569c391 | |||
| 5a08f977c3 | |||
| 4ec85af1fb | |||
| 00f88050f7 | |||
| c4f73e7752 | |||
| e84b8669a6 | |||
| 5b6eae2905 | |||
| c153077bde | |||
| 1796558733 | |||
| 2f7f8bff20 | |||
| dc9fb4ddb8 | |||
| f9c46f898f | |||
| 2d2eb5dd36 | |||
| 9562671bcc | |||
| 6a2aef27ea | |||
| c7d6173e20 | |||
| 62da7fe720 | |||
| f97f3e47cc | |||
| f47be1a7bb | |||
| 0cd2e69551 | |||
| 4b19010d56 | |||
| 49803bf958 | |||
| 33704dcdc1 | |||
| 78552287ae | |||
| a29689dfd3 | |||
| 2ca4738b59 | |||
| b4a2d5a06d | |||
| 7f2de3956c | |||
| 059b14e494 | |||
| 4d95817eec | |||
| d8c13db683 | |||
| a35e1ff8df | |||
| a0c5276fb1 | |||
| 696a2c4e48 | |||
| e983358dde | |||
| 9ae90c99ad | |||
| 4cd914970e | |||
| 4919424faf | |||
| e65fa89566 | |||
| 935a68fedc | |||
| 5c78f64978 | |||
| 328e49bd3c | |||
| 0f7c0205b6 | |||
| af0cd9bdff | |||
| 647a964853 | |||
| 58305dd6b3 | |||
| 677fc3a446 | |||
| 88c33ee96d | |||
| 23a0d41646 | |||
| 974f698e54 | |||
| 12f16a47e3 | |||
| bc7d6f0c66 | |||
| c8b446ab6c | |||
| 2cc64973fb | |||
| 599c3f6717 | |||
| 1c491bf719 | |||
| 10ac28b9d6 | |||
| 95b2e327a8 | |||
| 53d11c88bb | |||
| 7ef0f6d1b6 | |||
| 9012d6a1fc | |||
| e3d363c56f | |||
| 62a04ad38a | |||
| 1774cf8b79 | |||
| 6133eb4864 | |||
| 087f1d3be3 | |||
| 8cc7759584 | |||
| f9d70ceb6c | |||
| 3c516023c8 | |||
| 4c0c0c947b | |||
| 2ac6af5b08 | |||
| 7e68dd8d98 |
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
feat(bedrock): adding Amazon Nova
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Improve file handling for NextJS folder naming conventions and increase file listing limits. Fix glob pattern interpretation issues with parentheses in folder names
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Support VPC Endpoint options for Deepseek
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Handle input too large Anthropic
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Fix "See more" not showing up for tasks after task un-fold
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Fix tooltip css layer bug
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Fix gpt-4.5-preview's supportsPromptCache value to true
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": minor
|
||||
---
|
||||
|
||||
Support for Loading Files from the `.clinerules/` Directory
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": minor
|
||||
---
|
||||
|
||||
Display thinking chunks from Sonnet 3.7 in Bedrock
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
feat(bedrock): adding deepseek-r1
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Add hard limit for file size Cline reads into context
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Added a dedicated git getDiffCount function to reduce git operations
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": minor
|
||||
---
|
||||
|
||||
Added a script to create test tasks in dev mode
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
updated move context management out of cline
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": minor
|
||||
---
|
||||
|
||||
Adding Tailwind to the extension
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Update copyright for Readme Files
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": minor
|
||||
---
|
||||
|
||||
Enable VPC endpoint configuration for Amazon Bedrock in AWS Profile mode.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": minor
|
||||
---
|
||||
|
||||
Add SambaNova as a new API provider with support for text-based models. Users can now connect to SambaNova's API using their API key and access their hosted LLM services directly from within Cline.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": minor
|
||||
---
|
||||
|
||||
Added support for SambaNova QwQ-32B model
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": minor
|
||||
---
|
||||
|
||||
Add OpenAI "dynamic" model chatgpt-4o-latest
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Add warning for when checkpoints takes too long to load
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Fix issue with qwen model setting works incorrectly on switching Plan/Act mode settings
|
||||
@@ -2,4 +2,4 @@
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Add Korean language
|
||||
DangerButton.tsx to Tailwind
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": minor
|
||||
---
|
||||
|
||||
Allow to set computerUse for OpenAI Compatible provider
|
||||
+1
-1
@@ -252,7 +252,7 @@ class Cline {
|
||||
} catch (error) {
|
||||
// 4. Error handling with retry
|
||||
if (isOpenRouter && !this.didAutomaticallyRetryFailedApiRequest) {
|
||||
await delay(1000)
|
||||
await setTimeoutPromise(1000)
|
||||
this.didAutomaticallyRetryFailedApiRequest = true
|
||||
yield* this.attemptApiRequest(previousApiReqIndex)
|
||||
return
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
* @saoudrizwan @ocasta181 @NightTrek @pashpashpash @dcbartlett
|
||||
* @saoudrizwan @ocasta181 @NightTrek @pashpashpash @dcbartlett @saito-sv
|
||||
|
||||
@@ -1,5 +1,38 @@
|
||||
# Changelog
|
||||
|
||||
## [3.8.2]
|
||||
|
||||
- Fix bug where switching to plan/act would result in VS Code LM/OpenRouter model being reset
|
||||
|
||||
## [3.8.0]
|
||||
|
||||
- Add 'Add to Cline' as an option when you right-click in a file or the terminal, making it easier to add context to your current task
|
||||
- Add 'Fix with Cline' code action - when you see a lightbulb icon in your editor, you can now select 'Fix with Cline' to send the code and associated errors for Cline to fix. (Cursor users can also use the 'Quick Fix (CMD + .)' menu to see this option)
|
||||
- Add Account view to display billing and usage history for Cline account users. You can now keep track of credits used and transaction history right in the extension!
|
||||
- Add 'Sort underling provider routing' setting to Cline/OpenRouter allowing you to sort provider used by throughput, price, latency, or the default (combination of price and uptime)
|
||||
- Improve rich MCP display with dynamic image loading and support for GIFs
|
||||
- Add 'Documentation' menu item to easily access Cline's docs
|
||||
- Add OpenRouter's new usage_details feature for more reliable cost reporting
|
||||
- Display total space Cline takes on disk next to 'Delete all Tasks' button in History view
|
||||
- Fix 'Context Window Exceeded' error for OpenRouter/Cline Accounts (additional support coming soon)
|
||||
- Fix bug where OpenRouter model ID would be set to invalid value
|
||||
- Add button to delete MCP servers in a failure state
|
||||
|
||||
## [3.7.1]
|
||||
|
||||
- Fix issue with 'See more' button in task header not showing when starting new tasks
|
||||
- Fix issue with checkpoints using local git commit hooks
|
||||
|
||||
## [3.7.0]
|
||||
|
||||
- Cline now displays selectable options when asking questions or presenting a plan, saving you from having to type out responses!
|
||||
- Add support for a `.clinerules/` directory to load multiple files at once (thanks @ryo-ma!)
|
||||
- Prevent Cline from reading extremely large files into context that would overload context window
|
||||
- Improve checkpoints loading performance and display warning for large projects not suited for checkpoints
|
||||
- Add SambaNova API provider (thanks @saad-noodleseed!)
|
||||
- Add VPC endpoint option for AWS Bedrock profiles (thanks @minorunara!)
|
||||
- Add DeepSeek-R1 to AWS Bedrock (thanks @watany-dev!)
|
||||
|
||||
## [3.6.5]
|
||||
|
||||
- Add 'Delete all Task History' button to History view
|
||||
|
||||
Generated
+1769
-79
File diff suppressed because it is too large
Load Diff
+64
-9
@@ -2,12 +2,8 @@
|
||||
"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.6.10",
|
||||
"version": "3.8.2",
|
||||
"icon": "assets/icons/icon.png",
|
||||
"galleryBanner": {
|
||||
"color": "#617A91",
|
||||
"theme": "dark"
|
||||
},
|
||||
"engines": {
|
||||
"vscode": "^1.84.0"
|
||||
},
|
||||
@@ -73,7 +69,7 @@
|
||||
{
|
||||
"command": "cline.mcpButtonClicked",
|
||||
"title": "MCP Servers",
|
||||
"icon": "$(extensions)"
|
||||
"icon": "$(server)"
|
||||
},
|
||||
{
|
||||
"command": "cline.historyButtonClicked",
|
||||
@@ -85,6 +81,11 @@
|
||||
"title": "Open in Editor",
|
||||
"icon": "$(link-external)"
|
||||
},
|
||||
{
|
||||
"command": "cline.accountButtonClicked",
|
||||
"title": "Account",
|
||||
"icon": "$(account)"
|
||||
},
|
||||
{
|
||||
"command": "cline.settingsButtonClicked",
|
||||
"title": "Settings",
|
||||
@@ -94,6 +95,32 @@
|
||||
"command": "cline.openInNewTab",
|
||||
"title": "Open In New Tab",
|
||||
"category": "Cline"
|
||||
},
|
||||
{
|
||||
"command": "cline.dev.createTestTasks",
|
||||
"title": "Create Test Tasks",
|
||||
"category": "Cline",
|
||||
"when": "cline.isDevMode"
|
||||
},
|
||||
{
|
||||
"command": "cline.openDocumentation",
|
||||
"title": "Documentation",
|
||||
"icon": "$(book)"
|
||||
},
|
||||
{
|
||||
"command": "cline.addToChat",
|
||||
"title": "Add to Cline",
|
||||
"category": "Cline"
|
||||
},
|
||||
{
|
||||
"command": "cline.addTerminalOutputToChat",
|
||||
"title": "Add to Cline",
|
||||
"category": "Cline"
|
||||
},
|
||||
{
|
||||
"command": "cline.fixWithCline",
|
||||
"title": "Fix with Cline",
|
||||
"category": "Cline"
|
||||
}
|
||||
],
|
||||
"menus": {
|
||||
@@ -119,9 +146,32 @@
|
||||
"when": "view == claude-dev.SidebarProvider"
|
||||
},
|
||||
{
|
||||
"command": "cline.settingsButtonClicked",
|
||||
"command": "cline.openDocumentation",
|
||||
"group": "navigation@5",
|
||||
"when": "view == claude-dev.SidebarProvider"
|
||||
},
|
||||
{
|
||||
"command": "cline.accountButtonClicked",
|
||||
"group": "navigation@6",
|
||||
"when": "view == claude-dev.SidebarProvider"
|
||||
},
|
||||
{
|
||||
"command": "cline.settingsButtonClicked",
|
||||
"group": "navigation@7",
|
||||
"when": "view == claude-dev.SidebarProvider"
|
||||
}
|
||||
],
|
||||
"editor/context": [
|
||||
{
|
||||
"command": "cline.addToChat",
|
||||
"group": "navigation",
|
||||
"when": "editorHasSelection"
|
||||
}
|
||||
],
|
||||
"terminal/context": [
|
||||
{
|
||||
"command": "cline.addTerminalOutputToChat",
|
||||
"group": "navigation"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -271,7 +321,13 @@
|
||||
"@google-cloud/vertexai": "^1.9.3",
|
||||
"@google/generative-ai": "^0.18.0",
|
||||
"@mistralai/mistralai": "^1.5.0",
|
||||
"@modelcontextprotocol/sdk": "^1.0.1",
|
||||
"@modelcontextprotocol/sdk": "^1.7.0",
|
||||
"@opentelemetry/api": "^1.4.1",
|
||||
"@opentelemetry/exporter-trace-otlp-http": "^0.39.1",
|
||||
"@opentelemetry/resources": "^1.30.1",
|
||||
"@opentelemetry/sdk-node": "^0.39.1",
|
||||
"@opentelemetry/sdk-trace-node": "^1.30.1",
|
||||
"@opentelemetry/semantic-conventions": "^1.30.0",
|
||||
"@types/clone-deep": "^4.0.4",
|
||||
"@types/get-folder-size": "^3.0.4",
|
||||
"@types/pdf-parse": "^1.1.4",
|
||||
@@ -282,7 +338,6 @@
|
||||
"chokidar": "^4.0.1",
|
||||
"clone-deep": "^4.0.1",
|
||||
"default-shell": "^2.2.0",
|
||||
"delay": "^6.0.0",
|
||||
"diff": "^5.2.0",
|
||||
"execa": "^9.5.2",
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
|
||||
+2
-1
@@ -9,7 +9,7 @@ import { OllamaHandler } from "./providers/ollama"
|
||||
import { LmStudioHandler } from "./providers/lmstudio"
|
||||
import { GeminiHandler } from "./providers/gemini"
|
||||
import { OpenAiNativeHandler } from "./providers/openai-native"
|
||||
import { ApiStream } from "./transform/stream"
|
||||
import { ApiStream, ApiStreamUsageChunk } from "./transform/stream"
|
||||
import { DeepSeekHandler } from "./providers/deepseek"
|
||||
import { RequestyHandler } from "./providers/requesty"
|
||||
import { TogetherHandler } from "./providers/together"
|
||||
@@ -25,6 +25,7 @@ import { SambanovaHandler } from "./providers/sambanova"
|
||||
export interface ApiHandler {
|
||||
createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream
|
||||
getModel(): { id: string; info: ModelInfo }
|
||||
getApiStreamUsage?(): Promise<ApiStreamUsageChunk | undefined>
|
||||
}
|
||||
|
||||
export interface SingleCompletionHandler {
|
||||
|
||||
@@ -7,7 +7,12 @@ import { ApiHandlerOptions, bedrockDefaultModelId, BedrockModelId, bedrockModels
|
||||
import { calculateApiCostOpenAI } from "../../utils/cost"
|
||||
import { ApiStream } from "../transform/stream"
|
||||
import { fromNodeProviderChain } from "@aws-sdk/credential-providers"
|
||||
import { BedrockRuntimeClient, InvokeModelWithResponseStreamCommand } from "@aws-sdk/client-bedrock-runtime"
|
||||
import {
|
||||
BedrockRuntimeClient,
|
||||
ConversationRole,
|
||||
ConverseStreamCommand,
|
||||
InvokeModelWithResponseStreamCommand,
|
||||
} from "@aws-sdk/client-bedrock-runtime"
|
||||
|
||||
// https://docs.anthropic.com/en/api/claude-on-amazon-bedrock
|
||||
export class AwsBedrockHandler implements ApiHandler {
|
||||
@@ -23,6 +28,12 @@ export class AwsBedrockHandler implements ApiHandler {
|
||||
let modelId = await this.getModelId()
|
||||
const model = this.getModel()
|
||||
|
||||
// Check if this is an Amazon Nova model
|
||||
if (modelId.includes("amazon.nova")) {
|
||||
yield* this.createNovaMessage(systemPrompt, messages, modelId, model)
|
||||
return
|
||||
}
|
||||
|
||||
// Check if this is a Deepseek model
|
||||
if (modelId.includes("deepseek")) {
|
||||
yield* this.createDeepseekMessage(systemPrompt, messages, modelId, model)
|
||||
@@ -230,7 +241,7 @@ export class AwsBedrockHandler implements ApiHandler {
|
||||
awsSecretKey: credentials.secretAccessKey,
|
||||
awsSessionToken: credentials.sessionToken,
|
||||
awsRegion: this.getRegion(),
|
||||
...(this.options.awsBedrockEndpoint && { endpoint: this.options.awsBedrockEndpoint }),
|
||||
...(this.options.awsBedrockEndpoint && { baseURL: this.options.awsBedrockEndpoint }),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -462,4 +473,192 @@ export class AwsBedrockHandler implements ApiHandler {
|
||||
// Approximate 4 characters per token
|
||||
return Math.ceil(text.length / 4)
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a message using Amazon Nova models through AWS Bedrock
|
||||
* Implements support for Nova Micro, Nova Lite, and Nova Pro models
|
||||
*/
|
||||
private async *createNovaMessage(
|
||||
systemPrompt: string,
|
||||
messages: Anthropic.Messages.MessageParam[],
|
||||
modelId: string,
|
||||
model: { id: BedrockModelId; info: ModelInfo },
|
||||
): ApiStream {
|
||||
// Get Bedrock client with proper credentials
|
||||
const client = await this.getBedrockClient()
|
||||
|
||||
// Format messages for Nova model
|
||||
const formattedMessages = this.formatNovaMessages(messages)
|
||||
|
||||
// Prepare request for Nova model
|
||||
const command = new ConverseStreamCommand({
|
||||
modelId: modelId,
|
||||
messages: formattedMessages,
|
||||
system: systemPrompt ? [{ text: systemPrompt }] : undefined,
|
||||
inferenceConfig: {
|
||||
maxTokens: model.info.maxTokens || 5000,
|
||||
temperature: 0,
|
||||
// topP: 0.9, // Alternative: use topP instead of temperature
|
||||
},
|
||||
})
|
||||
|
||||
// Execute the streaming request and handle response
|
||||
try {
|
||||
const response = await client.send(command)
|
||||
|
||||
if (response.stream) {
|
||||
let hasReportedInputTokens = false
|
||||
|
||||
for await (const chunk of response.stream) {
|
||||
// Handle metadata events with token usage information
|
||||
if (chunk.metadata?.usage) {
|
||||
// Report complete token usage from the model itself
|
||||
const inputTokens = chunk.metadata.usage.inputTokens || 0
|
||||
const outputTokens = chunk.metadata.usage.outputTokens || 0
|
||||
yield {
|
||||
type: "usage",
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
totalCost: calculateApiCostOpenAI(model.info, inputTokens, outputTokens, 0, 0),
|
||||
}
|
||||
hasReportedInputTokens = true
|
||||
}
|
||||
|
||||
// Handle content delta (text generation)
|
||||
if (chunk.contentBlockDelta?.delta?.text) {
|
||||
yield {
|
||||
type: "text",
|
||||
text: chunk.contentBlockDelta.delta.text,
|
||||
}
|
||||
}
|
||||
|
||||
// Handle reasoning content if present
|
||||
if (chunk.contentBlockDelta?.delta?.reasoningContent?.text) {
|
||||
yield {
|
||||
type: "reasoning",
|
||||
reasoning: chunk.contentBlockDelta.delta.reasoningContent.text,
|
||||
}
|
||||
}
|
||||
|
||||
// Handle errors
|
||||
if (chunk.internalServerException) {
|
||||
yield {
|
||||
type: "text",
|
||||
text: `[ERROR] Internal server error: ${chunk.internalServerException.message}`,
|
||||
}
|
||||
} else if (chunk.modelStreamErrorException) {
|
||||
yield {
|
||||
type: "text",
|
||||
text: `[ERROR] Model stream error: ${chunk.modelStreamErrorException.message}`,
|
||||
}
|
||||
} else if (chunk.validationException) {
|
||||
yield {
|
||||
type: "text",
|
||||
text: `[ERROR] Validation error: ${chunk.validationException.message}`,
|
||||
}
|
||||
} else if (chunk.throttlingException) {
|
||||
yield {
|
||||
type: "text",
|
||||
text: `[ERROR] Throttling error: ${chunk.throttlingException.message}`,
|
||||
}
|
||||
} else if (chunk.serviceUnavailableException) {
|
||||
yield {
|
||||
type: "text",
|
||||
text: `[ERROR] Service unavailable: ${chunk.serviceUnavailableException.message}`,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error processing Nova model response:", error)
|
||||
yield {
|
||||
type: "text",
|
||||
text: `[ERROR] Failed to process Nova response: ${error instanceof Error ? error.message : String(error)}`,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats messages for Amazon Nova models according to the SDK specification
|
||||
*/
|
||||
private formatNovaMessages(messages: Anthropic.Messages.MessageParam[]): { role: ConversationRole; content: any[] }[] {
|
||||
return messages.map((message) => {
|
||||
// Determine role (user or assistant)
|
||||
const role = message.role === "user" ? ConversationRole.USER : ConversationRole.ASSISTANT
|
||||
|
||||
// Process content based on type
|
||||
let content: any[] = []
|
||||
|
||||
if (typeof message.content === "string") {
|
||||
// Simple text content
|
||||
content = [{ text: message.content }]
|
||||
} else if (Array.isArray(message.content)) {
|
||||
// Convert Anthropic content format to Nova content format
|
||||
content = message.content
|
||||
.map((item) => {
|
||||
// Text content
|
||||
if (item.type === "text") {
|
||||
return { text: item.text }
|
||||
}
|
||||
|
||||
// Image content
|
||||
if (item.type === "image") {
|
||||
// Handle different image source formats
|
||||
let imageData: Uint8Array
|
||||
let format = "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]) {
|
||||
format = formatMatch[1]
|
||||
// Ensure format is one of the allowed values
|
||||
if (!["png", "jpeg", "gif", "webp"].includes(format)) {
|
||||
format = "jpeg" // Default to jpeg if not supported
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get image data
|
||||
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 any))
|
||||
} else {
|
||||
console.error("Unsupported image data format")
|
||||
return null // Skip this item if format is not supported
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Could not convert image data to Uint8Array:", error)
|
||||
return null // Skip this item if conversion fails
|
||||
}
|
||||
|
||||
return {
|
||||
image: {
|
||||
format,
|
||||
source: {
|
||||
bytes: imageData,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Return null for unsupported content types
|
||||
return null
|
||||
})
|
||||
.filter(Boolean) // Remove any null items
|
||||
}
|
||||
|
||||
// Return formatted message
|
||||
return {
|
||||
role,
|
||||
content,
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
+83
-22
@@ -2,13 +2,15 @@ import { Anthropic } from "@anthropic-ai/sdk"
|
||||
import OpenAI from "openai"
|
||||
import { ApiHandler } from "../"
|
||||
import { ApiHandlerOptions, ModelInfo, openRouterDefaultModelId, openRouterDefaultModelInfo } from "../../shared/api"
|
||||
import { streamOpenRouterFormatRequest } from "../transform/openrouter-stream"
|
||||
import { ApiStream } from "../transform/stream"
|
||||
import { createOpenRouterStream } from "../transform/openrouter-stream"
|
||||
import { ApiStream, ApiStreamUsageChunk } from "../transform/stream"
|
||||
import axios from "axios"
|
||||
import { OpenRouterErrorResponse } from "./types"
|
||||
|
||||
export class ClineHandler implements ApiHandler {
|
||||
private options: ApiHandlerOptions
|
||||
private client: OpenAI
|
||||
lastGenerationId?: string
|
||||
|
||||
constructor(options: ApiHandlerOptions) {
|
||||
this.options = options
|
||||
@@ -19,36 +21,95 @@ export class ClineHandler implements ApiHandler {
|
||||
}
|
||||
|
||||
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
|
||||
const model = this.getModel()
|
||||
const genId = yield* streamOpenRouterFormatRequest(
|
||||
this.lastGenerationId = undefined
|
||||
|
||||
const stream = await createOpenRouterStream(
|
||||
this.client,
|
||||
systemPrompt,
|
||||
messages,
|
||||
model,
|
||||
this.getModel(),
|
||||
this.options.o3MiniReasoningEffort,
|
||||
this.options.thinkingBudgetTokens,
|
||||
this.options.openRouterProviderSorting,
|
||||
)
|
||||
|
||||
try {
|
||||
const response = await axios.get(`https://api.cline.bot/v1/generation?id=${genId}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.options.clineApiKey}`,
|
||||
},
|
||||
timeout: 5_000, // this request hangs sometimes
|
||||
})
|
||||
let didOutputUsage: boolean = false
|
||||
|
||||
const generation = response.data
|
||||
console.log("cline generation details:", generation)
|
||||
yield {
|
||||
type: "usage",
|
||||
inputTokens: generation?.native_tokens_prompt || 0,
|
||||
outputTokens: generation?.native_tokens_completion || 0,
|
||||
totalCost: generation?.total_cost || 0,
|
||||
for await (const chunk of stream) {
|
||||
// openrouter returns an error object instead of the openai sdk throwing an error
|
||||
if ("error" in chunk) {
|
||||
const error = chunk.error as OpenRouterErrorResponse["error"]
|
||||
console.error(`Cline API Error: ${error?.code} - ${error?.message}`)
|
||||
// Include metadata in the error message if available
|
||||
const metadataStr = error.metadata ? `\nMetadata: ${JSON.stringify(error.metadata, null, 2)}` : ""
|
||||
throw new Error(`Cline API Error ${error.code}: ${error.message}${metadataStr}`)
|
||||
}
|
||||
|
||||
if (!this.lastGenerationId && chunk.id) {
|
||||
this.lastGenerationId = chunk.id
|
||||
}
|
||||
|
||||
const delta = chunk.choices[0]?.delta
|
||||
if (delta?.content) {
|
||||
yield {
|
||||
type: "text",
|
||||
text: delta.content,
|
||||
}
|
||||
}
|
||||
|
||||
// Reasoning tokens are returned separately from the content
|
||||
if ("reasoning" in delta && delta.reasoning) {
|
||||
yield {
|
||||
type: "reasoning",
|
||||
// @ts-ignore-next-line
|
||||
reasoning: delta.reasoning,
|
||||
}
|
||||
}
|
||||
|
||||
if (!didOutputUsage && chunk.usage) {
|
||||
yield {
|
||||
type: "usage",
|
||||
inputTokens: chunk.usage.prompt_tokens || 0,
|
||||
outputTokens: chunk.usage.completion_tokens || 0,
|
||||
// @ts-ignore-next-line
|
||||
totalCost: chunk.usage.cost || 0,
|
||||
}
|
||||
didOutputUsage = true
|
||||
}
|
||||
} catch (error) {
|
||||
// ignore if fails
|
||||
console.error("Error fetching cline generation details:", error)
|
||||
}
|
||||
|
||||
// Fallback to generation endpoint if usage chunk not returned
|
||||
if (!didOutputUsage) {
|
||||
const apiStreamUsage = await this.getApiStreamUsage()
|
||||
if (apiStreamUsage) {
|
||||
yield apiStreamUsage
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async getApiStreamUsage(): Promise<ApiStreamUsageChunk | undefined> {
|
||||
if (this.lastGenerationId) {
|
||||
try {
|
||||
const response = await axios.get(`https://api.cline.bot/v1/generation?id=${this.lastGenerationId}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.options.clineApiKey}`,
|
||||
},
|
||||
timeout: 15_000, // this request hangs sometimes
|
||||
})
|
||||
|
||||
const generation = response.data
|
||||
return {
|
||||
type: "usage",
|
||||
inputTokens: generation?.native_tokens_prompt || 0,
|
||||
outputTokens: generation?.native_tokens_completion || 0,
|
||||
totalCost: generation?.total_cost || 0,
|
||||
}
|
||||
} catch (error) {
|
||||
// ignore if fails
|
||||
console.error("Error fetching cline generation details:", error)
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
getModel(): { id: string; info: ModelInfo } {
|
||||
|
||||
@@ -15,7 +15,8 @@ export class OpenAiHandler implements ApiHandler {
|
||||
constructor(options: ApiHandlerOptions) {
|
||||
this.options = options
|
||||
// Azure API shape slightly differs from the core API shape: https://github.com/openai/openai-node?tab=readme-ov-file#microsoft-azure-openai
|
||||
if (this.options.openAiBaseUrl?.toLowerCase().includes("azure.com")) {
|
||||
// Use azureApiVersion to determine if this is an Azure endpoint, since the URL may not always contain 'azure.com'
|
||||
if (this.options.azureApiVersion || this.options.openAiBaseUrl?.toLowerCase().includes("azure.com")) {
|
||||
this.client = new AzureOpenAI({
|
||||
baseURL: this.options.openAiBaseUrl,
|
||||
apiKey: this.options.openAiApiKey,
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
import axios from "axios"
|
||||
import delay from "delay"
|
||||
import { setTimeout as setTimeoutPromise } from "node:timers/promises"
|
||||
import OpenAI from "openai"
|
||||
import { withRetry } from "../retry"
|
||||
import { ApiHandler } from "../"
|
||||
import { ApiHandlerOptions, ModelInfo, openRouterDefaultModelId, openRouterDefaultModelInfo } from "../../shared/api"
|
||||
import { streamOpenRouterFormatRequest } from "../transform/openrouter-stream"
|
||||
import { ApiStream } from "../transform/stream"
|
||||
import { convertToR1Format } from "../transform/r1-format"
|
||||
import { withRetry } from "../retry"
|
||||
import { createOpenRouterStream } from "../transform/openrouter-stream"
|
||||
import { ApiStream, ApiStreamUsageChunk } from "../transform/stream"
|
||||
import { OpenRouterErrorResponse } from "./types"
|
||||
|
||||
export class OpenRouterHandler implements ApiHandler {
|
||||
private options: ApiHandlerOptions
|
||||
private client: OpenAI
|
||||
lastGenerationId?: string
|
||||
|
||||
constructor(options: ApiHandlerOptions) {
|
||||
this.options = options
|
||||
@@ -28,23 +28,80 @@ export class OpenRouterHandler implements ApiHandler {
|
||||
|
||||
@withRetry()
|
||||
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
|
||||
const model = this.getModel()
|
||||
const genId = yield* streamOpenRouterFormatRequest(
|
||||
this.lastGenerationId = undefined
|
||||
|
||||
const stream = await createOpenRouterStream(
|
||||
this.client,
|
||||
systemPrompt,
|
||||
messages,
|
||||
model,
|
||||
this.getModel(),
|
||||
this.options.o3MiniReasoningEffort,
|
||||
this.options.thinkingBudgetTokens,
|
||||
this.options.openRouterProviderSorting,
|
||||
)
|
||||
|
||||
if (genId) {
|
||||
await delay(500) // FIXME: necessary delay to ensure generation endpoint is ready
|
||||
let didOutputUsage: boolean = false
|
||||
|
||||
for await (const chunk of stream) {
|
||||
// openrouter returns an error object instead of the openai sdk throwing an error
|
||||
if ("error" in chunk) {
|
||||
const error = chunk.error as OpenRouterErrorResponse["error"]
|
||||
console.error(`OpenRouter API Error: ${error?.code} - ${error?.message}`)
|
||||
// Include metadata in the error message if available
|
||||
const metadataStr = error.metadata ? `\nMetadata: ${JSON.stringify(error.metadata, null, 2)}` : ""
|
||||
throw new Error(`OpenRouter API Error ${error.code}: ${error.message}${metadataStr}`)
|
||||
}
|
||||
|
||||
if (!this.lastGenerationId && chunk.id) {
|
||||
this.lastGenerationId = chunk.id
|
||||
}
|
||||
|
||||
const delta = chunk.choices[0]?.delta
|
||||
if (delta?.content) {
|
||||
yield {
|
||||
type: "text",
|
||||
text: delta.content,
|
||||
}
|
||||
}
|
||||
|
||||
// Reasoning tokens are returned separately from the content
|
||||
if ("reasoning" in delta && delta.reasoning) {
|
||||
yield {
|
||||
type: "reasoning",
|
||||
// @ts-ignore-next-line
|
||||
reasoning: delta.reasoning,
|
||||
}
|
||||
}
|
||||
|
||||
if (!didOutputUsage && chunk.usage) {
|
||||
yield {
|
||||
type: "usage",
|
||||
inputTokens: chunk.usage.prompt_tokens || 0,
|
||||
outputTokens: chunk.usage.completion_tokens || 0,
|
||||
// @ts-ignore-next-line
|
||||
totalCost: chunk.usage.cost || 0,
|
||||
}
|
||||
didOutputUsage = true
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to generation endpoint if usage chunk not returned
|
||||
if (!didOutputUsage) {
|
||||
const apiStreamUsage = await this.getApiStreamUsage()
|
||||
if (apiStreamUsage) {
|
||||
yield apiStreamUsage
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async getApiStreamUsage(): Promise<ApiStreamUsageChunk | undefined> {
|
||||
if (this.lastGenerationId) {
|
||||
await setTimeoutPromise(500) // FIXME: necessary delay to ensure generation endpoint is ready
|
||||
try {
|
||||
const generationIterator = this.fetchGenerationDetails(genId)
|
||||
const generationIterator = this.fetchGenerationDetails(this.lastGenerationId)
|
||||
const generation = (await generationIterator.next()).value
|
||||
// console.log("OpenRouter generation details:", generation)
|
||||
yield {
|
||||
return {
|
||||
type: "usage",
|
||||
// cacheWriteTokens: 0,
|
||||
// cacheReadTokens: 0,
|
||||
@@ -58,6 +115,7 @@ export class OpenRouterHandler implements ApiHandler {
|
||||
console.error("Error fetching OpenRouter generation details:", error)
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
@withRetry({ maxRetries: 4, baseDelay: 250, maxDelay: 1000, retryAllErrors: true })
|
||||
@@ -68,7 +126,7 @@ export class OpenRouterHandler implements ApiHandler {
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.options.openRouterApiKey}`,
|
||||
},
|
||||
timeout: 5_000, // this request hangs sometimes
|
||||
timeout: 15_000, // this request hangs sometimes
|
||||
})
|
||||
yield response.data?.data
|
||||
} catch (error) {
|
||||
|
||||
@@ -162,6 +162,21 @@ export class VertexHandler implements ApiHandler {
|
||||
break
|
||||
case "content_block_start":
|
||||
switch (chunk.content_block.type) {
|
||||
case "thinking":
|
||||
yield {
|
||||
type: "reasoning",
|
||||
reasoning: chunk.content_block.thinking || "",
|
||||
}
|
||||
break
|
||||
case "redacted_thinking":
|
||||
// Handle redacted thinking blocks - we still mark it as reasoning
|
||||
// but note that the content is encrypted
|
||||
yield {
|
||||
type: "reasoning",
|
||||
reasoning: "[Redacted thinking block]",
|
||||
}
|
||||
break
|
||||
|
||||
case "text":
|
||||
if (chunk.index > 0) {
|
||||
yield {
|
||||
@@ -178,6 +193,12 @@ export class VertexHandler implements ApiHandler {
|
||||
break
|
||||
case "content_block_delta":
|
||||
switch (chunk.delta.type) {
|
||||
case "thinking_delta":
|
||||
yield {
|
||||
type: "reasoning",
|
||||
reasoning: chunk.delta.thinking,
|
||||
}
|
||||
break
|
||||
case "text_delta":
|
||||
yield {
|
||||
type: "text",
|
||||
|
||||
@@ -6,14 +6,15 @@ import { Anthropic } from "@anthropic-ai/sdk"
|
||||
import OpenAI from "openai"
|
||||
import { OpenRouterErrorResponse } from "../providers/types"
|
||||
|
||||
export async function* streamOpenRouterFormatRequest(
|
||||
export async function createOpenRouterStream(
|
||||
client: OpenAI,
|
||||
systemPrompt: string,
|
||||
messages: Anthropic.Messages.MessageParam[],
|
||||
model: { id: string; info: ModelInfo },
|
||||
o3MiniReasoningEffort?: string,
|
||||
thinkingBudgetTokens?: number,
|
||||
): AsyncGenerator<ApiStreamChunk, string | undefined, unknown> {
|
||||
openRouterProviderSorting?: string,
|
||||
) {
|
||||
// Convert Anthropic messages to OpenAI format
|
||||
let openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [
|
||||
{ role: "system", content: systemPrompt },
|
||||
@@ -141,45 +142,13 @@ export async function* streamOpenRouterFormatRequest(
|
||||
top_p: topP,
|
||||
messages: openAiMessages,
|
||||
stream: true,
|
||||
stream_options: { include_usage: true },
|
||||
transforms: shouldApplyMiddleOutTransform ? ["middle-out"] : undefined,
|
||||
include_reasoning: true,
|
||||
...(model.id === "openai/o3-mini" ? { reasoning_effort: o3MiniReasoningEffort || "medium" } : {}),
|
||||
...(reasoning ? { reasoning } : {}),
|
||||
...(openRouterProviderSorting ? { provider: { sort: openRouterProviderSorting } } : {}),
|
||||
})
|
||||
|
||||
let genId: string | undefined
|
||||
|
||||
for await (const chunk of stream) {
|
||||
// openrouter returns an error object instead of the openai sdk throwing an error
|
||||
if ("error" in chunk) {
|
||||
const error = chunk.error as OpenRouterErrorResponse["error"]
|
||||
console.error(`OpenRouter API Error: ${error?.code} - ${error?.message}`)
|
||||
// Include metadata in the error message if available
|
||||
const metadataStr = error.metadata ? `\nMetadata: ${JSON.stringify(error.metadata, null, 2)}` : ""
|
||||
throw new Error(`OpenRouter API Error ${error.code}: ${error.message}${metadataStr}`)
|
||||
}
|
||||
|
||||
if (!genId && chunk.id) {
|
||||
genId = chunk.id
|
||||
}
|
||||
|
||||
const delta = chunk.choices[0]?.delta
|
||||
if (delta?.content) {
|
||||
yield {
|
||||
type: "text",
|
||||
text: delta.content,
|
||||
}
|
||||
}
|
||||
|
||||
// Reasoning tokens are returned separately from the content
|
||||
if ("reasoning" in delta && delta.reasoning) {
|
||||
yield {
|
||||
type: "reasoning",
|
||||
// @ts-ignore-next-line
|
||||
reasoning: delta.reasoning,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return genId
|
||||
return stream
|
||||
}
|
||||
|
||||
+245
-67
@@ -1,6 +1,6 @@
|
||||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
import cloneDeep from "clone-deep"
|
||||
import delay from "delay"
|
||||
import { setTimeout as setTimeoutPromise } from "node:timers/promises"
|
||||
import fs from "fs/promises"
|
||||
import getFolderSize from "get-folder-size"
|
||||
import os from "os"
|
||||
@@ -22,7 +22,7 @@ import { listFiles } from "../services/glob/list-files"
|
||||
import { regexSearchFiles } from "../services/ripgrep"
|
||||
import { parseSourceCodeForDefinitionsTopLevel } from "../services/tree-sitter"
|
||||
import { ApiConfiguration } from "../shared/api"
|
||||
import { findLast, findLastIndex } from "../shared/array"
|
||||
import { findLast, findLastIndex, parsePartialArrayString } from "../shared/array"
|
||||
import { AutoApprovalSettings } from "../shared/AutoApprovalSettings"
|
||||
import { BrowserSettings } from "../shared/BrowserSettings"
|
||||
import { ChatSettings } from "../shared/ChatSettings"
|
||||
@@ -35,8 +35,10 @@ import {
|
||||
ClineApiReqCancelReason,
|
||||
ClineApiReqInfo,
|
||||
ClineAsk,
|
||||
ClineAskQuestion,
|
||||
ClineAskUseMcpServer,
|
||||
ClineMessage,
|
||||
ClinePlanModeResponse,
|
||||
ClineSay,
|
||||
ClineSayBrowserAction,
|
||||
ClineSayTool,
|
||||
@@ -55,14 +57,21 @@ import { ClineIgnoreController, LOCK_TEXT_SYMBOL } from "./ignore/ClineIgnoreCon
|
||||
import { parseMentions } from "./mentions"
|
||||
import { formatResponse } from "./prompts/responses"
|
||||
import { addUserInstructions, SYSTEM_PROMPT } from "./prompts/system"
|
||||
import { getNextTruncationRange, getTruncatedMessages } from "./sliding-window"
|
||||
import { ContextManager } from "./context-management/ContextManager"
|
||||
import { OpenAiHandler } from "../api/providers/openai"
|
||||
import { ApiStream } from "../api/transform/stream"
|
||||
import { ClineHandler } from "../api/providers/cline"
|
||||
import { ClineProvider, GlobalFileNames } from "./webview/ClineProvider"
|
||||
import { ClineProvider } from "./webview/ClineProvider"
|
||||
import { DEFAULT_LANGUAGE_SETTINGS, getLanguageKey, LanguageDisplay, LanguageKey } from "../shared/Languages"
|
||||
import { telemetryService } from "../services/telemetry/TelemetryService"
|
||||
import { ConversationTelemetryService, TelemetryChatMessage } from "../services/telemetry/ConversationTelemetryService"
|
||||
import pTimeout from "p-timeout"
|
||||
import { GlobalFileNames } from "../global-constants"
|
||||
import {
|
||||
checkIsAnthropicContextWindowError,
|
||||
checkIsOpenRouterContextWindowError,
|
||||
} from "./context-management/context-error-handling"
|
||||
import { AnthropicHandler } from "../api/providers/anthropic"
|
||||
|
||||
const cwd = vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath).at(0) ?? path.join(os.homedir(), "Desktop") // may or may not exist but fs checking existence would immediately ask for permission which would be bad UX, need to come up with a better solution
|
||||
|
||||
@@ -76,6 +85,7 @@ export class Cline {
|
||||
private terminalManager: TerminalManager
|
||||
private urlContentFetcher: UrlContentFetcher
|
||||
browserSession: BrowserSession
|
||||
contextManager: ContextManager
|
||||
private didEditFile: boolean = false
|
||||
customInstructions?: string
|
||||
autoApprovalSettings: AutoApprovalSettings
|
||||
@@ -137,6 +147,7 @@ export class Cline {
|
||||
this.terminalManager = new TerminalManager()
|
||||
this.urlContentFetcher = new UrlContentFetcher(provider.context)
|
||||
this.browserSession = new BrowserSession(provider.context, browserSettings)
|
||||
this.contextManager = new ContextManager()
|
||||
this.diffViewProvider = new DiffViewProvider(cwd)
|
||||
this.customInstructions = customInstructions
|
||||
this.autoApprovalSettings = autoApprovalSettings
|
||||
@@ -1204,7 +1215,7 @@ export class Cline {
|
||||
// for their associated messages to be sent to the webview, maintaining
|
||||
// the correct order of messages (although the webview is smart about
|
||||
// grouping command_output messages despite any gaps anyways)
|
||||
await delay(50)
|
||||
await setTimeoutPromise(50)
|
||||
|
||||
result = result.trim()
|
||||
|
||||
@@ -1344,58 +1355,38 @@ export class Cline {
|
||||
)
|
||||
}
|
||||
|
||||
// If the previous API request's total token usage is close to the context window, truncate the conversation history to free up space for the new request
|
||||
if (previousApiReqIndex >= 0) {
|
||||
const previousRequest = this.clineMessages[previousApiReqIndex]
|
||||
if (previousRequest && previousRequest.text) {
|
||||
const { tokensIn, tokensOut, cacheWrites, cacheReads }: ClineApiReqInfo = JSON.parse(previousRequest.text)
|
||||
const totalTokens = (tokensIn || 0) + (tokensOut || 0) + (cacheWrites || 0) + (cacheReads || 0)
|
||||
let contextWindow = this.api.getModel().info.contextWindow || 128_000
|
||||
// FIXME: hack to get anyone using openai compatible with deepseek to have the proper context window instead of the default 128k. We need a way for the user to specify the context window for models they input through openai compatible
|
||||
if (this.api instanceof OpenAiHandler && this.api.getModel().id.toLowerCase().includes("deepseek")) {
|
||||
contextWindow = 64_000
|
||||
}
|
||||
let maxAllowedSize: number
|
||||
switch (contextWindow) {
|
||||
case 64_000: // deepseek models
|
||||
maxAllowedSize = contextWindow - 27_000
|
||||
break
|
||||
case 128_000: // most models
|
||||
maxAllowedSize = contextWindow - 30_000
|
||||
break
|
||||
case 200_000: // claude models
|
||||
maxAllowedSize = contextWindow - 40_000
|
||||
break
|
||||
default:
|
||||
maxAllowedSize = Math.max(contextWindow - 40_000, contextWindow * 0.8) // for deepseek, 80% of 64k meant only ~10k buffer which was too small and resulted in users getting context window errors.
|
||||
}
|
||||
|
||||
// This is the most reliable way to know when we're close to hitting the context window.
|
||||
if (totalTokens >= maxAllowedSize) {
|
||||
// Since the user may switch between models with different context windows, truncating half may not be enough (ie if switching from claude 200k to deepseek 64k, half truncation will only remove 100k tokens, but we need to remove much more)
|
||||
// So if totalTokens/2 is greater than maxAllowedSize, we truncate 3/4 instead of 1/2
|
||||
// FIXME: truncating the conversation in a way that is optimal for prompt caching AND takes into account multi-context window complexity is something we need to improve
|
||||
const keep = totalTokens / 2 > maxAllowedSize ? "quarter" : "half"
|
||||
|
||||
// NOTE: it's okay that we overwriteConversationHistory in resume task since we're only ever removing the last user message and not anything in the middle which would affect this range
|
||||
this.conversationHistoryDeletedRange = getNextTruncationRange(
|
||||
this.apiConversationHistory,
|
||||
this.conversationHistoryDeletedRange,
|
||||
keep,
|
||||
)
|
||||
await this.saveClineMessages() // saves task history item which we use to keep track of conversation history deleted range
|
||||
// await this.overwriteApiConversationHistory(truncatedMessages)
|
||||
}
|
||||
// Capture system prompt for telemetry,
|
||||
// ONLY if user is opted in, in advanced settings
|
||||
if (this.providerRef.deref()?.conversationTelemetryService.isOptedInToConversationTelemetry()) {
|
||||
const systemMessage: TelemetryChatMessage = {
|
||||
role: "system",
|
||||
content: systemPrompt,
|
||||
ts: Date.now(), // we dont uniquely identify system messages, so we use the timestamp as the id
|
||||
}
|
||||
|
||||
// no need for timeout here, as there's no timestamp to compare to
|
||||
this.providerRef.deref()?.conversationTelemetryService.captureMessage(this.taskId, systemMessage, {
|
||||
apiProvider: this.apiProvider,
|
||||
model: this.api.getModel().id,
|
||||
tokensIn: 0,
|
||||
tokensOut: 0,
|
||||
})
|
||||
}
|
||||
|
||||
// conversationHistoryDeletedRange is updated only when we're close to hitting the context window, so we don't continuously break the prompt cache
|
||||
const truncatedConversationHistory = getTruncatedMessages(
|
||||
const contextManagementMetadata = this.contextManager.getNewContextMessagesAndMetadata(
|
||||
this.apiConversationHistory,
|
||||
this.clineMessages,
|
||||
this.api,
|
||||
this.conversationHistoryDeletedRange,
|
||||
previousApiReqIndex,
|
||||
)
|
||||
|
||||
let stream = this.api.createMessage(systemPrompt, truncatedConversationHistory)
|
||||
if (contextManagementMetadata.updatedConversationHistoryDeletedRange) {
|
||||
this.conversationHistoryDeletedRange = contextManagementMetadata.conversationHistoryDeletedRange
|
||||
await this.saveClineMessages() // saves task history item which we use to keep track of conversation history deleted range
|
||||
}
|
||||
|
||||
let stream = this.api.createMessage(systemPrompt, contextManagementMetadata.truncatedConversationHistory)
|
||||
|
||||
const iterator = stream[Symbol.asyncIterator]()
|
||||
|
||||
@@ -1407,20 +1398,59 @@ export class Cline {
|
||||
this.isWaitingForFirstChunk = false
|
||||
} catch (error) {
|
||||
const isOpenRouter = this.api instanceof OpenRouterHandler || this.api instanceof ClineHandler
|
||||
if (isOpenRouter && !this.didAutomaticallyRetryFailedApiRequest) {
|
||||
const isAnthropic = this.api instanceof AnthropicHandler
|
||||
const isOpenRouterContextWindowError = checkIsOpenRouterContextWindowError(error) && isOpenRouter
|
||||
const isAnthropicContextWindowError = checkIsAnthropicContextWindowError(error) && isAnthropic
|
||||
|
||||
if (isAnthropic && isAnthropicContextWindowError && !this.didAutomaticallyRetryFailedApiRequest) {
|
||||
this.conversationHistoryDeletedRange = this.contextManager.getNextTruncationRange(
|
||||
this.apiConversationHistory,
|
||||
this.conversationHistoryDeletedRange,
|
||||
"quarter", // Force aggressive truncation
|
||||
)
|
||||
await this.saveClineMessages()
|
||||
|
||||
this.didAutomaticallyRetryFailedApiRequest = true
|
||||
} else if (isOpenRouter && !this.didAutomaticallyRetryFailedApiRequest) {
|
||||
if (isOpenRouterContextWindowError) {
|
||||
this.conversationHistoryDeletedRange = this.contextManager.getNextTruncationRange(
|
||||
this.apiConversationHistory,
|
||||
this.conversationHistoryDeletedRange,
|
||||
"quarter", // Force aggressive truncation
|
||||
)
|
||||
await this.saveClineMessages()
|
||||
}
|
||||
|
||||
console.log("first chunk failed, waiting 1 second before retrying")
|
||||
await delay(1000)
|
||||
await setTimeoutPromise(1000)
|
||||
this.didAutomaticallyRetryFailedApiRequest = true
|
||||
} else {
|
||||
// request failed after retrying automatically once, ask user if they want to retry again
|
||||
// note that this api_req_failed ask is unique in that we only present this option if the api hasn't streamed any content yet (ie it fails on the first chunk due), as it would allow them to hit a retry button. However if the api failed mid-stream, it could be in any arbitrary state where some tools may have executed, so that error is handled differently and requires cancelling the task entirely.
|
||||
|
||||
if (isOpenRouterContextWindowError || isAnthropicContextWindowError) {
|
||||
const truncatedConversationHistory = this.contextManager.getTruncatedMessages(
|
||||
this.apiConversationHistory,
|
||||
this.conversationHistoryDeletedRange,
|
||||
)
|
||||
|
||||
// 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.")
|
||||
this.didAutomaticallyRetryFailedApiRequest = false
|
||||
}
|
||||
}
|
||||
|
||||
const errorMessage = this.formatErrorWithStatusCode(error)
|
||||
|
||||
const { response } = await this.ask("api_req_failed", errorMessage)
|
||||
|
||||
if (response !== "yesButtonClicked") {
|
||||
// this will never happen since if noButtonClicked, we will clear current task, aborting this instance
|
||||
throw new Error("API request failed")
|
||||
}
|
||||
|
||||
await this.say("api_req_retried")
|
||||
}
|
||||
// delegate generator output from the recursive call
|
||||
@@ -1834,7 +1864,7 @@ export class Cline {
|
||||
await this.diffViewProvider.open(relPath)
|
||||
}
|
||||
await this.diffViewProvider.update(newContent, true)
|
||||
await delay(300) // wait for diff view to update
|
||||
await setTimeoutPromise(300) // wait for diff view to update
|
||||
this.diffViewProvider.scrollToFirstDiff()
|
||||
// showOmissionWarning(this.diffViewProvider.originalContent || "", newContent)
|
||||
|
||||
@@ -1856,7 +1886,7 @@ export class Cline {
|
||||
telemetryService.captureToolUsage(this.taskId, block.name, true, true)
|
||||
|
||||
// we need an artificial delay to let the diagnostics catch up to the changes
|
||||
await delay(3_500)
|
||||
await setTimeoutPromise(3_500)
|
||||
} else {
|
||||
// If auto-approval is enabled but this tool wasn't auto-approved, send notification
|
||||
showNotificationForApprovalIfAutoApprovalEnabled(
|
||||
@@ -2718,9 +2748,14 @@ export class Cline {
|
||||
}
|
||||
case "ask_followup_question": {
|
||||
const question: string | undefined = block.params.question
|
||||
const optionsRaw: string | undefined = block.params.options
|
||||
const sharedMessage = {
|
||||
question: removeClosingTag("question", question),
|
||||
options: parsePartialArrayString(removeClosingTag("options", optionsRaw)),
|
||||
} satisfies ClineAskQuestion
|
||||
try {
|
||||
if (block.partial) {
|
||||
await this.ask("followup", removeClosingTag("question", question), block.partial).catch(() => {})
|
||||
await this.ask("followup", JSON.stringify(sharedMessage), block.partial).catch(() => {})
|
||||
break
|
||||
} else {
|
||||
if (!question) {
|
||||
@@ -2738,8 +2773,25 @@ export class Cline {
|
||||
})
|
||||
}
|
||||
|
||||
const { text, images } = await this.ask("followup", question, false)
|
||||
await this.say("user_feedback", text ?? "", images)
|
||||
const { text, images } = await this.ask("followup", JSON.stringify(sharedMessage), false)
|
||||
|
||||
// Check if options contains the text response
|
||||
if (optionsRaw && text && parsePartialArrayString(optionsRaw).includes(text)) {
|
||||
// Valid option selected, don't show user message in UI
|
||||
// Update last followup message with selected option
|
||||
const lastFollowupMessage = findLast(this.clineMessages, (m) => m.ask === "followup")
|
||||
if (lastFollowupMessage) {
|
||||
lastFollowupMessage.text = JSON.stringify({
|
||||
...sharedMessage,
|
||||
selected: text,
|
||||
} satisfies ClineAskQuestion)
|
||||
await this.saveClineMessages()
|
||||
}
|
||||
} else {
|
||||
// Option not selected, send user feedback
|
||||
await this.say("user_feedback", text ?? "", images)
|
||||
}
|
||||
|
||||
pushToolResult(formatResponse.toolResult(`<answer>\n${text}\n</answer>`, images))
|
||||
|
||||
break
|
||||
@@ -2752,11 +2804,14 @@ export class Cline {
|
||||
}
|
||||
case "plan_mode_response": {
|
||||
const response: string | undefined = block.params.response
|
||||
const optionsRaw: string | undefined = block.params.options
|
||||
const sharedMessage = {
|
||||
response: removeClosingTag("response", response),
|
||||
options: parsePartialArrayString(removeClosingTag("options", optionsRaw)),
|
||||
} satisfies ClinePlanModeResponse
|
||||
try {
|
||||
if (block.partial) {
|
||||
await this.ask("plan_mode_response", removeClosingTag("response", response), block.partial).catch(
|
||||
() => {},
|
||||
)
|
||||
await this.ask("plan_mode_response", JSON.stringify(sharedMessage), block.partial).catch(() => {})
|
||||
break
|
||||
} else {
|
||||
if (!response) {
|
||||
@@ -2775,7 +2830,7 @@ export class Cline {
|
||||
// }
|
||||
|
||||
this.isAwaitingPlanResponse = true
|
||||
let { text, images } = await this.ask("plan_mode_response", response, false)
|
||||
let { text, images } = await this.ask("plan_mode_response", JSON.stringify(sharedMessage), false)
|
||||
this.isAwaitingPlanResponse = false
|
||||
|
||||
// webview invoke sendMessage will send this marker in order to put webview into the proper state (responding to an ask) and as a flag to extension that the user switched to ACT mode.
|
||||
@@ -2783,6 +2838,25 @@ export class Cline {
|
||||
text = ""
|
||||
}
|
||||
|
||||
// Check if options contains the text response
|
||||
if (optionsRaw && text && parsePartialArrayString(optionsRaw).includes(text)) {
|
||||
// Valid option selected, don't show user message in UI
|
||||
// Update last followup message with selected option
|
||||
const lastPlanMessage = findLast(this.clineMessages, (m) => m.ask === "plan_mode_response")
|
||||
if (lastPlanMessage) {
|
||||
lastPlanMessage.text = JSON.stringify({
|
||||
...sharedMessage,
|
||||
selected: text,
|
||||
} satisfies ClinePlanModeResponse)
|
||||
await this.saveClineMessages()
|
||||
}
|
||||
} else {
|
||||
// Option not selected, send user feedback
|
||||
if (text || images?.length) {
|
||||
await this.say("user_feedback", text ?? "", images)
|
||||
}
|
||||
}
|
||||
|
||||
if (this.didRespondToPlanAskBySwitchingMode) {
|
||||
pushToolResult(
|
||||
formatResponse.toolResult(
|
||||
@@ -2798,10 +2872,6 @@ export class Cline {
|
||||
pushToolResult(formatResponse.toolResult(`<user_message>\n${text}\n</user_message>`, images))
|
||||
}
|
||||
|
||||
if (text || images?.length) {
|
||||
await this.say("user_feedback", text ?? "", images)
|
||||
}
|
||||
|
||||
//
|
||||
break
|
||||
}
|
||||
@@ -3120,6 +3190,39 @@ export class Cline {
|
||||
|
||||
telemetryService.captureConversationTurnEvent(this.taskId, this.apiProvider, this.api.getModel().id, "user")
|
||||
|
||||
// Capture message data for telemetry,
|
||||
// ONLY if user is opted in, in advanced settings
|
||||
if (this.providerRef.deref()?.conversationTelemetryService.isOptedInToConversationTelemetry()) {
|
||||
// Get the last message from apiConversationHistory
|
||||
const lastMessage = this.apiConversationHistory[this.apiConversationHistory.length - 1]
|
||||
|
||||
// Get the corresponding timestamp from clineMessages
|
||||
// The last message in clineMessages should be the one we just added
|
||||
|
||||
const lastClineMessage = this.clineMessages[this.clineMessages.length - 1]
|
||||
const ts = lastClineMessage.ts
|
||||
|
||||
// Send individual message to telemetry
|
||||
this.providerRef.deref()?.conversationTelemetryService.captureMessage(
|
||||
this.taskId,
|
||||
// Add the timestamp to the message object for telemetry
|
||||
{
|
||||
...lastMessage,
|
||||
ts,
|
||||
},
|
||||
{
|
||||
apiProvider: this.apiProvider,
|
||||
model: this.api.getModel().id,
|
||||
tokensIn: 0,
|
||||
tokensOut: 0,
|
||||
},
|
||||
)
|
||||
|
||||
// Send entire conversation history to cleanup endpoint
|
||||
// This ensures deleted messages are properly handled in telemetry
|
||||
this.providerRef.deref()?.conversationTelemetryService.cleanupTask(this.taskId, this.clineMessages)
|
||||
}
|
||||
|
||||
// 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.clineMessages, (m) => m.say === "api_req_started")
|
||||
this.clineMessages[lastApiReqIndex].text = JSON.stringify({
|
||||
@@ -3197,6 +3300,36 @@ export class Cline {
|
||||
|
||||
telemetryService.captureConversationTurnEvent(this.taskId, this.apiProvider, this.api.getModel().id, "assistant")
|
||||
|
||||
// Capture message data for telemetry after assistant response
|
||||
// ONLY if user is opted in, in advanced settings
|
||||
if (this.providerRef.deref()?.conversationTelemetryService.isOptedInToConversationTelemetry()) {
|
||||
// Get the last message from apiConversationHistory
|
||||
const lastMessage = this.apiConversationHistory[this.apiConversationHistory.length - 1]
|
||||
|
||||
// Find the corresponding timestamp from clineMessages
|
||||
// For assistant messages, we need to find the most recent "text" message
|
||||
const lastTextMessage = findLast(this.clineMessages, (m) => m.say === "text")
|
||||
|
||||
// Add the timestamp to the message object for telemetry
|
||||
if (!lastTextMessage) {
|
||||
console.error("No text message found in clineMessages")
|
||||
} else {
|
||||
this.providerRef.deref()?.conversationTelemetryService.captureMessage(
|
||||
this.taskId,
|
||||
{
|
||||
...lastMessage,
|
||||
ts: lastTextMessage.ts,
|
||||
},
|
||||
{
|
||||
apiProvider: this.apiProvider,
|
||||
model: this.api.getModel().id,
|
||||
tokensIn: inputTokens,
|
||||
tokensOut: outputTokens,
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// signals to provider that it can retrieve the saved messages from disk, as abortTask can not be awaited on in nature
|
||||
this.didFinishAbortingStream = true
|
||||
}
|
||||
@@ -3218,6 +3351,7 @@ export class Cline {
|
||||
let assistantMessage = ""
|
||||
let reasoningMessage = ""
|
||||
this.isStreaming = true
|
||||
let didReceiveUsageChunk = false
|
||||
try {
|
||||
for await (const chunk of stream) {
|
||||
if (!chunk) {
|
||||
@@ -3225,6 +3359,7 @@ export class Cline {
|
||||
}
|
||||
switch (chunk.type) {
|
||||
case "usage":
|
||||
didReceiveUsageChunk = true
|
||||
inputTokens += chunk.inputTokens
|
||||
outputTokens += chunk.outputTokens
|
||||
cacheWriteTokens += chunk.cacheWriteTokens ?? 0
|
||||
@@ -3294,6 +3429,23 @@ export class Cline {
|
||||
this.isStreaming = false
|
||||
}
|
||||
|
||||
// OpenRouter/Cline may not return token usage as part of the stream (since it may abort early), so we fetch after the stream is finished
|
||||
// (updateApiReq below will update the api_req_started message with the usage details. we do this async so it updates the api_req_started message in the background)
|
||||
if (!didReceiveUsageChunk) {
|
||||
this.api.getApiStreamUsage?.().then(async (apiStreamUsage) => {
|
||||
if (apiStreamUsage) {
|
||||
inputTokens += apiStreamUsage.inputTokens
|
||||
outputTokens += apiStreamUsage.outputTokens
|
||||
cacheWriteTokens += apiStreamUsage.cacheWriteTokens ?? 0
|
||||
cacheReadTokens += apiStreamUsage.cacheReadTokens ?? 0
|
||||
totalCost = apiStreamUsage.totalCost
|
||||
}
|
||||
updateApiReqMsg()
|
||||
await this.saveClineMessages()
|
||||
await this.providerRef.deref()?.postStateToWebview()
|
||||
})
|
||||
}
|
||||
|
||||
// need to call here in case the stream was aborted
|
||||
if (this.abort) {
|
||||
throw new Error("Cline instance aborted")
|
||||
@@ -3327,6 +3479,32 @@ export class Cline {
|
||||
content: [{ type: "text", text: assistantMessage }],
|
||||
})
|
||||
|
||||
// Capture message data for telemetry after assistant response,
|
||||
// ONLY if user is opted in, in advanced settings
|
||||
if (this.providerRef.deref()?.conversationTelemetryService.isOptedInToConversationTelemetry()) {
|
||||
// Get the last message from apiConversationHistory
|
||||
const lastMessage = this.apiConversationHistory[this.apiConversationHistory.length - 1]
|
||||
|
||||
// Find the corresponding timestamp from clineMessages
|
||||
const lastClineMessage = this.clineMessages[this.clineMessages.length - 1]
|
||||
|
||||
if (lastClineMessage) {
|
||||
this.providerRef.deref()?.conversationTelemetryService.captureMessage(
|
||||
this.taskId,
|
||||
{
|
||||
...lastMessage,
|
||||
ts: lastClineMessage.ts,
|
||||
},
|
||||
{
|
||||
apiProvider: this.apiProvider,
|
||||
model: this.api.getModel().id,
|
||||
tokensIn: inputTokens,
|
||||
tokensOut: outputTokens,
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// NOTE: this comment is here for future reference - this was a workaround for userMessageContent not getting set to true. It was due to it not recursively calling for partial blocks when didRejectTool, so it would get stuck waiting for a partial block to complete before it could continue.
|
||||
// in case the content blocks finished
|
||||
// it may be the api stream finished after the last parsed content block was executed, so we are able to detect out of bounds and set userMessageContentReady to true (note you should not call presentAssistantMessage since if the last block is completed it will be presented again)
|
||||
@@ -3450,7 +3628,7 @@ export class Cline {
|
||||
|
||||
if (busyTerminals.length > 0 && this.didEditFile) {
|
||||
// || this.didEditFile
|
||||
await delay(300) // delay after saving file to let terminals catch up
|
||||
await setTimeoutPromise(300) // delay after saving file to let terminals catch up
|
||||
}
|
||||
|
||||
// let terminalWasBusy = false
|
||||
@@ -3564,7 +3742,7 @@ export class Cline {
|
||||
details +=
|
||||
"\nIn this mode you should focus on information gathering, asking questions, and architecting a solution. Once you have a plan, use the plan_mode_response tool to engage in a conversational back and forth with the user. Do not use the plan_mode_response tool until you've gathered all the information you need e.g. with read_file or ask_followup_question."
|
||||
details +=
|
||||
'\n(Remember: If it seems the user wants you to use tools only available in Act Mode, you should ask the user to "toggle to Act mode" (use those words) - they will have to manually do this themselves with the Plan/Act toggle button below. You do not have the ability to switch to Act Mode yourself, and must wait for the user to do it themselves once they are satisfied with the plan.)'
|
||||
'\n(Remember: If it seems the user wants you to use tools only available in Act Mode, you should ask the user to "toggle to Act mode" (use those words) - they will have to manually do this themselves with the Plan/Act toggle button below. You do not have the ability to switch to Act Mode yourself, and must wait for the user to do it themselves once they are satisfied with the plan. You also cannot present an option to toggle to Act mode, as this will be something you need to direct the user to do manually themselves.)'
|
||||
} else {
|
||||
details += "\nACT MODE"
|
||||
}
|
||||
|
||||
@@ -45,6 +45,7 @@ export const toolParamNames = [
|
||||
"arguments",
|
||||
"uri",
|
||||
"question",
|
||||
"options",
|
||||
"response",
|
||||
"result",
|
||||
] as const
|
||||
@@ -58,64 +59,3 @@ export interface ToolUse {
|
||||
params: Partial<Record<ToolParamName, string>>
|
||||
partial: boolean
|
||||
}
|
||||
|
||||
export interface ExecuteCommandToolUse extends ToolUse {
|
||||
name: "execute_command"
|
||||
// Pick<Record<ToolParamName, string>, "command"> makes "command" required, but Partial<> makes it optional
|
||||
params: Partial<Pick<Record<ToolParamName, string>, "command" | "requires_approval">>
|
||||
}
|
||||
|
||||
export interface ReadFileToolUse extends ToolUse {
|
||||
name: "read_file"
|
||||
params: Partial<Pick<Record<ToolParamName, string>, "path">>
|
||||
}
|
||||
|
||||
export interface WriteToFileToolUse extends ToolUse {
|
||||
name: "write_to_file"
|
||||
params: Partial<Pick<Record<ToolParamName, string>, "path" | "content">>
|
||||
}
|
||||
|
||||
export interface ReplaceInFileToolUse extends ToolUse {
|
||||
name: "replace_in_file"
|
||||
params: Partial<Pick<Record<ToolParamName, string>, "path" | "diff">>
|
||||
}
|
||||
|
||||
export interface SearchFilesToolUse extends ToolUse {
|
||||
name: "search_files"
|
||||
params: Partial<Pick<Record<ToolParamName, string>, "path" | "regex" | "file_pattern">>
|
||||
}
|
||||
|
||||
export interface ListFilesToolUse extends ToolUse {
|
||||
name: "list_files"
|
||||
params: Partial<Pick<Record<ToolParamName, string>, "path" | "recursive">>
|
||||
}
|
||||
|
||||
export interface ListCodeDefinitionNamesToolUse extends ToolUse {
|
||||
name: "list_code_definition_names"
|
||||
params: Partial<Pick<Record<ToolParamName, string>, "path">>
|
||||
}
|
||||
|
||||
export interface BrowserActionToolUse extends ToolUse {
|
||||
name: "browser_action"
|
||||
params: Partial<Pick<Record<ToolParamName, string>, "action" | "url" | "coordinate" | "text">>
|
||||
}
|
||||
|
||||
export interface UseMcpToolToolUse extends ToolUse {
|
||||
name: "use_mcp_tool"
|
||||
params: Partial<Pick<Record<ToolParamName, string>, "server_name" | "tool_name" | "arguments">>
|
||||
}
|
||||
|
||||
export interface AccessMcpResourceToolUse extends ToolUse {
|
||||
name: "access_mcp_resource"
|
||||
params: Partial<Pick<Record<ToolParamName, string>, "server_name" | "uri">>
|
||||
}
|
||||
|
||||
export interface AskFollowupQuestionToolUse extends ToolUse {
|
||||
name: "ask_followup_question"
|
||||
params: Partial<Pick<Record<ToolParamName, string>, "question">>
|
||||
}
|
||||
|
||||
export interface AttemptCompletionToolUse extends ToolUse {
|
||||
name: "attempt_completion"
|
||||
params: Partial<Pick<Record<ToolParamName, string>, "result" | "command">>
|
||||
}
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
import { ContextManager } from "./ContextManager"
|
||||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
import { expect } from "chai"
|
||||
|
||||
describe("ContextManager", () => {
|
||||
function createMessages(count: number): Anthropic.Messages.MessageParam[] {
|
||||
const messages: Anthropic.Messages.MessageParam[] = []
|
||||
|
||||
messages.push({
|
||||
role: "user",
|
||||
content: "Initial task message",
|
||||
})
|
||||
|
||||
let role: "user" | "assistant" = "assistant"
|
||||
for (let i = 1; i < count; i++) {
|
||||
messages.push({
|
||||
role,
|
||||
content: `Message ${i}`,
|
||||
})
|
||||
role = role === "user" ? "assistant" : "user"
|
||||
}
|
||||
|
||||
return messages
|
||||
}
|
||||
|
||||
describe("getNextTruncationRange", () => {
|
||||
let contextManager: ContextManager
|
||||
|
||||
beforeEach(() => {
|
||||
contextManager = new ContextManager()
|
||||
})
|
||||
|
||||
it("first truncation with half keep", () => {
|
||||
const messages = createMessages(11)
|
||||
const result = contextManager.getNextTruncationRange(messages)
|
||||
|
||||
expect(result).to.deep.equal([1, 4])
|
||||
})
|
||||
|
||||
it("first truncation with quarter keep", () => {
|
||||
const messages = createMessages(11)
|
||||
const result = contextManager.getNextTruncationRange(messages, undefined, "quarter")
|
||||
|
||||
expect(result).to.deep.equal([1, 6])
|
||||
})
|
||||
|
||||
it("sequential truncation with half keep", () => {
|
||||
const messages = createMessages(21)
|
||||
const firstRange = contextManager.getNextTruncationRange(messages)
|
||||
expect(firstRange).to.deep.equal([1, 10])
|
||||
|
||||
// Pass the previous range for sequential truncation
|
||||
const secondRange = contextManager.getNextTruncationRange(messages, firstRange)
|
||||
expect(secondRange).to.deep.equal([1, 14])
|
||||
})
|
||||
|
||||
it("sequential truncation with quarter keep", () => {
|
||||
const messages = createMessages(41)
|
||||
const firstRange = contextManager.getNextTruncationRange(messages, undefined, "quarter")
|
||||
|
||||
const secondRange = contextManager.getNextTruncationRange(messages, firstRange, "quarter")
|
||||
|
||||
expect(secondRange[0]).to.equal(1)
|
||||
expect(secondRange[1]).to.be.greaterThan(firstRange[1])
|
||||
})
|
||||
|
||||
it("ensures the last message in range is a user message", () => {
|
||||
const messages = createMessages(14)
|
||||
const result = contextManager.getNextTruncationRange(messages)
|
||||
|
||||
// Check if the message at the end of range is a user message
|
||||
const lastRemovedMessage = messages[result[1]]
|
||||
expect(lastRemovedMessage.role).to.equal("user")
|
||||
|
||||
// Check if the next message after the range is an assistant message
|
||||
const nextMessage = messages[result[1] + 1]
|
||||
expect(nextMessage.role).to.equal("assistant")
|
||||
})
|
||||
|
||||
it("handles small message arrays", () => {
|
||||
const messages = createMessages(3)
|
||||
const result = contextManager.getNextTruncationRange(messages)
|
||||
|
||||
expect(result).to.deep.equal([1, 0])
|
||||
})
|
||||
|
||||
it("preserves the message structure when truncating", () => {
|
||||
const messages = createMessages(20)
|
||||
const result = contextManager.getNextTruncationRange(messages)
|
||||
|
||||
// Get messages after removing the range
|
||||
const effectiveMessages = [...messages.slice(0, result[0]), ...messages.slice(result[1] + 1)]
|
||||
|
||||
// Check first message and alternating pattern
|
||||
expect(effectiveMessages[0].role).to.equal("user")
|
||||
for (let i = 1; i < effectiveMessages.length; i++) {
|
||||
const expectedRole = i % 2 === 1 ? "assistant" : "user"
|
||||
expect(effectiveMessages[i].role).to.equal(expectedRole)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe("getTruncatedMessages", () => {
|
||||
let contextManager: ContextManager
|
||||
|
||||
beforeEach(() => {
|
||||
contextManager = new ContextManager()
|
||||
})
|
||||
|
||||
it("returns original messages when no range is provided", () => {
|
||||
const messages = createMessages(3)
|
||||
|
||||
const result = contextManager.getTruncatedMessages(messages, undefined)
|
||||
expect(result).to.deep.equal(messages)
|
||||
})
|
||||
|
||||
it("correctly removes messages in the specified range", () => {
|
||||
const messages = createMessages(5)
|
||||
|
||||
const range: [number, number] = [1, 3]
|
||||
const result = contextManager.getTruncatedMessages(messages, range)
|
||||
|
||||
expect(result).to.have.lengthOf(2)
|
||||
expect(result[0]).to.deep.equal(messages[0])
|
||||
expect(result[1]).to.deep.equal(messages[4])
|
||||
})
|
||||
|
||||
it("works with a range that starts at the first message after task", () => {
|
||||
const messages = createMessages(4)
|
||||
|
||||
const range: [number, number] = [1, 2]
|
||||
const result = contextManager.getTruncatedMessages(messages, range)
|
||||
|
||||
expect(result).to.have.lengthOf(2)
|
||||
expect(result[0]).to.deep.equal(messages[0])
|
||||
expect(result[1]).to.deep.equal(messages[3])
|
||||
})
|
||||
|
||||
it("correctly handles removing a range while preserving alternation pattern", () => {
|
||||
const messages = createMessages(5)
|
||||
|
||||
const range: [number, number] = [1, 2]
|
||||
const result = contextManager.getTruncatedMessages(messages, range)
|
||||
|
||||
expect(result).to.have.lengthOf(3)
|
||||
expect(result[0]).to.deep.equal(messages[0])
|
||||
expect(result[1]).to.deep.equal(messages[3])
|
||||
expect(result[2]).to.deep.equal(messages[4])
|
||||
|
||||
expect(result[0].role).to.equal("user")
|
||||
expect(result[1].role).to.equal("assistant")
|
||||
expect(result[2].role).to.equal("user")
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,120 @@
|
||||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
import { ClineApiReqInfo, ClineMessage } from "../../shared/ExtensionMessage"
|
||||
import { ApiHandler } from "../../api"
|
||||
import { OpenAiHandler } from "../../api/providers/openai"
|
||||
|
||||
export class ContextManager {
|
||||
getNewContextMessagesAndMetadata(
|
||||
apiConversationHistory: Anthropic.Messages.MessageParam[],
|
||||
clineMessages: ClineMessage[],
|
||||
api: ApiHandler,
|
||||
conversationHistoryDeletedRange: [number, number] | undefined,
|
||||
previousApiReqIndex: number,
|
||||
) {
|
||||
let updatedConversationHistoryDeletedRange = false
|
||||
|
||||
// If the previous API request's total token usage is close to the context window, truncate the conversation history to free up space for the new request
|
||||
if (previousApiReqIndex >= 0) {
|
||||
const previousRequest = clineMessages[previousApiReqIndex]
|
||||
if (previousRequest && previousRequest.text) {
|
||||
const { tokensIn, tokensOut, cacheWrites, cacheReads }: ClineApiReqInfo = JSON.parse(previousRequest.text)
|
||||
const totalTokens = (tokensIn || 0) + (tokensOut || 0) + (cacheWrites || 0) + (cacheReads || 0)
|
||||
let contextWindow = api.getModel().info.contextWindow || 128_000
|
||||
// FIXME: hack to get anyone using openai compatible with deepseek to have the proper context window instead of the default 128k. We need a way for the user to specify the context window for models they input through openai compatible
|
||||
if (api instanceof OpenAiHandler && api.getModel().id.toLowerCase().includes("deepseek")) {
|
||||
contextWindow = 64_000
|
||||
}
|
||||
let maxAllowedSize: number
|
||||
switch (contextWindow) {
|
||||
case 64_000: // deepseek models
|
||||
maxAllowedSize = contextWindow - 27_000
|
||||
break
|
||||
case 128_000: // most models
|
||||
maxAllowedSize = contextWindow - 30_000
|
||||
break
|
||||
case 200_000: // claude models
|
||||
maxAllowedSize = contextWindow - 40_000
|
||||
break
|
||||
default:
|
||||
maxAllowedSize = Math.max(contextWindow - 40_000, contextWindow * 0.8) // for deepseek, 80% of 64k meant only ~10k buffer which was too small and resulted in users getting context window errors.
|
||||
}
|
||||
|
||||
// This is the most reliable way to know when we're close to hitting the context window.
|
||||
if (totalTokens >= maxAllowedSize) {
|
||||
// Since the user may switch between models with different context windows, truncating half may not be enough (ie if switching from claude 200k to deepseek 64k, half truncation will only remove 100k tokens, but we need to remove much more)
|
||||
// So if totalTokens/2 is greater than maxAllowedSize, we truncate 3/4 instead of 1/2
|
||||
// FIXME: truncating the conversation in a way that is optimal for prompt caching AND takes into account multi-context window complexity is something we need to improve
|
||||
const keep = totalTokens / 2 > maxAllowedSize ? "quarter" : "half"
|
||||
|
||||
// NOTE: it's okay that we overwriteConversationHistory in resume task since we're only ever removing the last user message and not anything in the middle which would affect this range
|
||||
conversationHistoryDeletedRange = this.getNextTruncationRange(
|
||||
apiConversationHistory,
|
||||
conversationHistoryDeletedRange,
|
||||
keep,
|
||||
)
|
||||
|
||||
updatedConversationHistoryDeletedRange = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// conversationHistoryDeletedRange is updated only when we're close to hitting the context window, so we don't continuously break the prompt cache
|
||||
const truncatedConversationHistory = this.getTruncatedMessages(apiConversationHistory, conversationHistoryDeletedRange)
|
||||
|
||||
return {
|
||||
conversationHistoryDeletedRange: conversationHistoryDeletedRange,
|
||||
updatedConversationHistoryDeletedRange: updatedConversationHistoryDeletedRange,
|
||||
truncatedConversationHistory: truncatedConversationHistory,
|
||||
}
|
||||
}
|
||||
|
||||
public getNextTruncationRange(
|
||||
apiMessages: Anthropic.Messages.MessageParam[],
|
||||
currentDeletedRange: [number, number] | undefined,
|
||||
keep: "half" | "quarter",
|
||||
): [number, number] {
|
||||
// Since we always keep the first message, currentDeletedRange[0] will always be 1 (for now until we have a smarter truncation algorithm)
|
||||
const rangeStartIndex = 1
|
||||
const startOfRest = currentDeletedRange ? currentDeletedRange[1] + 1 : 1
|
||||
|
||||
let messagesToRemove: number
|
||||
if (keep === "half") {
|
||||
// Remove half of remaining user-assistant pairs
|
||||
// We first calculate half of the messages then divide by 2 to get the number of pairs.
|
||||
// After flooring, we multiply by 2 to get the number of messages.
|
||||
// Note that this will also always be an even number.
|
||||
messagesToRemove = Math.floor((apiMessages.length - startOfRest) / 4) * 2 // Keep even number
|
||||
} else {
|
||||
// Remove 3/4 of remaining user-assistant pairs
|
||||
// We calculate 3/4ths of the messages then divide by 2 to get the number of pairs.
|
||||
// After flooring, we multiply by 2 to get the number of messages.
|
||||
// Note that this will also always be an even number.
|
||||
messagesToRemove = Math.floor(((apiMessages.length - startOfRest) * 3) / 4 / 2) * 2
|
||||
}
|
||||
|
||||
let rangeEndIndex = startOfRest + messagesToRemove - 1
|
||||
|
||||
// Make sure the last message being removed is a user message, so that the next message after the initial task message is an assistant message. This preservers the user-assistant-user-assistant structure.
|
||||
// NOTE: anthropic format messages are always user-assistant-user-assistant, while openai format messages can have multiple user messages in a row (we use anthropic format throughout cline)
|
||||
if (apiMessages[rangeEndIndex].role !== "user") {
|
||||
rangeEndIndex -= 1
|
||||
}
|
||||
|
||||
// this is an inclusive range that will be removed from the conversation history
|
||||
return [rangeStartIndex, rangeEndIndex]
|
||||
}
|
||||
|
||||
public getTruncatedMessages(
|
||||
messages: Anthropic.Messages.MessageParam[],
|
||||
deletedRange: [number, number] | undefined,
|
||||
): Anthropic.Messages.MessageParam[] {
|
||||
if (!deletedRange) {
|
||||
return messages
|
||||
}
|
||||
|
||||
const [start, end] = deletedRange
|
||||
// the range is inclusive - both start and end indices and everything in between will be removed from the final result.
|
||||
// NOTE: if you try to console log these, don't forget that logging a reference to an array may not provide the same result as logging a slice() snapshot of that array at that exact moment. The following DOES in fact include the latest assistant message.
|
||||
return [...messages.slice(0, start), ...messages.slice(end + 1)]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export function checkIsOpenRouterContextWindowError(error: any): boolean {
|
||||
return error.code === 400 && error.message?.includes("context length")
|
||||
}
|
||||
|
||||
export function checkIsAnthropicContextWindowError(response: any): boolean {
|
||||
return (
|
||||
response?.error?.error?.type === "invalid_request_error" &&
|
||||
response?.error?.error?.message?.includes("prompt is too long")
|
||||
)
|
||||
}
|
||||
@@ -72,7 +72,10 @@ export async function parseMentions(text: string, cwd: string, urlContentFetcher
|
||||
}
|
||||
}
|
||||
|
||||
for (const mention of mentions) {
|
||||
// Filter out duplicate mentions while preserving order
|
||||
const uniqueMentions = Array.from(new Set(mentions))
|
||||
|
||||
for (const mention of uniqueMentions) {
|
||||
if (mention.startsWith("http")) {
|
||||
let result: string
|
||||
if (launchBrowserError) {
|
||||
|
||||
@@ -216,9 +216,13 @@ Usage:
|
||||
Description: Ask the user a question to gather additional information needed to complete the task. This tool should be used when you encounter ambiguities, need clarification, or require more details to proceed effectively. It allows for interactive problem-solving by enabling direct communication with the user. Use this tool judiciously to maintain a balance between gathering necessary information and avoiding excessive back-and-forth.
|
||||
Parameters:
|
||||
- question: (required) The question to ask the user. This should be a clear, specific question that addresses the information you need.
|
||||
- options: (optional) An array of 2-5 options for the user to choose from. Each option should be a string describing a possible answer. You may not always need to provide options, but it may be helpful in many cases where it can save the user from having to type out a response manually.
|
||||
Usage:
|
||||
<ask_followup_question>
|
||||
<question>Your question here</question>
|
||||
<options>
|
||||
Array of options here (optional), e.g. ["Option 1", "Option 2", "Option 3"]
|
||||
</options>
|
||||
</ask_followup_question>
|
||||
|
||||
## attempt_completion
|
||||
@@ -239,9 +243,13 @@ Your final result description here
|
||||
Description: Respond to the user's inquiry in an effort to plan a solution to the user's task. This tool should be used when you need to provide a response to a question or statement from the user about how you plan to accomplish the task. This tool is only available in PLAN MODE. The environment_details will specify the current mode, if it is not PLAN MODE then you should not use this tool. Depending on the user's message, you may ask questions to get clarification about the user's request, architect a solution to the task, and to brainstorm ideas with the user. For example, if the user's task is to create a website, you may start by asking some clarifying questions, then present a detailed plan for how you will accomplish the task given the context, and perhaps engage in a back and forth to finalize the details before the user switches you to ACT MODE to implement the solution.
|
||||
Parameters:
|
||||
- response: (required) The response to provide to the user. Do not try to use tools in this parameter, this is simply a chat response.
|
||||
- options: (optional) An array of 2-5 options for the user to choose from. Each option should be a string describing a possible choice or path forward in the planning process. This can help guide the discussion and make it easier for the user to provide input on key decisions. You may not always need to provide options, but it may be helpful in many cases where it can save the user from having to type out a response manually. Do NOT present an option to toggle to Act mode, as this will be something you need to direct the user to do manually themselves.
|
||||
Usage:
|
||||
<plan_mode_response>
|
||||
<response>Your response here</response>
|
||||
<options>
|
||||
Array of options here (optional), e.g. ["Option 1", "Option 2", "Option 3"]
|
||||
</options>
|
||||
</plan_mode_response>
|
||||
|
||||
# Tool Use Examples
|
||||
@@ -813,7 +821,7 @@ You have access to two tools for working with files: **write_to_file** and **rep
|
||||
|
||||
## Important Considerations
|
||||
|
||||
- Using write_to_file requires providing the file’s complete final content.
|
||||
- Using write_to_file requires providing the file's complete final content.
|
||||
- If you only need to make small changes to an existing file, consider using replace_in_file instead to avoid unnecessarily rewriting the entire file.
|
||||
- While write_to_file should not be your default choice, don't hesitate to use it when the situation truly calls for it.
|
||||
|
||||
@@ -826,12 +834,12 @@ You have access to two tools for working with files: **write_to_file** and **rep
|
||||
## When to Use
|
||||
|
||||
- Small, localized changes like updating a few lines, function implementations, changing variable names, modifying a section of text, etc.
|
||||
- Targeted improvements where only specific portions of the file’s content needs to be altered.
|
||||
- Targeted improvements where only specific portions of the file's content needs to be altered.
|
||||
- Especially useful for long files where much of the file will remain unchanged.
|
||||
|
||||
## Advantages
|
||||
|
||||
- More efficient for minor edits, since you don’t need to supply the entire file content.
|
||||
- More efficient for minor edits, since you don't need to supply the entire file content.
|
||||
- Reduces the chance of errors that can occur when overwriting large files.
|
||||
|
||||
# Choosing the Appropriate Tool
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
|
||||
/*
|
||||
We can't implement a dynamically updating sliding window as it would break prompt cache
|
||||
every time. To maintain the benefits of caching, we need to keep conversation history
|
||||
static. This operation should be performed as infrequently as possible. If a user reaches
|
||||
a 200k context, we can assume that the first half is likely irrelevant to their current task.
|
||||
Therefore, this function should only be called when absolutely necessary to fit within
|
||||
context limits, not as a continuous process.
|
||||
*/
|
||||
// export function truncateHalfConversation(
|
||||
// messages: Anthropic.Messages.MessageParam[],
|
||||
// ): Anthropic.Messages.MessageParam[] {
|
||||
// // API expects messages to be in user-assistant order, and tool use messages must be followed by tool results. We need to maintain this structure while truncating.
|
||||
|
||||
// // Always keep the first Task message (this includes the project's file structure in environment_details)
|
||||
// const truncatedMessages = [messages[0]]
|
||||
|
||||
// // Remove half of user-assistant pairs
|
||||
// const messagesToRemove = Math.floor(messages.length / 4) * 2 // has to be even number
|
||||
|
||||
// const remainingMessages = messages.slice(messagesToRemove + 1) // has to start with assistant message since tool result cannot follow assistant message with no tool use
|
||||
// truncatedMessages.push(...remainingMessages)
|
||||
|
||||
// return truncatedMessages
|
||||
// }
|
||||
|
||||
/*
|
||||
getNextTruncationRange: Calculates the next range of messages to be "deleted"
|
||||
- Takes the full messages array and optional current deleted range
|
||||
- Always preserves the first message (task message)
|
||||
- Removes 1/2 of remaining messages (rounded down to even number) after current deleted range
|
||||
- Returns [startIndex, endIndex] representing inclusive range to delete
|
||||
|
||||
getTruncatedMessages: Constructs the truncated array using the deleted range
|
||||
- Takes full messages array and optional deleted range
|
||||
- Returns new array with messages in deleted range removed
|
||||
- Preserves order and structure of remaining messages
|
||||
|
||||
The range is represented as [startIndex, endIndex] where both indices are inclusive
|
||||
The functions maintain the original array integrity while allowing progressive truncation
|
||||
through the deletedRange parameter
|
||||
|
||||
Usage example:
|
||||
const messages = [user1, assistant1, user2, assistant2, user3, assistant3];
|
||||
let deletedRange = getNextTruncationRange(messages); // [1,2] (assistant1,user2)
|
||||
let truncated = getTruncatedMessages(messages, deletedRange);
|
||||
// [user1, assistant2, user3, assistant3]
|
||||
|
||||
deletedRange = getNextTruncationRange(messages, deletedRange); // [2,3] (assistant2,user3)
|
||||
truncated = getTruncatedMessages(messages, deletedRange);
|
||||
// [user1, assistant3]
|
||||
*/
|
||||
|
||||
export function getNextTruncationRange(
|
||||
messages: Anthropic.Messages.MessageParam[],
|
||||
currentDeletedRange: [number, number] | undefined = undefined,
|
||||
keep: "half" | "quarter" = "half",
|
||||
): [number, number] {
|
||||
// Since we always keep the first message, currentDeletedRange[0] will always be 1 (for now until we have a smarter truncation algorithm)
|
||||
const rangeStartIndex = 1
|
||||
const startOfRest = currentDeletedRange ? currentDeletedRange[1] + 1 : 1
|
||||
|
||||
let messagesToRemove: number
|
||||
if (keep === "half") {
|
||||
// Remove half of user-assistant pairs
|
||||
messagesToRemove = Math.floor((messages.length - startOfRest) / 4) * 2 // Keep even number
|
||||
} else {
|
||||
// Remove 3/4 of user-assistant pairs
|
||||
messagesToRemove = Math.floor((messages.length - startOfRest) / 8) * 3 * 2
|
||||
}
|
||||
|
||||
let rangeEndIndex = startOfRest + messagesToRemove - 1
|
||||
|
||||
// Make sure the last message being removed is a user message, so that the next message after the initial task message is an assistant message. This preservers the user-assistant-user-assistant structure.
|
||||
// NOTE: anthropic format messages are always user-assistant-user-assistant, while openai format messages can have multiple user messages in a row (we use anthropic format throughout cline)
|
||||
if (messages[rangeEndIndex].role !== "user") {
|
||||
rangeEndIndex -= 1
|
||||
}
|
||||
|
||||
// this is an inclusive range that will be removed from the conversation history
|
||||
return [rangeStartIndex, rangeEndIndex]
|
||||
}
|
||||
|
||||
export function getTruncatedMessages(
|
||||
messages: Anthropic.Messages.MessageParam[],
|
||||
deletedRange: [number, number] | undefined,
|
||||
): Anthropic.Messages.MessageParam[] {
|
||||
if (!deletedRange) {
|
||||
return messages
|
||||
}
|
||||
|
||||
const [start, end] = deletedRange
|
||||
// the range is inclusive - both start and end indices and everything in between will be removed from the final result.
|
||||
// NOTE: if you try to console log these, don't forget that logging a reference to an array may not provide the same result as logging a slice() snapshot of that array at that exact moment. The following DOES in fact include the latest assistant message.
|
||||
return [...messages.slice(0, start), ...messages.slice(end + 1)]
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import { fetchOpenGraphData, isImageUrl } from "../../integrations/misc/link-pre
|
||||
import { selectImages } from "../../integrations/misc/process-images"
|
||||
import { getTheme } from "../../integrations/theme/getTheme"
|
||||
import WorkspaceTracker from "../../integrations/workspace/WorkspaceTracker"
|
||||
import { ClineAccountService } from "../../services/account/ClineAccountService"
|
||||
import { McpHub } from "../../services/mcp/McpHub"
|
||||
import { UserInfo } from "../../shared/UserInfo"
|
||||
import { ApiConfiguration, ApiProvider, ModelInfo } from "../../shared/api"
|
||||
@@ -36,6 +37,10 @@ import { telemetryService } from "../../services/telemetry/TelemetryService"
|
||||
import { TelemetrySetting } from "../../shared/TelemetrySetting"
|
||||
import { cleanupLegacyCheckpoints } from "../../integrations/checkpoints/CheckpointMigration"
|
||||
import CheckpointTracker from "../../integrations/checkpoints/CheckpointTracker"
|
||||
import { getTotalTasksSize } from "../../utils/storage"
|
||||
import { ConversationTelemetryService } from "../../services/telemetry/ConversationTelemetryService"
|
||||
import { GlobalFileNames } from "../../global-constants"
|
||||
import { setTimeout as setTimeoutPromise } from "node:timers/promises"
|
||||
|
||||
/*
|
||||
https://github.com/microsoft/vscode-webview-ui-toolkit-samples/blob/main/default/weather-webview/src/providers/WeatherViewProvider.ts
|
||||
@@ -89,6 +94,7 @@ type GlobalStateKey =
|
||||
| "azureApiVersion"
|
||||
| "openRouterModelId"
|
||||
| "openRouterModelInfo"
|
||||
| "openRouterProviderSorting"
|
||||
| "autoApprovalSettings"
|
||||
| "browserSettings"
|
||||
| "chatSettings"
|
||||
@@ -97,6 +103,7 @@ type GlobalStateKey =
|
||||
| "previousModeApiProvider"
|
||||
| "previousModeModelId"
|
||||
| "previousModeThinkingBudgetTokens"
|
||||
| "previousModeVsCodeLmModelSelector"
|
||||
| "previousModeModelInfo"
|
||||
| "liteLlmBaseUrl"
|
||||
| "liteLlmModelId"
|
||||
@@ -109,14 +116,6 @@ type GlobalStateKey =
|
||||
| "thinkingBudgetTokens"
|
||||
| "planActSeparateModelsSetting"
|
||||
|
||||
export const GlobalFileNames = {
|
||||
apiConversationHistory: "api_conversation_history.json",
|
||||
uiMessages: "ui_messages.json",
|
||||
openRouterModels: "openrouter_models.json",
|
||||
mcpSettings: "cline_mcp_settings.json",
|
||||
clineRules: ".clinerules",
|
||||
}
|
||||
|
||||
export class ClineProvider implements vscode.WebviewViewProvider {
|
||||
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"
|
||||
@@ -126,7 +125,9 @@ export class ClineProvider implements vscode.WebviewViewProvider {
|
||||
private cline?: Cline
|
||||
workspaceTracker?: WorkspaceTracker
|
||||
mcpHub?: McpHub
|
||||
private latestAnnouncementId = "feb-19-2025" // update to some unique identifier when we add a new announcement
|
||||
accountService?: ClineAccountService
|
||||
private latestAnnouncementId = "march-22-2025" // update to some unique identifier when we add a new announcement
|
||||
conversationTelemetryService: ConversationTelemetryService
|
||||
|
||||
constructor(
|
||||
readonly context: vscode.ExtensionContext,
|
||||
@@ -136,6 +137,8 @@ export class ClineProvider implements vscode.WebviewViewProvider {
|
||||
ClineProvider.activeInstances.add(this)
|
||||
this.workspaceTracker = new WorkspaceTracker(this)
|
||||
this.mcpHub = new McpHub(this)
|
||||
this.accountService = new ClineAccountService(this)
|
||||
this.conversationTelemetryService = new ConversationTelemetryService(this)
|
||||
|
||||
// Clean up legacy checkpoints
|
||||
cleanupLegacyCheckpoints(this.context.globalStorageUri.fsPath, this.outputChannel).catch((error) => {
|
||||
@@ -166,6 +169,8 @@ export class ClineProvider implements vscode.WebviewViewProvider {
|
||||
this.workspaceTracker = undefined
|
||||
this.mcpHub?.dispose()
|
||||
this.mcpHub = undefined
|
||||
this.accountService = undefined
|
||||
this.conversationTelemetryService.shutdown()
|
||||
this.outputChannel.appendLine("Disposed all disposables")
|
||||
ClineProvider.activeInstances.delete(this)
|
||||
}
|
||||
@@ -574,6 +579,13 @@ export class ClineProvider implements vscode.WebviewViewProvider {
|
||||
await this.togglePlanActModeWithChatSettings(message.chatSettings, message.chatContent)
|
||||
}
|
||||
break
|
||||
case "optionsResponse":
|
||||
await this.postMessageToWebview({
|
||||
type: "invoke",
|
||||
invoke: "sendMessage",
|
||||
text: message.text,
|
||||
})
|
||||
break
|
||||
// case "relaunchChromeDebugMode":
|
||||
// if (this.cline) {
|
||||
// this.cline.browserSession.relaunchChromeDebugMode()
|
||||
@@ -719,6 +731,14 @@ export class ClineProvider implements vscode.WebviewViewProvider {
|
||||
await this.handleSignOut()
|
||||
break
|
||||
}
|
||||
case "showAccountViewClicked": {
|
||||
await this.postMessageToWebview({ type: "action", action: "accountButtonClicked" })
|
||||
break
|
||||
}
|
||||
case "fetchUserCreditsData": {
|
||||
await this.fetchUserCreditsData()
|
||||
break
|
||||
}
|
||||
case "showMcpView": {
|
||||
await this.postMessageToWebview({ type: "action", action: "mcpButtonClicked" })
|
||||
break
|
||||
@@ -809,6 +829,10 @@ export class ClineProvider implements vscode.WebviewViewProvider {
|
||||
}
|
||||
break
|
||||
}
|
||||
case "requestTotalTasksSize": {
|
||||
this.refreshTotalTasksSize()
|
||||
break
|
||||
}
|
||||
case "restartMcpServer": {
|
||||
try {
|
||||
await this.mcpHub?.restartConnection(message.text!)
|
||||
@@ -910,6 +934,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
|
||||
case "clearAllTaskHistory": {
|
||||
await this.deleteAllTaskHistory()
|
||||
await this.postStateToWebview()
|
||||
this.refreshTotalTasksSize()
|
||||
this.postMessageToWebview({ type: "relinquishControl" })
|
||||
break
|
||||
}
|
||||
@@ -940,6 +965,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
|
||||
previousModeApiProvider: newApiProvider,
|
||||
previousModeModelId: newModelId,
|
||||
previousModeModelInfo: newModelInfo,
|
||||
previousModeVsCodeLmModelSelector: newVsCodeLmModelSelector,
|
||||
previousModeThinkingBudgetTokens: newThinkingBudgetTokens,
|
||||
planActSeparateModelsSetting,
|
||||
} = await this.getState()
|
||||
@@ -967,7 +993,8 @@ export class ClineProvider implements vscode.WebviewViewProvider {
|
||||
await this.updateGlobalState("previousModeModelInfo", apiConfiguration.openRouterModelInfo)
|
||||
break
|
||||
case "vscode-lm":
|
||||
await this.updateGlobalState("previousModeModelId", apiConfiguration.vsCodeLmModelSelector)
|
||||
// Important we don't set modelId to this, as it's an object not string (webview expects model id to be a string)
|
||||
await this.updateGlobalState("previousModeVsCodeLmModelSelector", apiConfiguration.vsCodeLmModelSelector)
|
||||
break
|
||||
case "openai":
|
||||
await this.updateGlobalState("previousModeModelId", apiConfiguration.openAiModelId)
|
||||
@@ -988,7 +1015,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
|
||||
}
|
||||
|
||||
// Restore the model used in previous mode
|
||||
if (newApiProvider || newModelId || newThinkingBudgetTokens !== undefined) {
|
||||
if (newApiProvider || newModelId || newThinkingBudgetTokens !== undefined || newVsCodeLmModelSelector) {
|
||||
await this.updateGlobalState("apiProvider", newApiProvider)
|
||||
await this.updateGlobalState("thinkingBudgetTokens", newThinkingBudgetTokens)
|
||||
switch (newApiProvider) {
|
||||
@@ -1008,7 +1035,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
|
||||
await this.updateGlobalState("openRouterModelInfo", newModelInfo)
|
||||
break
|
||||
case "vscode-lm":
|
||||
await this.updateGlobalState("vsCodeLmModelSelector", newModelId)
|
||||
await this.updateGlobalState("vsCodeLmModelSelector", newVsCodeLmModelSelector)
|
||||
break
|
||||
case "openai":
|
||||
await this.updateGlobalState("openAiModelId", newModelId)
|
||||
@@ -1131,6 +1158,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
|
||||
azureApiVersion,
|
||||
openRouterModelId,
|
||||
openRouterModelInfo,
|
||||
openRouterProviderSorting,
|
||||
vsCodeLmModelSelector,
|
||||
liteLlmBaseUrl,
|
||||
liteLlmModelId,
|
||||
@@ -1180,6 +1208,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
|
||||
await this.updateGlobalState("azureApiVersion", azureApiVersion)
|
||||
await this.updateGlobalState("openRouterModelId", openRouterModelId)
|
||||
await this.updateGlobalState("openRouterModelInfo", openRouterModelInfo)
|
||||
await this.updateGlobalState("openRouterProviderSorting", openRouterProviderSorting)
|
||||
await this.updateGlobalState("vsCodeLmModelSelector", vsCodeLmModelSelector)
|
||||
await this.updateGlobalState("liteLlmBaseUrl", liteLlmBaseUrl)
|
||||
await this.updateGlobalState("liteLlmModelId", liteLlmModelId)
|
||||
@@ -1301,6 +1330,20 @@ export class ClineProvider implements vscode.WebviewViewProvider {
|
||||
}
|
||||
}
|
||||
|
||||
// Account
|
||||
|
||||
async fetchUserCreditsData() {
|
||||
try {
|
||||
await Promise.all([
|
||||
this.accountService?.fetchBalance(),
|
||||
this.accountService?.fetchUsageTransactions(),
|
||||
this.accountService?.fetchPaymentTransactions(),
|
||||
])
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch user credits data:", error)
|
||||
}
|
||||
}
|
||||
|
||||
// Auth
|
||||
|
||||
public async validateAuthState(state: string | null): Promise<boolean> {
|
||||
@@ -1339,7 +1382,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
|
||||
}
|
||||
|
||||
await this.postStateToWebview()
|
||||
vscode.window.showInformationMessage("Successfully logged in to Cline")
|
||||
// vscode.window.showInformationMessage("Successfully logged in to Cline")
|
||||
} catch (error) {
|
||||
console.error("Failed to handle auth callback:", error)
|
||||
vscode.window.showErrorMessage("Failed to log in to Cline")
|
||||
@@ -1709,6 +1752,104 @@ Here is the project's README to help you get started:\n\n${mcpDetails.readmeCont
|
||||
return models
|
||||
}
|
||||
|
||||
// Context menus and code actions
|
||||
|
||||
getFileMentionFromPath(filePath: string) {
|
||||
const cwd = vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath).at(0)
|
||||
if (!cwd) {
|
||||
return "@/" + filePath
|
||||
}
|
||||
const relativePath = path.relative(cwd, filePath)
|
||||
return "@/" + relativePath
|
||||
}
|
||||
|
||||
// 'Add to Cline' context menu in editor and code action
|
||||
async addSelectedCodeToChat(code: string, filePath: string, languageId: string, diagnostics?: vscode.Diagnostic[]) {
|
||||
// Ensure the sidebar view is visible
|
||||
await vscode.commands.executeCommand("claude-dev.SidebarProvider.focus")
|
||||
await setTimeoutPromise(100)
|
||||
|
||||
// Post message to webview with the selected code
|
||||
const fileMention = this.getFileMentionFromPath(filePath)
|
||||
|
||||
let input = `${fileMention}\n\`\`\`\n${code}\n\`\`\``
|
||||
if (diagnostics) {
|
||||
const problemsString = this.convertDiagnosticsToProblemsString(diagnostics)
|
||||
input += `\nProblems:\n${problemsString}`
|
||||
}
|
||||
|
||||
await this.postMessageToWebview({
|
||||
type: "addToInput",
|
||||
text: input,
|
||||
})
|
||||
|
||||
console.log("addSelectedCodeToChat", code, filePath, languageId)
|
||||
}
|
||||
|
||||
// 'Add to Cline' context menu in Terminal
|
||||
async addSelectedTerminalOutputToChat(output: string, terminalName: string) {
|
||||
// Ensure the sidebar view is visible
|
||||
await vscode.commands.executeCommand("claude-dev.SidebarProvider.focus")
|
||||
await setTimeoutPromise(100)
|
||||
|
||||
// Post message to webview with the selected terminal output
|
||||
// await this.postMessageToWebview({
|
||||
// type: "addSelectedTerminalOutput",
|
||||
// output,
|
||||
// terminalName
|
||||
// })
|
||||
|
||||
await this.postMessageToWebview({
|
||||
type: "addToInput",
|
||||
text: `Terminal output:\n\`\`\`\n${output}\n\`\`\``,
|
||||
})
|
||||
|
||||
console.log("addSelectedTerminalOutputToChat", output, terminalName)
|
||||
}
|
||||
|
||||
// 'Fix with Cline' in code actions
|
||||
async fixWithCline(code: string, filePath: string, languageId: string, diagnostics: vscode.Diagnostic[]) {
|
||||
// Ensure the sidebar view is visible
|
||||
await vscode.commands.executeCommand("claude-dev.SidebarProvider.focus")
|
||||
await setTimeoutPromise(100)
|
||||
|
||||
const fileMention = this.getFileMentionFromPath(filePath)
|
||||
const problemsString = this.convertDiagnosticsToProblemsString(diagnostics)
|
||||
await this.initClineWithTask(
|
||||
`Fix the following code in ${fileMention}\n\`\`\`\n${code}\n\`\`\`\n\nProblems:\n${problemsString}`,
|
||||
)
|
||||
|
||||
console.log("fixWithCline", code, filePath, languageId, diagnostics, problemsString)
|
||||
}
|
||||
|
||||
convertDiagnosticsToProblemsString(diagnostics: vscode.Diagnostic[]) {
|
||||
let problemsString = ""
|
||||
for (const diagnostic of diagnostics) {
|
||||
let label: string
|
||||
switch (diagnostic.severity) {
|
||||
case vscode.DiagnosticSeverity.Error:
|
||||
label = "Error"
|
||||
break
|
||||
case vscode.DiagnosticSeverity.Warning:
|
||||
label = "Warning"
|
||||
break
|
||||
case vscode.DiagnosticSeverity.Information:
|
||||
label = "Information"
|
||||
break
|
||||
case vscode.DiagnosticSeverity.Hint:
|
||||
label = "Hint"
|
||||
break
|
||||
default:
|
||||
label = "Diagnostic"
|
||||
}
|
||||
const line = diagnostic.range.start.line + 1 // VSCode lines are 0-indexed
|
||||
const source = diagnostic.source ? `${diagnostic.source} ` : ""
|
||||
problemsString += `\n- [${source}${label}] Line ${line}: ${diagnostic.message}`
|
||||
}
|
||||
problemsString = problemsString.trim()
|
||||
return problemsString
|
||||
}
|
||||
|
||||
// Task history
|
||||
|
||||
async getTaskWithId(id: string): Promise<{
|
||||
@@ -1781,46 +1922,56 @@ Here is the project's README to help you get started:\n\n${mcpDetails.readmeCont
|
||||
// await this.postStateToWebview()
|
||||
}
|
||||
|
||||
async refreshTotalTasksSize() {
|
||||
getTotalTasksSize(this.context.globalStorageUri.fsPath)
|
||||
.then((newTotalSize) => {
|
||||
this.postMessageToWebview({
|
||||
type: "totalTasksSize",
|
||||
totalTasksSize: newTotalSize,
|
||||
})
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Error calculating total tasks size:", error)
|
||||
})
|
||||
}
|
||||
|
||||
async deleteTaskWithId(id: string) {
|
||||
console.info("deleteTaskWithId: ", id)
|
||||
|
||||
if (id === this.cline?.taskId) {
|
||||
await this.clearTask()
|
||||
console.debug("cleared task")
|
||||
try {
|
||||
if (id === this.cline?.taskId) {
|
||||
await this.clearTask()
|
||||
console.debug("cleared task")
|
||||
}
|
||||
|
||||
const { taskDirPath, apiConversationHistoryFilePath, uiMessagesFilePath } = await this.getTaskWithId(id)
|
||||
|
||||
const updatedTaskHistory = await this.deleteTaskFromState(id)
|
||||
|
||||
// Delete the task files
|
||||
const apiConversationHistoryFileExists = await fileExistsAtPath(apiConversationHistoryFilePath)
|
||||
if (apiConversationHistoryFileExists) {
|
||||
await fs.unlink(apiConversationHistoryFilePath)
|
||||
}
|
||||
const uiMessagesFileExists = await fileExistsAtPath(uiMessagesFilePath)
|
||||
if (uiMessagesFileExists) {
|
||||
await fs.unlink(uiMessagesFilePath)
|
||||
}
|
||||
const legacyMessagesFilePath = path.join(taskDirPath, "claude_messages.json")
|
||||
if (await fileExistsAtPath(legacyMessagesFilePath)) {
|
||||
await fs.unlink(legacyMessagesFilePath)
|
||||
}
|
||||
|
||||
await fs.rmdir(taskDirPath) // succeeds if the dir is empty
|
||||
|
||||
if (updatedTaskHistory.length === 0) {
|
||||
await this.deleteAllTaskHistory()
|
||||
}
|
||||
} catch (error) {
|
||||
console.debug(`Error deleting task:`, error)
|
||||
}
|
||||
|
||||
const { taskDirPath, apiConversationHistoryFilePath, uiMessagesFilePath } = await this.getTaskWithId(id)
|
||||
|
||||
// Delete checkpoints
|
||||
console.info("deleting checkpoints")
|
||||
const taskHistory = ((await this.getGlobalState("taskHistory")) as HistoryItem[] | undefined) || []
|
||||
const historyItem = taskHistory.find((item) => item.id === id)
|
||||
//console.log("historyItem: ", historyItem)
|
||||
// if (historyItem) {
|
||||
// try {
|
||||
// await CheckpointTracker.deleteCheckpoints(id, historyItem, this.context.globalStorageUri.fsPath)
|
||||
// } catch (error) {
|
||||
// console.error(`Failed to delete checkpoints for task ${id}:`, error)
|
||||
// }
|
||||
// }
|
||||
|
||||
await this.deleteTaskFromState(id)
|
||||
|
||||
// Delete the task files
|
||||
const apiConversationHistoryFileExists = await fileExistsAtPath(apiConversationHistoryFilePath)
|
||||
if (apiConversationHistoryFileExists) {
|
||||
await fs.unlink(apiConversationHistoryFilePath)
|
||||
}
|
||||
const uiMessagesFileExists = await fileExistsAtPath(uiMessagesFilePath)
|
||||
if (uiMessagesFileExists) {
|
||||
await fs.unlink(uiMessagesFilePath)
|
||||
}
|
||||
const legacyMessagesFilePath = path.join(taskDirPath, "claude_messages.json")
|
||||
if (await fileExistsAtPath(legacyMessagesFilePath)) {
|
||||
await fs.unlink(legacyMessagesFilePath)
|
||||
}
|
||||
|
||||
await fs.rmdir(taskDirPath) // succeeds if the dir is empty
|
||||
this.refreshTotalTasksSize()
|
||||
}
|
||||
|
||||
async deleteTaskFromState(id: string) {
|
||||
@@ -1831,6 +1982,8 @@ Here is the project's README to help you get started:\n\n${mcpDetails.readmeCont
|
||||
|
||||
// Notify the webview that the task has been deleted
|
||||
await this.postStateToWebview()
|
||||
|
||||
return updatedTaskHistory
|
||||
}
|
||||
|
||||
async postStateToWebview() {
|
||||
@@ -1969,6 +2122,7 @@ Here is the project's README to help you get started:\n\n${mcpDetails.readmeCont
|
||||
azureApiVersion,
|
||||
openRouterModelId,
|
||||
openRouterModelInfo,
|
||||
openRouterProviderSorting,
|
||||
lastShownAnnouncementId,
|
||||
customInstructions,
|
||||
taskHistory,
|
||||
@@ -1982,6 +2136,7 @@ Here is the project's README to help you get started:\n\n${mcpDetails.readmeCont
|
||||
previousModeApiProvider,
|
||||
previousModeModelId,
|
||||
previousModeModelInfo,
|
||||
previousModeVsCodeLmModelSelector,
|
||||
previousModeThinkingBudgetTokens,
|
||||
qwenApiLine,
|
||||
liteLlmApiKey,
|
||||
@@ -2031,6 +2186,7 @@ Here is the project's README to help you get started:\n\n${mcpDetails.readmeCont
|
||||
this.getGlobalState("azureApiVersion") as Promise<string | undefined>,
|
||||
this.getGlobalState("openRouterModelId") as Promise<string | undefined>,
|
||||
this.getGlobalState("openRouterModelInfo") as Promise<ModelInfo | undefined>,
|
||||
this.getGlobalState("openRouterProviderSorting") as Promise<string | undefined>,
|
||||
this.getGlobalState("lastShownAnnouncementId") as Promise<string | undefined>,
|
||||
this.getGlobalState("customInstructions") as Promise<string | undefined>,
|
||||
this.getGlobalState("taskHistory") as Promise<HistoryItem[] | undefined>,
|
||||
@@ -2044,6 +2200,7 @@ Here is the project's README to help you get started:\n\n${mcpDetails.readmeCont
|
||||
this.getGlobalState("previousModeApiProvider") as Promise<ApiProvider | undefined>,
|
||||
this.getGlobalState("previousModeModelId") as Promise<string | undefined>,
|
||||
this.getGlobalState("previousModeModelInfo") as Promise<ModelInfo | undefined>,
|
||||
this.getGlobalState("previousModeVsCodeLmModelSelector") as Promise<vscode.LanguageModelChatSelector | undefined>,
|
||||
this.getGlobalState("previousModeThinkingBudgetTokens") as Promise<number | undefined>,
|
||||
this.getGlobalState("qwenApiLine") as Promise<string | undefined>,
|
||||
this.getSecret("liteLlmApiKey") as Promise<string | undefined>,
|
||||
@@ -2135,6 +2292,7 @@ Here is the project's README to help you get started:\n\n${mcpDetails.readmeCont
|
||||
azureApiVersion,
|
||||
openRouterModelId,
|
||||
openRouterModelInfo,
|
||||
openRouterProviderSorting,
|
||||
vsCodeLmModelSelector,
|
||||
o3MiniReasoningEffort,
|
||||
thinkingBudgetTokens,
|
||||
@@ -2156,6 +2314,7 @@ Here is the project's README to help you get started:\n\n${mcpDetails.readmeCont
|
||||
previousModeApiProvider,
|
||||
previousModeModelId,
|
||||
previousModeModelInfo,
|
||||
previousModeVsCodeLmModelSelector,
|
||||
previousModeThinkingBudgetTokens,
|
||||
mcpMarketplaceEnabled,
|
||||
telemetrySetting: telemetrySetting || "unset",
|
||||
|
||||
@@ -0,0 +1,288 @@
|
||||
import * as vscode from "vscode"
|
||||
import * as fs from "fs/promises"
|
||||
import * as path from "path"
|
||||
import { ClineProvider } from "../../core/webview/ClineProvider"
|
||||
import { HistoryItem } from "../../shared/HistoryItem"
|
||||
import { ClineMessage } from "../../shared/ExtensionMessage"
|
||||
|
||||
/**
|
||||
* Registers development-only commands for task manipulation.
|
||||
* These are only activated in development mode.
|
||||
*/
|
||||
export function registerTaskCommands(context: vscode.ExtensionContext, provider: ClineProvider): vscode.Disposable[] {
|
||||
return [
|
||||
vscode.commands.registerCommand("cline.dev.createTestTasks", async () => {
|
||||
const count = await vscode.window.showInputBox({
|
||||
title: "Test Tasks",
|
||||
prompt: "How many test tasks to create?",
|
||||
value: "10",
|
||||
})
|
||||
|
||||
if (!count) {
|
||||
return
|
||||
}
|
||||
|
||||
const tasksCount = parseInt(count)
|
||||
const globalStoragePath = context.globalStorageUri.fsPath
|
||||
const tasksDir = path.join(globalStoragePath, "tasks")
|
||||
|
||||
vscode.window.withProgress(
|
||||
{
|
||||
location: vscode.ProgressLocation.Notification,
|
||||
title: `Creating ${tasksCount} test tasks...`,
|
||||
cancellable: false,
|
||||
},
|
||||
async (progress) => {
|
||||
for (let i = 0; i < tasksCount; i++) {
|
||||
// Generate a timestamp to ensure unique IDs
|
||||
const timestamp = Date.now() + i
|
||||
const taskId = `${timestamp}`
|
||||
const taskDir = path.join(tasksDir, taskId)
|
||||
|
||||
await fs.mkdir(taskDir, { recursive: true })
|
||||
|
||||
// Generate a task prompt
|
||||
const taskName = getRandomTaskName(i)
|
||||
|
||||
// Create realistic message sequence
|
||||
const messages = createRealisticMessageSequence(timestamp, taskName, i)
|
||||
|
||||
// Create API conversation history file
|
||||
await fs.writeFile(
|
||||
path.join(taskDir, "api_conversation_history.json"),
|
||||
JSON.stringify(
|
||||
[
|
||||
{
|
||||
role: "user",
|
||||
content: [{ type: "text", text: `<task>\n${taskName}\n</task>` }],
|
||||
},
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `I'll help you ${taskName.toLowerCase()}. Let me break this down into steps.`,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
null,
|
||||
2,
|
||||
),
|
||||
)
|
||||
|
||||
// Create UI messages file with realistic message sequence
|
||||
await fs.writeFile(path.join(taskDir, "ui_messages.json"), JSON.stringify(messages, null, 2))
|
||||
|
||||
// Create history item to be shown in the HistoryView
|
||||
const historyItem: HistoryItem = {
|
||||
id: taskId,
|
||||
ts: timestamp,
|
||||
task: taskName,
|
||||
tokensIn: Math.floor(100 + Math.random() * 900), // Random token count from 100-1000
|
||||
tokensOut: Math.floor(200 + Math.random() * 1800), // Random token count from 200-2000
|
||||
cacheWrites: i % 3 === 0 ? Math.floor(50 + Math.random() * 150) : undefined, // Only add cache writes to every 3rd task
|
||||
cacheReads: i % 3 === 0 ? Math.floor(20 + Math.random() * 80) : undefined, // Only add cache reads to every 3rd task
|
||||
totalCost: Number((0.0001 + Math.random() * 0.01).toFixed(5)), // Random cost from $0.0001 to $0.0101
|
||||
size: 1024 * 1024, // 1MB
|
||||
}
|
||||
|
||||
// Update task history in global state
|
||||
await provider.updateTaskHistory(historyItem)
|
||||
|
||||
progress.report({ increment: 100 / tasksCount })
|
||||
}
|
||||
|
||||
// Update the UI to show the new tasks
|
||||
await provider.postStateToWebview()
|
||||
|
||||
vscode.window.showInformationMessage(`Created ${tasksCount} test tasks`)
|
||||
},
|
||||
)
|
||||
}),
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a realistic sequence of messages that would occur in a typical task
|
||||
*/
|
||||
function createRealisticMessageSequence(baseTimestamp: number, taskPrompt: string, taskIndex: number): ClineMessage[] {
|
||||
// Use an incrementing timestamp to ensure messages appear in sequence
|
||||
let timestamp = baseTimestamp
|
||||
const getNextTimestamp = () => {
|
||||
timestamp += 1000 // Add 1 second between messages
|
||||
return timestamp
|
||||
}
|
||||
|
||||
// Variables to make different test tasks look unique
|
||||
const fileName = getRandomFileName(taskIndex)
|
||||
const commitHash = `commit${taskIndex}${Math.floor(Math.random() * 1000000).toString(16)}`
|
||||
|
||||
// Create a realistic message sequence
|
||||
const messages: ClineMessage[] = [
|
||||
// Initial task message - uses "say" with "text" which is the format used in Cline.ts
|
||||
{
|
||||
ts: baseTimestamp,
|
||||
type: "say",
|
||||
say: "text",
|
||||
text: taskPrompt,
|
||||
},
|
||||
|
||||
// API request started
|
||||
{
|
||||
ts: getNextTimestamp(),
|
||||
type: "say",
|
||||
say: "api_req_started",
|
||||
text: JSON.stringify({
|
||||
request: `<task>\n${taskPrompt}\n</task>`,
|
||||
tokensIn: Math.floor(100 + Math.random() * 200),
|
||||
tokensOut: Math.floor(300 + Math.random() * 500),
|
||||
}),
|
||||
},
|
||||
|
||||
// Reasoning message
|
||||
{
|
||||
ts: getNextTimestamp(),
|
||||
type: "say",
|
||||
say: "reasoning",
|
||||
text: `I'll approach this task by breaking it down into manageable steps. First, I'll analyze the requirements, then create a plan, and finally implement the solution systematically.`,
|
||||
},
|
||||
|
||||
// Text response
|
||||
{
|
||||
ts: getNextTimestamp(),
|
||||
type: "say",
|
||||
say: "text",
|
||||
text: `I'll help you with this task. Let me start by creating the necessary files and implementing the core functionality.`,
|
||||
},
|
||||
]
|
||||
|
||||
// Add task-specific messages based on index modulo to create variety
|
||||
const messageType = taskIndex % 5
|
||||
|
||||
if (messageType === 0 || messageType === 2) {
|
||||
// Tool use - file operations
|
||||
messages.push({
|
||||
ts: getNextTimestamp(),
|
||||
type: "say",
|
||||
say: "tool",
|
||||
text: JSON.stringify({
|
||||
tool: "newFileCreated",
|
||||
path: fileName,
|
||||
content: `// Sample code for ${taskPrompt}`,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
if (messageType === 1 || messageType === 3) {
|
||||
// Command execution
|
||||
messages.push(
|
||||
{
|
||||
ts: getNextTimestamp(),
|
||||
type: "ask",
|
||||
ask: "command",
|
||||
text: `ls -la`,
|
||||
},
|
||||
{
|
||||
ts: getNextTimestamp(),
|
||||
type: "say",
|
||||
say: "command_output",
|
||||
text: `total 24\ndrwxr-xr-x 3 user staff 96 Mar 10 12:34 .\ndrwxr-xr-x 8 user staff 256 Mar 10 12:30 ..\n-rw-r--r-- 1 user staff 158 Mar 10 12:34 ${fileName}`,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
if (messageType === 2 || messageType === 4) {
|
||||
// Browser actions
|
||||
messages.push(
|
||||
{
|
||||
ts: getNextTimestamp(),
|
||||
type: "ask",
|
||||
ask: "browser_action_launch",
|
||||
text: `https://example.com`,
|
||||
},
|
||||
{
|
||||
ts: getNextTimestamp(),
|
||||
type: "say",
|
||||
say: "browser_action_result",
|
||||
text: JSON.stringify({
|
||||
logs: "Page loaded successfully",
|
||||
screenshot:
|
||||
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==",
|
||||
}),
|
||||
},
|
||||
{
|
||||
ts: getNextTimestamp(),
|
||||
type: "say",
|
||||
say: "browser_action",
|
||||
text: JSON.stringify({
|
||||
action: "close",
|
||||
}),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// Add checkpoint
|
||||
messages.push({
|
||||
ts: getNextTimestamp(),
|
||||
type: "say",
|
||||
say: "checkpoint_created",
|
||||
lastCheckpointHash: commitHash,
|
||||
})
|
||||
|
||||
// Add completion result (all tasks end with this)
|
||||
messages.push({
|
||||
ts: getNextTimestamp(),
|
||||
type: "say",
|
||||
say: "completion_result",
|
||||
text: `I've completed the task to ${taskPrompt.toLowerCase()}. The implementation includes all the required functionality and meets the specifications. ${"x".repeat(1024 * 1024)}`, // 1MB file
|
||||
lastCheckpointHash: commitHash,
|
||||
})
|
||||
|
||||
return messages
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a random task name for test data
|
||||
*/
|
||||
function getRandomTaskName(index: number): string {
|
||||
const tasks = [
|
||||
"Create a simple todo application",
|
||||
"Build a weather forecast widget",
|
||||
"Implement a markdown parser",
|
||||
"Design a responsive landing page",
|
||||
"Develop a currency converter",
|
||||
"Create a file upload component",
|
||||
"Build a data visualization dashboard",
|
||||
"Implement a search functionality",
|
||||
"Create a user authentication system",
|
||||
"Design a dark mode toggle",
|
||||
"Build a countdown timer",
|
||||
"Create a drag and drop interface",
|
||||
"Implement form validation",
|
||||
"Design a multi-step wizard",
|
||||
"Create a notification system",
|
||||
]
|
||||
|
||||
return tasks[index % tasks.length] + ` (Test ${index + 1})`
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a random file name for test data
|
||||
*/
|
||||
function getRandomFileName(index: number): string {
|
||||
const files = [
|
||||
"index.html",
|
||||
"styles.css",
|
||||
"script.js",
|
||||
"app.jsx",
|
||||
"main.ts",
|
||||
"utils.py",
|
||||
"config.json",
|
||||
"server.js",
|
||||
"data.csv",
|
||||
"README.md",
|
||||
]
|
||||
|
||||
return files[index % files.length]
|
||||
}
|
||||
+171
-4
@@ -1,6 +1,6 @@
|
||||
// The module 'vscode' contains the VS Code extensibility API
|
||||
// Import the module and reference it with the alias vscode in your code below
|
||||
import delay from "delay"
|
||||
import { setTimeout as setTimeoutPromise } from "node:timers/promises"
|
||||
import * as vscode from "vscode"
|
||||
import { ClineProvider } from "./core/webview/ClineProvider"
|
||||
import { Logger } from "./services/logging/Logger"
|
||||
@@ -32,6 +32,8 @@ export function activate(context: vscode.ExtensionContext) {
|
||||
|
||||
const sidebarProvider = new ClineProvider(context, outputChannel)
|
||||
|
||||
vscode.commands.executeCommand("setContext", "cline.isDevMode", IS_DEV && IS_DEV === "true")
|
||||
|
||||
context.subscriptions.push(
|
||||
vscode.window.registerWebviewViewProvider(ClineProvider.sideBarId, sidebarProvider, {
|
||||
webviewOptions: { retainContextWhenHidden: true },
|
||||
@@ -88,7 +90,7 @@ export function activate(context: vscode.ExtensionContext) {
|
||||
tabProvider.resolveWebviewView(panel)
|
||||
|
||||
// Lock the editor group so clicking on files doesn't open them over the panel
|
||||
await delay(100)
|
||||
await setTimeoutPromise(100)
|
||||
await vscode.commands.executeCommand("workbench.action.lockEditorGroup")
|
||||
}
|
||||
|
||||
@@ -115,14 +117,20 @@ export function activate(context: vscode.ExtensionContext) {
|
||||
)
|
||||
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand("cline.accountLoginClicked", () => {
|
||||
vscode.commands.registerCommand("cline.accountButtonClicked", () => {
|
||||
sidebarProvider.postMessageToWebview({
|
||||
type: "action",
|
||||
action: "accountLoginClicked",
|
||||
action: "accountButtonClicked",
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand("cline.openDocumentation", () => {
|
||||
vscode.env.openExternal(vscode.Uri.parse("https://docs.cline.bot/"))
|
||||
}),
|
||||
)
|
||||
|
||||
/*
|
||||
We use the text document content provider API to show the left side for diff view by creating a virtual document for the original content. This makes it readonly so users know to edit the right side if they want to keep their changes.
|
||||
|
||||
@@ -187,6 +195,165 @@ export function activate(context: vscode.ExtensionContext) {
|
||||
}
|
||||
context.subscriptions.push(vscode.window.registerUriHandler({ handleUri }))
|
||||
|
||||
// Register size testing commands in development mode
|
||||
if (IS_DEV && IS_DEV === "true") {
|
||||
// Use dynamic import to avoid loading the module in production
|
||||
import("./dev/commands/tasks")
|
||||
.then((module) => {
|
||||
const devTaskCommands = module.registerTaskCommands(context, sidebarProvider)
|
||||
context.subscriptions.push(...devTaskCommands)
|
||||
Logger.log("Cline dev task commands registered")
|
||||
})
|
||||
.catch((error) => {
|
||||
Logger.log("Failed to register dev task commands: " + error)
|
||||
})
|
||||
}
|
||||
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand("cline.addToChat", async (range?: vscode.Range, diagnostics?: vscode.Diagnostic[]) => {
|
||||
const editor = vscode.window.activeTextEditor
|
||||
if (!editor) {
|
||||
return
|
||||
}
|
||||
|
||||
// Use provided range if available, otherwise use current selection
|
||||
// (vscode command passes an argument in the first param by default, so we need to ensure it's a Range object)
|
||||
const textRange = range instanceof vscode.Range ? range : editor.selection
|
||||
const selectedText = editor.document.getText(textRange)
|
||||
|
||||
if (!selectedText) {
|
||||
return
|
||||
}
|
||||
|
||||
// Get the file path and language ID
|
||||
const filePath = editor.document.uri.fsPath
|
||||
const languageId = editor.document.languageId
|
||||
|
||||
// Send to sidebar provider
|
||||
await sidebarProvider.addSelectedCodeToChat(
|
||||
selectedText,
|
||||
filePath,
|
||||
languageId,
|
||||
Array.isArray(diagnostics) ? diagnostics : undefined,
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand("cline.addTerminalOutputToChat", async () => {
|
||||
const terminal = vscode.window.activeTerminal
|
||||
if (!terminal) {
|
||||
return
|
||||
}
|
||||
|
||||
// Save current clipboard content
|
||||
const tempCopyBuffer = await vscode.env.clipboard.readText()
|
||||
|
||||
try {
|
||||
// Copy the *existing* terminal selection (without selecting all)
|
||||
await vscode.commands.executeCommand("workbench.action.terminal.copySelection")
|
||||
|
||||
// Get copied content
|
||||
let terminalContents = (await vscode.env.clipboard.readText()).trim()
|
||||
|
||||
// Restore original clipboard content
|
||||
await vscode.env.clipboard.writeText(tempCopyBuffer)
|
||||
|
||||
if (!terminalContents) {
|
||||
// No terminal content was copied (either nothing selected or some error)
|
||||
return
|
||||
}
|
||||
|
||||
// [Optional] Any additional logic to process multi-line content can remain here
|
||||
// For example:
|
||||
/*
|
||||
const lines = terminalContents.split("\n")
|
||||
const lastLine = lines.pop()?.trim()
|
||||
if (lastLine) {
|
||||
let i = lines.length - 1
|
||||
while (i >= 0 && !lines[i].trim().startsWith(lastLine)) {
|
||||
i--
|
||||
}
|
||||
terminalContents = lines.slice(Math.max(i, 0)).join("\n")
|
||||
}
|
||||
*/
|
||||
|
||||
// Send to sidebar provider
|
||||
await sidebarProvider.addSelectedTerminalOutputToChat(terminalContents, terminal.name)
|
||||
} catch (error) {
|
||||
// Ensure clipboard is restored even if an error occurs
|
||||
await vscode.env.clipboard.writeText(tempCopyBuffer)
|
||||
console.error("Error getting terminal contents:", error)
|
||||
vscode.window.showErrorMessage("Failed to get terminal contents")
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
// Register code action provider
|
||||
context.subscriptions.push(
|
||||
vscode.languages.registerCodeActionsProvider(
|
||||
"*",
|
||||
new (class implements vscode.CodeActionProvider {
|
||||
public static readonly providedCodeActionKinds = [vscode.CodeActionKind.QuickFix]
|
||||
|
||||
provideCodeActions(
|
||||
document: vscode.TextDocument,
|
||||
range: vscode.Range,
|
||||
context: vscode.CodeActionContext,
|
||||
): vscode.CodeAction[] {
|
||||
// Expand range to include surrounding 3 lines
|
||||
const expandedRange = new vscode.Range(
|
||||
Math.max(0, range.start.line - 3),
|
||||
0,
|
||||
Math.min(document.lineCount - 1, range.end.line + 3),
|
||||
document.lineAt(Math.min(document.lineCount - 1, range.end.line + 3)).text.length,
|
||||
)
|
||||
|
||||
const addAction = new vscode.CodeAction("Add to Cline", vscode.CodeActionKind.QuickFix)
|
||||
addAction.command = {
|
||||
command: "cline.addToChat",
|
||||
title: "Add to Cline",
|
||||
arguments: [expandedRange, context.diagnostics],
|
||||
}
|
||||
|
||||
const fixAction = new vscode.CodeAction("Fix with Cline", vscode.CodeActionKind.QuickFix)
|
||||
fixAction.command = {
|
||||
command: "cline.fixWithCline",
|
||||
title: "Fix with Cline",
|
||||
arguments: [expandedRange, context.diagnostics],
|
||||
}
|
||||
|
||||
// Only show actions when there are errors
|
||||
if (context.diagnostics.length > 0) {
|
||||
return [addAction, fixAction]
|
||||
} else {
|
||||
return []
|
||||
}
|
||||
}
|
||||
})(),
|
||||
{
|
||||
providedCodeActionKinds: [vscode.CodeActionKind.QuickFix],
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
// Register the command handler
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand("cline.fixWithCline", async (range: vscode.Range, diagnostics: any[]) => {
|
||||
const editor = vscode.window.activeTextEditor
|
||||
if (!editor) {
|
||||
return
|
||||
}
|
||||
|
||||
const selectedText = editor.document.getText(range)
|
||||
const filePath = editor.document.uri.fsPath
|
||||
const languageId = editor.document.languageId
|
||||
|
||||
// Send to sidebar provider with diagnostics
|
||||
await sidebarProvider.fixWithCline(selectedText, filePath, languageId, diagnostics)
|
||||
}),
|
||||
)
|
||||
|
||||
return createClineAPI(outputChannel, sidebarProvider)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
// NOTE: These are here temporarily until we find a better home for them
|
||||
export const GlobalFileNames = {
|
||||
apiConversationHistory: "api_conversation_history.json",
|
||||
uiMessages: "ui_messages.json",
|
||||
openRouterModels: "openrouter_models.json",
|
||||
mcpSettings: "cline_mcp_settings.json",
|
||||
clineRules: ".clinerules",
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import * as path from "path"
|
||||
import simpleGit, { SimpleGit } from "simple-git"
|
||||
import { fileExistsAtPath } from "../../utils/fs"
|
||||
import { getLfsPatterns, writeExcludesFile } from "./CheckpointExclusions"
|
||||
import { telemetryService } from "../../services/telemetry/TelemetryService"
|
||||
|
||||
interface CheckpointAddResult {
|
||||
success: boolean
|
||||
@@ -53,7 +54,7 @@ export class GitOperations {
|
||||
* - Unable to create initial commit
|
||||
* - LFS pattern setup fails
|
||||
*/
|
||||
public async initShadowGit(gitPath: string, cwd: string): Promise<string> {
|
||||
public async initShadowGit(gitPath: string, cwd: string, taskId: string): Promise<string> {
|
||||
console.info(`Initializing shadow git`)
|
||||
|
||||
// If repo exists, just verify worktree
|
||||
@@ -72,6 +73,7 @@ export class GitOperations {
|
||||
}
|
||||
|
||||
// Initialize new repo
|
||||
const startTime = performance.now()
|
||||
const checkpointsDir = path.dirname(gitPath)
|
||||
console.warn(`Creating new shadow git in ${checkpointsDir}`)
|
||||
|
||||
@@ -93,6 +95,9 @@ export class GitOperations {
|
||||
// Initial commit only on first repo creation
|
||||
await git.commit("initial commit", { "--allow-empty": null })
|
||||
|
||||
const durationMs = Math.round(performance.now() - startTime)
|
||||
telemetryService.captureCheckpointUsage(taskId, "shadow_git_initialized", durationMs)
|
||||
|
||||
console.warn(`Shadow git initialization completed`)
|
||||
|
||||
return gitPath
|
||||
@@ -179,6 +184,7 @@ export class GitOperations {
|
||||
* - Nested git repo handling fails
|
||||
*/
|
||||
public async addCheckpointFiles(git: SimpleGit): Promise<CheckpointAddResult> {
|
||||
const startTime = performance.now()
|
||||
try {
|
||||
// Update exclude patterns before each commit
|
||||
await this.renameNestedGitRepos(true)
|
||||
@@ -186,6 +192,8 @@ export class GitOperations {
|
||||
|
||||
try {
|
||||
await git.add(".")
|
||||
const durationMs = Math.round(performance.now() - startTime)
|
||||
console.debug(`Checkpoint add operation completed in ${durationMs}ms`)
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
console.error("Checkpoint add operation failed:", error)
|
||||
|
||||
@@ -88,6 +88,7 @@ class CheckpointTracker {
|
||||
}
|
||||
try {
|
||||
console.info(`Creating new CheckpointTracker for task ${taskId}`)
|
||||
const startTime = performance.now()
|
||||
|
||||
// Check if checkpoints are disabled in VS Code settings
|
||||
const enableCheckpoints = vscode.workspace.getConfiguration("cline").get<boolean>("enableCheckpoints") ?? true
|
||||
@@ -109,9 +110,10 @@ class CheckpointTracker {
|
||||
const newTracker = new CheckpointTracker(globalStoragePath, taskId, workingDir, cwdHash)
|
||||
|
||||
const gitPath = await getShadowGitPath(newTracker.globalStoragePath, newTracker.taskId, newTracker.cwdHash)
|
||||
await newTracker.gitOperations.initShadowGit(gitPath, workingDir)
|
||||
await newTracker.gitOperations.initShadowGit(gitPath, workingDir, taskId)
|
||||
|
||||
telemetryService.captureCheckpointUsage(taskId, "shadow_git_initialized")
|
||||
const durationMs = Math.round(performance.now() - startTime)
|
||||
telemetryService.captureCheckpointUsage(taskId, "shadow_git_initialized", durationMs)
|
||||
|
||||
return newTracker
|
||||
} catch (error) {
|
||||
@@ -148,6 +150,8 @@ class CheckpointTracker {
|
||||
public async commit(): Promise<string | undefined> {
|
||||
try {
|
||||
console.info(`Creating new checkpoint commit for task ${this.taskId}`)
|
||||
const startTime = performance.now()
|
||||
|
||||
const gitPath = await getShadowGitPath(this.globalStoragePath, this.taskId, this.cwdHash)
|
||||
const git = simpleGit(path.dirname(gitPath))
|
||||
|
||||
@@ -160,10 +164,14 @@ class CheckpointTracker {
|
||||
console.info(`Creating checkpoint commit with message: ${commitMessage}`)
|
||||
const result = await git.commit(commitMessage, {
|
||||
"--allow-empty": null,
|
||||
"--no-verify": null,
|
||||
})
|
||||
const commitHash = result.commit || ""
|
||||
console.warn(`Checkpoint commit created.`)
|
||||
telemetryService.captureCheckpointUsage(this.taskId, "commit_created")
|
||||
|
||||
const durationMs = Math.round(performance.now() - startTime)
|
||||
telemetryService.captureCheckpointUsage(this.taskId, "commit_created", durationMs)
|
||||
|
||||
return commitHash
|
||||
} catch (error) {
|
||||
console.error("Failed to create checkpoint:", {
|
||||
@@ -229,12 +237,16 @@ class CheckpointTracker {
|
||||
*/
|
||||
public async resetHead(commitHash: string): Promise<void> {
|
||||
console.info(`Resetting to checkpoint: ${commitHash}`)
|
||||
const startTime = performance.now()
|
||||
|
||||
const gitPath = await getShadowGitPath(this.globalStoragePath, this.taskId, this.cwdHash)
|
||||
const git = simpleGit(path.dirname(gitPath))
|
||||
console.debug(`Using shadow git at: ${gitPath}`)
|
||||
await git.reset(["--hard", commitHash]) // Hard reset to target commit
|
||||
console.debug(`Successfully reset to checkpoint: ${commitHash}`)
|
||||
telemetryService.captureCheckpointUsage(this.taskId, "restored")
|
||||
|
||||
const durationMs = Math.round(performance.now() - startTime)
|
||||
telemetryService.captureCheckpointUsage(this.taskId, "restored", durationMs)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -261,6 +273,8 @@ class CheckpointTracker {
|
||||
after: string
|
||||
}>
|
||||
> {
|
||||
const startTime = performance.now()
|
||||
|
||||
const gitPath = await getShadowGitPath(this.globalStoragePath, this.taskId, this.cwdHash)
|
||||
const git = simpleGit(path.dirname(gitPath))
|
||||
|
||||
@@ -308,6 +322,9 @@ class CheckpointTracker {
|
||||
})
|
||||
}
|
||||
|
||||
const durationMs = Math.round(performance.now() - startTime)
|
||||
telemetryService.captureCheckpointUsage(this.taskId, "diff_generated", durationMs)
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -320,6 +337,8 @@ class CheckpointTracker {
|
||||
* @returns The number of files changed between the commits
|
||||
*/
|
||||
public async getDiffCount(lhsHash: string, rhsHash?: string): Promise<number> {
|
||||
const startTime = performance.now()
|
||||
|
||||
const gitPath = await getShadowGitPath(this.globalStoragePath, this.taskId, this.cwdHash)
|
||||
const git = simpleGit(path.dirname(gitPath))
|
||||
|
||||
@@ -331,6 +350,9 @@ class CheckpointTracker {
|
||||
const diffRange = rhsHash ? `${lhsHash}..${rhsHash}` : lhsHash
|
||||
const diffSummary = await git.diffSummary([diffRange])
|
||||
|
||||
const durationMs = Math.round(performance.now() - startTime)
|
||||
telemetryService.captureCheckpointUsage(this.taskId, "diff_generated", durationMs)
|
||||
|
||||
return diffSummary.files.length
|
||||
}
|
||||
}
|
||||
|
||||
@@ -158,7 +158,6 @@ export class DiffViewProvider {
|
||||
await updatedDocument.save()
|
||||
}
|
||||
|
||||
// await delay(100)
|
||||
// get text after save in case there is any auto-formatting done by the editor
|
||||
const postSaveContent = updatedDocument.getText()
|
||||
|
||||
|
||||
@@ -63,7 +63,6 @@ export async function fetchOpenGraphData(url: string): Promise<OpenGraphData> {
|
||||
type: data.ogType,
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error fetching Open Graph data for ${url}:`, error)
|
||||
// Return basic information based on the URL
|
||||
try {
|
||||
const urlObj = new URL(url)
|
||||
@@ -100,8 +99,7 @@ export async function isImageUrl(url: string): Promise<boolean> {
|
||||
const contentType = response.headers["content-type"]
|
||||
return contentType && contentType.startsWith("image/")
|
||||
} catch (error) {
|
||||
console.error(`Error checking if URL is an image: ${url}`, error)
|
||||
// If we can't determine, fall back to checking the file extension
|
||||
return /\.(jpg|jpeg|png|gif|webp|svg)$/i.test(url)
|
||||
return /\.(jpg|jpeg|png|gif|webp|bmp|svg|tiff|tif|avif)$/i.test(url)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import axios, { AxiosRequestConfig, AxiosResponse } from "axios"
|
||||
import { ClineProvider } from "../../core/webview/ClineProvider"
|
||||
import type { BalanceResponse, PaymentTransaction, UsageTransaction } from "../../shared/ClineAccount"
|
||||
|
||||
export class ClineAccountService {
|
||||
private readonly baseUrl = "https://api.cline.bot/v1"
|
||||
private providerRef: WeakRef<ClineProvider>
|
||||
|
||||
constructor(provider: ClineProvider) {
|
||||
this.providerRef = new WeakRef(provider)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the user's Cline Account key from the apiConfiguration
|
||||
*/
|
||||
private async getClineApiKey(): Promise<string | undefined> {
|
||||
const provider = this.providerRef.deref()
|
||||
if (!provider) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const { apiConfiguration } = await provider.getStateToPostToWebview()
|
||||
return apiConfiguration?.clineApiKey
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to make authenticated requests to the Cline API
|
||||
* @param endpoint The API endpoint to call (without the base URL)
|
||||
* @param config Additional axios request configuration
|
||||
* @returns The API response data
|
||||
* @throws Error if the API key is not found or the request fails
|
||||
*/
|
||||
private async authenticatedRequest<T>(endpoint: string, config: AxiosRequestConfig = {}): Promise<T> {
|
||||
const clineApiKey = await this.getClineApiKey()
|
||||
|
||||
if (!clineApiKey) {
|
||||
throw new Error("Cline API key not found")
|
||||
}
|
||||
|
||||
const url = `${this.baseUrl}${endpoint}`
|
||||
const requestConfig: AxiosRequestConfig = {
|
||||
...config,
|
||||
headers: {
|
||||
Authorization: `Bearer ${clineApiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
...config.headers,
|
||||
},
|
||||
}
|
||||
|
||||
const response: AxiosResponse<T> = await axios.get(url, requestConfig)
|
||||
|
||||
if (!response.data) {
|
||||
throw new Error(`Invalid response from ${endpoint} API`)
|
||||
}
|
||||
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the user's current credit balance
|
||||
*/
|
||||
async fetchBalance(): Promise<BalanceResponse | undefined> {
|
||||
try {
|
||||
const data = await this.authenticatedRequest<BalanceResponse>("/user/credits/balance")
|
||||
|
||||
// Post to webview
|
||||
await this.providerRef.deref()?.postMessageToWebview({
|
||||
type: "userCreditsBalance",
|
||||
userCreditsBalance: data,
|
||||
})
|
||||
|
||||
return data
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch balance:", error)
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the user's usage transactions
|
||||
*/
|
||||
async fetchUsageTransactions(): Promise<UsageTransaction[] | undefined> {
|
||||
try {
|
||||
const data = await this.authenticatedRequest<UsageTransaction[]>("/user/credits/usage")
|
||||
|
||||
// Post to webview
|
||||
await this.providerRef.deref()?.postMessageToWebview({
|
||||
type: "userCreditsUsage",
|
||||
userCreditsUsage: data,
|
||||
})
|
||||
|
||||
return data
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch usage transactions:", error)
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the user's payment transactions
|
||||
*/
|
||||
async fetchPaymentTransactions(): Promise<PaymentTransaction[] | undefined> {
|
||||
try {
|
||||
const data = await this.authenticatedRequest<PaymentTransaction[]>("/user/credits/payments")
|
||||
|
||||
// Post to webview
|
||||
await this.providerRef.deref()?.postMessageToWebview({
|
||||
type: "userCreditsPayments",
|
||||
userCreditsPayments: data,
|
||||
})
|
||||
|
||||
return data
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch payment transactions:", error)
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import { Browser, Page, ScreenshotOptions, TimeoutError, launch } from "puppetee
|
||||
// @ts-ignore
|
||||
import PCR from "puppeteer-chromium-resolver"
|
||||
import pWaitFor from "p-wait-for"
|
||||
import delay from "delay"
|
||||
import { setTimeout as setTimeoutPromise } from "node:timers/promises"
|
||||
import { fileExistsAtPath } from "../../utils/fs"
|
||||
import { BrowserActionResult } from "../../shared/ExtensionMessage"
|
||||
import { BrowserSettings } from "../../shared/BrowserSettings"
|
||||
@@ -295,7 +295,7 @@ export class BrowserSession {
|
||||
}
|
||||
|
||||
lastHTMLSize = currentHTMLSize
|
||||
await delay(checkDurationMsecs)
|
||||
await setTimeoutPromise(checkDurationMsecs)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -314,7 +314,7 @@ export class BrowserSession {
|
||||
this.currentMousePosition = coordinate
|
||||
|
||||
// Small delay to check if click triggered any network activity
|
||||
await delay(100)
|
||||
await setTimeoutPromise(100)
|
||||
|
||||
if (hasNetworkActivity) {
|
||||
// If we detected network activity, wait for navigation/loading
|
||||
@@ -346,7 +346,7 @@ export class BrowserSession {
|
||||
behavior: "auto",
|
||||
})
|
||||
})
|
||||
await delay(300)
|
||||
await setTimeoutPromise(300)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -358,7 +358,7 @@ export class BrowserSession {
|
||||
behavior: "auto",
|
||||
})
|
||||
})
|
||||
await delay(300)
|
||||
await setTimeoutPromise(300)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import * as path from "path"
|
||||
import { arePathsEqual } from "../../utils/path"
|
||||
|
||||
export async function listFiles(dirPath: string, recursive: boolean, limit: number): Promise<[string[], boolean]> {
|
||||
// First resolve the path normally - path.resolve doesn't care about glob special characters
|
||||
const absolutePath = path.resolve(dirPath)
|
||||
// Do not allow listing files in root or home directory, which cline tends to want to do when the user's prompt is vague.
|
||||
const root = process.platform === "win32" ? path.parse(absolutePath).root : "/"
|
||||
@@ -48,6 +49,7 @@ export async function listFiles(dirPath: string, recursive: boolean, limit: numb
|
||||
}
|
||||
|
||||
// * globs all files in one dir, ** globs files in nested directories
|
||||
// For non-recursive listing, we still use a simple pattern
|
||||
const filePaths = recursive ? await globbyLevelByLevel(limit, options) : (await globby("*", options)).slice(0, limit)
|
||||
|
||||
return [filePaths, filePaths.length >= limit]
|
||||
@@ -80,7 +82,11 @@ async function globbyLevelByLevel(limit: number, options?: Options) {
|
||||
}
|
||||
results.add(file)
|
||||
if (file.endsWith("/")) {
|
||||
queue.push(`${file}*`)
|
||||
// Escape parentheses in the path to prevent glob pattern interpretation
|
||||
// This is crucial for NextJS folder naming conventions which use parentheses like (auth), (dashboard)
|
||||
// Without escaping, glob treats parentheses as special pattern grouping characters
|
||||
const escapedFile = file.replace(/\(/g, "\\(").replace(/\)/g, "\\)")
|
||||
queue.push(`${escapedFile}*`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,13 +8,13 @@ import {
|
||||
ReadResourceResultSchema,
|
||||
} from "@modelcontextprotocol/sdk/types.js"
|
||||
import chokidar, { FSWatcher } from "chokidar"
|
||||
import delay from "delay"
|
||||
import { setTimeout as setTimeoutPromise } from "node:timers/promises"
|
||||
import deepEqual from "fast-deep-equal"
|
||||
import * as fs from "fs/promises"
|
||||
import * as path from "path"
|
||||
import * as vscode from "vscode"
|
||||
import { z } from "zod"
|
||||
import { ClineProvider, GlobalFileNames } from "../../core/webview/ClineProvider"
|
||||
import { ClineProvider } from "../../core/webview/ClineProvider"
|
||||
import {
|
||||
DEFAULT_MCP_TIMEOUT_SECONDS,
|
||||
McpMode,
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
import { fileExistsAtPath } from "../../utils/fs"
|
||||
import { arePathsEqual } from "../../utils/path"
|
||||
import { secondsToMs } from "../../utils/time"
|
||||
import { GlobalFileNames } from "../../global-constants"
|
||||
export type McpConnection = {
|
||||
server: McpServer
|
||||
client: Client
|
||||
@@ -414,7 +415,7 @@ export class McpHub {
|
||||
connection.server.status = "connecting"
|
||||
connection.server.error = ""
|
||||
await this.notifyWebviewOfServerChanges()
|
||||
await delay(500) // artificial delay to show user that server is restarting
|
||||
await setTimeoutPromise(500) // artificial delay to show user that server is restarting
|
||||
try {
|
||||
await this.deleteConnection(serverName)
|
||||
// Try to connect again using existing config
|
||||
|
||||
@@ -0,0 +1,312 @@
|
||||
import { context, SpanKind, trace } from "@opentelemetry/api"
|
||||
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http"
|
||||
import { Resource } from "@opentelemetry/resources"
|
||||
import { SimpleSpanProcessor } from "@opentelemetry/sdk-trace-base"
|
||||
import { NodeTracerProvider } from "@opentelemetry/sdk-trace-node"
|
||||
import { ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION } from "@opentelemetry/semantic-conventions"
|
||||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
import * as vscode from "vscode"
|
||||
import { ClineProvider } from "../../core/webview/ClineProvider"
|
||||
|
||||
export type TelemetryChatMessage = {
|
||||
role: "user" | "assistant" | "system"
|
||||
ts: number
|
||||
content: Anthropic.Messages.MessageParam["content"]
|
||||
}
|
||||
|
||||
interface ConversationMetadata {
|
||||
apiProvider?: string
|
||||
model?: string
|
||||
tokensIn: number
|
||||
tokensOut: number
|
||||
}
|
||||
|
||||
const { IS_DEV } = process.env
|
||||
|
||||
/**
|
||||
Cline Telemetry (currently only available in DEV builds)
|
||||
|
||||
Advanced Setting to opt-in to LLM observability, allowing you to share message data, code, and more extensive telemetry to help improve prompts used in Cline, train our models, and understand failure states more accurately.
|
||||
|
||||
"cline.conversationTelemetry": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"markdownDescription": "Share message data, code, and more extensive telemetry. This data may be used to improve prompts used in Cline, train models, and understand failure states more accurately. [Learn more](https://docs.cline.bot/more-info/llm-observability)"
|
||||
}
|
||||
*/
|
||||
|
||||
export class ConversationTelemetryService {
|
||||
private providerRef: WeakRef<ClineProvider>
|
||||
private distinctId: string = vscode.env.machineId
|
||||
private apiEndpoint: string = "https://api.cline.bot/v1/traces"
|
||||
private tracerProvider: NodeTracerProvider | undefined
|
||||
private tracer: any
|
||||
private messageIndices: Map<string, number> = new Map()
|
||||
|
||||
constructor(provider: ClineProvider) {
|
||||
this.providerRef = new WeakRef(provider)
|
||||
}
|
||||
|
||||
private async getClineApiKey(): Promise<string | undefined> {
|
||||
const provider = this.providerRef.deref()
|
||||
if (!provider) {
|
||||
return undefined
|
||||
}
|
||||
const { apiConfiguration } = await provider.getStateToPostToWebview()
|
||||
return apiConfiguration?.clineApiKey
|
||||
}
|
||||
|
||||
public isOptedInToConversationTelemetry(): boolean {
|
||||
// First check global telemetry level - telemetry should only be enabled when level is "all"
|
||||
const telemetryLevel = vscode.workspace.getConfiguration("telemetry").get<string>("telemetryLevel", "all")
|
||||
const isGlobalTelemetryEnabled = telemetryLevel === "all"
|
||||
|
||||
// User has to manually opt in to conversation telemetry in Advanced Settings
|
||||
const isConversationTelemetryEnabled =
|
||||
vscode.workspace.getConfiguration("cline").get<boolean>("conversationTelemetry") ?? false
|
||||
|
||||
// Currently only enabled in dev environment
|
||||
const isDevEnvironment = !!IS_DEV
|
||||
|
||||
return isDevEnvironment && isGlobalTelemetryEnabled && isConversationTelemetryEnabled
|
||||
}
|
||||
|
||||
private async initializeTracer() {
|
||||
try {
|
||||
// Create a resource that identifies our service
|
||||
const resource = new Resource({
|
||||
[ATTR_SERVICE_NAME]: "cline-extension",
|
||||
[ATTR_SERVICE_VERSION]: "1.0.0",
|
||||
})
|
||||
|
||||
const clineApiKey = await this.getClineApiKey()
|
||||
|
||||
console.log("[ConversationTelemetry] Initializing OpenTelemetry tracer...")
|
||||
|
||||
// Configure the OTLP exporter
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
// Add API key to headers if available
|
||||
if (clineApiKey) {
|
||||
headers["Authorization"] = `Bearer ${clineApiKey}`
|
||||
}
|
||||
|
||||
const exporter = new OTLPTraceExporter({
|
||||
url: this.apiEndpoint,
|
||||
headers,
|
||||
})
|
||||
|
||||
// Create the span processor
|
||||
const spanProcessor = new SimpleSpanProcessor(exporter as any)
|
||||
|
||||
// Create the trace provider with the span processor in the config
|
||||
this.tracerProvider = new NodeTracerProvider({
|
||||
resource,
|
||||
spanProcessors: [spanProcessor as any],
|
||||
})
|
||||
|
||||
// Register the provider
|
||||
this.tracerProvider.register()
|
||||
|
||||
// Get a tracer
|
||||
this.tracer = trace.getTracer("cline-conversation-tracer")
|
||||
|
||||
console.log("[ConversationTelemetry] OpenTelemetry tracer initialized successfully")
|
||||
} catch (error) {
|
||||
console.error("[ConversationTelemetry] Failed to initialize OpenTelemetry tracer:", error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Captures a message in the conversation as an OpenTelemetry span
|
||||
* ONLY HAPPENS IF USER IS OPTED INTO CONVERSATION TELEMETRY IN ADVANCED SETTINGS
|
||||
*/
|
||||
public async captureMessage(taskId: string, message: TelemetryChatMessage, metadata: ConversationMetadata) {
|
||||
// Do NOT capture message if user has not explicitly opted in
|
||||
if (!this.isOptedInToConversationTelemetry()) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!this.tracer) {
|
||||
await this.initializeTracer()
|
||||
}
|
||||
|
||||
try {
|
||||
// Convert taskId to a valid trace ID (must be 32 hex chars)
|
||||
const traceId = this.generateTraceIdFromTimestamp(taskId)
|
||||
|
||||
// Convert message timestamp to a valid span ID (must be 16 hex chars)
|
||||
if (!message.ts && message.ts !== 0) {
|
||||
throw new Error("Message timestamp is required")
|
||||
}
|
||||
|
||||
const timestamp = message.ts
|
||||
const spanId = this.generateSpanIdFromTimestamp(timestamp)
|
||||
|
||||
// Create a span context with our IDs
|
||||
const spanContext = trace.setSpanContext(context.active(), {
|
||||
traceId,
|
||||
spanId,
|
||||
isRemote: false,
|
||||
traceFlags: 1, // Sampled
|
||||
})
|
||||
|
||||
// Start a new span with the context
|
||||
const span = this.tracer.startSpan(
|
||||
`message.${message.role}`,
|
||||
{
|
||||
kind: SpanKind.CLIENT,
|
||||
startTime: this.millisecondsToHrTime(timestamp), // Convert to nanoseconds
|
||||
},
|
||||
spanContext,
|
||||
)
|
||||
|
||||
// Get the message index for this task
|
||||
const messageIndex = this.getNextMessageIndex(taskId)
|
||||
|
||||
// Add attributes to the span
|
||||
span.setAttribute("task.id", taskId)
|
||||
span.setAttribute("user.id", this.distinctId)
|
||||
span.setAttribute("message.role", message.role)
|
||||
span.setAttribute("message.timestamp", timestamp)
|
||||
span.setAttribute("message.index", messageIndex)
|
||||
|
||||
const c = message.content
|
||||
|
||||
// Add Braintrust-compatible attributes
|
||||
span.setAttribute("gen_ai.request.model", metadata.model)
|
||||
|
||||
if (message.role === "user") {
|
||||
span.setAttribute("gen_ai.prompt", this.extractContent(message))
|
||||
} else if (message.role === "assistant") {
|
||||
span.setAttribute("gen_ai.completion", this.extractContent(message))
|
||||
span.setAttribute("gen_ai.usage.prompt_tokens", metadata.tokensIn)
|
||||
span.setAttribute("gen_ai.usage.completion_tokens", metadata.tokensOut)
|
||||
} else if (message.role === "system") {
|
||||
span.setAttribute("gen_ai.system_prompt", this.extractContent(message))
|
||||
}
|
||||
|
||||
// Add custom metadata in Braintrust format
|
||||
span.setAttribute("braintrust.metadata.api_provider", metadata.apiProvider)
|
||||
span.setAttribute("braintrust.metadata.ts", message.ts)
|
||||
|
||||
// End the span immediately since messages are discrete events
|
||||
span.end(this.millisecondsToHrTime(timestamp)) // Convert to nanoseconds
|
||||
|
||||
console.log(`[ConversationTelemetry] Captured ${message.role} message for task ${taskId}`, { span })
|
||||
} catch (error) {
|
||||
console.error("[ConversationTelemetry] Error capturing message:", error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a decimal timestamp to a valid trace ID (32 hex chars)
|
||||
*/
|
||||
private generateTraceIdFromTimestamp(timestamp: string): string {
|
||||
// Pad with zeros and convert to hex
|
||||
const hex = BigInt(timestamp).toString(16).padStart(32, "0")
|
||||
return hex.substring(0, 32) // Ensure it's exactly 32 chars
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts milliseconds to high-resolution time format expected by OpenTelemetry
|
||||
* Returns [seconds, nanoseconds]
|
||||
*/
|
||||
private millisecondsToHrTime(milliseconds: number): [number, number] {
|
||||
return [
|
||||
Math.floor(milliseconds / 1000), // seconds
|
||||
(milliseconds % 1000) * 1000000, // nanoseconds (remainder in ms * 10^6)
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a decimal timestamp to a valid span ID (16 hex chars)
|
||||
*/
|
||||
private generateSpanIdFromTimestamp(timestamp: number): string {
|
||||
// Pad with zeros and convert to hex
|
||||
const hex = BigInt(timestamp).toString(16).padStart(16, "0")
|
||||
return hex.substring(0, 16) // Ensure it's exactly 16 chars
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to extract content from different message formats
|
||||
*/
|
||||
private extractContent(message: TelemetryChatMessage): string {
|
||||
if (typeof message.content === "string") {
|
||||
return message.content
|
||||
}
|
||||
|
||||
return message.content
|
||||
.map((block) => (block.type === "text" ? block.text : null))
|
||||
.filter(Boolean)
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
/**
|
||||
* Track message indices per task
|
||||
*/
|
||||
private getNextMessageIndex(taskId: string): number {
|
||||
const currentIndex = this.messageIndices.get(taskId) || 0
|
||||
this.messageIndices.set(taskId, currentIndex + 1)
|
||||
return currentIndex
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends conversation data to cleanup endpoint to remove deleted messages from telemetry
|
||||
* ONLY HAPPENS IF USER IS OPTED INTO CONVERSATION TELEMETRY IN ADVANCED SETTINGS
|
||||
*/
|
||||
public async cleanupTask(taskId: string, conversationData: any): Promise<void> {
|
||||
// Do NOT send data if user has not explicitly opted in
|
||||
if (!this.isOptedInToConversationTelemetry()) {
|
||||
return
|
||||
}
|
||||
|
||||
const clineApiKey = await this.getClineApiKey()
|
||||
if (!clineApiKey) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// Configure the headers with API key
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
// Add API key to headers
|
||||
headers["Authorization"] = `Bearer ${clineApiKey}`
|
||||
|
||||
// Send the data to the cleanup endpoint
|
||||
const cleanupEndpoint = `${this.apiEndpoint.replace("/traces", "/traces/cleanup")}`
|
||||
|
||||
// Use fetch API to send the data
|
||||
const response = await fetch(cleanupEndpoint, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
taskId: taskId,
|
||||
conversationData,
|
||||
userId: this.distinctId,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to send cleanup data: ${response.status} ${response.statusText}`)
|
||||
}
|
||||
|
||||
console.log(`[ConversationTelemetry] Cleanup data sent for task ${taskId}`)
|
||||
} catch (error) {
|
||||
console.error("[ConversationTelemetry] Error sending cleanup data:", error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shutdown the tracer provider
|
||||
*/
|
||||
public async shutdown(): Promise<void> {
|
||||
if (this.tracerProvider) {
|
||||
await this.tracerProvider.shutdown()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { PostHog } from "posthog-node"
|
||||
import * as vscode from "vscode"
|
||||
import { version as extensionVersion } from "../../../package.json"
|
||||
|
||||
/**
|
||||
* PostHogClient handles telemetry event tracking for the Cline extension
|
||||
@@ -65,6 +66,8 @@ class PostHogClient {
|
||||
private distinctId: string = vscode.env.machineId
|
||||
/** Whether telemetry is currently enabled based on user and VSCode settings */
|
||||
private telemetryEnabled: boolean = false
|
||||
/** Current version of the extension */
|
||||
private readonly version: string = extensionVersion
|
||||
|
||||
/**
|
||||
* Private constructor to enforce singleton pattern
|
||||
@@ -120,7 +123,12 @@ class PostHogClient {
|
||||
public capture(event: { event: string; properties?: any }): void {
|
||||
// Only send events if telemetry is enabled
|
||||
if (this.telemetryEnabled) {
|
||||
this.client.capture({ distinctId: this.distinctId, event: event.event, properties: event.properties })
|
||||
// Include extension version in all event properties
|
||||
const propertiesWithVersion = {
|
||||
...event.properties,
|
||||
extension_version: this.version,
|
||||
}
|
||||
this.client.capture({ distinctId: this.distinctId, event: event.event, properties: propertiesWithVersion })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -250,13 +258,19 @@ class PostHogClient {
|
||||
* Records interactions with the git-based checkpoint system
|
||||
* @param taskId Unique identifier for the task
|
||||
* @param action The type of checkpoint action
|
||||
* @param durationMs Optional duration of the operation in milliseconds
|
||||
*/
|
||||
public captureCheckpointUsage(taskId: string, action: "shadow_git_initialized" | "commit_created" | "restored") {
|
||||
public captureCheckpointUsage(
|
||||
taskId: string,
|
||||
action: "shadow_git_initialized" | "commit_created" | "restored" | "diff_generated",
|
||||
durationMs?: number,
|
||||
) {
|
||||
this.capture({
|
||||
event: PostHogClient.EVENTS.TASK.CHECKPOINT_USED,
|
||||
properties: {
|
||||
taskId,
|
||||
action,
|
||||
durationMs,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
export interface BalanceResponse {
|
||||
currentBalance: number
|
||||
}
|
||||
|
||||
export interface UsageTransaction {
|
||||
spentAt: string
|
||||
credits: string
|
||||
modelProvider: string
|
||||
model: string
|
||||
promptTokens: string
|
||||
completionTokens: string
|
||||
}
|
||||
|
||||
export interface PaymentTransaction {
|
||||
paidAt: string
|
||||
amountCents: string
|
||||
credits: string
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { ChatSettings } from "./ChatSettings"
|
||||
import { HistoryItem } from "./HistoryItem"
|
||||
import { McpServer, McpMarketplaceCatalog, McpMarketplaceItem, McpDownloadResponse } from "./mcp"
|
||||
import { TelemetrySetting } from "./TelemetrySetting"
|
||||
import type { BalanceResponse, UsageTransaction, PaymentTransaction } from "../shared/ClineAccount"
|
||||
|
||||
// webview will hold state
|
||||
export interface ExtensionMessage {
|
||||
@@ -34,6 +35,11 @@ export interface ExtensionMessage {
|
||||
| "openGraphData"
|
||||
| "isImageUrlResult"
|
||||
| "didUpdateSettings"
|
||||
| "userCreditsBalance"
|
||||
| "userCreditsUsage"
|
||||
| "userCreditsPayments"
|
||||
| "totalTasksSize"
|
||||
| "addToInput"
|
||||
text?: string
|
||||
action?:
|
||||
| "chatButtonClicked"
|
||||
@@ -43,6 +49,7 @@ export interface ExtensionMessage {
|
||||
| "didBecomeVisible"
|
||||
| "accountLoginClicked"
|
||||
| "accountLogoutClicked"
|
||||
| "accountButtonClicked"
|
||||
invoke?: Invoke
|
||||
state?: ExtensionState
|
||||
images?: string[]
|
||||
@@ -69,6 +76,10 @@ export interface ExtensionMessage {
|
||||
}
|
||||
url?: string
|
||||
isImage?: boolean
|
||||
userCreditsBalance?: BalanceResponse
|
||||
userCreditsUsage?: UsageTransaction[]
|
||||
userCreditsPayments?: PaymentTransaction[]
|
||||
totalTasksSize?: number | null
|
||||
}
|
||||
|
||||
export type Invoke = "sendMessage" | "primaryButtonClick" | "secondaryButtonClick"
|
||||
@@ -78,27 +89,27 @@ export type Platform = "aix" | "darwin" | "freebsd" | "linux" | "openbsd" | "sun
|
||||
export const DEFAULT_PLATFORM = "unknown"
|
||||
|
||||
export interface ExtensionState {
|
||||
version: string
|
||||
apiConfiguration?: ApiConfiguration
|
||||
customInstructions?: string
|
||||
uriScheme?: string
|
||||
currentTaskItem?: HistoryItem
|
||||
checkpointTrackerErrorMessage?: string
|
||||
clineMessages: ClineMessage[]
|
||||
taskHistory: HistoryItem[]
|
||||
shouldShowAnnouncement: boolean
|
||||
autoApprovalSettings: AutoApprovalSettings
|
||||
browserSettings: BrowserSettings
|
||||
chatSettings: ChatSettings
|
||||
checkpointTrackerErrorMessage?: string
|
||||
clineMessages: ClineMessage[]
|
||||
currentTaskItem?: HistoryItem
|
||||
customInstructions?: string
|
||||
mcpMarketplaceEnabled?: boolean
|
||||
planActSeparateModelsSetting: boolean
|
||||
platform: Platform
|
||||
shouldShowAnnouncement: boolean
|
||||
taskHistory: HistoryItem[]
|
||||
telemetrySetting: TelemetrySetting
|
||||
uriScheme?: string
|
||||
userInfo?: {
|
||||
displayName: string | null
|
||||
email: string | null
|
||||
photoURL: string | null
|
||||
}
|
||||
mcpMarketplaceEnabled?: boolean
|
||||
telemetrySetting: TelemetrySetting
|
||||
planActSeparateModelsSetting: boolean
|
||||
version: string
|
||||
vscMachineId: string
|
||||
}
|
||||
|
||||
@@ -199,6 +210,18 @@ export interface ClineAskUseMcpServer {
|
||||
uri?: string
|
||||
}
|
||||
|
||||
export interface ClinePlanModeResponse {
|
||||
response: string
|
||||
options?: string[]
|
||||
selected?: string
|
||||
}
|
||||
|
||||
export interface ClineAskQuestion {
|
||||
question: string
|
||||
options?: string[]
|
||||
selected?: string
|
||||
}
|
||||
|
||||
export interface ClineApiReqInfo {
|
||||
request?: string
|
||||
tokensIn?: number
|
||||
|
||||
@@ -45,6 +45,7 @@ export interface WebviewMessage {
|
||||
| "getLatestState"
|
||||
| "accountLoginClicked"
|
||||
| "accountLogoutClicked"
|
||||
| "showAccountViewClicked"
|
||||
| "authStateChanged"
|
||||
| "authCallback"
|
||||
| "fetchMcpMarketplace"
|
||||
@@ -61,6 +62,9 @@ export interface WebviewMessage {
|
||||
| "invoke"
|
||||
| "updateSettings"
|
||||
| "clearAllTaskHistory"
|
||||
| "fetchUserCreditsData"
|
||||
| "optionsResponse"
|
||||
| "requestTotalTasksSize"
|
||||
// | "relaunchChromeDebugMode"
|
||||
text?: string
|
||||
disabled?: boolean
|
||||
|
||||
+53
-1
@@ -31,6 +31,7 @@ export interface ApiHandlerOptions {
|
||||
openRouterApiKey?: string
|
||||
openRouterModelId?: string
|
||||
openRouterModelInfo?: ModelInfo
|
||||
openRouterProviderSorting?: string
|
||||
awsAccessKey?: string
|
||||
awsSecretKey?: string
|
||||
awsSessionToken?: string
|
||||
@@ -158,6 +159,33 @@ export const anthropicModels = {
|
||||
export type BedrockModelId = keyof typeof bedrockModels
|
||||
export const bedrockDefaultModelId: BedrockModelId = "anthropic.claude-3-7-sonnet-20250219-v1:0"
|
||||
export const bedrockModels = {
|
||||
"amazon.nova-pro-v1:0": {
|
||||
maxTokens: 5000,
|
||||
contextWindow: 300_000,
|
||||
supportsImages: true,
|
||||
supportsComputerUse: false,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 0.8,
|
||||
outputPrice: 3.2,
|
||||
},
|
||||
"amazon.nova-lite-v1:0": {
|
||||
maxTokens: 5000,
|
||||
contextWindow: 300_000,
|
||||
supportsImages: true,
|
||||
supportsComputerUse: false,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 0.06,
|
||||
outputPrice: 0.24,
|
||||
},
|
||||
"amazon.nova-micro-v1:0": {
|
||||
maxTokens: 5000,
|
||||
contextWindow: 128_000,
|
||||
supportsImages: false,
|
||||
supportsComputerUse: false,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 0.035,
|
||||
outputPrice: 0.14,
|
||||
},
|
||||
"anthropic.claude-3-7-sonnet-20250219-v1:0": {
|
||||
maxTokens: 8192,
|
||||
contextWindow: 200_000,
|
||||
@@ -577,11 +605,19 @@ export const openAiNativeModels = {
|
||||
outputPrice: 0.6,
|
||||
cacheReadsPrice: 0.075,
|
||||
},
|
||||
"gpt-4.5-preview": {
|
||||
"chatgpt-4o-latest": {
|
||||
maxTokens: 16_384,
|
||||
contextWindow: 128_000,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 5,
|
||||
outputPrice: 15,
|
||||
},
|
||||
"gpt-4.5-preview": {
|
||||
maxTokens: 16_384,
|
||||
contextWindow: 128_000,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: true,
|
||||
inputPrice: 75,
|
||||
outputPrice: 150,
|
||||
},
|
||||
@@ -1088,6 +1124,14 @@ export const mistralModels = {
|
||||
inputPrice: 0.1,
|
||||
outputPrice: 0.1,
|
||||
},
|
||||
"mistral-small-latest": {
|
||||
maxTokens: 131_000,
|
||||
contextWindow: 131_000,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 0.1,
|
||||
outputPrice: 0.3,
|
||||
},
|
||||
"mistral-small-2501": {
|
||||
maxTokens: 32_000,
|
||||
contextWindow: 32_000,
|
||||
@@ -1355,4 +1399,12 @@ export const sambanovaModels = {
|
||||
inputPrice: 0,
|
||||
outputPrice: 0,
|
||||
},
|
||||
"QwQ-32B": {
|
||||
maxTokens: 4096,
|
||||
contextWindow: 16_000,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 0.5,
|
||||
outputPrice: 1.0,
|
||||
},
|
||||
} as const satisfies Record<string, ModelInfo>
|
||||
|
||||
@@ -20,3 +20,37 @@ export function findLast<T>(array: Array<T>, predicate: (value: T, index: number
|
||||
const index = findLastIndex(array, predicate)
|
||||
return index === -1 ? undefined : array[index]
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a partial or complete stringified array into an actual array.
|
||||
* Handles both complete JSON strings and incomplete array strings.
|
||||
* Splits on the specific tokens: [" ", " "]
|
||||
* @param arrayString A string representation of an array, which may be incomplete
|
||||
* @returns Array of strings parsed from the input
|
||||
*/
|
||||
export function parsePartialArrayString(arrayString: string): string[] {
|
||||
try {
|
||||
// Try parsing as complete JSON first
|
||||
return JSON.parse(arrayString)
|
||||
} catch {
|
||||
// If JSON parsing fails, handle as partial string
|
||||
const trimmed = arrayString.trim()
|
||||
if (!trimmed.startsWith('["')) {
|
||||
return []
|
||||
}
|
||||
|
||||
// Remove leading ["
|
||||
let content = trimmed.slice(2)
|
||||
// Remove trailing "] if it exists
|
||||
content = content.replace(/"]$/, "")
|
||||
if (!content) {
|
||||
return []
|
||||
}
|
||||
|
||||
// Split on ", " token and handle the parts
|
||||
return content
|
||||
.split('", "')
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import path from "path"
|
||||
import getFolderSize from "get-folder-size"
|
||||
|
||||
/**
|
||||
* Gets the total size of tasks and checkpoints directories
|
||||
* @param storagePath The base storage path (typically globalStorageUri.fsPath)
|
||||
* @returns The total size in bytes, or null if calculation fails
|
||||
*/
|
||||
export async function getTotalTasksSize(storagePath: string): Promise<number | null> {
|
||||
const tasksDir = path.join(storagePath, "tasks")
|
||||
const checkpointsDir = path.join(storagePath, "checkpoints")
|
||||
|
||||
try {
|
||||
const tasksSize = await getFolderSize.loose(tasksDir)
|
||||
const checkpointsSize = await getFolderSize.loose(checkpointsDir)
|
||||
return tasksSize + checkpointsSize
|
||||
} catch (error) {
|
||||
console.error("Failed to calculate total task size:", error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
Generated
+23
-4
@@ -21,6 +21,7 @@
|
||||
"posthog-js": "^1.224.0",
|
||||
"pretty-bytes": "^6.1.1",
|
||||
"react": "^18.3.1",
|
||||
"react-countup": "^6.5.3",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-remark": "^2.1.0",
|
||||
"react-textarea-autosize": "^8.5.7",
|
||||
@@ -49,7 +50,7 @@
|
||||
"tailwindcss": "^4.0.12",
|
||||
"typescript": "^5.7.3",
|
||||
"typescript-eslint": "^8.18.2",
|
||||
"vite": "^6.1.1",
|
||||
"vite": "^6.2.1",
|
||||
"vitest": "^3.0.5"
|
||||
}
|
||||
},
|
||||
@@ -4177,6 +4178,12 @@
|
||||
"layout-base": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/countup.js": {
|
||||
"version": "2.8.0",
|
||||
"resolved": "https://registry.npmjs.org/countup.js/-/countup.js-2.8.0.tgz",
|
||||
"integrity": "sha512-f7xEhX0awl4NOElHulrl4XRfKoNH3rB+qfNSZZyjSZhaAoUk6elvhH+MNxMmlmuUJ2/QNTWPSA7U4mNtIAKljQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
@@ -7095,6 +7102,18 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-countup": {
|
||||
"version": "6.5.3",
|
||||
"resolved": "https://registry.npmjs.org/react-countup/-/react-countup-6.5.3.tgz",
|
||||
"integrity": "sha512-udnqVQitxC7QWADSPDOxVWULkLvKUWrDapn5i53HE4DPRVgs+Y5rr4bo25qEl8jSh+0l2cToJgGMx+clxPM3+w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"countup.js": "^2.8.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">= 16.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-dom": {
|
||||
"version": "18.3.1",
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
|
||||
@@ -8359,9 +8378,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "6.2.0",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.2.0.tgz",
|
||||
"integrity": "sha512-7dPxoo+WsT/64rDcwoOjk76XHj+TqNTIvHKcuMQ1k4/SeHDaQt5GFAeLYzrimZrMpn/O6DtdI03WUjdxuPM0oQ==",
|
||||
"version": "6.2.1",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.2.1.tgz",
|
||||
"integrity": "sha512-n2GnqDb6XPhlt9B8olZPrgMD/es/Nd1RdChF6CBD/fHW6pUyUTt2sQW2fPRX5GiD9XEa6+8A6A4f2vT6pSsE7Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
"posthog-js": "^1.224.0",
|
||||
"pretty-bytes": "^6.1.1",
|
||||
"react": "^18.3.1",
|
||||
"react-countup": "^6.5.3",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-remark": "^2.1.0",
|
||||
"react-textarea-autosize": "^8.5.7",
|
||||
@@ -53,7 +54,7 @@
|
||||
"tailwindcss": "^4.0.12",
|
||||
"typescript": "^5.7.3",
|
||||
"typescript-eslint": "^8.18.2",
|
||||
"vite": "^6.1.1",
|
||||
"vite": "^6.2.1",
|
||||
"vitest": "^3.0.5"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ const AppContent = () => {
|
||||
setShowMcp(true)
|
||||
setShowAccount(false)
|
||||
break
|
||||
case "accountLoginClicked":
|
||||
case "accountButtonClicked":
|
||||
setShowSettings(false)
|
||||
setShowHistory(false)
|
||||
setShowMcp(false)
|
||||
@@ -96,6 +96,7 @@ const AppContent = () => {
|
||||
showHistoryView={() => {
|
||||
setShowSettings(false)
|
||||
setShowMcp(false)
|
||||
setShowAccount(false)
|
||||
setShowHistory(true)
|
||||
}}
|
||||
isHidden={showSettings || showHistory || showMcp || showAccount}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { SVGProps } from "react"
|
||||
|
||||
const ClineLogoWhite = (props: SVGProps<SVGSVGElement>) => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="47" height="50" viewBox="0 0 47 50" fill="none" {...props}>
|
||||
<path
|
||||
d="M46.4075 28.1192L43.5011 22.3166V18.9747C43.5011 13.4354 39.0302 8.94931 33.5162 8.94931H28.5491C28.9086 8.21513 29.106 7.3898 29.106 6.5189C29.106 3.44039 26.6149 0.949219 23.5363 0.949219C20.4578 0.949219 17.9667 3.44039 17.9667 6.5189C17.9667 7.3898 18.1641 8.21513 18.5236 8.94931H13.5565C8.04249 8.94931 3.57155 13.4354 3.57155 18.9747V22.3166L0.604424 28.104C0.305687 28.6863 0.305687 29.3799 0.604424 29.9622L3.57155 35.6838V39.0256C3.57155 44.5649 8.04249 49.0511 13.5565 49.0511H33.5162C39.0302 49.0511 43.5011 44.5649 43.5011 39.0256V35.6838L46.4024 29.942C46.691 29.3698 46.691 28.6964 46.4075 28.1192ZM20.4983 32.8483C20.4983 35.3648 18.4578 37.4053 15.9413 37.4053C13.4248 37.4053 11.3843 35.3648 11.3843 32.8483V24.747C11.3843 22.2305 13.4248 20.19 15.9413 20.19C18.4578 20.19 20.4983 22.2305 20.4983 24.747V32.8483ZM35.182 32.8483C35.182 35.3648 33.1415 37.4053 30.625 37.4053C28.1085 37.4053 26.068 35.3648 26.068 32.8483V24.747C26.068 22.2305 28.1085 20.19 30.625 20.19C33.1415 20.19 35.182 22.2305 35.182 24.747V32.8483Z"
|
||||
fill="white"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
export default ClineLogoWhite
|
||||
@@ -1,8 +1,12 @@
|
||||
import { VSCodeButton, VSCodeDivider } from "@vscode/webview-ui-toolkit/react"
|
||||
import { memo } from "react"
|
||||
import { VSCodeButton, VSCodeDivider, VSCodeLink } from "@vscode/webview-ui-toolkit/react"
|
||||
import { memo, useEffect, useState } from "react"
|
||||
import { useFirebaseAuth } from "../../context/FirebaseAuthContext"
|
||||
import { vscode } from "../../utils/vscode"
|
||||
import VSCodeButtonLink from "../common/VSCodeButtonLink"
|
||||
import ClineLogoWhite from "../../assets/ClineLogoWhite"
|
||||
import CountUp from "react-countup"
|
||||
import CreditsHistoryTable from "./CreditsHistoryTable"
|
||||
import { UsageTransaction, PaymentTransaction } from "../../../../src/shared/ClineAccount"
|
||||
|
||||
type AccountViewProps = {
|
||||
onDone: () => void
|
||||
@@ -10,38 +14,13 @@ type AccountViewProps = {
|
||||
|
||||
const AccountView = ({ onDone }: AccountViewProps) => {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: "fixed",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
padding: "10px 0px 0px 20px",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
overflow: "hidden",
|
||||
}}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
marginBottom: "17px",
|
||||
paddingRight: 17,
|
||||
}}>
|
||||
<h3 style={{ color: "var(--vscode-foreground)", margin: 0 }}>Cline Account</h3>
|
||||
<div className="fixed inset-0 flex flex-col overflow-hidden pt-[10px] pl-[20px]">
|
||||
<div className="flex justify-between items-center mb-[17px] pr-[17px]">
|
||||
<h3 className="text-[var(--vscode-foreground)] m-0">Account</h3>
|
||||
<VSCodeButton onClick={onDone}>Done</VSCodeButton>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
flexGrow: 1,
|
||||
overflowY: "scroll",
|
||||
paddingRight: 8,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
}}>
|
||||
<div style={{ marginBottom: 5 }}>
|
||||
<div className="flex-grow overflow-hidden pr-[8px] flex flex-col">
|
||||
<div className="h-full mb-[5px]">
|
||||
<ClineAccountView />
|
||||
</div>
|
||||
</div>
|
||||
@@ -51,6 +30,37 @@ const AccountView = ({ onDone }: AccountViewProps) => {
|
||||
|
||||
export const ClineAccountView = () => {
|
||||
const { user, handleSignOut } = useFirebaseAuth()
|
||||
const [balance, setBalance] = useState(0)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [usageData, setUsageData] = useState<UsageTransaction[]>([])
|
||||
const [paymentsData, setPaymentsData] = useState<PaymentTransaction[]>([])
|
||||
|
||||
// Listen for balance and transaction data updates from the extension
|
||||
useEffect(() => {
|
||||
const handleMessage = (event: MessageEvent) => {
|
||||
const message = event.data
|
||||
if (message.type === "userCreditsBalance" && message.userCreditsBalance) {
|
||||
setBalance(message.userCreditsBalance.currentBalance)
|
||||
} else if (message.type === "userCreditsUsage" && message.userCreditsUsage) {
|
||||
setUsageData(message.userCreditsUsage.usageTransactions)
|
||||
} else if (message.type === "userCreditsPayments" && message.userCreditsPayments) {
|
||||
setPaymentsData(message.userCreditsPayments.paymentTransactions)
|
||||
}
|
||||
setIsLoading(false)
|
||||
}
|
||||
|
||||
window.addEventListener("message", handleMessage)
|
||||
|
||||
// Fetch all account data when component mounts
|
||||
if (user) {
|
||||
setIsLoading(true)
|
||||
vscode.postMessage({ type: "fetchUserCreditsData" })
|
||||
}
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("message", handleMessage)
|
||||
}
|
||||
}, [user])
|
||||
|
||||
const handleLogin = () => {
|
||||
vscode.postMessage({ type: "accountLoginClicked" })
|
||||
@@ -63,108 +73,96 @@ export const ClineAccountView = () => {
|
||||
handleSignOut()
|
||||
}
|
||||
return (
|
||||
<div style={{ maxWidth: "600px" }}>
|
||||
<div className="h-full flex flex-col">
|
||||
{user ? (
|
||||
<div
|
||||
style={{
|
||||
padding: "8px 10px",
|
||||
border: "1px solid var(--vscode-input-border)",
|
||||
borderRadius: "2px",
|
||||
backgroundColor: "var(--vscode-dropdown-background)",
|
||||
}}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "8px",
|
||||
}}>
|
||||
{user.photoURL ? (
|
||||
<img
|
||||
src={user.photoURL}
|
||||
alt="Profile"
|
||||
style={{
|
||||
width: 38,
|
||||
height: 38,
|
||||
borderRadius: "50%",
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
width: 38,
|
||||
height: 38,
|
||||
borderRadius: "50%",
|
||||
backgroundColor: "var(--vscode-button-background)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
fontSize: "20px",
|
||||
color: "var(--vscode-button-foreground)",
|
||||
}}>
|
||||
{user.displayName?.[0] || user.email?.[0] || "?"}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "4px",
|
||||
}}>
|
||||
{user.displayName && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: "13px",
|
||||
fontWeight: "bold",
|
||||
color: "var(--vscode-foreground)",
|
||||
}}>
|
||||
{user.displayName}
|
||||
<div className="flex flex-col pr-3 h-full">
|
||||
<div className="flex flex-col w-full">
|
||||
<div className="flex items-center mb-6 flex-wrap gap-y-4">
|
||||
{user.photoURL ? (
|
||||
<img src={user.photoURL} alt="Profile" className="size-16 rounded-full mr-4" />
|
||||
) : (
|
||||
<div className="size-16 rounded-full bg-[var(--vscode-button-background)] flex items-center justify-center text-2xl text-[var(--vscode-button-foreground)] mr-4">
|
||||
{user.displayName?.[0] || user.email?.[0] || "?"}
|
||||
</div>
|
||||
)}
|
||||
{user.email && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: "13px",
|
||||
color: "var(--vscode-descriptionForeground)",
|
||||
}}>
|
||||
{user.email}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ display: "flex", gap: "8px", flexWrap: "wrap" }}>
|
||||
<VSCodeButtonLink
|
||||
href="https://app.cline.bot/credits"
|
||||
appearance="primary"
|
||||
style={{
|
||||
transform: "scale(0.85)",
|
||||
transformOrigin: "left center",
|
||||
width: "fit-content",
|
||||
marginTop: 2,
|
||||
marginBottom: 0,
|
||||
marginRight: -12,
|
||||
}}>
|
||||
Account
|
||||
</VSCodeButtonLink>
|
||||
<VSCodeButton
|
||||
appearance="secondary"
|
||||
onClick={handleLogout}
|
||||
style={{
|
||||
transform: "scale(0.85)",
|
||||
transformOrigin: "left center",
|
||||
width: "fit-content",
|
||||
marginTop: 2,
|
||||
marginBottom: 0,
|
||||
marginRight: -12,
|
||||
}}>
|
||||
Log out
|
||||
</VSCodeButton>
|
||||
|
||||
<div className="flex flex-col">
|
||||
{user.displayName && (
|
||||
<h2 className="text-[var(--vscode-foreground)] m-0 mb-1 text-lg font-medium">
|
||||
{user.displayName}
|
||||
</h2>
|
||||
)}
|
||||
|
||||
{user.email && (
|
||||
<div className="text-sm text-[var(--vscode-descriptionForeground)]">{user.email}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-full flex gap-2 flex-col min-[225px]:flex-row">
|
||||
<div className="w-full min-[225px]:w-1/2">
|
||||
<VSCodeButtonLink href="https://app.cline.bot/credits" appearance="primary" className="w-full">
|
||||
Dashboard
|
||||
</VSCodeButtonLink>
|
||||
</div>
|
||||
<VSCodeButton appearance="secondary" onClick={handleLogout} className="w-full min-[225px]:w-1/2">
|
||||
Log out
|
||||
</VSCodeButton>
|
||||
</div>
|
||||
|
||||
<VSCodeDivider className="w-full my-6" />
|
||||
|
||||
<div className="w-full flex flex-col items-center">
|
||||
<div className="text-sm text-[var(--vscode-descriptionForeground)] mb-3">CURRENT BALANCE</div>
|
||||
|
||||
<div className="text-4xl font-bold text-[var(--vscode-foreground)] mb-6 flex items-center gap-2">
|
||||
{isLoading ? (
|
||||
<div className="text-[var(--vscode-descriptionForeground)]">Loading...</div>
|
||||
) : (
|
||||
<>
|
||||
<span>$</span>
|
||||
<CountUp end={balance} duration={0.66} decimals={2} />
|
||||
<VSCodeButton
|
||||
appearance="icon"
|
||||
className="mt-1"
|
||||
onClick={() => vscode.postMessage({ type: "fetchUserCreditsData" })}>
|
||||
<span className="codicon codicon-refresh"></span>
|
||||
</VSCodeButton>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="w-full">
|
||||
<VSCodeButtonLink href="https://app.cline.bot/credits/#buy" className="w-full">
|
||||
Add Credits
|
||||
</VSCodeButtonLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<VSCodeDivider className="mt-6 mb-3 w-full" />
|
||||
|
||||
<div className="flex-grow flex flex-col min-h-0 pb-[0px]">
|
||||
<CreditsHistoryTable isLoading={isLoading} usageData={usageData} paymentsData={paymentsData} />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{}}>
|
||||
<VSCodeButton onClick={handleLogin} style={{ marginTop: 0 }}>
|
||||
Sign Up with Cline
|
||||
<div className="flex flex-col items-center pr-3 max-w-[400px]">
|
||||
<ClineLogoWhite className="size-16 mb-4" />
|
||||
|
||||
<p style={{}}>
|
||||
Sign up for an account to get access to the latest models, billing dashboard to view usage and credits,
|
||||
and more upcoming features.
|
||||
</p>
|
||||
|
||||
<VSCodeButton onClick={handleLogin} className="w-full mb-4">
|
||||
Sign up with Cline
|
||||
</VSCodeButton>
|
||||
|
||||
<p className="text-[var(--vscode-descriptionForeground)] text-xs text-center m-0">
|
||||
By continuing, you agree to the <VSCodeLink href="https://cline.bot/tos">Terms of Service</VSCodeLink> and{" "}
|
||||
<VSCodeLink href="https://cline.bot/privacy">Privacy Policy.</VSCodeLink>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import { VSCodeDataGrid, VSCodeDataGridRow, VSCodeDataGridCell } from "@vscode/webview-ui-toolkit/react"
|
||||
import { useState } from "react"
|
||||
import { TabButton } from "../mcp/McpView"
|
||||
import { UsageTransaction, PaymentTransaction } from "../../../../src/shared/ClineAccount"
|
||||
import { formatDollars, formatTimestamp } from "../../utils/format"
|
||||
|
||||
interface CreditsHistoryTableProps {
|
||||
isLoading: boolean
|
||||
usageData: UsageTransaction[]
|
||||
paymentsData: PaymentTransaction[]
|
||||
}
|
||||
|
||||
const CreditsHistoryTable = ({ isLoading, usageData, paymentsData }: CreditsHistoryTableProps) => {
|
||||
const [activeTab, setActiveTab] = useState<"usage" | "payments">("usage")
|
||||
|
||||
return (
|
||||
<div className="flex flex-col flex-grow h-full">
|
||||
{/* Tabs container */}
|
||||
<div className="flex border-b border-[var(--vscode-panel-border)]">
|
||||
<TabButton isActive={activeTab === "usage"} onClick={() => setActiveTab("usage")}>
|
||||
USAGE HISTORY
|
||||
</TabButton>
|
||||
<TabButton isActive={activeTab === "payments"} onClick={() => setActiveTab("payments")}>
|
||||
PAYMENTS HISTORY
|
||||
</TabButton>
|
||||
</div>
|
||||
|
||||
{/* Content container */}
|
||||
<div className="mt-[15px] mb-[0px] rounded-md overflow-auto flex-grow">
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center items-center p-4">
|
||||
<div className="text-[var(--vscode-descriptionForeground)]">Loading...</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{activeTab === "usage" && (
|
||||
<>
|
||||
{usageData.length > 0 ? (
|
||||
<VSCodeDataGrid>
|
||||
<VSCodeDataGridRow row-type="header">
|
||||
<VSCodeDataGridCell cell-type="columnheader" grid-column="1">
|
||||
Date
|
||||
</VSCodeDataGridCell>
|
||||
<VSCodeDataGridCell cell-type="columnheader" grid-column="2">
|
||||
Model
|
||||
</VSCodeDataGridCell>
|
||||
{/* <VSCodeDataGridCell cell-type="columnheader" grid-column="3">
|
||||
Tokens Used
|
||||
</VSCodeDataGridCell> */}
|
||||
<VSCodeDataGridCell cell-type="columnheader" grid-column="3">
|
||||
Credits Used
|
||||
</VSCodeDataGridCell>
|
||||
</VSCodeDataGridRow>
|
||||
|
||||
{usageData.map((row, index) => (
|
||||
<VSCodeDataGridRow key={index}>
|
||||
<VSCodeDataGridCell grid-column="1">
|
||||
{formatTimestamp(row.spentAt)}
|
||||
</VSCodeDataGridCell>
|
||||
<VSCodeDataGridCell grid-column="2">{`${row.modelProvider}/${row.model}`}</VSCodeDataGridCell>
|
||||
{/* <VSCodeDataGridCell grid-column="3">{`${row.promptTokens} → ${row.completionTokens}`}</VSCodeDataGridCell> */}
|
||||
<VSCodeDataGridCell grid-column="3">{`$${Number(row.credits).toFixed(7)}`}</VSCodeDataGridCell>
|
||||
</VSCodeDataGridRow>
|
||||
))}
|
||||
</VSCodeDataGrid>
|
||||
) : (
|
||||
<div className="flex justify-center items-center p-4">
|
||||
<div className="text-[var(--vscode-descriptionForeground)]">No usage history</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{activeTab === "payments" && (
|
||||
<>
|
||||
{paymentsData.length > 0 ? (
|
||||
<VSCodeDataGrid>
|
||||
<VSCodeDataGridRow row-type="header">
|
||||
<VSCodeDataGridCell cell-type="columnheader" grid-column="1">
|
||||
Date
|
||||
</VSCodeDataGridCell>
|
||||
<VSCodeDataGridCell cell-type="columnheader" grid-column="2">
|
||||
Total Cost
|
||||
</VSCodeDataGridCell>
|
||||
<VSCodeDataGridCell cell-type="columnheader" grid-column="3">
|
||||
Credits
|
||||
</VSCodeDataGridCell>
|
||||
</VSCodeDataGridRow>
|
||||
|
||||
{paymentsData.map((row, index) => (
|
||||
<VSCodeDataGridRow key={index}>
|
||||
<VSCodeDataGridCell grid-column="1">
|
||||
{formatTimestamp(row.paidAt)}
|
||||
</VSCodeDataGridCell>
|
||||
<VSCodeDataGridCell grid-column="2">{`$${formatDollars(parseInt(row.amountCents))}`}</VSCodeDataGridCell>
|
||||
<VSCodeDataGridCell grid-column="3">{`${row.credits}`}</VSCodeDataGridCell>
|
||||
</VSCodeDataGridRow>
|
||||
))}
|
||||
</VSCodeDataGrid>
|
||||
) : (
|
||||
<div className="flex justify-center items-center p-4">
|
||||
<div className="text-[var(--vscode-descriptionForeground)]">No payment history</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default CreditsHistoryTable
|
||||
@@ -31,34 +31,22 @@ const Announcement = ({ version, hideAnnouncement }: AnnouncementProps) => {
|
||||
</h3>
|
||||
<ul style={{ margin: "0 0 8px", paddingLeft: "12px" }}>
|
||||
<li>
|
||||
<b>Introducing MCP Marketplace:</b> Discover and install the best MCP servers right from the extension, with
|
||||
new servers added regularly! Get started by going to the{" "}
|
||||
<span className="codicon codicon-extensions" style={{ marginRight: "4px", fontSize: 10 }}></span>
|
||||
<VSCodeLink
|
||||
onClick={() => {
|
||||
vscode.postMessage({ type: "showMcpView" })
|
||||
}}>
|
||||
MCP Servers tab
|
||||
</VSCodeLink>
|
||||
.
|
||||
<b>Add to Cline:</b> Right-click selected text in any file or terminal to quickly add context to your current
|
||||
task! Plus, when you see a lightbulb icon, select 'Fix with Cline' to have Cline fix errors in your code.
|
||||
</li>
|
||||
<li>
|
||||
<b>Mermaid diagrams in Plan mode!</b> Cline can now visualize his plans using flowcharts, sequences,
|
||||
entity-relationships, and more. When he explains his approach using mermaid, you'll see a diagram right in
|
||||
chat that you can click to expand.
|
||||
<b>Billing Dashboard:</b> Track your remaining credits and transaction history right in the extension with a{" "}
|
||||
<span className="codicon codicon-account" style={{ fontSize: 11 }}></span> Cline account!
|
||||
</li>
|
||||
<li>
|
||||
Use <code>@terminal</code> to reference terminal contents, and <code>@git</code> to reference working changes
|
||||
and commits!
|
||||
<b>Faster Inference:</b> Cline/OpenRouter users can sort underlying providers used by throughput, price, and
|
||||
latency. Sorting by throughput will output faster generations (at a higher cost).
|
||||
</li>
|
||||
<li>
|
||||
New visual indicator for checkpoints after edits & commands, and automatic checkpoint at the start of each
|
||||
task.
|
||||
<b>Enhanced MCP Support:</b> Dynamic image loading with GIF support, and a new delete button to clean up
|
||||
failed servers.
|
||||
</li>
|
||||
</ul>
|
||||
<VSCodeLink href="https://x.com/sdrzn/status/1892262424881090721" style={{ display: "inline" }}>
|
||||
See a demo of the changes here!
|
||||
</VSCodeLink>
|
||||
{/*<ul style={{ margin: "0 0 8px", paddingLeft: "12px" }}>
|
||||
<li>
|
||||
OpenRouter now supports prompt caching! They also have much higher rate limits than other providers,
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
import React from "react"
|
||||
|
||||
interface ChatErrorBoundaryProps {
|
||||
children: React.ReactNode
|
||||
errorTitle?: string
|
||||
errorBody?: string
|
||||
height?: string
|
||||
}
|
||||
|
||||
interface ChatErrorBoundaryState {
|
||||
hasError: boolean
|
||||
error: Error | null
|
||||
}
|
||||
|
||||
/**
|
||||
* A reusable error boundary component specifically designed for chat widgets.
|
||||
* It provides a consistent error UI with customizable title and body text.
|
||||
*/
|
||||
export class ChatErrorBoundary extends React.Component<ChatErrorBoundaryProps, ChatErrorBoundaryState> {
|
||||
constructor(props: ChatErrorBoundaryProps) {
|
||||
super(props)
|
||||
this.state = { hasError: false, error: null }
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error) {
|
||||
return { hasError: true, error }
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
|
||||
console.error("Error in ChatErrorBoundary:", error.message)
|
||||
console.error("Component stack:", errorInfo.componentStack)
|
||||
}
|
||||
|
||||
render() {
|
||||
const { errorTitle, errorBody, height } = this.props
|
||||
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
padding: "10px",
|
||||
color: "var(--vscode-errorForeground)",
|
||||
height: height || "auto",
|
||||
maxWidth: "512px",
|
||||
overflow: "auto",
|
||||
border: "1px solid var(--vscode-editorError-foreground)",
|
||||
borderRadius: "4px",
|
||||
backgroundColor: "var(--vscode-inputValidation-errorBackground, rgba(255, 0, 0, 0.1))",
|
||||
}}>
|
||||
<h3 style={{ margin: "0 0 8px 0" }}>{errorTitle || "Something went wrong displaying this content"}</h3>
|
||||
<p style={{ margin: "0" }}>{errorBody || `Error: ${this.state.error?.message || "Unknown error"}`}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return this.props.children
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A demo component that throws an error after a delay.
|
||||
* This is useful for testing error boundaries during development
|
||||
*/
|
||||
interface ErrorAfterDelayProps {
|
||||
numSecondsToWait?: number
|
||||
}
|
||||
|
||||
interface ErrorAfterDelayState {
|
||||
tickCount: number
|
||||
}
|
||||
|
||||
export class ErrorAfterDelay extends React.Component<ErrorAfterDelayProps, ErrorAfterDelayState> {
|
||||
private intervalID: NodeJS.Timeout | null = null
|
||||
|
||||
constructor(props: ErrorAfterDelayProps) {
|
||||
super(props)
|
||||
this.state = {
|
||||
tickCount: 0,
|
||||
}
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
const secondsToWait = this.props.numSecondsToWait ?? 5
|
||||
|
||||
this.intervalID = setInterval(() => {
|
||||
if (this.state.tickCount >= secondsToWait) {
|
||||
if (this.intervalID) {
|
||||
clearInterval(this.intervalID)
|
||||
}
|
||||
// Error boundaries don't catch async code :(
|
||||
// So this only works by throwing inside of a setState
|
||||
this.setState(() => {
|
||||
throw new Error("This is an error for testing the error boundary")
|
||||
})
|
||||
} else {
|
||||
this.setState({
|
||||
tickCount: this.state.tickCount + 1,
|
||||
})
|
||||
}
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
if (this.intervalID) {
|
||||
clearInterval(this.intervalID)
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
// Add a small visual indicator that this component will cause an error
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
right: 0,
|
||||
background: "rgba(255, 0, 0, 0.5)",
|
||||
color: "var(--vscode-errorForeground)",
|
||||
padding: "2px 5px",
|
||||
fontSize: "12px",
|
||||
borderRadius: "0 0 0 4px",
|
||||
zIndex: 100,
|
||||
}}>
|
||||
Error in {this.state.tickCount}/{this.props.numSecondsToWait ?? 5} seconds
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default ChatErrorBoundary
|
||||
@@ -5,8 +5,10 @@ import { useEvent, useSize } from "react-use"
|
||||
import styled from "styled-components"
|
||||
import {
|
||||
ClineApiReqInfo,
|
||||
ClineAskQuestion,
|
||||
ClineAskUseMcpServer,
|
||||
ClineMessage,
|
||||
ClinePlanModeResponse,
|
||||
ClineSayTool,
|
||||
COMPLETION_RESULT_CHANGES_FLAG,
|
||||
ExtensionMessage,
|
||||
@@ -15,18 +17,19 @@ import { COMMAND_OUTPUT_STRING, COMMAND_REQ_APP_STRING } from "../../../../src/s
|
||||
import { useExtensionState } from "../../context/ExtensionStateContext"
|
||||
import { findMatchingResourceOrTemplate, getMcpServerDisplayName } from "../../utils/mcp"
|
||||
import { vscode } from "../../utils/vscode"
|
||||
import { CheckmarkControl } from "../common/CheckmarkControl"
|
||||
import { CheckpointControls, CheckpointOverlay } from "../common/CheckpointControls"
|
||||
import CodeAccordian, { cleanPathPrefix } from "../common/CodeAccordian"
|
||||
import CodeBlock, { CODE_BLOCK_BG_COLOR } from "../common/CodeBlock"
|
||||
import MarkdownBlock from "../common/MarkdownBlock"
|
||||
import SuccessButton from "../common/SuccessButton"
|
||||
import Thumbnails from "../common/Thumbnails"
|
||||
import McpResourceRow from "../mcp/McpResourceRow"
|
||||
import McpToolRow from "../mcp/McpToolRow"
|
||||
import { highlightMentions } from "./TaskHeader"
|
||||
import CreditLimitError from "./CreditLimitError"
|
||||
import { CheckmarkControl } from "../common/CheckmarkControl"
|
||||
import McpResponseDisplay from "../mcp/McpResponseDisplay"
|
||||
import CreditLimitError from "./CreditLimitError"
|
||||
import { OptionsButtons } from "./OptionsButtons"
|
||||
import { highlightMentions } from "./TaskHeader"
|
||||
import SuccessButton from "../common/SuccessButton"
|
||||
|
||||
const ChatRowContainer = styled.div`
|
||||
padding: 10px 6px 10px 15px;
|
||||
@@ -790,30 +793,8 @@ export const ChatRowContent = ({ message, isExpanded, onToggleExpand, lastModifi
|
||||
)
|
||||
case "api_req_finished":
|
||||
return null // we should never see this message type
|
||||
// case "mcp_server_response":
|
||||
// return <McpResponseDisplay responseText={message.text || ""} />
|
||||
case "mcp_server_response":
|
||||
return (
|
||||
<>
|
||||
<div style={{ paddingTop: 0 }}>
|
||||
<div
|
||||
style={{
|
||||
marginBottom: "4px",
|
||||
opacity: 0.8,
|
||||
fontSize: "12px",
|
||||
textTransform: "uppercase",
|
||||
}}>
|
||||
Response
|
||||
</div>
|
||||
<CodeAccordian
|
||||
code={message.text}
|
||||
language="json"
|
||||
isExpanded={true}
|
||||
onToggleExpand={onToggleExpand}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
return <McpResponseDisplay responseText={message.text || ""} />
|
||||
case "text":
|
||||
return (
|
||||
<div>
|
||||
@@ -1039,8 +1020,8 @@ export const ChatRowContent = ({ message, isExpanded, onToggleExpand, lastModifi
|
||||
})
|
||||
}}
|
||||
style={{
|
||||
width: "100%",
|
||||
cursor: seeNewChangesDisabled ? "wait" : "pointer",
|
||||
width: "100%",
|
||||
}}>
|
||||
<i className="codicon codicon-new-file" style={{ marginRight: 6 }} />
|
||||
See new changes
|
||||
@@ -1198,6 +1179,19 @@ export const ChatRowContent = ({ message, isExpanded, onToggleExpand, lastModifi
|
||||
return null // Don't render anything when we get a completion_result ask without text
|
||||
}
|
||||
case "followup":
|
||||
let question: string | undefined
|
||||
let options: string[] | undefined
|
||||
let selected: string | undefined
|
||||
try {
|
||||
const parsedMessage = JSON.parse(message.text || "{}") as ClineAskQuestion
|
||||
question = parsedMessage.question
|
||||
options = parsedMessage.options
|
||||
selected = parsedMessage.selected
|
||||
} catch (e) {
|
||||
// legacy messages would pass question directly
|
||||
question = message.text
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{title && (
|
||||
@@ -1207,16 +1201,39 @@ export const ChatRowContent = ({ message, isExpanded, onToggleExpand, lastModifi
|
||||
</div>
|
||||
)}
|
||||
<div style={{ paddingTop: 10 }}>
|
||||
<Markdown markdown={message.text} />
|
||||
<Markdown markdown={question} />
|
||||
<OptionsButtons
|
||||
options={options}
|
||||
selected={selected}
|
||||
isActive={isLast && lastModifiedMessage?.ask === "followup"}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
case "plan_mode_response":
|
||||
case "plan_mode_response": {
|
||||
let response: string | undefined
|
||||
let options: string[] | undefined
|
||||
let selected: string | undefined
|
||||
try {
|
||||
const parsedMessage = JSON.parse(message.text || "{}") as ClinePlanModeResponse
|
||||
response = parsedMessage.response
|
||||
options = parsedMessage.options
|
||||
selected = parsedMessage.selected
|
||||
} catch (e) {
|
||||
// legacy messages would pass response directly
|
||||
response = message.text
|
||||
}
|
||||
return (
|
||||
<div style={{}}>
|
||||
<Markdown markdown={message.text} />
|
||||
<Markdown markdown={response} />
|
||||
<OptionsButtons
|
||||
options={options}
|
||||
selected={selected}
|
||||
isActive={isLast && lastModifiedMessage?.ask === "plan_mode_response"}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
default:
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -453,6 +453,18 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
|
||||
setSelectedImages((prevImages) => [...prevImages, ...newImages].slice(0, MAX_IMAGES_PER_MESSAGE))
|
||||
}
|
||||
break
|
||||
case "addToInput":
|
||||
setInputValue((prevValue) => {
|
||||
const newText = message.text ?? ""
|
||||
return prevValue ? `${prevValue}\n${newText}` : newText
|
||||
})
|
||||
// Add scroll to bottom after state update
|
||||
setTimeout(() => {
|
||||
if (textAreaRef.current) {
|
||||
textAreaRef.current.scrollTop = textAreaRef.current.scrollHeight
|
||||
}
|
||||
}, 0)
|
||||
break
|
||||
case "invoke":
|
||||
switch (message.invoke!) {
|
||||
case "sendMessage":
|
||||
|
||||
@@ -30,7 +30,7 @@ const CreditLimitError: React.FC<CreditLimitErrorProps> = ({ currentBalance, tot
|
||||
</div>
|
||||
|
||||
<VSCodeButtonLink
|
||||
href="https://app.cline.bot/credits"
|
||||
href="https://app.cline.bot/credits/#buy"
|
||||
style={{
|
||||
width: "100%",
|
||||
marginBottom: "8px",
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import styled from "styled-components"
|
||||
import { CODE_BLOCK_BG_COLOR } from "../common/CodeBlock"
|
||||
import { vscode } from "../../utils/vscode"
|
||||
|
||||
const OptionButton = styled.button<{ isSelected?: boolean; isNotSelectable?: boolean }>`
|
||||
padding: 8px 12px;
|
||||
background: ${(props) => (props.isSelected ? "var(--vscode-focusBorder)" : CODE_BLOCK_BG_COLOR)};
|
||||
color: ${(props) => (props.isSelected ? "white" : "var(--vscode-input-foreground)")};
|
||||
border: 1px solid var(--vscode-editorGroup-border);
|
||||
border-radius: 2px;
|
||||
cursor: ${(props) => (props.isNotSelectable ? "default" : "pointer")};
|
||||
text-align: left;
|
||||
font-size: 12px;
|
||||
|
||||
${(props) =>
|
||||
!props.isNotSelectable &&
|
||||
`
|
||||
&:hover {
|
||||
background: var(--vscode-focusBorder);
|
||||
color: white;
|
||||
}
|
||||
`}
|
||||
`
|
||||
|
||||
export const OptionsButtons = ({
|
||||
options,
|
||||
selected,
|
||||
isActive,
|
||||
}: {
|
||||
options?: string[]
|
||||
selected?: string
|
||||
isActive?: boolean
|
||||
}) => {
|
||||
if (!options?.length) return null
|
||||
|
||||
const hasSelected = selected !== undefined && options.includes(selected)
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "8px",
|
||||
paddingTop: 15,
|
||||
// marginTop: "22px",
|
||||
}}>
|
||||
{/* <div style={{ color: "var(--vscode-descriptionForeground)", fontSize: "11px", textTransform: "uppercase" }}>
|
||||
SELECT ONE:
|
||||
</div> */}
|
||||
{options.map((option, index) => (
|
||||
<OptionButton
|
||||
key={index}
|
||||
isSelected={option === selected}
|
||||
isNotSelectable={hasSelected || !isActive}
|
||||
onClick={() => {
|
||||
if (hasSelected || !isActive) {
|
||||
return
|
||||
}
|
||||
vscode.postMessage({
|
||||
type: "optionsResponse",
|
||||
text: option,
|
||||
})
|
||||
}}>
|
||||
{option}
|
||||
</OptionButton>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -34,7 +34,7 @@ const TaskHeader: React.FC<TaskHeaderProps> = ({
|
||||
onClose,
|
||||
}) => {
|
||||
const { apiConfiguration, currentTaskItem, checkpointTrackerErrorMessage } = useExtensionState()
|
||||
const [isTaskExpanded, setIsTaskExpanded] = useState(true)
|
||||
const [isTaskExpanded, setIsTaskExpanded] = useState(false)
|
||||
const [isTextExpanded, setIsTextExpanded] = useState(false)
|
||||
const [showSeeMore, setShowSeeMore] = useState(false)
|
||||
const textContainerRef = useRef<HTMLDivElement>(null)
|
||||
@@ -52,6 +52,13 @@ const TaskHeader: React.FC<TaskHeaderProps> = ({
|
||||
}
|
||||
}, [checkpointTrackerErrorMessage])
|
||||
|
||||
// Reset isTextExpanded when task is collapsed
|
||||
useEffect(() => {
|
||||
if (!isTaskExpanded) {
|
||||
setIsTextExpanded(false)
|
||||
}
|
||||
}, [isTaskExpanded])
|
||||
|
||||
/*
|
||||
When dealing with event listeners in React components that depend on state variables, we face a challenge. We want our listener to always use the most up-to-date version of a callback function that relies on current state, but we don't want to constantly add and remove event listeners as that function updates. This scenario often arises with resize listeners or other window events. Simply adding the listener in a useEffect with an empty dependency array risks using stale state, while including the callback in the dependencies can lead to unnecessary re-registrations of the listener. There are react hook libraries that provide a elegant solution to this problem by utilizing the useRef hook to maintain a reference to the latest callback function without triggering re-renders or effect re-runs. This approach ensures that our event listener always has access to the most current state while minimizing performance overhead and potential memory leaks from multiple listener registrations.
|
||||
Sources
|
||||
@@ -94,19 +101,22 @@ const TaskHeader: React.FC<TaskHeaderProps> = ({
|
||||
}, [isTextExpanded, windowHeight])
|
||||
|
||||
useEffect(() => {
|
||||
if (textRef.current && textContainerRef.current) {
|
||||
let textContainerHeight = textContainerRef.current.clientHeight
|
||||
if (!textContainerHeight) {
|
||||
textContainerHeight = textContainerRef.current.getBoundingClientRect().height
|
||||
}
|
||||
const isOverflowing = textRef.current.scrollHeight > textContainerHeight
|
||||
// necessary to show see more button again if user resizes window to expand and then back to collapse
|
||||
if (!isOverflowing) {
|
||||
setIsTextExpanded(false)
|
||||
}
|
||||
setShowSeeMore(isOverflowing)
|
||||
if (isTaskExpanded && textRef.current && textContainerRef.current) {
|
||||
// Use requestAnimationFrame to ensure DOM is fully updated
|
||||
requestAnimationFrame(() => {
|
||||
// Check if refs are still valid
|
||||
if (textRef.current && textContainerRef.current) {
|
||||
let textContainerHeight = textContainerRef.current.clientHeight
|
||||
if (!textContainerHeight) {
|
||||
textContainerHeight = textContainerRef.current.getBoundingClientRect().height
|
||||
}
|
||||
const isOverflowing = textRef.current.scrollHeight > textContainerHeight
|
||||
|
||||
setShowSeeMore(isOverflowing)
|
||||
}
|
||||
})
|
||||
}
|
||||
}, [task.text, windowWidth])
|
||||
}, [task.text, windowWidth, isTaskExpanded])
|
||||
|
||||
const isCostAvailable = useMemo(() => {
|
||||
const openAiCompatHasPricing =
|
||||
@@ -395,49 +405,52 @@ const TaskHeader: React.FC<TaskHeaderProps> = ({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{shouldShowPromptCacheInfo && (cacheReads !== undefined || cacheWrites !== undefined) && (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "4px",
|
||||
flexWrap: "wrap",
|
||||
}}>
|
||||
<span style={{ fontWeight: "bold" }}>Cache:</span>
|
||||
<span
|
||||
{shouldShowPromptCacheInfo &&
|
||||
(cacheReads !== undefined ||
|
||||
cacheWrites !== undefined ||
|
||||
apiConfiguration?.apiProvider === "anthropic") && (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "3px",
|
||||
gap: "4px",
|
||||
flexWrap: "wrap",
|
||||
}}>
|
||||
<i
|
||||
className="codicon codicon-database"
|
||||
<span style={{ fontWeight: "bold" }}>Cache:</span>
|
||||
<span
|
||||
style={{
|
||||
fontSize: "12px",
|
||||
fontWeight: "bold",
|
||||
marginBottom: "-1px",
|
||||
}}
|
||||
/>
|
||||
+{formatLargeNumber(cacheWrites || 0)}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "3px",
|
||||
}}>
|
||||
<i
|
||||
className="codicon codicon-arrow-right"
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "3px",
|
||||
}}>
|
||||
<i
|
||||
className="codicon codicon-database"
|
||||
style={{
|
||||
fontSize: "12px",
|
||||
fontWeight: "bold",
|
||||
marginBottom: "-1px",
|
||||
}}
|
||||
/>
|
||||
+{formatLargeNumber(cacheWrites || 0)}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
fontSize: "12px",
|
||||
fontWeight: "bold",
|
||||
marginBottom: 0,
|
||||
}}
|
||||
/>
|
||||
{formatLargeNumber(cacheReads || 0)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "3px",
|
||||
}}>
|
||||
<i
|
||||
className="codicon codicon-arrow-right"
|
||||
style={{
|
||||
fontSize: "12px",
|
||||
fontWeight: "bold",
|
||||
marginBottom: 0,
|
||||
}}
|
||||
/>
|
||||
{formatLargeNumber(cacheReads || 0)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{ContextWindowComponent}
|
||||
{isCostAvailable && (
|
||||
<div
|
||||
|
||||
@@ -1,30 +1,23 @@
|
||||
import { VSCodeButton } from "@vscode/webview-ui-toolkit/react"
|
||||
import styled from "styled-components"
|
||||
|
||||
const StyledButton = styled(VSCodeButton)`
|
||||
--danger-button-bg: #c42b2b;
|
||||
--danger-button-hover: #a82424;
|
||||
--danger-button-active: #8f1f1f;
|
||||
|
||||
background-color: var(--danger-button-bg) !important;
|
||||
border-color: var(--danger-button-bg) !important;
|
||||
color: #ffffff !important;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--danger-button-hover) !important;
|
||||
border-color: var(--danger-button-hover) !important;
|
||||
}
|
||||
|
||||
&:active {
|
||||
background-color: var(--danger-button-active) !important;
|
||||
border-color: var(--danger-button-active) !important;
|
||||
}
|
||||
`
|
||||
|
||||
interface DangerButtonProps extends React.ComponentProps<typeof VSCodeButton> {}
|
||||
|
||||
const DangerButton: React.FC<DangerButtonProps> = (props) => {
|
||||
return <StyledButton {...props} />
|
||||
return (
|
||||
<VSCodeButton
|
||||
{...props}
|
||||
className={`
|
||||
!bg-[#c42b2b]
|
||||
!border-[#c42b2b]
|
||||
!text-white
|
||||
hover:!bg-[#a82424]
|
||||
hover:!border-[#a82424]
|
||||
active:!bg-[#8f1f1f]
|
||||
active:!border-[#8f1f1f]
|
||||
${props.className || ""}
|
||||
`}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default DangerButton
|
||||
|
||||
@@ -1,30 +1,25 @@
|
||||
import { VSCodeButton } from "@vscode/webview-ui-toolkit/react"
|
||||
import styled from "styled-components"
|
||||
|
||||
const StyledButton = styled(VSCodeButton)`
|
||||
--success-button-bg: #176f2c;
|
||||
--success-button-hover: #197f31;
|
||||
--success-button-active: #156528;
|
||||
interface SuccessButtonTWProps extends React.ComponentProps<typeof VSCodeButton> {}
|
||||
|
||||
background-color: var(--success-button-bg) !important;
|
||||
border-color: var(--success-button-bg) !important;
|
||||
color: #ffffff !important;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--success-button-hover) !important;
|
||||
border-color: var(--success-button-hover) !important;
|
||||
}
|
||||
|
||||
&:active {
|
||||
background-color: var(--success-button-active) !important;
|
||||
border-color: var(--success-button-active) !important;
|
||||
}
|
||||
`
|
||||
|
||||
interface SuccessButtonProps extends React.ComponentProps<typeof VSCodeButton> {}
|
||||
|
||||
const SuccessButton: React.FC<SuccessButtonProps> = (props) => {
|
||||
return <StyledButton {...props} />
|
||||
const SuccessButtonTW: React.FC<SuccessButtonTWProps> = (props) => {
|
||||
return (
|
||||
<VSCodeButton
|
||||
{...props}
|
||||
className={`
|
||||
!bg-[#176f2c]
|
||||
!border-[#176f2c]
|
||||
!text-white
|
||||
hover:!bg-[#197f31]
|
||||
hover:!border-[#197f31]
|
||||
active:!bg-[#156528]
|
||||
active:!border-[#156528]
|
||||
${props.className || ""}
|
||||
`
|
||||
.replace(/\s+/g, " ")
|
||||
.trim()}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default SuccessButton
|
||||
export default SuccessButtonTW
|
||||
|
||||
@@ -6,9 +6,9 @@ import { memo, useMemo, useState, useEffect, useCallback } from "react"
|
||||
import Fuse, { FuseResult } from "fuse.js"
|
||||
import { formatLargeNumber } from "../../utils/format"
|
||||
import { formatSize } from "../../utils/size"
|
||||
import DangerButton from "../common/DangerButton"
|
||||
import { ExtensionMessage } from "../../../../src/shared/ExtensionMessage"
|
||||
import { useEvent } from "react-use"
|
||||
import DangerButton from "../common/DangerButton"
|
||||
|
||||
type HistoryViewProps = {
|
||||
onDone: () => void
|
||||
@@ -17,7 +17,7 @@ type HistoryViewProps = {
|
||||
type SortOption = "newest" | "oldest" | "mostExpensive" | "mostTokens" | "mostRelevant"
|
||||
|
||||
const HistoryView = ({ onDone }: HistoryViewProps) => {
|
||||
const { taskHistory } = useExtensionState()
|
||||
const { taskHistory, totalTasksSize } = useExtensionState()
|
||||
const [searchQuery, setSearchQuery] = useState("")
|
||||
const [sortOption, setSortOption] = useState<SortOption>("newest")
|
||||
const [lastNonRelevantSort, setLastNonRelevantSort] = useState<SortOption | null>("newest")
|
||||
@@ -28,9 +28,13 @@ const HistoryView = ({ onDone }: HistoryViewProps) => {
|
||||
setDeleteAllDisabled(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEvent("message", handleMessage)
|
||||
|
||||
// Request total tasks size when component mounts
|
||||
useEffect(() => {
|
||||
vscode.postMessage({ type: "requestTotalTasksSize" })
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (searchQuery && sortOption !== "mostRelevant" && !lastNonRelevantSort) {
|
||||
setLastNonRelevantSort(sortOption)
|
||||
@@ -471,7 +475,7 @@ const HistoryView = ({ onDone }: HistoryViewProps) => {
|
||||
setDeleteAllDisabled(true)
|
||||
vscode.postMessage({ type: "clearAllTaskHistory" })
|
||||
}}>
|
||||
Delete All History
|
||||
Delete All History{totalTasksSize !== null ? ` (${formatSize(totalTasksSize)})` : ""}
|
||||
</DangerButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,336 @@
|
||||
import React, { useEffect, useRef } from "react"
|
||||
import { vscode } from "../../utils/vscode"
|
||||
import DOMPurify from "dompurify"
|
||||
import { getSafeHostname, formatUrlForOpening, checkIfImageUrl } from "./McpRichUtil"
|
||||
import ChatErrorBoundary from "../chat/ChatErrorBoundary"
|
||||
|
||||
interface ImagePreviewProps {
|
||||
url: string
|
||||
}
|
||||
|
||||
// Use a class component to ensure complete isolation between instances
|
||||
class ImagePreview extends React.Component<
|
||||
ImagePreviewProps,
|
||||
{
|
||||
loading: boolean
|
||||
error: string | null
|
||||
fetchStartTime: number
|
||||
}
|
||||
> {
|
||||
private imgRef = React.createRef<HTMLImageElement>()
|
||||
private timeoutId: NodeJS.Timeout | null = null
|
||||
private heartbeatId: NodeJS.Timeout | null = null
|
||||
|
||||
constructor(props: ImagePreviewProps) {
|
||||
super(props)
|
||||
this.state = {
|
||||
loading: true,
|
||||
error: null,
|
||||
fetchStartTime: Date.now(),
|
||||
}
|
||||
}
|
||||
|
||||
// Track aspect ratio for proper display
|
||||
private aspectRatio: number = 1
|
||||
|
||||
componentDidMount() {
|
||||
// Set up a timeout to handle cases where the image never loads or errors
|
||||
this.timeoutId = setTimeout(() => {
|
||||
console.log(`Image load timeout for ${this.props.url}`)
|
||||
if (this.state.loading) {
|
||||
this.setState({
|
||||
loading: false,
|
||||
error: `Timeout loading image: ${this.props.url}`,
|
||||
})
|
||||
}
|
||||
}, 15000)
|
||||
|
||||
// Set up a heartbeat to update the UI with elapsed time
|
||||
this.heartbeatId = setInterval(() => {
|
||||
if (this.state.loading) {
|
||||
this.forceUpdate() // Just update the component to show new elapsed time
|
||||
}
|
||||
}, 1000)
|
||||
|
||||
// First, check the content type to verify it's actually an image
|
||||
this.checkContentType(this.props.url)
|
||||
}
|
||||
|
||||
// Check if the URL is an image using content type verification
|
||||
checkContentType(url: string) {
|
||||
// Always verify content type, even for URLs that look like images by extension
|
||||
checkIfImageUrl(url)
|
||||
.then((isImage) => {
|
||||
if (isImage) {
|
||||
console.log(`URL is confirmed as image: ${url}`)
|
||||
this.loadImage(url)
|
||||
} else {
|
||||
console.log(`URL is not an image: ${url}`)
|
||||
this.handleImageError()
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.log(`Error checking if URL is an image: ${error}`)
|
||||
// Don't fallback to direct image loading on error
|
||||
// Instead, report the error so the URL can be handled as a non-image
|
||||
this.handleImageError()
|
||||
})
|
||||
}
|
||||
|
||||
// Load the image after content type check or as fallback
|
||||
loadImage(url: string) {
|
||||
const isSvg = /\.svg(\?.*)?$/i.test(url)
|
||||
|
||||
// For SVG files, we don't need to calculate aspect ratio as they're vector-based
|
||||
if (isSvg) {
|
||||
console.log(`SVG image detected, skipping aspect ratio calculation: ${url}`)
|
||||
// Default aspect ratio for SVGs
|
||||
this.aspectRatio = 1
|
||||
this.handleImageLoad()
|
||||
return
|
||||
}
|
||||
|
||||
// Create a test image to check if the URL loads and get dimensions
|
||||
const testImg = new Image()
|
||||
|
||||
testImg.onload = () => {
|
||||
console.log(`Test image loaded successfully: ${url}`)
|
||||
|
||||
// Calculate aspect ratio for proper display
|
||||
if (testImg.width > 0 && testImg.height > 0) {
|
||||
this.aspectRatio = testImg.width / testImg.height
|
||||
}
|
||||
|
||||
this.handleImageLoad()
|
||||
}
|
||||
|
||||
testImg.onerror = () => {
|
||||
console.log(`Test image failed to load: ${url}`)
|
||||
this.handleImageError()
|
||||
}
|
||||
|
||||
// Force CORS mode to be anonymous to avoid CORS issues
|
||||
testImg.crossOrigin = "anonymous"
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.cleanup()
|
||||
}
|
||||
|
||||
private cleanup() {
|
||||
if (this.timeoutId) {
|
||||
clearTimeout(this.timeoutId)
|
||||
this.timeoutId = null
|
||||
}
|
||||
|
||||
if (this.heartbeatId) {
|
||||
clearInterval(this.heartbeatId)
|
||||
this.heartbeatId = null
|
||||
}
|
||||
}
|
||||
|
||||
// Handle image load event
|
||||
handleImageLoad = () => {
|
||||
console.log(`Image loaded successfully: ${this.props.url}`)
|
||||
this.setState({ loading: false })
|
||||
this.cleanup()
|
||||
}
|
||||
|
||||
// Handle image error event
|
||||
handleImageError = () => {
|
||||
console.log(`Image failed to load: ${this.props.url}`)
|
||||
this.setState({
|
||||
loading: false,
|
||||
error: `Failed to load image: ${this.props.url}`,
|
||||
})
|
||||
this.cleanup()
|
||||
}
|
||||
|
||||
render() {
|
||||
const { url } = this.props
|
||||
const { loading, error, fetchStartTime } = this.state
|
||||
|
||||
// Calculate elapsed time for loading state
|
||||
const elapsedSeconds = loading ? Math.floor((Date.now() - fetchStartTime) / 1000) : 0
|
||||
|
||||
// Fallback display while loading
|
||||
if (loading) {
|
||||
return (
|
||||
<div
|
||||
className="image-preview-loading"
|
||||
style={{
|
||||
padding: "12px",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
border: "1px solid var(--vscode-editorWidget-border, rgba(127, 127, 127, 0.3))",
|
||||
borderRadius: "4px",
|
||||
height: "128px",
|
||||
maxWidth: "512px",
|
||||
}}>
|
||||
<div style={{ display: "flex", alignItems: "center", marginBottom: "8px" }}>
|
||||
<div
|
||||
className="loading-spinner"
|
||||
style={{
|
||||
marginRight: "8px",
|
||||
width: "16px",
|
||||
height: "16px",
|
||||
border: "2px solid rgba(127, 127, 127, 0.3)",
|
||||
borderTopColor: "var(--vscode-textLink-foreground, #3794ff)",
|
||||
borderRadius: "50%",
|
||||
animation: "spin 1s linear infinite",
|
||||
}}
|
||||
/>
|
||||
<style>
|
||||
{`
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
`}
|
||||
</style>
|
||||
Loading image from {getSafeHostname(url)}...
|
||||
</div>
|
||||
{elapsedSeconds > 3 && (
|
||||
<div style={{ fontSize: "11px", color: "var(--vscode-descriptionForeground)" }}>
|
||||
{elapsedSeconds > 60
|
||||
? `Waiting for ${Math.floor(elapsedSeconds / 60)}m ${elapsedSeconds % 60}s...`
|
||||
: `Waiting for ${elapsedSeconds}s...`}
|
||||
</div>
|
||||
)}
|
||||
{/* Hidden image that we'll use to detect load/error events */}
|
||||
{/\.svg(\?.*)?$/i.test(url) ? (
|
||||
<object
|
||||
type="image/svg+xml"
|
||||
data={DOMPurify.sanitize(url)}
|
||||
style={{ display: "none" }}
|
||||
onLoad={this.handleImageLoad}
|
||||
onError={this.handleImageError}
|
||||
/>
|
||||
) : (
|
||||
<img
|
||||
src={DOMPurify.sanitize(url)}
|
||||
alt=""
|
||||
ref={this.imgRef}
|
||||
onLoad={this.handleImageLoad}
|
||||
onError={this.handleImageError}
|
||||
style={{ display: "none" }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Handle error state
|
||||
if (error) {
|
||||
return (
|
||||
<div
|
||||
className="image-preview-error"
|
||||
style={{
|
||||
padding: "12px",
|
||||
border: "1px solid var(--vscode-editorWidget-border, rgba(127, 127, 127, 0.3))",
|
||||
borderRadius: "4px",
|
||||
color: "var(--vscode-errorForeground)",
|
||||
}}
|
||||
onClick={() => {
|
||||
vscode.postMessage({
|
||||
type: "openInBrowser",
|
||||
url: DOMPurify.sanitize(url),
|
||||
})
|
||||
}}>
|
||||
<div style={{ fontWeight: "bold" }}>Failed to load image</div>
|
||||
<div style={{ fontSize: "12px", marginTop: "4px" }}>{getSafeHostname(url)}</div>
|
||||
<div style={{ fontSize: "11px", marginTop: "8px", color: "var(--vscode-textLink-foreground)" }}>
|
||||
Click to open in browser
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Render the image
|
||||
return (
|
||||
<div
|
||||
className="image-preview"
|
||||
style={{
|
||||
margin: "10px 0",
|
||||
maxWidth: "100%",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
onClick={() => {
|
||||
vscode.postMessage({
|
||||
type: "openInBrowser",
|
||||
url: DOMPurify.sanitize(formatUrlForOpening(url)),
|
||||
})
|
||||
}}>
|
||||
{/\.svg(\?.*)?$/i.test(url) ? (
|
||||
// Special handling for SVG images
|
||||
<object
|
||||
type="image/svg+xml"
|
||||
data={DOMPurify.sanitize(url)}
|
||||
style={{
|
||||
width: "85%",
|
||||
height: "auto",
|
||||
borderRadius: "4px",
|
||||
}}
|
||||
aria-label={`SVG from ${getSafeHostname(url)}`}>
|
||||
{/* Fallback if object tag fails */}
|
||||
<img
|
||||
src={DOMPurify.sanitize(url)}
|
||||
alt={`SVG from ${getSafeHostname(url)}`}
|
||||
style={{
|
||||
width: "85%",
|
||||
height: "auto",
|
||||
borderRadius: "4px",
|
||||
}}
|
||||
/>
|
||||
</object>
|
||||
) : (
|
||||
<img
|
||||
src={DOMPurify.sanitize(url)}
|
||||
alt={`Image from ${getSafeHostname(url)}`}
|
||||
style={{
|
||||
width: "85%",
|
||||
height: "auto",
|
||||
borderRadius: "4px",
|
||||
// Use contain only for very extreme aspect ratios, otherwise use cover
|
||||
objectFit: this.aspectRatio > 3 || this.aspectRatio < 0.33 ? "contain" : "cover",
|
||||
}}
|
||||
loading="eager"
|
||||
onLoad={(e) => {
|
||||
// Double-check aspect ratio from the actual loaded image
|
||||
const img = e.currentTarget
|
||||
if (img.naturalWidth > 0 && img.naturalHeight > 0) {
|
||||
const newAspectRatio = img.naturalWidth / img.naturalHeight
|
||||
|
||||
// Update object-fit based on actual aspect ratio
|
||||
// Use contain only for very extreme aspect ratios, otherwise use cover
|
||||
if (newAspectRatio > 3 || newAspectRatio < 0.33) {
|
||||
img.style.objectFit = "contain"
|
||||
} else {
|
||||
img.style.objectFit = "cover"
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Create a wrapper component that memoizes the ImagePreview to prevent unnecessary re-renders
|
||||
const MemoizedImagePreview = React.memo(
|
||||
(props: ImagePreviewProps) => <ImagePreview {...props} />,
|
||||
(prevProps, nextProps) => prevProps.url === nextProps.url, // Only re-render if URL changes
|
||||
)
|
||||
|
||||
// Wrap the ImagePreview component with an error boundary
|
||||
const ImagePreviewWithErrorBoundary: React.FC<ImagePreviewProps> = (props) => {
|
||||
return (
|
||||
<ChatErrorBoundary errorTitle="Something went wrong displaying this image">
|
||||
<MemoizedImagePreview {...props} />
|
||||
</ChatErrorBoundary>
|
||||
)
|
||||
}
|
||||
|
||||
export default ImagePreviewWithErrorBoundary
|
||||
@@ -1,6 +1,8 @@
|
||||
import React, { useEffect, useState } from "react"
|
||||
import { vscode } from "../../utils/vscode"
|
||||
import DOMPurify from "dompurify"
|
||||
import { getSafeHostname, normalizeRelativeUrl } from "./McpRichUtil"
|
||||
import ChatErrorBoundary from "../chat/ChatErrorBoundary"
|
||||
|
||||
interface OpenGraphData {
|
||||
title?: string
|
||||
@@ -15,174 +17,376 @@ interface LinkPreviewProps {
|
||||
url: string
|
||||
}
|
||||
|
||||
const LinkPreview: React.FC<LinkPreviewProps> = ({ url }) => {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [ogData, setOgData] = useState<OpenGraphData | null>(null)
|
||||
// Error types for better UI feedback
|
||||
type ErrorType = "timeout" | "network" | "general" | null
|
||||
|
||||
useEffect(() => {
|
||||
const fetchOpenGraphData = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
// Use a class component to ensure complete isolation between instances
|
||||
class LinkPreview extends React.Component<
|
||||
LinkPreviewProps,
|
||||
{
|
||||
loading: boolean
|
||||
error: ErrorType
|
||||
errorMessage: string | null
|
||||
ogData: OpenGraphData | null
|
||||
hasCompletedFetch: boolean // Track if fetch has completed (success or error)
|
||||
fetchStartTime: number // Track when the fetch started
|
||||
}
|
||||
> {
|
||||
private messageListener: ((event: MessageEvent) => void) | null = null
|
||||
private timeoutId: NodeJS.Timeout | null = null
|
||||
private heartbeatId: NodeJS.Timeout | null = null
|
||||
|
||||
// Send a message to the extension to fetch Open Graph data
|
||||
vscode.postMessage({
|
||||
type: "fetchOpenGraphData",
|
||||
text: url,
|
||||
})
|
||||
constructor(props: LinkPreviewProps) {
|
||||
super(props)
|
||||
this.state = {
|
||||
loading: true,
|
||||
error: null,
|
||||
errorMessage: null,
|
||||
ogData: null,
|
||||
hasCompletedFetch: false,
|
||||
fetchStartTime: 0,
|
||||
}
|
||||
}
|
||||
|
||||
// Set up a listener for the response
|
||||
const messageListener = (event: MessageEvent) => {
|
||||
const message = event.data
|
||||
if (message.type === "openGraphData" && message.url === url) {
|
||||
setOgData(message.openGraphData)
|
||||
setLoading(false)
|
||||
window.removeEventListener("message", messageListener)
|
||||
}
|
||||
}
|
||||
componentDidMount() {
|
||||
// Only fetch if we haven't completed a fetch yet
|
||||
if (!this.state.hasCompletedFetch) {
|
||||
this.fetchOpenGraphData()
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("message", messageListener)
|
||||
componentWillUnmount() {
|
||||
this.cleanup()
|
||||
}
|
||||
|
||||
// Clean up the listener if the component unmounts
|
||||
return () => {
|
||||
window.removeEventListener("message", messageListener)
|
||||
}
|
||||
} catch (err) {
|
||||
setError("Failed to fetch preview data")
|
||||
setLoading(false)
|
||||
}
|
||||
// Prevent updates if fetch has completed
|
||||
shouldComponentUpdate(nextProps: LinkPreviewProps, nextState: any) {
|
||||
// If URL changes, allow update
|
||||
if (nextProps.url !== this.props.url) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Fetch Open Graph data immediately when component mounts
|
||||
fetchOpenGraphData()
|
||||
}, [url])
|
||||
// If we've completed a fetch and state hasn't changed, prevent update
|
||||
if (
|
||||
this.state.hasCompletedFetch &&
|
||||
this.state.loading === nextState.loading &&
|
||||
this.state.error === nextState.error &&
|
||||
this.state.ogData === nextState.ogData
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Fallback display while loading
|
||||
if (loading) {
|
||||
return true
|
||||
}
|
||||
|
||||
private cleanup() {
|
||||
// Clean up event listeners and timeouts
|
||||
if (this.messageListener) {
|
||||
window.removeEventListener("message", this.messageListener)
|
||||
this.messageListener = null
|
||||
}
|
||||
|
||||
if (this.timeoutId) {
|
||||
clearTimeout(this.timeoutId)
|
||||
this.timeoutId = null
|
||||
}
|
||||
|
||||
if (this.heartbeatId) {
|
||||
clearInterval(this.heartbeatId)
|
||||
this.heartbeatId = null
|
||||
}
|
||||
}
|
||||
|
||||
private fetchOpenGraphData() {
|
||||
try {
|
||||
// Record fetch start time
|
||||
const startTime = Date.now()
|
||||
this.setState({ fetchStartTime: startTime })
|
||||
|
||||
// Send a message to the extension to fetch Open Graph data
|
||||
vscode.postMessage({
|
||||
type: "fetchOpenGraphData",
|
||||
text: this.props.url,
|
||||
})
|
||||
|
||||
// Set up a listener for the response
|
||||
this.messageListener = (event: MessageEvent) => {
|
||||
const message = event.data
|
||||
if (message.type === "openGraphData" && message.url === this.props.url) {
|
||||
// Check if there was an error in the response
|
||||
if (message.error) {
|
||||
this.setState({
|
||||
error: "network",
|
||||
errorMessage: message.error,
|
||||
loading: false,
|
||||
hasCompletedFetch: true,
|
||||
})
|
||||
} else {
|
||||
this.setState({
|
||||
ogData: message.openGraphData,
|
||||
loading: false,
|
||||
hasCompletedFetch: true, // Mark as completed
|
||||
})
|
||||
}
|
||||
this.cleanup()
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("message", this.messageListener)
|
||||
|
||||
// Instead of a fixed timeout, use a heartbeat to update the loading message
|
||||
// with the elapsed time, but don't actually timeout
|
||||
this.heartbeatId = setInterval(() => {
|
||||
const elapsedSeconds = Math.floor((Date.now() - startTime) / 1000)
|
||||
if (elapsedSeconds > 0) {
|
||||
this.forceUpdate() // Just update the component to show new elapsed time
|
||||
}
|
||||
}, 1000)
|
||||
} catch (err) {
|
||||
this.setState({
|
||||
error: "general",
|
||||
errorMessage: err instanceof Error ? err.message : "Unknown error occurred",
|
||||
loading: false,
|
||||
hasCompletedFetch: true, // Mark as completed on error
|
||||
})
|
||||
this.cleanup()
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const { url } = this.props
|
||||
const { loading, error, errorMessage, ogData, fetchStartTime } = this.state
|
||||
|
||||
// Calculate elapsed time for loading state
|
||||
const elapsedSeconds = loading ? Math.floor((Date.now() - fetchStartTime) / 1000) : 0
|
||||
|
||||
// Fallback display while loading
|
||||
if (loading) {
|
||||
return (
|
||||
<div
|
||||
className="link-preview-loading"
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
border: "1px solid var(--vscode-editorWidget-border, rgba(127, 127, 127, 0.3))",
|
||||
borderRadius: "4px",
|
||||
height: "128px",
|
||||
maxWidth: "512px",
|
||||
}}>
|
||||
<div style={{ display: "flex", alignItems: "center", marginBottom: "8px" }}>
|
||||
<div
|
||||
className="loading-spinner"
|
||||
style={{
|
||||
marginRight: "8px",
|
||||
width: "16px",
|
||||
height: "16px",
|
||||
border: "2px solid rgba(127, 127, 127, 0.3)",
|
||||
borderTopColor: "var(--vscode-textLink-foreground, #3794ff)",
|
||||
borderRadius: "50%",
|
||||
animation: "spin 1s linear infinite",
|
||||
}}
|
||||
/>
|
||||
<style>
|
||||
{`
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
`}
|
||||
</style>
|
||||
Loading preview for {getSafeHostname(url)}...
|
||||
</div>
|
||||
{elapsedSeconds > 5 && (
|
||||
<div style={{ fontSize: "11px", color: "var(--vscode-descriptionForeground)" }}>
|
||||
{elapsedSeconds > 60
|
||||
? `Waiting for ${Math.floor(elapsedSeconds / 60)}m ${elapsedSeconds % 60}s...`
|
||||
: `Waiting for ${elapsedSeconds}s...`}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Handle different error states with specific messages
|
||||
if (error) {
|
||||
let errorDisplay = "Unable to load preview"
|
||||
|
||||
if (error === "timeout") {
|
||||
errorDisplay = "Preview request timed out"
|
||||
} else if (error === "network") {
|
||||
errorDisplay = "Network error loading preview"
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="link-preview-error"
|
||||
style={{
|
||||
padding: "12px",
|
||||
border: "1px solid var(--vscode-editorWidget-border, rgba(127, 127, 127, 0.3))",
|
||||
borderRadius: "4px",
|
||||
color: "var(--vscode-errorForeground)",
|
||||
height: "128px",
|
||||
maxWidth: "512px",
|
||||
overflow: "auto",
|
||||
}}
|
||||
onClick={() => {
|
||||
vscode.postMessage({
|
||||
type: "openInBrowser",
|
||||
url: DOMPurify.sanitize(url),
|
||||
})
|
||||
}}>
|
||||
<div style={{ fontWeight: "bold" }}>{errorDisplay}</div>
|
||||
<div style={{ fontSize: "12px", marginTop: "4px" }}>{getSafeHostname(url)}</div>
|
||||
{errorMessage && <div style={{ fontSize: "11px", marginTop: "4px", opacity: 0.8 }}>{errorMessage}</div>}
|
||||
<div style={{ fontSize: "11px", marginTop: "8px", color: "var(--vscode-textLink-foreground)" }}>
|
||||
Click to open in browser
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Create a fallback object if ogData is null
|
||||
const data = ogData || {
|
||||
title: getSafeHostname(url),
|
||||
description: "No description available",
|
||||
siteName: getSafeHostname(url),
|
||||
url: url,
|
||||
}
|
||||
|
||||
// Render the Open Graph preview
|
||||
return (
|
||||
<div
|
||||
className="link-preview-loading"
|
||||
className="link-preview"
|
||||
style={{
|
||||
padding: "12px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
border: "1px solid var(--vscode-editorWidget-border, rgba(127, 127, 127, 0.3))",
|
||||
borderRadius: "4px",
|
||||
overflow: "hidden",
|
||||
cursor: "pointer",
|
||||
height: "128px",
|
||||
maxWidth: "512px",
|
||||
}}
|
||||
onClick={() => {
|
||||
vscode.postMessage({
|
||||
type: "openInBrowser",
|
||||
url: DOMPurify.sanitize(url),
|
||||
})
|
||||
}}>
|
||||
{data.image && (
|
||||
<div className="link-preview-image" style={{ width: "128px", height: "128px", flexShrink: 0 }}>
|
||||
<img
|
||||
src={DOMPurify.sanitize(normalizeRelativeUrl(data.image, url))}
|
||||
alt=""
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
objectFit: "contain", // Use contain for link preview thumbnails to handle logos
|
||||
objectPosition: "center", // Center the image
|
||||
}}
|
||||
onLoad={(e) => {
|
||||
// Check aspect ratio to determine if we should use contain or cover
|
||||
const img = e.currentTarget
|
||||
if (img.naturalWidth > 0 && img.naturalHeight > 0) {
|
||||
const aspectRatio = img.naturalWidth / img.naturalHeight
|
||||
|
||||
// Use contain for extreme aspect ratios (logos), cover for photos
|
||||
if (aspectRatio > 2.5 || aspectRatio < 0.4) {
|
||||
img.style.objectFit = "contain"
|
||||
} else {
|
||||
img.style.objectFit = "cover"
|
||||
}
|
||||
}
|
||||
}}
|
||||
onError={(e) => {
|
||||
console.log(`Image could not be loaded: ${data.image}`)
|
||||
// Hide the broken image
|
||||
;(e.target as HTMLImageElement).style.display = "none"
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className="loading-spinner"
|
||||
className="link-preview-content"
|
||||
style={{
|
||||
marginRight: "8px",
|
||||
width: "16px",
|
||||
height: "16px",
|
||||
border: "2px solid rgba(127, 127, 127, 0.3)",
|
||||
borderTopColor: "var(--vscode-textLink-foreground, #3794ff)",
|
||||
borderRadius: "50%",
|
||||
animation: "spin 1s linear infinite",
|
||||
}}
|
||||
/>
|
||||
<style>
|
||||
{`
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
`}
|
||||
</style>
|
||||
Loading preview for {new URL(url).hostname}...
|
||||
flex: 1,
|
||||
padding: "12px",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
overflow: "hidden",
|
||||
height: "100%", // Ensure full height
|
||||
}}>
|
||||
{/* Top section with title and URL - top aligned */}
|
||||
<div className="link-preview-top">
|
||||
<div
|
||||
className="link-preview-title"
|
||||
style={{
|
||||
fontWeight: "bold",
|
||||
marginBottom: "4px",
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
}}>
|
||||
{data.title || "No title"}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="link-preview-url"
|
||||
style={{
|
||||
fontSize: "12px",
|
||||
color: "var(--vscode-textLink-foreground, #3794ff)",
|
||||
marginBottom: "8px", // Increased for better separation
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
}}>
|
||||
{data.siteName || getSafeHostname(url)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description with space-around in the remaining space */}
|
||||
<div
|
||||
className="link-preview-description-container"
|
||||
style={{
|
||||
flex: 1, // Take up remaining space
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
justifyContent: "space-around", // Space around in the remaining area
|
||||
}}>
|
||||
<div
|
||||
className="link-preview-description"
|
||||
style={{
|
||||
fontSize: "12px",
|
||||
color: "var(--vscode-descriptionForeground, rgba(204, 204, 204, 0.7))",
|
||||
overflow: "hidden",
|
||||
display: "-webkit-box",
|
||||
WebkitLineClamp: 3,
|
||||
WebkitBoxOrient: "vertical",
|
||||
textOverflow: "ellipsis",
|
||||
}}>
|
||||
{data.description || "No description available"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Create a fallback object if ogData is null
|
||||
const data = ogData || {
|
||||
title: new URL(url).hostname,
|
||||
description: "No description available",
|
||||
siteName: new URL(url).hostname,
|
||||
url: url,
|
||||
}
|
||||
// Create a wrapper component that memoizes the LinkPreview to prevent unnecessary re-renders
|
||||
const MemoizedLinkPreview = React.memo(
|
||||
(props: LinkPreviewProps) => <LinkPreview {...props} />,
|
||||
(prevProps, nextProps) => prevProps.url === nextProps.url, // Only re-render if URL changes
|
||||
)
|
||||
|
||||
// Render the Open Graph preview
|
||||
// Wrap the LinkPreview component with an error boundary
|
||||
const LinkPreviewWithErrorBoundary: React.FC<LinkPreviewProps> = (props) => {
|
||||
return (
|
||||
<div
|
||||
className="link-preview"
|
||||
style={{
|
||||
display: "flex",
|
||||
border: "1px solid var(--vscode-editorWidget-border, rgba(127, 127, 127, 0.3))",
|
||||
borderRadius: "4px",
|
||||
overflow: "hidden",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
onClick={() => {
|
||||
vscode.postMessage({
|
||||
type: "openInBrowser",
|
||||
url: DOMPurify.sanitize(url),
|
||||
})
|
||||
}}>
|
||||
{data.image && (
|
||||
<div className="link-preview-image" style={{ width: "128px", height: "128px", flexShrink: 0 }}>
|
||||
<img
|
||||
src={DOMPurify.sanitize(data.image)}
|
||||
alt=""
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
objectFit: "cover",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className="link-preview-content"
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: "12px",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
overflow: "hidden",
|
||||
}}>
|
||||
<div
|
||||
className="link-preview-title"
|
||||
style={{
|
||||
fontWeight: "bold",
|
||||
marginBottom: "4px",
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
}}>
|
||||
{data.title || "No title"}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="link-preview-url"
|
||||
style={{
|
||||
fontSize: "12px",
|
||||
color: "var(--vscode-textLink-foreground, #3794ff)",
|
||||
marginBottom: "8px",
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
}}>
|
||||
{data.siteName || new URL(url).hostname}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="link-preview-description"
|
||||
style={{
|
||||
fontSize: "12px",
|
||||
color: "var(--vscode-descriptionForeground, rgba(204, 204, 204, 0.7))",
|
||||
overflow: "hidden",
|
||||
display: "-webkit-box",
|
||||
WebkitLineClamp: 3,
|
||||
WebkitBoxOrient: "vertical",
|
||||
textOverflow: "ellipsis",
|
||||
}}>
|
||||
{data.description || "No description available"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ChatErrorBoundary errorTitle="Something went wrong displaying this link preview">
|
||||
<MemoizedLinkPreview {...props} />
|
||||
</ChatErrorBoundary>
|
||||
)
|
||||
}
|
||||
|
||||
export default LinkPreview
|
||||
export default LinkPreviewWithErrorBoundary
|
||||
|
||||
@@ -1,180 +1,23 @@
|
||||
import React, { useEffect, useState, useCallback } from "react"
|
||||
import { vscode } from "../../utils/vscode"
|
||||
import LinkPreview from "./LinkPreview"
|
||||
import ImagePreview from "./ImagePreview"
|
||||
import { vscode } from "../../utils/vscode"
|
||||
import DOMPurify from "dompurify"
|
||||
import styled from "styled-components"
|
||||
import { CODE_BLOCK_BG_COLOR } from "../common/CodeBlock"
|
||||
import DOMPurify from "dompurify"
|
||||
import ChatErrorBoundary from "../chat/ChatErrorBoundary"
|
||||
import {
|
||||
safeCreateUrl,
|
||||
isUrl,
|
||||
getSafeHostname,
|
||||
isLocalhostUrl,
|
||||
normalizeRelativeUrl,
|
||||
formatUrlForOpening,
|
||||
checkIfImageUrl,
|
||||
} from "./McpRichUtil"
|
||||
|
||||
// We'll use the backend isImageUrl function for HEAD requests
|
||||
// This is a client-side fallback for data URLs and obvious image extensions
|
||||
const isImageUrlSync = (str: string): boolean => {
|
||||
// Check for data URLs which are definitely images
|
||||
if (str.startsWith("data:image/")) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check for common image file extensions
|
||||
return str.match(/\.(jpg|jpeg|png|gif|webp)$/i) !== null
|
||||
}
|
||||
|
||||
export const isUrl = (str: string): boolean => {
|
||||
// Basic URL validation
|
||||
const urlPattern = /^(https?:\/\/)?([a-zA-Z0-9-]+\.)+[a-zA-Z]{2,}(\/[^\s]*)?$/
|
||||
return urlPattern.test(str)
|
||||
}
|
||||
|
||||
// Function to check if a URL is an image using HEAD request
|
||||
export const checkIfImageUrl = async (url: string): Promise<boolean> => {
|
||||
// For data URLs, we can check synchronously
|
||||
if (url.startsWith("data:image/")) {
|
||||
return true
|
||||
}
|
||||
|
||||
// For http/https URLs, we need to send a message to the extension
|
||||
if (url.startsWith("http")) {
|
||||
try {
|
||||
// Create a promise that will resolve when we get a response
|
||||
return new Promise((resolve) => {
|
||||
// Set up a one-time listener for the response
|
||||
const messageListener = (event: MessageEvent) => {
|
||||
const message = event.data
|
||||
if (message.type === "isImageUrlResult" && message.url === url) {
|
||||
window.removeEventListener("message", messageListener)
|
||||
resolve(message.isImage)
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("message", messageListener)
|
||||
|
||||
// Send the request to the extension
|
||||
vscode.postMessage({
|
||||
type: "checkIsImageUrl",
|
||||
text: url,
|
||||
})
|
||||
|
||||
// Set a timeout to avoid hanging indefinitely
|
||||
setTimeout(() => {
|
||||
window.removeEventListener("message", messageListener)
|
||||
// Fall back to extension check
|
||||
resolve(isImageUrlSync(url))
|
||||
}, 3000)
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Error checking if URL is an image:", error)
|
||||
return isImageUrlSync(url)
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to extension check for other URLs
|
||||
return isImageUrlSync(url)
|
||||
}
|
||||
|
||||
// No longer needed as our regex directly extracts the URL part
|
||||
|
||||
// Helper to ensure URL is in a format that can be opened
|
||||
export const formatUrlForOpening = (url: string): string => {
|
||||
// If it's a data URI, return as is
|
||||
if (url.startsWith("data:image/")) {
|
||||
return url
|
||||
}
|
||||
|
||||
// If it's a regular URL but doesn't have a protocol, add https://
|
||||
if (!url.startsWith("http://") && !url.startsWith("https://")) {
|
||||
return `https://${url}`
|
||||
}
|
||||
|
||||
return url
|
||||
}
|
||||
|
||||
// Find all URLs (both image and regular) in an object
|
||||
export const findUrls = async (obj: any): Promise<{ imageUrls: string[]; regularUrls: string[] }> => {
|
||||
const imageUrls: string[] = []
|
||||
const regularUrls: string[] = []
|
||||
const pendingChecks: Promise<void>[] = []
|
||||
|
||||
if (typeof obj === "object" && obj !== null) {
|
||||
for (const value of Object.values(obj)) {
|
||||
if (typeof value === "string") {
|
||||
// First check with synchronous method
|
||||
if (isImageUrlSync(value)) {
|
||||
imageUrls.push(value)
|
||||
} else if (isUrl(value)) {
|
||||
// For URLs that don't obviously look like images, we'll check asynchronously
|
||||
const checkPromise = checkIfImageUrl(value).then((isImage) => {
|
||||
if (isImage) {
|
||||
imageUrls.push(value)
|
||||
} else {
|
||||
regularUrls.push(value)
|
||||
}
|
||||
})
|
||||
pendingChecks.push(checkPromise)
|
||||
}
|
||||
} else if (typeof value === "object") {
|
||||
const nestedUrlsPromise = findUrls(value).then((nestedUrls) => {
|
||||
imageUrls.push(...nestedUrls.imageUrls)
|
||||
regularUrls.push(...nestedUrls.regularUrls)
|
||||
})
|
||||
pendingChecks.push(nestedUrlsPromise)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for all async checks to complete
|
||||
await Promise.all(pendingChecks)
|
||||
|
||||
return { imageUrls, regularUrls }
|
||||
}
|
||||
|
||||
// Extract URLs from text using regex
|
||||
export const extractUrlsFromText = async (text: string): Promise<{ imageUrls: string[]; regularUrls: string[] }> => {
|
||||
const imageUrls: string[] = []
|
||||
const regularUrls: string[] = []
|
||||
const pendingChecks: Promise<void>[] = []
|
||||
|
||||
// Match URLs with image: prefix and extract just the URL part
|
||||
const imageMatches = text.match(/image:\s*(https?:\/\/[^\s]+)/g)
|
||||
if (imageMatches) {
|
||||
// Extract just the URL part from matches with image: prefix
|
||||
const extractedUrls = imageMatches
|
||||
.map((match) => {
|
||||
const urlMatch = /image:\s*(https?:\/\/[^\s]+)/.exec(match)
|
||||
return urlMatch ? urlMatch[1] : null
|
||||
})
|
||||
.filter(Boolean) as string[]
|
||||
|
||||
imageUrls.push(...extractedUrls)
|
||||
}
|
||||
|
||||
// Match all URLs (including those that might be in the middle of paragraphs)
|
||||
const urlMatches = text.match(/https?:\/\/[^\s]+/g)
|
||||
if (urlMatches) {
|
||||
// Filter out URLs that are already in imageUrls
|
||||
const filteredUrls = urlMatches.filter((url) => !imageUrls.includes(url))
|
||||
|
||||
// Check each URL to see if it's an image
|
||||
for (const url of filteredUrls) {
|
||||
// First check with synchronous method
|
||||
if (isImageUrlSync(url)) {
|
||||
imageUrls.push(url)
|
||||
} else {
|
||||
// For URLs that don't obviously look like images, we'll check asynchronously
|
||||
const checkPromise = checkIfImageUrl(url).then((isImage) => {
|
||||
if (isImage) {
|
||||
imageUrls.push(url)
|
||||
} else {
|
||||
regularUrls.push(url)
|
||||
}
|
||||
})
|
||||
pendingChecks.push(checkPromise)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for all async checks to complete
|
||||
await Promise.all(pendingChecks)
|
||||
|
||||
return { imageUrls, regularUrls }
|
||||
}
|
||||
// Maximum number of URLs to process in total, per response
|
||||
export const MAX_URLS = 50
|
||||
|
||||
const ResponseHeader = styled.div`
|
||||
display: flex;
|
||||
@@ -271,7 +114,7 @@ interface McpResponseDisplayProps {
|
||||
// Represents a URL found in the text with its position and metadata
|
||||
interface UrlMatch {
|
||||
url: string // The actual URL
|
||||
fullMatch: string // The full matched text (including any prefix like "image:")
|
||||
fullMatch: string // The full matched text
|
||||
index: number // Position in the text
|
||||
isImage: boolean // Whether this URL is an image
|
||||
isProcessed: boolean // Whether we've already processed this URL (to avoid duplicates)
|
||||
@@ -282,59 +125,159 @@ const McpResponseDisplay: React.FC<McpResponseDisplayProps> = ({ responseText })
|
||||
const [displayMode, setDisplayMode] = useState<"rich" | "plain">(() => {
|
||||
// Get saved preference from localStorage, default to 'rich'
|
||||
const savedMode = localStorage.getItem("mcpDisplayMode")
|
||||
return (savedMode === "plain" ? "plain" : "rich") as "rich" | "plain"
|
||||
return savedMode === "plain" ? "plain" : "rich"
|
||||
})
|
||||
const [urlMatches, setUrlMatches] = useState<UrlMatch[]>([])
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
// Add a counter state for forcing re-renders to make toggling run smoother
|
||||
const [forceUpdateCounter, setForceUpdateCounter] = useState(0)
|
||||
|
||||
const toggleDisplayMode = useCallback(() => {
|
||||
const newMode = displayMode === "rich" ? "plain" : "rich"
|
||||
|
||||
// Force an immediate re-render
|
||||
setForceUpdateCounter((prev) => prev + 1)
|
||||
|
||||
// Update display mode and save preference
|
||||
setDisplayMode(newMode)
|
||||
localStorage.setItem("mcpDisplayMode", newMode)
|
||||
|
||||
// If switching to plain mode, cancel any ongoing processing
|
||||
if (newMode === "plain") {
|
||||
console.log("Switching to plain mode - cancelling URL processing")
|
||||
setUrlMatches([]) // Clear any existing matches when switching to plain mode
|
||||
} else {
|
||||
// If switching to rich mode, the useEffect will re-run and fetch data
|
||||
console.log("Switching to rich mode - will start URL processing")
|
||||
}
|
||||
}, [displayMode])
|
||||
|
||||
// Find all URLs in the text and determine if they're images
|
||||
useEffect(() => {
|
||||
// Skip all processing if in plain mode
|
||||
if (displayMode === "plain") {
|
||||
setIsLoading(false)
|
||||
setUrlMatches([]) // Clear any existing matches when in plain mode
|
||||
return
|
||||
}
|
||||
|
||||
// Use a direct boolean for cancellation that's scoped to this effect run
|
||||
let processingCanceled = false
|
||||
|
||||
const processResponse = async () => {
|
||||
console.log("Processing MCP response for URL extraction")
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const text = responseText || ""
|
||||
const matches: UrlMatch[] = []
|
||||
|
||||
const urlRegex = /https?:\/\/[^\s]+/g
|
||||
const urlRegex = /https?:\/\/[^\s<>"']+/g
|
||||
let urlMatch: RegExpExecArray | null
|
||||
let urlCount = 0
|
||||
|
||||
while ((urlMatch = urlRegex.exec(text)) !== null) {
|
||||
// First pass: Extract all URLs and immediately make them available for rendering
|
||||
while ((urlMatch = urlRegex.exec(text)) !== null && urlCount < MAX_URLS) {
|
||||
// Get the original URL from the match - never modify the original URL text
|
||||
const url = urlMatch[0]
|
||||
const fullMatch = url
|
||||
|
||||
// Skip invalid URLs
|
||||
if (!isUrl(url)) {
|
||||
console.log("Skipping invalid URL:", url)
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip localhost URLs to prevent security issues
|
||||
if (isLocalhostUrl(url)) {
|
||||
console.log("Skipping localhost URL:", url)
|
||||
continue
|
||||
}
|
||||
|
||||
matches.push({
|
||||
url,
|
||||
fullMatch,
|
||||
fullMatch: url,
|
||||
index: urlMatch.index,
|
||||
isImage: false, // Will check later
|
||||
isProcessed: false,
|
||||
})
|
||||
|
||||
urlCount++
|
||||
}
|
||||
|
||||
// Check if URLs are images
|
||||
for (const match of matches) {
|
||||
match.isImage = await checkIfImageUrl(match.url)
|
||||
console.log(`Found ${matches.length} URLs in text, will check if they are images`)
|
||||
|
||||
// Set matches immediately so UI can start rendering with loading states
|
||||
setUrlMatches(matches.sort((a, b) => a.index - b.index))
|
||||
|
||||
// Mark loading as complete to show content immediately
|
||||
setIsLoading(false)
|
||||
|
||||
// Process image checks in the background - one at a time to avoid network flooding
|
||||
const processImageChecks = async () => {
|
||||
console.log(`Starting sequential URL processing for ${matches.length} URLs`)
|
||||
|
||||
for (let i = 0; i < matches.length; i++) {
|
||||
// Skip already processed URLs (from extension check)
|
||||
if (matches[i].isProcessed) continue
|
||||
|
||||
// Check if processing has been canceled (switched to plain mode)
|
||||
if (processingCanceled) {
|
||||
console.log("URL processing canceled - display mode changed to plain")
|
||||
return
|
||||
}
|
||||
|
||||
const match = matches[i]
|
||||
console.log(`Processing URL ${i + 1} of ${matches.length}: ${match.url}`)
|
||||
|
||||
try {
|
||||
// Process each URL individually
|
||||
const isImage = await checkIfImageUrl(match.url)
|
||||
|
||||
// Skip if processing has been canceled
|
||||
if (processingCanceled) return
|
||||
|
||||
// Update the match in place
|
||||
match.isImage = isImage
|
||||
match.isProcessed = true
|
||||
|
||||
// Update state after each URL to show progress
|
||||
// Create a new array to ensure React detects the state change
|
||||
setUrlMatches([...matches])
|
||||
} catch (err) {
|
||||
console.log(`URL check error: ${match.url}`, err)
|
||||
match.isProcessed = true
|
||||
|
||||
// Update state even on error
|
||||
if (!processingCanceled) {
|
||||
setUrlMatches([...matches])
|
||||
}
|
||||
}
|
||||
|
||||
// Delay between URL processing to avoid overwhelming the network
|
||||
if (!processingCanceled && i < matches.length - 1) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`URL processing complete. Found ${matches.filter((m) => m.isImage).length} image URLs`)
|
||||
}
|
||||
|
||||
// Sort by position in the text
|
||||
matches.sort((a, b) => a.index - b.index)
|
||||
|
||||
setUrlMatches(matches)
|
||||
// Start the background processing
|
||||
processImageChecks()
|
||||
} catch (error) {
|
||||
console.error("Error processing MCP response:", error)
|
||||
} finally {
|
||||
setError("Failed to process response content. Switch to plain text mode to view safely.")
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
processResponse()
|
||||
}, [responseText])
|
||||
|
||||
// Cleanup function to cancel processing if component unmounts or dependencies change
|
||||
return () => {
|
||||
processingCanceled = true
|
||||
console.log("Cleaning up URL processing")
|
||||
}
|
||||
}, [responseText, displayMode, forceUpdateCounter])
|
||||
|
||||
// Function to render content based on display mode
|
||||
const renderContent = () => {
|
||||
@@ -343,15 +286,26 @@ const McpResponseDisplay: React.FC<McpResponseDisplayProps> = ({ responseText })
|
||||
return <UrlText>{responseText}</UrlText>
|
||||
}
|
||||
|
||||
// Show error message if there was an error
|
||||
if (error) {
|
||||
return (
|
||||
<>
|
||||
<div style={{ color: "var(--vscode-errorForeground)", marginBottom: "10px" }}>{error}</div>
|
||||
<UrlText>{responseText}</UrlText>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// For rich display mode, show the text with embedded content
|
||||
if (displayMode === "rich" && !isLoading) {
|
||||
if (!isLoading) {
|
||||
// We already know displayMode is "rich" if we get here
|
||||
// Create an array of text segments and embedded content
|
||||
const segments: JSX.Element[] = []
|
||||
let lastIndex = 0
|
||||
let segmentIndex = 0
|
||||
|
||||
// Reset the processed flag for all URLs
|
||||
const processedUrls = new Set<string>()
|
||||
// Track embed count for logging
|
||||
let embedCount = 0
|
||||
|
||||
// Add the text before the first URL
|
||||
if (urlMatches.length === 0) {
|
||||
@@ -375,38 +329,51 @@ const McpResponseDisplay: React.FC<McpResponseDisplayProps> = ({ responseText })
|
||||
const urlEndIndex = index + fullMatch.length
|
||||
|
||||
// Add embedded content after the URL
|
||||
// For images, use the ImagePreview component
|
||||
if (match.isImage) {
|
||||
segments.push(
|
||||
<div key={`embed-${segmentIndex++}`} style={{ margin: "10px 0" }}>
|
||||
<img
|
||||
src={DOMPurify.sanitize(url)}
|
||||
alt={`Image for ${url}`}
|
||||
style={{
|
||||
width: "85%",
|
||||
height: "auto",
|
||||
borderRadius: "4px",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
onClick={() => {
|
||||
const formattedUrl = formatUrlForOpening(url)
|
||||
vscode.postMessage({
|
||||
type: "openInBrowser",
|
||||
url: DOMPurify.sanitize(formattedUrl),
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</div>,
|
||||
)
|
||||
} else if (!processedUrls.has(url)) {
|
||||
// For non-image URLs, only show the preview once
|
||||
segments.push(
|
||||
<div key={`embed-${segmentIndex++}`} style={{ margin: "10px 0" }}>
|
||||
<LinkPreview url={formatUrlForOpening(url)} />
|
||||
<div key={`embed-image-${url}-${segmentIndex++}`}>
|
||||
{/* Use formatUrlForOpening for network calls but preserve original URL in display */}
|
||||
<ImagePreview url={formatUrlForOpening(url)} />
|
||||
</div>,
|
||||
)
|
||||
embedCount++
|
||||
// console.log(`Added image embed for ${url}, embed count: ${embedCount}`);
|
||||
} else if (match.isProcessed) {
|
||||
// For non-image URLs or URLs we haven't processed yet, show link preview
|
||||
try {
|
||||
// Skip localhost URLs
|
||||
if (!isLocalhostUrl(url)) {
|
||||
// Use a unique key that includes the URL to ensure each preview is isolated
|
||||
segments.push(
|
||||
<div key={`embed-${url}-${segmentIndex++}`} style={{ margin: "10px 0" }}>
|
||||
{/* Already using formatUrlForOpening for link previews */}
|
||||
<LinkPreview url={formatUrlForOpening(url)} />
|
||||
</div>,
|
||||
)
|
||||
|
||||
// Mark this URL as processed
|
||||
processedUrls.add(url)
|
||||
embedCount++
|
||||
// console.log(`Added link preview for ${url}, embed count: ${embedCount}`);
|
||||
}
|
||||
} catch (e) {
|
||||
console.log("Link preview could not be created")
|
||||
// Show error message for failed link preview
|
||||
segments.push(
|
||||
<div
|
||||
key={`embed-error-${segmentIndex++}`}
|
||||
style={{
|
||||
margin: "10px 0",
|
||||
padding: "8px",
|
||||
color: "var(--vscode-errorForeground)",
|
||||
border: "1px solid var(--vscode-editorError-foreground)",
|
||||
borderRadius: "4px",
|
||||
height: "128px", // Fixed height
|
||||
overflow: "auto", // Allow scrolling if content overflows
|
||||
}}>
|
||||
Failed to create preview for: {url}
|
||||
</div>,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Update lastIndex for next segment
|
||||
@@ -442,7 +409,7 @@ const McpResponseDisplay: React.FC<McpResponseDisplayProps> = ({ responseText })
|
||||
</ResponseContainer>
|
||||
)
|
||||
} catch (error) {
|
||||
console.error("Error parsing MCP response:", error)
|
||||
console.log("Error rendering MCP response - falling back to plain text")
|
||||
return (
|
||||
<ResponseContainer>
|
||||
<ResponseHeader>
|
||||
@@ -457,4 +424,13 @@ const McpResponseDisplay: React.FC<McpResponseDisplayProps> = ({ responseText })
|
||||
}
|
||||
}
|
||||
|
||||
export default McpResponseDisplay
|
||||
// Wrap the entire McpResponseDisplay component with an error boundary
|
||||
const McpResponseDisplayWithErrorBoundary: React.FC<McpResponseDisplayProps> = (props) => {
|
||||
return (
|
||||
<ChatErrorBoundary>
|
||||
<McpResponseDisplay {...props} />
|
||||
</ChatErrorBoundary>
|
||||
)
|
||||
}
|
||||
|
||||
export default McpResponseDisplayWithErrorBoundary
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
import { vscode } from "../../utils/vscode"
|
||||
|
||||
// Safely create a URL object with error handling and ensure HTTPS
|
||||
export const safeCreateUrl = (url: string): URL | null => {
|
||||
try {
|
||||
// Convert HTTP to HTTPS for security
|
||||
if (url.startsWith("http://")) {
|
||||
url = url.replace("http://", "https://")
|
||||
}
|
||||
|
||||
return new URL(url)
|
||||
} catch (e) {
|
||||
// If the URL doesn't have a protocol, add https://
|
||||
if (!url.startsWith("https://")) {
|
||||
try {
|
||||
return new URL(`https://${url}`)
|
||||
} catch (e) {
|
||||
console.log(`Invalid URL: ${url}`)
|
||||
return null
|
||||
}
|
||||
}
|
||||
console.log(`Invalid URL: ${url}`)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// Check if a string is a valid URL
|
||||
export const isUrl = (str: string): boolean => {
|
||||
return safeCreateUrl(str) !== null
|
||||
}
|
||||
|
||||
// Get hostname safely
|
||||
export const getSafeHostname = (url: string): string => {
|
||||
try {
|
||||
const urlObj = safeCreateUrl(url)
|
||||
return urlObj ? urlObj.hostname : "unknown-host"
|
||||
} catch (e) {
|
||||
return "unknown-host"
|
||||
}
|
||||
}
|
||||
|
||||
// Check if a URL is a localhost URL by examining the hostname
|
||||
export const isLocalhostUrl = (url: string): boolean => {
|
||||
try {
|
||||
const hostname = getSafeHostname(url)
|
||||
return (
|
||||
hostname === "localhost" ||
|
||||
hostname === "127.0.0.1" ||
|
||||
hostname === "0.0.0.0" ||
|
||||
hostname.startsWith("192.168.") ||
|
||||
hostname.startsWith("10.") ||
|
||||
hostname.endsWith(".local")
|
||||
)
|
||||
} catch (e) {
|
||||
// If we can't parse the URL, assume it's not localhost
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Function to normalize relative URLs by combining with a base URL
|
||||
export const normalizeRelativeUrl = (relativeUrl: string, baseUrl: string): string => {
|
||||
// If it's already an absolute URL or a data URL, return as is
|
||||
if (relativeUrl.startsWith("http://") || relativeUrl.startsWith("https://") || relativeUrl.startsWith("data:")) {
|
||||
return relativeUrl
|
||||
}
|
||||
|
||||
try {
|
||||
// Parse the base URL
|
||||
const baseUrlObj = safeCreateUrl(baseUrl)
|
||||
if (!baseUrlObj) {
|
||||
return relativeUrl // If we can't parse the base URL, return original
|
||||
}
|
||||
|
||||
// Handle different types of relative paths
|
||||
if (relativeUrl.startsWith("//")) {
|
||||
// Protocol-relative URL
|
||||
return `${baseUrlObj.protocol}${relativeUrl}`
|
||||
} else if (relativeUrl.startsWith("/")) {
|
||||
// Root-relative URL
|
||||
return `${baseUrlObj.protocol}//${baseUrlObj.host}${relativeUrl}`
|
||||
} else {
|
||||
// Path-relative URL
|
||||
// Get the directory part of the URL
|
||||
let basePath = baseUrlObj.pathname
|
||||
if (!basePath.endsWith("/")) {
|
||||
// If the path doesn't end with a slash, remove the file part
|
||||
basePath = basePath.substring(0, basePath.lastIndexOf("/") + 1)
|
||||
}
|
||||
return `${baseUrlObj.protocol}//${baseUrlObj.host}${basePath}${relativeUrl}`
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(`Error normalizing relative URL: ${error}`)
|
||||
return relativeUrl // Return original on error
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to ensure URL is in a format that can be opened
|
||||
export const formatUrlForOpening = (url: string): string => {
|
||||
// If it's a data URI, return as is
|
||||
if (url.startsWith("data:image/")) {
|
||||
return url
|
||||
}
|
||||
|
||||
// Use safeCreateUrl to validate and format the URL
|
||||
const urlObj = safeCreateUrl(url)
|
||||
if (urlObj) {
|
||||
return urlObj.href
|
||||
}
|
||||
|
||||
console.log(`Invalid URL format: ${url}`)
|
||||
// Return a safe fallback that won't crash
|
||||
return "about:blank"
|
||||
}
|
||||
|
||||
// Function to check if a URL is an image using HEAD request
|
||||
export const checkIfImageUrl = async (url: string): Promise<boolean> => {
|
||||
// For data URLs, we can check synchronously
|
||||
if (url.startsWith("data:image/")) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Create a secure URL for the check but don't modify the original URL
|
||||
let secureUrl = url
|
||||
// Convert HTTP to HTTPS for security in the network request only
|
||||
if (secureUrl.startsWith("http://")) {
|
||||
secureUrl = secureUrl.replace("http://", "https://")
|
||||
console.log(`Using HTTPS version for image check: ${secureUrl}`)
|
||||
}
|
||||
|
||||
// Validate URL before proceeding
|
||||
if (!isUrl(url)) {
|
||||
console.log("Invalid URL format:", url)
|
||||
return false
|
||||
}
|
||||
|
||||
// For https URLs, we need to send a message to the extension
|
||||
if (url.startsWith("https")) {
|
||||
try {
|
||||
// Create a promise that will resolve when we get a response
|
||||
return new Promise((resolve) => {
|
||||
let timeoutId: ReturnType<typeof setTimeout> | undefined = undefined
|
||||
|
||||
// Set up a one-time listener for the response
|
||||
const messageListener = (event: MessageEvent) => {
|
||||
const message = event.data
|
||||
if (message.type === "isImageUrlResult" && message.url === url) {
|
||||
window.removeEventListener("message", messageListener)
|
||||
resolve(message.isImage)
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("message", messageListener)
|
||||
|
||||
// Send the request to the extension
|
||||
vscode.postMessage({
|
||||
type: "checkIsImageUrl",
|
||||
text: url,
|
||||
})
|
||||
|
||||
// Set a timeout to avoid hanging indefinitely
|
||||
timeoutId = setTimeout(() => {
|
||||
window.removeEventListener("message", messageListener)
|
||||
console.log("Hit timeout waiting for checkIsImageUrl")
|
||||
resolve(false)
|
||||
}, 3000)
|
||||
})
|
||||
} catch (error) {
|
||||
console.log("Error checking if URL is an image:", url)
|
||||
// Don't fall back to extension check on error
|
||||
// Instead, return false to indicate it's not an image
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Don't fall back to extension check for other URLs
|
||||
// Only data URLs (handled above) are guaranteed to be images
|
||||
// For all other URLs, we need proper content type verification
|
||||
console.log(`URL protocol not supported for image check: ${url}`)
|
||||
return false
|
||||
}
|
||||
@@ -13,10 +13,10 @@ import { DEFAULT_MCP_TIMEOUT_SECONDS, McpServer } from "../../../../src/shared/m
|
||||
import { useExtensionState } from "../../context/ExtensionStateContext"
|
||||
import { getMcpServerDisplayName } from "../../utils/mcp"
|
||||
import { vscode } from "../../utils/vscode"
|
||||
import DangerButton from "../common/DangerButton"
|
||||
import McpMarketplaceView from "./marketplace/McpMarketplaceView"
|
||||
import McpResourceRow from "./McpResourceRow"
|
||||
import McpToolRow from "./McpToolRow"
|
||||
import DangerButton from "../common/DangerButton"
|
||||
|
||||
type McpViewProps = {
|
||||
onDone: () => void
|
||||
@@ -387,6 +387,13 @@ const ServerRow = ({ server }: { server: McpServer }) => {
|
||||
}}>
|
||||
{server.status === "connecting" ? "Retrying..." : "Retry Connection"}
|
||||
</VSCodeButton>
|
||||
|
||||
<DangerButton
|
||||
style={{ width: "calc(100% - 20px)", margin: "0 10px 10px 10px" }}
|
||||
disabled={isDeleting}
|
||||
onClick={handleDelete}>
|
||||
{isDeleting ? "Deleting..." : "Delete Server"}
|
||||
</DangerButton>
|
||||
</div>
|
||||
) : (
|
||||
isExpanded && (
|
||||
@@ -478,12 +485,9 @@ const ServerRow = ({ server }: { server: McpServer }) => {
|
||||
</VSCodeButton>
|
||||
|
||||
<DangerButton
|
||||
onClick={handleDelete}
|
||||
style={{ width: "calc(100% - 14px)", margin: "5px 7px 3px 7px" }}
|
||||
disabled={isDeleting}
|
||||
style={{
|
||||
width: "calc(100% - 14px)",
|
||||
margin: "5px 7px 3px 7px",
|
||||
}}>
|
||||
onClick={handleDelete}>
|
||||
{isDeleting ? "Deleting..." : "Delete Server"}
|
||||
</DangerButton>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,683 @@
|
||||
# How To Test Rich MCP Responses
|
||||
|
||||
Use the `echo` MCP server to read back one of the test cases below into an MCP response.
|
||||
https://github.com/Garoth/echo-mcp
|
||||
|
||||
Manually check the embeds, images, and whatever other enhancements for proper rendering.
|
||||
Remember that toggling Rich MCP off should cancel pending fetches. If the toggle was
|
||||
set to Plain, then the image/link previews should never be fetched until it's enabled.
|
||||
Remember that rich display mode will only load the first n URLs, currently set to 50
|
||||
|
||||
## Main Test Case
|
||||
|
||||
Working Image URLs
|
||||
|
||||
jpg: https://yavuzceliker.github.io/sample-images/image-205.jpg
|
||||
webp: https://seenandheard.app/assets/img/face-2.webp
|
||||
svg: https://seenandheard.app/assets/img/logo-white.svg
|
||||
|
||||
Looks like Image URL but is website
|
||||
|
||||
site: https://github.com/google/pprof/blob/main/doc/images/webui/flame-multi.png
|
||||
raw png: https://raw.githubusercontent.com/google/pprof/refs/heads/main/doc/images/webui/flame-multi.png
|
||||
|
||||
Gif:
|
||||
|
||||
https://upload.wikimedia.org/wikipedia/commons/thumb/d/d0/01_Das_Sandberg-Modell.gif/750px-01_Das_Sandberg-Modell.gif
|
||||
|
||||
Normal Working URLs for OG Embeds
|
||||
|
||||
https://www.google.com
|
||||
https://www.blogger.com
|
||||
https://youtube.com
|
||||
https://linkedin.com
|
||||
https://support.google.com
|
||||
https://cloudflare.com
|
||||
https://microsoft.com
|
||||
https://apple.com
|
||||
https://en.wikipedia.org
|
||||
https://play.google.com
|
||||
https://wordpress.org
|
||||
|
||||
Attack URLs & Unsupported Formats
|
||||
|
||||
data:text/html,<h1>Hello World</h1>
|
||||
data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==
|
||||
javascript:alert('XSS')
|
||||
mailto:user@example.com
|
||||
tel:+1-234-567-8901
|
||||
sms:+1-234-567-8901?body=Hello
|
||||
https://www.example.com/path/to/file.html?param=<script>alert('XSS')</script>
|
||||
https://www.example.com/path/to/file.html?param=<img src="x" onerror="alert('XSS')">
|
||||
https://www.example.com/path/to/file.html?param=javascript:alert('XSS')
|
||||
https://www.example.com/path/to/file.html?param=data:text/html,<script>alert('XSS')</script>
|
||||
https://www.example.com/path/to/file.html?param=data:image/svg+xml,<svg onload="alert('XSS')">
|
||||
https://www.example.com/path/to/file.html?param=<iframe src="javascript:alert('XSS')">
|
||||
https://www.example.com/path/to/file.html?param=<a href="javascript:alert('XSS')">Click me</a>
|
||||
|
||||
Broken & Weird Edge Cases
|
||||
|
||||
https://tectum.io/blog/dex-tools/
|
||||
http://0.0.0.0:8025/img.png
|
||||
https://localhost:8080/img.jpg
|
||||
http://localhost:8080/
|
||||
https://localhost/
|
||||
http://httpbin.org/#/
|
||||
https://snthonstcrgrfonhenth.com/nthshtf
|
||||
http://domain/.well-known/acme-challenge/token
|
||||
https://<strong>dextools</strong>.apiable.io/(Only
|
||||
|
||||
## Generated Links Test Case
|
||||
|
||||
1. https://www.google.com
|
||||
2. http://example.com/path/to/resource?query=value#fragment
|
||||
3. https://images.unsplash.com/photo-1575936123452-b67c3203c357
|
||||
4. file:///home/user/document.txt
|
||||
5. https://user:password@example.com:8080/path
|
||||
6. http://192.168.1.1:8080
|
||||
7. https://www.example.com/path with spaces/file.html
|
||||
8. ftp://ftp.example.com/pub/file.zip
|
||||
9. https://www.example.com/index.php?id=1&name=test
|
||||
10. https://subdomain.example.co.uk/path
|
||||
11. https://www.example.com/path/to/image.jpg
|
||||
12. https://www.example.com:8443/secure
|
||||
13. http://localhost:3000
|
||||
14. https://www.example.com/path/to/file.pdf#page=10
|
||||
15. https://www.example.com/search?q=query+with+spaces
|
||||
16. https://www.example.com/path/to/file.html#section-2
|
||||
17. https://www.example.com/path/to/file.php?id=123&action=view
|
||||
18. https://www.example.com/path/to/file.html?param1=value1¶m2=value2#fragment
|
||||
19. https://www.example.com/path/to/file.html?param=value with spaces
|
||||
20. https://www.example.com/path/to/file.html?param=value%20with%20encoded%20spaces
|
||||
21. https://www.example.com/path/to/file.html?param=value+with+plus+signs
|
||||
22. https://www.example.com/path/to/file.html?param=special@characters!
|
||||
23. https://www.example.com/path/to/file.html?param=special%40characters%21
|
||||
24. https://www.example.com/path/to/file.html?param=value¶m=duplicate
|
||||
25. https://www.example.com/path/to/file.html?param=
|
||||
26. https://www.example.com/path/to/file.html?=value
|
||||
27. https://www.example.com/path/to/file.html?
|
||||
28. https://www.example.com/path/to/file.html#
|
||||
29. https://www.example.com/path/to/file.html#fragment1#fragment2
|
||||
30. https://www.example.com/path/to/file.html?param1=value1#fragment?param2=value2
|
||||
31. https://www.example.com/index.html#!hashbang
|
||||
32. https://www.example.com/path/to/file.html?param=value#fragment=value
|
||||
33. https://www.example.com/path/to/file.html?param=value¶m2=value2#fragment
|
||||
34. https://www.example.com/path/to/file.html?param=value¶m2=value2#fragment=value
|
||||
35. https://www.example.com/path/to/file.html?param=value¶m2=value2#fragment?param3=value3
|
||||
36. https://www.example.com/path/to/file.html?param=value¶m2=value2#fragment¶m3=value3
|
||||
37. https://www.example.com/path/to/file.html?param=value¶m2=value2#fragment#fragment2
|
||||
38. https://www.example.com/path/to/file.html?param=value¶m2=value2#fragment/path
|
||||
39. https://www.example.com/path/to/file.html?param=value¶m2=value2#fragment?param3=value3¶m4=value4
|
||||
40. https://www.example.com/path/to/file.html?param=value¶m2=value2#fragment¶m3=value3¶m4=value4
|
||||
41. data:text/html,<h1>Hello World</h1>
|
||||
42. data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==
|
||||
43. javascript:alert('XSS')
|
||||
44. mailto:user@example.com
|
||||
45. tel:+1-234-567-8901
|
||||
46. sms:+1-234-567-8901?body=Hello
|
||||
47. https://www.example.com/path/to/file.html?param=<script>alert('XSS')</script>
|
||||
48. https://www.example.com/path/to/file.html?param=<img src="x" onerror="alert('XSS')">
|
||||
49. https://www.example.com/path/to/file.html?param=javascript:alert('XSS')
|
||||
50. https://www.example.com/path/to/file.html?param=data:text/html,<script>alert('XSS')</script>
|
||||
51. https://www.example.com/path/to/file.html?param=data:image/svg+xml,<svg onload="alert('XSS')">
|
||||
52. https://www.example.com/path/to/file.html?param=<iframe src="javascript:alert('XSS')">
|
||||
53. https://www.example.com/path/to/file.html?param=<a href="javascript:alert('XSS')">Click me</a>
|
||||
54. https://www.example.com/path/to/file.html?param=<img src="x" onerror="alert('XSS')">
|
||||
55. https://www.example.com/path/to/file.html?param=<svg><script>alert('XSS')</script></svg>
|
||||
56. https://www.example.com/path/to/file.html?param=<svg><animate onbegin="alert('XSS')" attributeName="x" />
|
||||
57. https://www.example.com/path/to/file.html?param=<img src="x" onerror="alert('XSS')">
|
||||
58. https://www.example.com/path/to/file.html?param=<body onload="alert('XSS')">
|
||||
59. https://www.example.com/path/to/file.html?param=<input autofocus onfocus="alert('XSS')">
|
||||
60. https://www.example.com/path/to/file.html?param=<video src="x" onerror="alert('XSS')">
|
||||
61. https://www.example.com/path/to/file.html?param=<audio src="x" onerror="alert('XSS')">
|
||||
62. https://www.example.com/path/to/file.html?param=<iframe srcdoc="<script>alert('XSS')</script>">
|
||||
63. https://www.example.com/path/to/file.html?param=<math><maction actiontype="statusline#" xlink:href="javascript:alert('XSS')">Click
|
||||
64. https://www.example.com/path/to/file.html?param=<form action="javascript:alert('XSS')"><input type="submit">
|
||||
65. https://www.example.com/path/to/file.html?param=<isindex action="javascript:alert('XSS')" type="image">
|
||||
66. https://www.example.com/path/to/file.html?param=<object data="javascript:alert('XSS')">
|
||||
67. https://www.example.com/path/to/file.html?param=<embed src="javascript:alert('XSS')">
|
||||
68. https://www.example.com/path/to/file.html?param=<svg><script>alert('XSS')</script>
|
||||
69. https://www.example.com/path/to/file.html?param=<marquee onstart="alert('XSS')">
|
||||
70. https://www.example.com/path/to/file.html?param=<div style="background-image: url(javascript:alert('XSS'))">
|
||||
71. https://www.example.com/path/to/file.html?param=<link rel="stylesheet" href="javascript:alert('XSS')">
|
||||
72. https://www.example.com/path/to/file.html?param=<table background="javascript:alert('XSS')">
|
||||
73. https://www.example.com/path/to/file.html?param=<div style="width: expression(alert('XSS'))">
|
||||
74. https://www.example.com/path/to/file.html?param=<style>@import "javascript:alert('XSS')";</style>
|
||||
75. https://www.example.com/path/to/file.html?param=<meta http-equiv="refresh" content="0;url=javascript:alert('XSS')">
|
||||
76. https://www.example.com/path/to/file.html?param=<iframe src="data:text/html,<script>alert('XSS')</script>">
|
||||
77. https://www.example.com/path/to/file.html?param=<svg><set attributeName="onload" to="alert('XSS')" />
|
||||
78. https://www.example.com/path/to/file.html?param=<script>alert('XSS')</script>
|
||||
79. https://www.example.com/path/to/file.html?param=<img src="x" onerror="alert('XSS')">
|
||||
80. https://www.example.com/path/to/file.html?param=<svg><animate xlink:href="#xss" attributeName="href" values="javascript:alert('XSS')" />
|
||||
81. https://www.example.com/path/to/file.html?param=<svg><a><animate attributeName="href" values="javascript:alert('XSS')" />
|
||||
82. https://www.example.com/path/to/file.html?param=<svg><a xlink:href="javascript:alert('XSS')"><text x="20" y="20">XSS</text></a>
|
||||
83. https://www.example.com/path/to/file.html?param=<svg><a><animate attributeName="href" values="javascript:alert('XSS')" /><text x="20" y="20">XSS</text></a>
|
||||
84. https://www.example.com/path/to/file.html?param=<svg><discard onbegin="alert('XSS')" />
|
||||
85. https://www.example.com/path/to/file.html?param=<svg><script>alert('XSS')</script></svg>
|
||||
86. https://www.example.com/path/to/file.html?param=<svg><script>alert('XSS')</script>
|
||||
87. https://www.example.com/path/to/file.html?param=<svg><animate onbegin="alert('XSS')" attributeName="x" />
|
||||
88. https://www.example.com/path/to/file.html?param=<svg><animate onbegin="alert('XSS')" attributeName="x" />
|
||||
89. https://www.example.com/path/to/file.html?param=<svg><animate onbegin="alert('XSS')" attributeName="x" />
|
||||
90. https://www.example.com/path/to/file.html?param=<svg><animate onbegin="alert('XSS')" attributeName="x" />
|
||||
91. https://www.example.com/path/to/file.html?param=<svg><animate onbegin="alert('XSS')" attributeName="x" />
|
||||
92. https://www.example.com/path/to/file.html?param=<svg><animate onbegin="alert('XSS')" attributeName="x" />
|
||||
93. https://www.example.com/path/to/file.html?param=<svg><animate onbegin="alert('XSS')" attributeName="x" />
|
||||
94. https://www.example.com/path/to/file.html?param=<svg><animate onbegin="alert('XSS')" attributeName="x" />
|
||||
95. https://www.example.com/path/to/file.html?param=<svg><animate onbegin="alert('XSS')" attributeName="x" />
|
||||
96. https://www.example.com/path/to/file.html?param=<svg><animate onbegin="alert('XSS')" attributeName="x" />
|
||||
97. https://www.example.com/path/to/file.html?param=<svg><animate onbegin="alert('XSS')" attributeName="x" />
|
||||
98. https://www.example.com/path/to/file.html?param=<svg><animate onbegin="alert('XSS')" attributeName="x" />
|
||||
99. https://www.example.com/path/to/file.html?param=<svg><animate onbegin="alert('XSS')" attributeName="x" />
|
||||
100. https://www.example.com/path/to/file.html?param=<svg><animate onbegin="alert('XSS')" attributeName="x" />
|
||||
101. https://www.example.com/path/to/file.html?param=<svg><animate onbegin="alert('XSS')" attributeName="x" />
|
||||
102. https://www.example.com/path/to/file.html?param=<svg><animate onbegin="alert('XSS')" attributeName="x" />
|
||||
103. https://www.example.com/path/to/file.html?param=<svg><animate onbegin="alert('XSS')" attributeName="x" />
|
||||
104. https://www.example.com/path/to/file.html?param=<svg><animate onbegin="alert('XSS')" attributeName="x" />
|
||||
105. https://www.example.com/path/to/file.html?param=<svg><animate onbegin="alert('XSS')" attributeName="x" />
|
||||
106. https://www.example.com/path/to/file.html?param=<svg><animate onbegin="alert('XSS')" attributeName="x" />
|
||||
107. https://www.example.com/path/to/file.html?param=<svg><animate onbegin="alert('XSS')" attributeName="x" />
|
||||
108. https://www.example.com/path/to/file.html?param=<svg><animate onbegin="alert('XSS')" attributeName="x" />
|
||||
|
||||
|
||||
## Popular URLs by Popularity Test Case
|
||||
|
||||
1. https://www.google.com
|
||||
2. https://www.blogger.com
|
||||
3. https://youtube.com
|
||||
4. https://linkedin.com
|
||||
5. https://support.google.com
|
||||
6. https://cloudflare.com
|
||||
7. https://microsoft.com
|
||||
8. https://apple.com
|
||||
9. https://en.wikipedia.org
|
||||
10. https://play.google.com
|
||||
11. https://wordpress.org
|
||||
12. https://docs.google.com
|
||||
13. https://mozilla.org
|
||||
14. https://maps.google.com
|
||||
15. https://youtu.be
|
||||
16. https://drive.google.com
|
||||
17. https://bp.blogspot.com
|
||||
18. https://sites.google.com
|
||||
19. https://googleusercontent.com
|
||||
20. https://accounts.google.com
|
||||
21. https://t.me
|
||||
22. https://europa.eu
|
||||
23. https://plus.google.com
|
||||
24. https://whatsapp.com
|
||||
25. https://adobe.com
|
||||
26. https://facebook.com
|
||||
27. https://policies.google.com
|
||||
28. https://uol.com.br
|
||||
29. https://istockphoto.com
|
||||
30. https://vimeo.com
|
||||
31. https://vk.com
|
||||
32. https://github.com
|
||||
33. https://amazon.com
|
||||
34. https://search.google.com
|
||||
35. https://bbc.co.uk
|
||||
36. https://google.de
|
||||
37. https://live.com
|
||||
38. https://gravatar.com
|
||||
39. https://nih.gov
|
||||
40. https://dan.com
|
||||
41. https://files.wordpress.com
|
||||
42. https://www.yahoo.com
|
||||
43. https://cnn.com
|
||||
44. https://dropbox.com
|
||||
45. https://wikimedia.org
|
||||
46. https://creativecommons.org
|
||||
47. https://google.com.br
|
||||
48. https://line.me
|
||||
49. https://googleblog.com
|
||||
50. https://opera.com
|
||||
51. https://es.wikipedia.org
|
||||
52. https://globo.com
|
||||
53. https://brandbucket.com
|
||||
54. https://myspace.com
|
||||
55. https://slideshare.net
|
||||
56. https://paypal.com
|
||||
57. https://tiktok.com
|
||||
58. https://netvibes.com
|
||||
59. https://theguardian.com
|
||||
60. https://who.int
|
||||
61. https://goo.gl
|
||||
62. https://medium.com
|
||||
63. https://tools.google.com
|
||||
64. https://draft.blogger.com
|
||||
65. https://pt.wikipedia.org
|
||||
66. https://fr.wikipedia.org
|
||||
67. https://www.weebly.com
|
||||
68. https://news.google.com
|
||||
69. https://developers.google.com
|
||||
70. https://w3.org
|
||||
71. https://mail.google.com
|
||||
72. https://gstatic.com
|
||||
73. https://jimdofree.com
|
||||
74. https://cpanel.net
|
||||
75. https://imdb.com
|
||||
76. https://wa.me
|
||||
77. https://feedburner.com
|
||||
78. https://enable-javascript.com
|
||||
79. https://nytimes.com
|
||||
80. https://workspace.google.com
|
||||
81. https://ok.ru
|
||||
82. https://google.es
|
||||
83. https://dailymotion.com
|
||||
84. https://afternic.com
|
||||
85. https://bloomberg.com
|
||||
86. https://amazon.de
|
||||
87. https://photos.google.com
|
||||
88. https://wiley.com
|
||||
89. https://aliexpress.com
|
||||
90. https://indiatimes.com
|
||||
91. https://youronlinechoices.com
|
||||
92. https://elpais.com
|
||||
93. https://tinyurl.com
|
||||
94. https://yadi.sk
|
||||
95. https://spotify.com
|
||||
96. https://huffpost.com
|
||||
97. https://ru.wikipedia.org
|
||||
98. https://google.fr
|
||||
99. https://webmd.com
|
||||
100. https://samsung.com
|
||||
101. https://independent.co.uk
|
||||
102. https://amazon.co.jp
|
||||
103. https://get.google.com
|
||||
104. https://amazon.co.uk
|
||||
105. https://4shared.com
|
||||
106. https://telegram.me
|
||||
107. https://planalto.gov.br
|
||||
108. https://businessinsider.com
|
||||
109. https://ig.com.br
|
||||
110. https://issuu.com
|
||||
111. https://www.gov.br
|
||||
112. https://wsj.com
|
||||
113. https://hugedomains.com
|
||||
114. https://picasaweb.google.com
|
||||
115. https://usatoday.com
|
||||
116. https://scribd.com
|
||||
117. https://www.gov.uk
|
||||
118. https://storage.googleapis.com
|
||||
119. https://huffingtonpost.com
|
||||
120. https://bbc.com
|
||||
121. https://estadao.com.br
|
||||
122. https://nature.com
|
||||
123. https://mediafire.com
|
||||
124. https://washingtonpost.com
|
||||
125. https://forms.gle
|
||||
126. https://namecheap.com
|
||||
127. https://forbes.com
|
||||
128. https://mirror.co.uk
|
||||
129. https://soundcloud.com
|
||||
130. https://fb.com
|
||||
131. https://marketingplatform.google
|
||||
132. https://domainmarket.com
|
||||
133. https://ytimg.com
|
||||
134. https://terra.com.br
|
||||
135. https://google.co.uk
|
||||
136. https://shutterstock.com
|
||||
137. https://dailymail.co.uk
|
||||
138. https://reg.ru
|
||||
139. https://t.co
|
||||
140. https://cdc.gov
|
||||
141. https://thesun.co.uk
|
||||
142. https://wp.com
|
||||
143. https://cnet.com
|
||||
144. https://instagram.com
|
||||
145. https://researchgate.net
|
||||
146. https://google.it
|
||||
147. https://fandom.com
|
||||
148. https://office.com
|
||||
149. https://list-manage.com
|
||||
150. https://msn.com
|
||||
151. https://un.org
|
||||
152. https://de.wikipedia.org
|
||||
153. https://ovh.com
|
||||
154. https://mail.ru
|
||||
155. https://bing.com
|
||||
156. https://news.yahoo.com
|
||||
157. https://myaccount.google.com
|
||||
158. https://hatena.ne.jp
|
||||
159. https://shopify.com
|
||||
160. https://adssettings.google.com
|
||||
161. https://bit.ly
|
||||
162. https://reuters.com
|
||||
163. https://booking.com
|
||||
164. https://discord.com
|
||||
165. https://buydomains.com
|
||||
166. https://nasa.gov
|
||||
167. https://aboutads.info
|
||||
168. https://time.com
|
||||
169. https://abril.com.br
|
||||
170. https://change.org
|
||||
171. https://nginx.org
|
||||
172. https://twitter.com
|
||||
173. https://www.wikipedia.org
|
||||
174. https://archive.org
|
||||
175. https://cbsnews.com
|
||||
176. https://networkadvertising.org
|
||||
177. https://telegraph.co.uk
|
||||
178. https://pinterest.com
|
||||
179. https://google.co.jp
|
||||
180. https://pixabay.com
|
||||
181. https://zendesk.com
|
||||
182. https://cpanel.com
|
||||
183. https://vistaprint.com
|
||||
184. https://sky.com
|
||||
185. https://windows.net
|
||||
186. https://alicdn.com
|
||||
187. https://google.ca
|
||||
188. https://lemonde.fr
|
||||
189. https://newyorker.com
|
||||
190. https://webnode.page
|
||||
191. https://surveymonkey.com
|
||||
192. https://translate.google.com
|
||||
193. https://calendar.google.com
|
||||
194. https://amazonaws.com
|
||||
195. https://academia.edu
|
||||
196. https://apache.org
|
||||
197. https://imageshack.us
|
||||
198. https://akamaihd.net
|
||||
199. https://nginx.com
|
||||
200. https://discord.gg
|
||||
201. https://thetimes.co.uk
|
||||
202. https://search.yahoo.com
|
||||
203. https://amazon.fr
|
||||
204. https://yelp.com
|
||||
205. https://berkeley.edu
|
||||
206. https://google.ru
|
||||
207. https://sedoparking.com
|
||||
208. https://cbc.ca
|
||||
209. https://unesco.org
|
||||
210. https://ggpht.com
|
||||
211. https://privacyshield.gov
|
||||
212. https://www.over-blog.com
|
||||
213. https://clarin.com
|
||||
214. https://www.wix.com
|
||||
215. https://whitehouse.gov
|
||||
216. https://icann.org
|
||||
217. https://gnu.org
|
||||
218. https://yandex.ru
|
||||
219. https://francetvinfo.fr
|
||||
220. https://gmail.com
|
||||
221. https://mozilla.com
|
||||
222. https://ziddu.com
|
||||
223. https://guardian.co.uk
|
||||
224. https://twitch.tv
|
||||
225. https://sedo.com
|
||||
226. https://foxnews.com
|
||||
227. https://rambler.ru
|
||||
228. https://books.google.com
|
||||
229. https://stanford.edu
|
||||
230. https://wikihow.com
|
||||
231. https://it.wikipedia.org
|
||||
232. https://20minutos.es
|
||||
233. https://sfgate.com
|
||||
234. https://liveinternet.ru
|
||||
235. https://ja.wikipedia.org
|
||||
236. https://000webhost.com
|
||||
237. https://espn.com
|
||||
238. https://eventbrite.com
|
||||
239. https://disney.com
|
||||
240. https://statista.com
|
||||
241. https://addthis.com
|
||||
242. https://pinterest.fr
|
||||
243. https://lavanguardia.com
|
||||
244. https://vkontakte.ru
|
||||
245. https://doubleclick.net
|
||||
246. https://bp2.blogger.com
|
||||
247. https://skype.com
|
||||
248. https://sciencedaily.com
|
||||
249. https://bloglovin.com
|
||||
250. https://insider.com
|
||||
251. https://pl.wikipedia.org
|
||||
252. https://sputniknews.com
|
||||
253. https://id.wikipedia.org
|
||||
254. https://doi.org
|
||||
255. https://nypost.com
|
||||
256. https://elmundo.es
|
||||
257. https://abcnews.go.com
|
||||
258. https://ipv4.google.com
|
||||
259. https://deezer.com
|
||||
260. https://express.co.uk
|
||||
261. https://detik.com
|
||||
262. https://mystrikingly.com
|
||||
263. https://rakuten.co.jp
|
||||
264. https://amzn.to
|
||||
265. https://arxiv.org
|
||||
266. https://alibaba.com
|
||||
267. https://fb.me
|
||||
268. https://wikia.com
|
||||
269. https://t-online.de
|
||||
270. https://telegra.ph
|
||||
271. https://mega.nz
|
||||
272. https://usnews.com
|
||||
273. https://plos.org
|
||||
274. https://naver.com
|
||||
275. https://ibm.com
|
||||
276. https://smh.com.au
|
||||
277. https://dw.com
|
||||
278. https://google.nl
|
||||
279. https://lefigaro.fr
|
||||
280. https://bp1.blogger.com
|
||||
281. https://picasa.google.com
|
||||
282. https://theatlantic.com
|
||||
283. https://nydailynews.com
|
||||
284. https://themeforest.net
|
||||
285. https://rtve.es
|
||||
286. https://newsweek.com
|
||||
287. https://ovh.net
|
||||
288. https://ca.gov
|
||||
289. https://goodreads.com
|
||||
290. https://economist.com
|
||||
291. https://target.com
|
||||
292. https://marca.com
|
||||
293. https://kickstarter.com
|
||||
294. https://hindustantimes.com
|
||||
295. https://weibo.com
|
||||
296. https://finance.yahoo.com
|
||||
297. https://huawei.com
|
||||
298. https://e-monsite.com
|
||||
299. https://hubspot.com
|
||||
300. https://npr.org
|
||||
301. https://netflix.com
|
||||
302. https://gizmodo.com
|
||||
303. https://netlify.app
|
||||
304. https://yandex.com
|
||||
305. https://mashable.com
|
||||
306. https://cnil.fr
|
||||
307. https://latimes.com
|
||||
308. https://steampowered.com
|
||||
309. https://rt.com
|
||||
310. https://photobucket.com
|
||||
311. https://quora.com
|
||||
312. https://nbcnews.com
|
||||
313. https://android.com
|
||||
314. https://instructables.com
|
||||
315. https://www.canalblog.com
|
||||
316. https://www.livejournal.com
|
||||
317. https://ouest-france.fr
|
||||
318. https://tripadvisor.com
|
||||
319. https://ovhcloud.com
|
||||
320. https://pexels.com
|
||||
321. https://oracle.com
|
||||
322. https://yahoo.co.jp
|
||||
323. https://addtoany.com
|
||||
324. https://sakura.ne.jp
|
||||
325. https://cointernet.com.co
|
||||
326. https://twimg.com
|
||||
327. https://britannica.com
|
||||
328. https://php.net
|
||||
329. https://standard.co.uk
|
||||
330. https://groups.google.com
|
||||
331. https://cnbc.com
|
||||
332. https://loc.gov
|
||||
333. https://qq.com
|
||||
334. https://buzzfeed.com
|
||||
335. https://godaddy.com
|
||||
336. https://ikea.com
|
||||
337. https://disqus.com
|
||||
338. https://taringa.net
|
||||
339. https://ea.com
|
||||
340. https://dropcatch.com
|
||||
341. https://techcrunch.com
|
||||
342. https://canva.com
|
||||
343. https://offset.com
|
||||
344. https://ebay.com
|
||||
345. https://zoom.us
|
||||
346. https://cambridge.org
|
||||
347. https://unsplash.com
|
||||
348. https://playstation.com
|
||||
349. https://people.com
|
||||
350. https://springer.com
|
||||
351. https://psychologytoday.com
|
||||
352. https://sendspace.com
|
||||
353. https://home.pl
|
||||
354. https://rapidshare.com
|
||||
355. https://prezi.com
|
||||
356. https://photos1.blogger.com
|
||||
357. https://thenai.org
|
||||
358. https://ftc.gov
|
||||
359. https://google.pl
|
||||
360. https://ted.com
|
||||
361. https://secureserver.net
|
||||
362. https://code.google.com
|
||||
363. https://plesk.com
|
||||
364. https://aol.com
|
||||
365. https://biglobe.ne.jp
|
||||
366. https://hp.com
|
||||
367. https://canada.ca
|
||||
368. https://linktr.ee
|
||||
369. https://hollywoodreporter.com
|
||||
370. https://ietf.org
|
||||
371. https://clickbank.net
|
||||
372. https://harvard.edu
|
||||
373. https://amazon.es
|
||||
374. https://oup.com
|
||||
375. https://timeweb.ru
|
||||
376. https://engadget.com
|
||||
377. https://vice.com
|
||||
378. https://cornell.edu
|
||||
379. https://dreamstime.com
|
||||
380. https://tmz.com
|
||||
381. https://gofundme.com
|
||||
382. https://pbs.org
|
||||
383. https://stackoverflow.com
|
||||
384. https://abc.net.au
|
||||
385. https://sciencedirect.com
|
||||
386. https://ft.com
|
||||
387. https://variety.com
|
||||
388. https://alexa.com
|
||||
389. https://abc.es
|
||||
390. https://walmart.com
|
||||
391. https://gooyaabitemplates.com
|
||||
392. https://redbull.com
|
||||
393. https://ssl-images-amazon.com
|
||||
394. https://theverge.com
|
||||
395. https://spiegel.de
|
||||
396. https://about.com
|
||||
397. https://nationalgeographic.com
|
||||
398. https://bandcamp.com
|
||||
399. https://m.wikipedia.org
|
||||
400. https://zippyshare.com
|
||||
401. https://wired.com
|
||||
402. https://freepik.com
|
||||
403. https://outlook.com
|
||||
404. https://mit.edu
|
||||
405. https://sapo.pt
|
||||
406. https://goo.ne.jp
|
||||
407. https://java.com
|
||||
408. https://google.co.th
|
||||
409. https://scmp.com
|
||||
410. https://mayoclinic.org
|
||||
411. https://scholastic.com
|
||||
412. https://nba.com
|
||||
413. https://reverbnation.com
|
||||
414. https://depositfiles.com
|
||||
415. https://video.google.com
|
||||
416. https://howstuffworks.com
|
||||
417. https://cbslocal.com
|
||||
418. https://merriam-webster.com
|
||||
419. https://focus.de
|
||||
420. https://admin.ch
|
||||
421. https://gfycat.com
|
||||
422. https://com.com
|
||||
423. https://narod.ru
|
||||
424. https://boston.com
|
||||
425. https://sony.com
|
||||
426. https://justjared.com
|
||||
427. https://bitly.com
|
||||
428. https://jstor.org
|
||||
429. https://amebaownd.com
|
||||
430. https://g.co
|
||||
431. https://gsmarena.com
|
||||
432. https://lexpress.fr
|
||||
433. https://reddit.com
|
||||
434. https://usgs.gov
|
||||
435. https://bigcommerce.com
|
||||
436. https://gettyimages.com
|
||||
437. https://ign.com
|
||||
438. https://justgiving.com
|
||||
439. https://techradar.com
|
||||
440. https://weather.com
|
||||
441. https://amazon.ca
|
||||
442. https://justice.gov
|
||||
443. https://sciencemag.org
|
||||
444. https://pcmag.com
|
||||
445. https://theconversation.com
|
||||
446. https://foursquare.com
|
||||
447. https://flickr.com
|
||||
448. https://giphy.com
|
||||
449. https://tvtropes.org
|
||||
450. https://fifa.com
|
||||
451. https://upenn.edu
|
||||
452. https://digg.com
|
||||
453. https://bestfreecams.club
|
||||
454. https://histats.com
|
||||
455. https://salesforce.com
|
||||
456. https://blog.google
|
||||
457. https://apnews.com
|
||||
458. https://theglobeandmail.com
|
||||
459. https://m.me
|
||||
460. https://europapress.es
|
||||
461. https://washington.edu
|
||||
462. https://thefreedictionary.com
|
||||
463. https://jhu.edu
|
||||
464. https://euronews.com
|
||||
465. https://liberation.fr
|
||||
466. https://ads.google.com
|
||||
467. https://trustpilot.com
|
||||
468. https://google.com.tw
|
||||
469. https://softonic.com
|
||||
470. https://kakao.com
|
||||
471. https://storage.canalblog.com
|
||||
472. https://interia.pl
|
||||
473. https://metro.co.uk
|
||||
474. https://viglink.com
|
||||
475. https://last.fm
|
||||
476. https://blackberry.com
|
||||
477. https://public-api.wordpress.com
|
||||
478. https://sina.com.cn
|
||||
479. https://unicef.org
|
||||
480. https://archives.gov
|
||||
481. https://nps.gov
|
||||
482. https://utexas.edu
|
||||
483. https://biblegateway.com
|
||||
484. https://usda.gov
|
||||
485. https://indiegogo.com
|
||||
486. https://nikkei.com
|
||||
487. https://radiofrance.fr
|
||||
488. https://repubblica.it
|
||||
489. https://substack.com
|
||||
490. https://ap.org
|
||||
491. https://nicovideo.jp
|
||||
492. https://joomla.org
|
||||
493. https://news.com.au
|
||||
494. https://allaboutcookies.org
|
||||
495. https://mailchimp.com
|
||||
496. https://stores.jp
|
||||
497. https://intel.com
|
||||
498. https://bp0.blogger.com
|
||||
499. https://box.com
|
||||
499. https://nhk.or.jp
|
||||
@@ -51,8 +51,8 @@ import { useExtensionState } from "../../context/ExtensionStateContext"
|
||||
import { vscode } from "../../utils/vscode"
|
||||
import { getAsVar, VSC_DESCRIPTION_FOREGROUND } from "../../utils/vscStyles"
|
||||
import VSCodeButtonLink from "../common/VSCodeButtonLink"
|
||||
import OpenRouterModelPicker, { ModelDescriptionMarkdown } from "./OpenRouterModelPicker"
|
||||
import AccountView, { ClineAccountView } from "../account/AccountView"
|
||||
import OpenRouterModelPicker, { ModelDescriptionMarkdown, OPENROUTER_MODEL_PICKER_Z_INDEX } from "./OpenRouterModelPicker"
|
||||
import { ClineAccountInfoCard } from "./ClineAccountInfoCard"
|
||||
|
||||
interface ApiOptionsProps {
|
||||
showModelOptions: boolean
|
||||
@@ -62,9 +62,9 @@ interface ApiOptionsProps {
|
||||
}
|
||||
|
||||
// This is necessary to ensure dropdown opens downward, important for when this is used in popup
|
||||
const DROPDOWN_Z_INDEX = 1001 // Higher than the OpenRouterModelPicker's and ModelSelectorTooltip's z-index
|
||||
const DROPDOWN_Z_INDEX = OPENROUTER_MODEL_PICKER_Z_INDEX + 2 // Higher than the OpenRouterModelPicker's and ModelSelectorTooltip's z-index
|
||||
|
||||
const DropdownContainer = styled.div<{ zIndex?: number }>`
|
||||
export const DropdownContainer = styled.div<{ zIndex?: number }>`
|
||||
position: relative;
|
||||
z-index: ${(props) => props.zIndex || DROPDOWN_Z_INDEX};
|
||||
|
||||
@@ -95,6 +95,7 @@ const ApiOptions = ({ showModelOptions, apiErrorMessage, modelIdErrorMessage, is
|
||||
const [awsEndpointSelected, setAwsEndpointSelected] = useState(!!apiConfiguration?.awsBedrockEndpoint)
|
||||
const [modelConfigurationSelected, setModelConfigurationSelected] = useState(false)
|
||||
const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false)
|
||||
const [providerSortingSelected, setProviderSortingSelected] = useState(!!apiConfiguration?.openRouterProviderSorting)
|
||||
|
||||
const handleInputChange = (field: keyof ApiConfiguration) => (event: any) => {
|
||||
setApiConfiguration({
|
||||
@@ -215,8 +216,8 @@ const ApiOptions = ({ showModelOptions, apiErrorMessage, modelIdErrorMessage, is
|
||||
</DropdownContainer>
|
||||
|
||||
{selectedProvider === "cline" && (
|
||||
<div style={{ marginBottom: 8, marginTop: 4 }}>
|
||||
<ClineAccountView />
|
||||
<div style={{ marginBottom: 14, marginTop: 4 }}>
|
||||
<ClineAccountInfoCard />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -675,7 +676,7 @@ const ApiOptions = ({ showModelOptions, apiErrorMessage, modelIdErrorMessage, is
|
||||
placeholder="Enter Project ID...">
|
||||
<span style={{ fontWeight: 500 }}>Google Cloud Project ID</span>
|
||||
</VSCodeTextField>
|
||||
<DropdownContainer zIndex={DROPDOWN_Z_INDEX - 2} className="dropdown-container">
|
||||
<DropdownContainer zIndex={DROPDOWN_Z_INDEX - 1} className="dropdown-container">
|
||||
<label htmlFor="vertex-region-dropdown">
|
||||
<span style={{ fontWeight: 500 }}>Google Cloud Region</span>
|
||||
</label>
|
||||
@@ -1353,6 +1354,57 @@ const ApiOptions = ({ showModelOptions, apiErrorMessage, modelIdErrorMessage, is
|
||||
</p>
|
||||
)}
|
||||
|
||||
{(selectedProvider === "openrouter" || selectedProvider === "cline") && showModelOptions && (
|
||||
<>
|
||||
<VSCodeCheckbox
|
||||
style={{ marginTop: -10 }}
|
||||
checked={providerSortingSelected}
|
||||
onChange={(e: any) => {
|
||||
const isChecked = e.target.checked === true
|
||||
setProviderSortingSelected(isChecked)
|
||||
if (!isChecked) {
|
||||
setApiConfiguration({
|
||||
...apiConfiguration,
|
||||
openRouterProviderSorting: "",
|
||||
})
|
||||
}
|
||||
}}>
|
||||
Sort underlying provider routing
|
||||
</VSCodeCheckbox>
|
||||
|
||||
{providerSortingSelected && (
|
||||
<div style={{ marginBottom: -6 }}>
|
||||
<DropdownContainer className="dropdown-container" zIndex={OPENROUTER_MODEL_PICKER_Z_INDEX + 1}>
|
||||
<VSCodeDropdown
|
||||
style={{ width: "100%", marginTop: 3 }}
|
||||
value={apiConfiguration?.openRouterProviderSorting}
|
||||
onChange={(e: any) => {
|
||||
setApiConfiguration({
|
||||
...apiConfiguration,
|
||||
openRouterProviderSorting: e.target.value,
|
||||
})
|
||||
}}>
|
||||
<VSCodeOption value="">Default</VSCodeOption>
|
||||
<VSCodeOption value="price">Price</VSCodeOption>
|
||||
<VSCodeOption value="throughput">Throughput</VSCodeOption>
|
||||
<VSCodeOption value="latency">Latency</VSCodeOption>
|
||||
</VSCodeDropdown>
|
||||
</DropdownContainer>
|
||||
<p style={{ fontSize: "12px", marginTop: 3, color: "var(--vscode-descriptionForeground)" }}>
|
||||
{!apiConfiguration?.openRouterProviderSorting &&
|
||||
"Default behavior is to load balance requests across providers (like AWS, Google Vertex, Anthropic), prioritizing price while considering provider uptime"}
|
||||
{apiConfiguration?.openRouterProviderSorting === "price" &&
|
||||
"Sort providers by price, prioritizing the lowest cost provider"}
|
||||
{apiConfiguration?.openRouterProviderSorting === "throughput" &&
|
||||
"Sort providers by throughput, prioritizing the provider with the highest throughput (may increase cost)"}
|
||||
{apiConfiguration?.openRouterProviderSorting === "latency" &&
|
||||
"Sort providers by response time, prioritizing the provider with the lowest latency"}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{selectedProvider !== "openrouter" &&
|
||||
selectedProvider !== "cline" &&
|
||||
selectedProvider !== "openai" &&
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { VSCodeButton } from "@vscode/webview-ui-toolkit/react"
|
||||
import { useFirebaseAuth } from "../../context/FirebaseAuthContext"
|
||||
import { vscode } from "../../utils/vscode"
|
||||
|
||||
export const ClineAccountInfoCard = () => {
|
||||
const { user, handleSignOut } = useFirebaseAuth()
|
||||
|
||||
const handleLogin = () => {
|
||||
vscode.postMessage({ type: "accountLoginClicked" })
|
||||
}
|
||||
|
||||
const handleLogout = () => {
|
||||
// First notify extension to clear API keys and state
|
||||
vscode.postMessage({ type: "accountLogoutClicked" })
|
||||
// Then sign out of Firebase
|
||||
handleSignOut()
|
||||
}
|
||||
|
||||
const handleShowAccount = () => {
|
||||
vscode.postMessage({ type: "showAccountViewClicked" })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-[600px]">
|
||||
{user ? (
|
||||
<VSCodeButton appearance="secondary" onClick={handleShowAccount}>
|
||||
View Billing & Usage
|
||||
</VSCodeButton>
|
||||
) : (
|
||||
// <div className="p-2 rounded-[2px] bg-[var(--vscode-dropdown-background)]">
|
||||
// <div className="flex items-center gap-3">
|
||||
// {user.photoURL ? (
|
||||
// <img src={user.photoURL} alt="Profile" className="w-[38px] h-[38px] rounded-full flex-shrink-0" />
|
||||
// ) : (
|
||||
// <div className="w-[38px] h-[38px] rounded-full bg-[var(--vscode-button-background)] flex items-center justify-center text-xl text-[var(--vscode-button-foreground)] flex-shrink-0">
|
||||
// {user.displayName?.[0] || user.email?.[0] || "?"}
|
||||
// </div>
|
||||
// )}
|
||||
// <div className="flex flex-col gap-1 flex-1 overflow-hidden">
|
||||
// {user.displayName && (
|
||||
// <div className="text-[13px] font-bold text-[var(--vscode-foreground)] break-words">
|
||||
// {user.displayName}
|
||||
// </div>
|
||||
// )}
|
||||
// {user.email && (
|
||||
// <div className="text-[13px] text-[var(--vscode-descriptionForeground)] break-words overflow-hidden text-ellipsis">
|
||||
// {user.email}
|
||||
// </div>
|
||||
// )}
|
||||
// <div className="flex gap-2 flex-wrap mt-1">
|
||||
|
||||
// <VSCodeButton
|
||||
// appearance="secondary"
|
||||
// onClick={handleLogout}
|
||||
// className="scale-[0.85] origin-left w-fit mt-0.5 mb-0 -mr-3">
|
||||
// Log out
|
||||
// </VSCodeButton>
|
||||
// </div>
|
||||
// </div>
|
||||
// </div>
|
||||
// </div>
|
||||
<div>
|
||||
<VSCodeButton onClick={handleLogin} className="mt-0">
|
||||
Sign Up with Cline
|
||||
</VSCodeButton>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { VSCodeLink, VSCodeTextField } from "@vscode/webview-ui-toolkit/react"
|
||||
import { VSCodeCheckbox, VSCodeDropdown, VSCodeLink, VSCodeOption, VSCodeTextField } from "@vscode/webview-ui-toolkit/react"
|
||||
import Fuse from "fuse.js"
|
||||
import React, { KeyboardEvent, memo, useEffect, useMemo, useRef, useState } from "react"
|
||||
import { useRemark } from "react-remark"
|
||||
@@ -8,7 +8,7 @@ import { openRouterDefaultModelId } from "../../../../src/shared/api"
|
||||
import { useExtensionState } from "../../context/ExtensionStateContext"
|
||||
import { vscode } from "../../utils/vscode"
|
||||
import { highlight } from "../history/HistoryView"
|
||||
import { ModelInfoView, normalizeApiConfiguration } from "./ApiOptions"
|
||||
import { DropdownContainer, ModelInfoView, normalizeApiConfiguration } from "./ApiOptions"
|
||||
import { CODE_BLOCK_BG_COLOR } from "../common/CodeBlock"
|
||||
import ThinkingBudgetSlider from "./ThinkingBudgetSlider"
|
||||
|
||||
@@ -222,6 +222,7 @@ const OpenRouterModelPicker: React.FC<OpenRouterModelPickerProps> = ({ isPopup }
|
||||
{showBudgetSlider && (
|
||||
<ThinkingBudgetSlider apiConfiguration={apiConfiguration} setApiConfiguration={setApiConfiguration} />
|
||||
)}
|
||||
|
||||
<ModelInfoView
|
||||
selectedModelId={selectedModelId}
|
||||
modelInfo={selectedModelInfo}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useExtensionState } from "../../context/ExtensionStateContext"
|
||||
import { validateApiConfiguration } from "../../utils/validate"
|
||||
import { vscode } from "../../utils/vscode"
|
||||
import ApiOptions from "../settings/ApiOptions"
|
||||
import ClineLogoWhite from "../../assets/ClineLogoWhite"
|
||||
|
||||
const WelcomeView = () => {
|
||||
const { apiConfiguration } = useExtensionState()
|
||||
@@ -46,7 +47,7 @@ const WelcomeView = () => {
|
||||
}}>
|
||||
<h2>Hi, I'm Cline</h2>
|
||||
<div style={{ display: "flex", justifyContent: "center", margin: "20px 0" }}>
|
||||
<ClineLogo />
|
||||
<ClineLogoWhite className="size-16" />
|
||||
</div>
|
||||
<p>
|
||||
I can do all kinds of tasks thanks to breakthroughs in{" "}
|
||||
@@ -91,22 +92,4 @@ const WelcomeView = () => {
|
||||
)
|
||||
}
|
||||
|
||||
const ClineLogo: React.FC<{ style?: React.CSSProperties }> = ({ style }) => {
|
||||
// (can't use svgs in vsc extensions)
|
||||
const logoBase64 =
|
||||
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADoAAAA8CAYAAAA34qk1AAAAAXNSR0IArs4c6QAAAERlWElmTU0AKgAAAAgAAYdpAAQAAAABAAAAGgAAAAAAA6ABAAMAAAABAAEAAKACAAQAAAABAAAAOqADAAQAAAABAAAAPAAAAAAs615UAAAGuElEQVRoBd1aW2hcRRj+5yRp4242m0tbSqMitSLaaFGLUXvDB0HUmlYRFXxQQRG0iFXxwctDfWgDKlgREYRWkaJPGsULUjX6IFRbkwZaaL1AUWI1JnvJ7ibpJmf8ZrNnc86ZmXPJZtM9DhzOzH+b/zszZ+af/xxGS1A4543pPD3OON1LjK5Al8txnWFEXzYQ9bW0sLO1dgN91baMTfD1DYzeRS/XaXoawwN4IplgH2j4i0KuKdDUJL+EzdIwPE34ecuJHmpvYQf95BbKrxlQTFeWydPXcOzmgM5lZg3q7oyxPwPKhxIzQkmHEE7naCfEg4IUlpMNJu0J0UUo0ZoBZYzuCOXJnPD2BegEUqkZUPS+NpAHTqEVo6Pc9312qgRr1RLobDAXnFLFFbQgPacVuVU7oJx+kbvzpmBlHFnDWMFbamHcqoFidTVwNbkvZtAnYV3ijD522ym3EVdUV0JtL+jUSE3QpgaDdmDf24qrCwZWwYWqHQkAIyVG3CQaQoDRf26Svli1iuUC6JVEAgMdz/Pt6GAfFK4MarzGcmnY35uM037G2JRfX75Az3Ieby7Qe8TpLj9j54XP6Ffsv72JBDvp1b8n0FyOr55B4A0DG7yM1AEvCx/ubmthh3W+aIHifVyeydEAThs36JTrjJ5t4HSjbmS1qy5Avh4hkOKZtyJW7v+D8wtUA6AEOp7jVwHkIyqFuqZxWpfI05MqH5VTNzPB+7Gn3alSiAAtMztNF3d2MvHeVoo0oqkUbwPI2yoS0askG5fJ/ktAjSa6Hdgao4dv3mMMVO98a64mAQX5WrdQBNtS2kYCirBuTQSBuV2WMEhAoSFi16iX+AjnMTsIFdClCNDtPtSk3vSv86ChAhq24wLSJi+ZJm3Bsp5EdLIe0/9B7FsnwhqC/CET21oTozVFRl2iDtqipEGlfTSd4wMwvg1XkDJIM3RPWxv7zS0sQshsnvoAWrmBO+QZ5SD3QHuc9Tvo5UaqwHcyk95H0zEdVbIWrThJrStXsgmrrRrRZovpc8/rQAo9HJ2mW+P0FKoi5elXntWBFIrtMfYRjojP+Rmx85ubyYHDATSV59dA+Hq7gq6O6bpPNZJ2eYDl2JEftdMU9WPJGL2toDtIrS30JghDDqJHA3GvI4R1AMWZcw90pemssocM/LcqupvW1sx+B+2Mm25rD5QeiI2gqgoZOPadiqekcdptzyhWgGYKvAeGAudicwkaVHagInK9LPr8WaWioiGNErxPos6mGO2y7FSAcpNetogB7jxMtg5Doc3tYGXV8iQ/OFUWF4mnInB62hrVEtDxLN8CuVtUshGndVijWgJqGKFGM1rYy6NqlA7ZwffNaIGc81aM6v0GIpmgwUEUQZZ85py2GlgM/hexrdcoYGVHzj3M3uRlrZ55nL438Dl9CMv/D/XsZ5W+pc0iHZrbR016vkpjdauOUPW1jg6WKQFtT7ABeBok+F4oILwm2jL3sLXseQaMBJYta6VmppCfRqko4jNfmFFl6Sm+dt4F7xocXKeV4HSpludmsBCyQpfRq1baswI0GWNH4NCnbtvadpE2ank2Bs6lIqO4wUZyVNFnIDslJRZClmisWKD9VmcVoIKAoPlF3HAG9i+YAbvE91I/yWyOHoOM42zo0tkeZHZkMvwyeBYm3/yK9uCNFfg4jB1xOaJsYhPejP+InlEyy8Rsll+OFb3PSwa8OA7wB/DQmnRy4C0zG+kg+IEzDIZJ79jtySPCaNou4FPvy+T4gfFxnnTL4RD/sGnQj6AHcW4rHtpPqRyXprhIBoB3FEf4m9x9eLWnp5048Io4S8ickaWcxzI+iDk/jNzOhVgENqIu5VYtYY873h46CaeOlWWEHfGTpDwgHkYEy50zWqxPD3ExlWF/s0gDVFEEoG6A667ChlJV9aRmlZIRI7r/V1IB/TtimGR3kbVwZ0AkoJh5f8maEaNwGnF7LAGFwFG3UATb1mJWcV0COnuOPge3WJGIYgU/XLndloCKSB9Bw2duwQi1U8Wp0mA5XJaACi6W3RdwE3taFMtee+hnAVAC7UywE9gP37KEInQ/jV/m3lD5q93eRXyZLtA3CL02qRTrkJZBfNvT2spOqXxTjqgQxLeOc41m6f8/aQVTGTrPtDQisx06kMI3LVDBxO9m/0zEaQuqH4p2nZZTYiTLWRKti55AhdZFjE3iZ8L7MMdvRXNYa2npGeOIiXfjnbwaI3nar3vtO6pSxHvLspPUgw9SvdiCtuGU0gW51biWqeQXkSZ2gFFcI3D4OHLR/ZMx+sod5nn19x8Bu+YF5eP/fAAAAABJRU5ErkJggg=="
|
||||
|
||||
return (
|
||||
<img
|
||||
src={logoBase64}
|
||||
style={{
|
||||
width: "57px",
|
||||
height: "60px",
|
||||
...style,
|
||||
}}
|
||||
alt="Cline Logo"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default WelcomeView
|
||||
|
||||
@@ -20,6 +20,7 @@ interface ExtensionStateContextType extends ExtensionState {
|
||||
mcpServers: McpServer[]
|
||||
mcpMarketplaceCatalog: McpMarketplaceCatalog
|
||||
filePaths: string[]
|
||||
totalTasksSize: number | null
|
||||
setApiConfiguration: (config: ApiConfiguration) => void
|
||||
setCustomInstructions: (value?: string) => void
|
||||
setTelemetrySetting: (value: TelemetrySetting) => void
|
||||
@@ -52,6 +53,7 @@ export const ExtensionStateContextProvider: React.FC<{
|
||||
const [openRouterModels, setOpenRouterModels] = useState<Record<string, ModelInfo>>({
|
||||
[openRouterDefaultModelId]: openRouterDefaultModelInfo,
|
||||
})
|
||||
const [totalTasksSize, setTotalTasksSize] = useState<number | null>(null)
|
||||
|
||||
const [openAiModels, setOpenAiModels] = useState<string[]>([])
|
||||
const [mcpServers, setMcpServers] = useState<McpServer[]>([])
|
||||
@@ -137,6 +139,10 @@ export const ExtensionStateContextProvider: React.FC<{
|
||||
}
|
||||
break
|
||||
}
|
||||
case "totalTasksSize": {
|
||||
setTotalTasksSize(message.totalTasksSize ?? null)
|
||||
break
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
@@ -156,6 +162,7 @@ export const ExtensionStateContextProvider: React.FC<{
|
||||
mcpServers,
|
||||
mcpMarketplaceCatalog,
|
||||
filePaths,
|
||||
totalTasksSize,
|
||||
setApiConfiguration: (value) =>
|
||||
setState((prevState) => ({
|
||||
...prevState,
|
||||
|
||||
@@ -10,3 +10,27 @@ export function formatLargeNumber(num: number): string {
|
||||
}
|
||||
return num.toString()
|
||||
}
|
||||
|
||||
// Helper to format cents as dollars with 2 decimal places
|
||||
export function formatDollars(cents?: number): string {
|
||||
if (cents === undefined) {
|
||||
return ""
|
||||
}
|
||||
|
||||
return (cents / 100).toFixed(2)
|
||||
}
|
||||
|
||||
export function formatTimestamp(timestamp: string): string {
|
||||
const date = new Date(timestamp)
|
||||
|
||||
const dateFormatter = new Intl.DateTimeFormat("en-US", {
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
year: "2-digit",
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
hour12: true,
|
||||
})
|
||||
|
||||
return dateFormatter.format(date)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user