Compare commits

..
Author SHA1 Message Date
Trevor Hudson e3a0d19e44 change posthog apikey 2025-02-28 17:59:12 -08:00
Dennis Bartlett 12dbf1556b Revert Policy changes and TOS addition (#2027) 2025-02-28 17:49:22 -08:00
pashpashpash b4149b5a18 vertex prompt caching (#2026)
* vertex prompt caching

* duplicate declare

* changeset
2025-02-28 17:30:25 -08:00
akfoster cee959eeec Merge pull request #2019 from cline/dev
Merge dev into main
2025-02-28 17:08:06 -08:00
pashpashpash 46121094d7 package-lock.json 2025-02-28 16:56:46 -08:00
Frostbourne 6fe1015310 Update ClineProvider.ts 2025-02-28 14:26:08 -08:00
Dennis Bartlett bb902f9b6b Fix conflict 2025-02-28 14:24:51 -08:00
Dennis Bartlett dfeb57d3af Fix Lint 2025-02-28 14:06:00 -08:00
Dennis Bartlett 9232d752b2 Merge remote-tracking branch 'upstream/main' into dev 2025-02-28 14:05:27 -08:00
Daniel TrugmanandDennis Bartlett d1a097cbd4 Requesty dynamic model selection (#1836)
* Extract reuseable ModelDescriptionMarkdown from OpenRouter model picker

* Requesty: Add model picker component

* Refactor readOpenRouterModels to allow any dynamic list filename

* Extract parsePrice to allow reuse by other providers

* Simplify model display name switch case

* Requesty: Add dynamic model list fetching from API

* Requesty: Add default model selection

* Requesty: Specify max_tokens when sending request

* Add changeset

---------

Co-authored-by: Dennis Bartlett <bartlett.dc.1@gmail.com>
2025-02-28 13:29:34 -08:00
Daniel Steigman a02eb40c31 Merge pull request #2014 from cline/andrewmonostate/add-xai-provider
feat: add X AI provider integration
2025-02-28 13:20:39 -08:00
nighttrek c87fb06f33 Merge branch 'dev' into pr/cline/2014 2025-02-28 13:17:35 -08:00
Evan eedae086a3 Fix Merge Conflict resolutions
Fixes Merge conflict resolutions from Main to Dev that were incorrectly chosen.
2025-02-28 13:12:40 -08:00
Dennis Bartlett c1d589f6be Feature/privacy policy (#2015)
* Update URL syntax
2025-02-28 12:15:25 -08:00
Andrew Monostate d7e9ead730 feat: add X AI provider integration 2025-02-28 12:01:00 -08:00
Frostbourne 9df45fb216 Update PRIVACY.md
Fix typo
2025-02-28 12:00:32 -08:00
Daniel Trugman d6184e9dac OpenAI & DeepSeek cost calculation (#1864)
* Add OpenAI compatible cost calculation

* Requesty: Prepare for correct price calculation

* Native OpenAI: Update model caching info

According to [OpenAI's
website](https://platform.openai.com/docs/guides/prompt-caching),
gpt-4o, gpt-4o-mini, o1-preview and o1-mini support caching.

For gpt-4o, even though gpt-4o-2024-05-13 and
chatgpt-4o-latest do no support caching, users will see there are no
cached tokens, which will help avoid confusion.

* Native OpenAI: Call getModel once

* Native OpenAI: Extract yield usage into method

* Native OpenAI: Add caching and cost info to task header

* DeepSeek: Add cost info to task header

* Add changeset
2025-02-28 11:25:17 -08:00
brownrw8 d01b99463e Update Claude Sonnet 3.5 -> 3.7 in README(s) (#1983)
* update Claude Sonnet 35. -> 3.7 in README(s)

* Create calm-laws-camp.md
2025-02-27 20:07:19 -08:00
Saoud Rizwan 3525592c61 Prepare for release 2025-02-27 18:51:15 -08:00
Saoud Rizwan 1913b69ca5 Move GPT-4.5 2025-02-27 18:49:19 -08:00
Saoud Rizwan aa714f28e1 Add GPT-4.5 2025-02-27 18:48:00 -08:00
Saoud Rizwan eef04542d2 Revert package-lock 2025-02-27 18:40:37 -08:00
Jon AtkinsonandDennis Bartlett 602d25075b fix: Respect the user's XDG Document folder settings on Linux. (#1884)
This is related to issue #899.

Co-authored-by: Dennis Bartlett <bartlett.dc.1@gmail.com>
2025-02-27 18:23:29 -08:00
Eric Li ea2f4080f8 opt: Enhance OpenAiHandler to diagnose request issues and handle empty streams (#1702) 2025-02-27 17:21:00 -08:00
Dennis Bartlett 10cde3671e Feature/privacy policy (#1994)
* Update Privacy to point to website. Add ToS to point to website.

* Lint Fix
2025-02-27 17:19:26 -08:00
Andrei EdellandAndrei Edell 43e939666d Hugelung/gpt 4.5 (#1999)
* Add gpt-4.5-preview

* changeset

---------

Co-authored-by: Andrei Edell <andrei@nugbase.com>
2025-02-27 16:29:19 -08:00
Dennis Bartlett ff0eb8fa77 Fix rebase conflict resolution discrepency 2025-02-27 16:18:49 -08:00
Frostbourne db4f6a72c3 style: Make marketplace tags scrollable (#1857)
* Make marketplace tags scrollable

* Changeset
2025-02-27 15:54:51 -08:00
pashpashpash 39980e9be7 Revert "Better Streaming Support (#1980)" (#1993)
This reverts commit 93856ab3b0.
2025-02-27 15:50:02 -08:00
brownrw8andellipsis-dev[bot] 9b5cefd69d Better Streaming Support (#1980)
* feat: enterprise support

* Update src/api/providers/enterprise.ts

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

* refactor + anthropic

* fix

* update chunking

* fix enterprise providers

* minor refactor for chunking

* comments

* defaults

* tests

* remove imports

* suggested fixes

* decouple message type

* upgrade libs, prompt caching no longer in beta for claude models

* updates for tests

* enterprise -> streaming provider

* update tests

* remove section from anthropic.ts

* handle specific GCP Vertex invalid_grant error

* finish comment

* add caching stats to chunking

---------

Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>
2025-02-27 15:46:01 -08:00
canvrno 30a4a0e1f1 Check git out - Checkpoints 2.0
**Branch-Per-Task:** Each repo now has a single Shadow Git repo, with separate branches per task (instead of one Shadow Git repo per task).
-   **Legacy Support:** Existing Checkpoints remain functional, while all new Checkpoints use branch-per-task.

-   **Commits:** Legacy tasks commit to legacy Checkpoints; new tasks commit using branch-per-task.
-   **Diffing & Deletions:** Both legacy and branch-per-task Checkpoints support diffing and deletion.

No migration needed—existing tasks stay as-is, and new tasks adopt **branch-per-task** automatically.
2025-02-27 15:40:52 -08:00
Trevor Hudson e5d616b0bf add optional telemetry (#1939)
* add optional telemetry

* move capture

* add changeset

* implement suggestions
2025-02-27 15:40:47 -08:00
5d8172a25f feat: Set preferred language in settings and have it update system prompt (#1538)
* feat: set preferred language in settings and have it update system prompt

* Update webview-ui/src/components/settings/PreferredLanguagePicker.tsx

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

* remove unnecessary useEffect

* tweak prompt

* formatting from main

* Update Cline.ts

* Revert "Update Cline.ts"

This reverts commit bee6f0fee0.

* Fixes

* Update Cline.ts

* move preferredLanguage to Advanced Settings

* remove unneccessary import

* remove more imports

* skip language prompt for default language `en`

* Update package.json

* lint

---------

Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>
Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2025-02-27 14:45:46 -08:00
FrostbourneandDennis Bartlett 916660a390 [ENG-126] Migrate to Vite (#1876)
* Initial webview vite migration

* Make vite work

* Fix test running

* Enable HMR, disable vite chunking

* Silence type checking errors

* Vite doesn't use browserslist

* get rid of breaking css flag

* add doc to getHMRHtmlContent

* Make it work

* Changeset

* Add IS_DEV to env definitions

* Update tasks to include HMR

* Update CSP image rules

* prettier

* reintroduce IS_DEV in env

* add new deps to pkg lock

---------

Co-authored-by: Dennis Bartlett <bartlett.dc.1@gmail.com>
2025-02-27 14:45:46 -08:00
akfoster 4506eb39a6 Add .clineignore to .gitignore (#1966)
* clineignor should be ignored by git

* changeset
2025-02-27 14:45:46 -08:00
Andrei EdellandAndrei Edell 202e3a6d05 Hugelung/rich mcp response (#1941)
* showing images after mcp responses

* images now open in a webview tab

* Open Graph link metadata display for MCP responses

* almost totally working rich mcp response display with images and embeds

* closer

* header for response display

* updated styling of mcp responses

* default to plain text if rich response is loading

* formatting fix

* added changeset output

* remove some old code

* add the dashed border back

* avoid XSS attacks by sanitizing the preview image urls and embeds

* remove incorrect vendor prefix css

* delete old version of open image implementation

* undo some comment removals and cleanups to make PR easier to read

---------

Co-authored-by: Andrei Edell <andrei@nugbase.com>
2025-02-27 14:45:42 -08:00
Doug Daniels a229b8ce9d feat(vertex): Add prompt caching support for Claude on Vertex AI (#1885)
* feat(vertex): Add prompt caching support for Claude on Vertex AI

* Remove countTokens update claude 3.7

* claude-3-7-sonnet@20250219 support in Vertex AI as default model
2025-02-26 22:28:02 -08:00
Daniel Steigman d58c947c41 v0.1 of a cline rules file for the extension (#1847)
* v0.1 of the cline rules file for the extension

* added changeset

* improved the cline rules file

* added new version of the webview state definition

* added more in depth pass from gemini
2025-02-26 20:14:43 -08:00
FlavioInacta 9449f5dcd1 fix: Checking "Support Images" had no effect on first click (#1922) 2025-02-26 19:53:33 -08:00
Minhao-Zhang 7bc02926b4 Fix Official DeepSeek-V3 cost (#1944)
* update the api price for deepseek (discount period is over)

* update the api price for deepseek (discount period is over)
2025-02-26 19:05:19 -08:00
watany 5e65ea04c9 fix: Anthropic's default model is 3.7 (#1971)
* fix: Anthropic's default model is 3.7

* changeset
2025-02-26 18:42:39 -08:00
Saoud Rizwan e14fd78767 Use GUI telemetry toggle to more clearly show user if they're opted in or not 2025-02-25 17:34:36 -08:00
Saoud Rizwan 8c5f8b253b Fix resetting telemetryEnabled if global setting is toggled off 2025-02-25 15:43:40 -08:00
Saoud Rizwan db463dc1ef Add open from history event 2025-02-25 15:27:34 -08:00
Saoud Rizwan a13aba4bd4 Prepare for release 2025-02-25 15:25:08 -08:00
Saoud Rizwan 568932492b Add optional opt-in telemetry to help fix bugs and improve product 2025-02-25 15:23:38 -08:00
89 changed files with 4587 additions and 724 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
Add correct cost and tokens info to Native OpenAI and DeepSeek providers
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
Update Claude Sonnet 35. -> 3.7 in README(s)
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": minor
---
Added X AI as a new provider with support for all current models including Grok-2 and Grok Vision. This integration enables users to connect to X AI's API using their API key and access models with context windows up to 131K tokens. The implementation includes proper handling for vision models and accurate pricing information.
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
Added a .clinerules file with details about our most common features
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
Make tags section in marketplace scrollable
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
opt: Enhance OpenAiHandler to diagnose request issues and handle empty streams
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
.clineignore should be included in .gitignore
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
Change how Cline finds the path to the user's Documents folder by querying xdg-user-dir on Linux systems.
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": minor
---
Add preferred language option to settings
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": minor
---
GPT 4.5 Preview added
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
Fix checking "Support Images" setting had no effect on first click
+11
View File
@@ -0,0 +1,11 @@
---
"claude-dev": patch
---
Add dynamic model fetching for the Requesty provider.
Instead of manually typing the model name, the extension dynamically fetches
all the supported model names from Requesty's /v1/models API.
This allows users to use a fuzzy search logic when choosing the models and
also guarantees the information for each model is up to date.
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": minor
---
feat(vertex): Add prompt caching support for Claude on Vertex AI
+11
View File
@@ -0,0 +1,11 @@
---
"claude-dev": minor
---
- **Branch-Per-Task:** Each repo now has a single Shadow Git repo, with separate branches per task (instead of one Shadow Git repo per task).
- **Legacy Support:** Existing Checkpoints remain functional, while all new Checkpoints use branch-per-task.
- **Commits:** Legacy tasks commit to legacy Checkpoints; new tasks commit using branch-per-task.
- **Diffing & Deletions:** Both legacy and branch-per-task Checkpoints support diffing and deletion.
No migration needed—existing tasks stay as-is, and new tasks adopt **branch-per-task** automatically.
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": minor
---
Update anthropic SDK to the latest version
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": minor
---
re-added prompt caching to vertex
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
fix: Anthropic's default model is 3.7
+517
View File
@@ -0,0 +1,517 @@
# Cline Extension Architecture & Development Guide
## Project Overview
Cline is a VSCode extension that provides AI assistance through a combination of a core extension backend and a React-based webview frontend. The extension is built with TypeScript and follows a modular architecture pattern.
## Architecture Overview
```mermaid
graph TB
subgraph VSCode Extension Host
subgraph Core Extension
ExtensionEntry[Extension Entry<br/>src/extension.ts]
ClineProvider[ClineProvider<br/>src/core/webview/ClineProvider.ts]
ClineClass[Cline Class<br/>src/core/Cline.ts]
GlobalState[VSCode Global State]
SecretsStorage[VSCode Secrets Storage]
end
subgraph Webview UI
WebviewApp[React App<br/>webview-ui/src/App.tsx]
ExtStateContext[ExtensionStateContext<br/>webview-ui/src/context/ExtensionStateContext.tsx]
ReactComponents[React Components]
end
subgraph Storage
TaskStorage[Task Storage<br/>Per-Task Files & History]
CheckpointSystem[Git-based Checkpoints]
end
end
%% Core Extension Data Flow
ExtensionEntry --> ClineProvider
ClineProvider --> ClineClass
ClineClass --> GlobalState
ClineClass --> SecretsStorage
ClineClass --> TaskStorage
ClineClass --> CheckpointSystem
%% Webview Data Flow
WebviewApp --> ExtStateContext
ExtStateContext --> ReactComponents
%% Bidirectional Communication
ClineProvider <-->|postMessage| ExtStateContext
style GlobalState fill:#f9f,stroke:#333,stroke-width:2px
style SecretsStorage fill:#f9f,stroke:#333,stroke-width:2px
style ExtStateContext fill:#bbf,stroke:#333,stroke-width:2px
style ClineProvider fill:#bfb,stroke:#333,stroke-width:2px
```
## Definitions
- core extension: Anything inside the src folder starting with the Cline.ts file
- core extension state: Managed by the ClineProvider class in src/core/webview/ClineProvider.ts, which serves as the single source of truth for the extension's state. It manages multiple types of persistent storage (global state, workspace state, and secrets), handles state distribution to both the core extension and webview components, and coordinates state across multiple extension instances. This includes managing API configurations, task history, settings, and MCP configurations.
- webview: Anything inside the webview-ui. All the react or view's seen by the user and user interaction compone
- webview state: Managed by ExtensionStateContext in webview-ui/src/context/ExtensionStateContext.tsx, which provides React components with access to the extension's state through a context provider pattern. It maintains local state for UI components, handles real-time updates through message events, manages partial message updates, and provides methods for state modifications. The context includes extension version, messages, task history, theme, API configurations, MCP servers, marketplace catalog, and workspace file paths. It synchronizes with the core extension through VSCode's message passing system and provides type-safe access to state through a custom hook (useExtensionState).
### Core Extension State
The `ClineProvider` class manages multiple types of persistent storage:
- **Global State:** Stored across all VSCode instances. Used for settings and data that should persist globally.
- **Workspace State:** Specific to the current workspace. Used for task-specific data and settings.
- **Secrets:** Secure storage for sensitive information like API keys.
The `ClineProvider` handles the distribution of state to both the core extension and webview components. It also coordinates state across multiple extension instances, ensuring consistency.
### Webview State
The `ExtensionStateContext` in `webview-ui/src/context/ExtensionStateContext.tsx` provides React components with access to the extension's state. It uses a context provider pattern and maintains local state for UI components. The context includes:
- Extension version
- Messages
- Task history
- Theme
- API configurations
- MCP servers
- Marketplace catalog
- Workspace file paths
It synchronizes with the core extension through VSCode's message passing system and provides type-safe access to the state via a custom hook (`useExtensionState`).
## Core Extension (Cline.ts)
The Cline class is the heart of the extension, managing task execution, state persistence, and tool coordination. Each task runs in its own instance of the Cline class, ensuring isolation and proper state management.
### Task Execution Loop
The core task execution loop follows this pattern:
```typescript
class Cline {
async initiateTaskLoop(userContent: UserContent, isNewTask: boolean) {
while (!this.abort) {
// 1. Make API request and stream response
const stream = this.attemptApiRequest()
// 2. Parse and present content blocks
for await (const chunk of stream) {
switch (chunk.type) {
case "text":
// Parse into content blocks
this.assistantMessageContent = parseAssistantMessage(chunk.text)
// Present blocks to user
await this.presentAssistantMessage()
break
}
}
// 3. Wait for tool execution to complete
await pWaitFor(() => this.userMessageContentReady)
// 4. Continue loop with tool result
const recDidEndLoop = await this.recursivelyMakeClineRequests(
this.userMessageContent
)
}
}
}
```
### Message Streaming System
The streaming system handles real-time updates and partial content:
```typescript
class Cline {
async presentAssistantMessage() {
// Handle streaming locks to prevent race conditions
if (this.presentAssistantMessageLocked) {
this.presentAssistantMessageHasPendingUpdates = true
return
}
this.presentAssistantMessageLocked = true
// Present current content block
const block = this.assistantMessageContent[this.currentStreamingContentIndex]
// Handle different types of content
switch (block.type) {
case "text":
await this.say("text", content, undefined, block.partial)
break
case "tool_use":
// Handle tool execution
break
}
// Move to next block if complete
if (!block.partial) {
this.currentStreamingContentIndex++
}
}
}
```
### Tool Execution Flow
Tools follow a strict execution pattern:
```typescript
class Cline {
async executeToolWithApproval(block: ToolBlock) {
// 1. Check auto-approval settings
if (this.shouldAutoApproveTool(block.name)) {
await this.say("tool", message)
this.consecutiveAutoApprovedRequestsCount++
} else {
// 2. Request user approval
const didApprove = await askApproval("tool", message)
if (!didApprove) {
this.didRejectTool = true
return
}
}
// 3. Execute tool
const result = await this.executeTool(block)
// 4. Save checkpoint
await this.saveCheckpoint()
// 5. Return result to API
return result
}
}
```
### Error Handling & Recovery
The system includes robust error handling:
```typescript
class Cline {
async handleError(action: string, error: Error) {
// 1. Check if task was abandoned
if (this.abandoned) return
// 2. Format error message
const errorString = `Error ${action}: ${error.message}`
// 3. Present error to user
await this.say("error", errorString)
// 4. Add error to tool results
pushToolResult(formatResponse.toolError(errorString))
// 5. Cleanup resources
await this.diffViewProvider.revertChanges()
await this.browserSession.closeBrowser()
}
}
```
### API Request & Token Management
The Cline class handles API requests with built-in retry, streaming, and token management:
```typescript
class Cline {
async *attemptApiRequest(previousApiReqIndex: number): ApiStream {
// 1. Wait for MCP servers to connect
await pWaitFor(() => this.providerRef.deref()?.mcpHub?.isConnecting !== true)
// 2. Manage context window
const previousRequest = this.clineMessages[previousApiReqIndex]
if (previousRequest?.text) {
const { tokensIn, tokensOut } = JSON.parse(previousRequest.text)
const totalTokens = (tokensIn || 0) + (tokensOut || 0)
// Truncate conversation if approaching context limit
if (totalTokens >= maxAllowedSize) {
this.conversationHistoryDeletedRange = getNextTruncationRange(
this.apiConversationHistory,
this.conversationHistoryDeletedRange,
totalTokens / 2 > maxAllowedSize ? "quarter" : "half"
)
}
}
// 3. Handle streaming with automatic retry
try {
this.isWaitingForFirstChunk = true
const firstChunk = await iterator.next()
yield firstChunk.value
this.isWaitingForFirstChunk = false
// Stream remaining chunks
yield* iterator
} catch (error) {
// 4. Error handling with retry
if (isOpenRouter && !this.didAutomaticallyRetryFailedApiRequest) {
await delay(1000)
this.didAutomaticallyRetryFailedApiRequest = true
yield* this.attemptApiRequest(previousApiReqIndex)
return
}
// 5. Ask user to retry if automatic retry failed
const { response } = await this.ask(
"api_req_failed",
this.formatErrorWithStatusCode(error)
)
if (response === "yesButtonClicked") {
await this.say("api_req_retried")
yield* this.attemptApiRequest(previousApiReqIndex)
return
}
}
}
}
```
Key features:
1. **Context Window Management**
- Tracks token usage across requests
- Automatically truncates conversation when needed
- Preserves important context while freeing space
- Handles different model context sizes
2. **Streaming Architecture**
- Real-time chunk processing
- Partial content handling
- Race condition prevention
- Error recovery during streaming
3. **Error Handling**
- Automatic retry for transient failures
- User-prompted retry for persistent issues
- Detailed error reporting
- State cleanup on failure
4. **Token Tracking**
- Per-request token counting
- Cumulative usage tracking
- Cost calculation
- Cache hit monitoring
### Task State & Resumption
The Cline class provides robust task state management and resumption capabilities:
```typescript
class Cline {
async resumeTaskFromHistory() {
// 1. Load saved state
this.clineMessages = await this.getSavedClineMessages()
this.apiConversationHistory = await this.getSavedApiConversationHistory()
// 2. Handle interrupted tool executions
const lastMessage = this.apiConversationHistory[this.apiConversationHistory.length - 1]
if (lastMessage.role === "assistant") {
const toolUseBlocks = content.filter(block => block.type === "tool_use")
if (toolUseBlocks.length > 0) {
// Add interrupted tool responses
const toolResponses = toolUseBlocks.map(block => ({
type: "tool_result",
tool_use_id: block.id,
content: "Task was interrupted before this tool call could be completed."
}))
modifiedOldUserContent = [...toolResponses]
}
}
// 3. Notify about interruption
const agoText = this.getTimeAgoText(lastMessage?.ts)
newUserContent.push({
type: "text",
text: `[TASK RESUMPTION] This task was interrupted ${agoText}. It may or may not be complete, so please reassess the task context.`
})
// 4. Resume task execution
await this.initiateTaskLoop(newUserContent, false)
}
private async saveTaskState() {
// Save conversation history
await this.saveApiConversationHistory()
await this.saveClineMessages()
// Create checkpoint
const commitHash = await this.checkpointTracker?.commit()
// Update task history
await this.providerRef.deref()?.updateTaskHistory({
id: this.taskId,
ts: lastMessage.ts,
task: taskMessage.text,
// ... other metadata
})
}
}
```
Key aspects of task state management:
1. **Task Persistence**
- Each task has a unique ID and dedicated storage directory
- Conversation history is saved after each message
- File changes are tracked through Git-based checkpoints
- Terminal output and browser state are preserved
2. **State Recovery**
- Tasks can be resumed from any point
- Interrupted tool executions are handled gracefully
- File changes can be restored from checkpoints
- Context is preserved across VSCode sessions
3. **Workspace Synchronization**
- File changes are tracked through Git
- Checkpoints are created after tool executions
- State can be restored to any checkpoint
- Changes can be compared between checkpoints
4. **Error Recovery**
- Failed API requests can be retried
- Interrupted tool executions are marked
- Resources are cleaned up properly
- User is notified of state changes
## Data Flow & State Management
### Core Extension Role
The core extension (ClineProvider) acts as the single source of truth for all persistent state. It:
- Manages VSCode global state and secrets storage
- Coordinates state updates between components
- Ensures state consistency across webview reloads
- Handles task-specific state persistence
- Manages checkpoint creation and restoration
### Terminal Management
The Cline class manages terminal instances and command execution:
```typescript
class Cline {
async executeCommandTool(command: string): Promise<[boolean, ToolResponse]> {
// 1. Get or create terminal
const terminalInfo = await this.terminalManager.getOrCreateTerminal(cwd)
terminalInfo.terminal.show()
// 2. Execute command with output streaming
const process = this.terminalManager.runCommand(terminalInfo, command)
// 3. Handle real-time output
let result = ""
process.on("line", (line) => {
result += line + "\n"
if (!didContinue) {
sendCommandOutput(line)
} else {
this.say("command_output", line)
}
})
// 4. Wait for completion or user feedback
let completed = false
process.once("completed", () => {
completed = true
})
await process
// 5. Return result
if (completed) {
return [false, `Command executed.\n${result}`]
} else {
return [
false,
`Command is still running in the user's terminal.\n${result}\n\nYou will be updated on the terminal status and new output in the future.`
]
}
}
}
```
Key features:
1. **Terminal Instance Management**
- Multiple terminal support
- Terminal state tracking (busy/inactive)
- Process cooldown monitoring
- Output history per terminal
2. **Command Execution**
- Real-time output streaming
- User feedback handling
- Process state monitoring
- Error recovery
### Browser Session Management
The Cline class handles browser automation through Puppeteer:
```typescript
class Cline {
async executeBrowserAction(action: BrowserAction): Promise<BrowserActionResult> {
switch (action) {
case "launch":
// 1. Launch browser with fixed resolution
await this.browserSession.launchBrowser()
return await this.browserSession.navigateToUrl(url)
case "click":
// 2. Handle click actions with coordinates
return await this.browserSession.click(coordinate)
case "type":
// 3. Handle keyboard input
return await this.browserSession.type(text)
case "close":
// 4. Clean up resources
return await this.browserSession.closeBrowser()
}
}
}
```
Key aspects:
1. **Browser Control**
- Fixed 900x600 resolution window
- Single instance per task lifecycle
- Automatic cleanup on task completion
- Console log capture
2. **Interaction Handling**
- Coordinate-based clicking
- Keyboard input simulation
- Screenshot capture
- Error recovery
## Conclusion
This guide provides a comprehensive overview of the Cline extension architecture, with special focus on state management, data persistence, and code organization. Following these patterns ensures robust feature implementation with proper state handling across the extension's components.
Remember:
- Always persist important state in the extension
- The core extension exists in the src/ folder
- Use proper typing for all state and messages
- Handle errors and edge cases
- Test state persistence across webview reloads
- Follow the established patterns for consistency
- Place new code in appropriate directories
- Maintain clear separation of concerns
- Install dependencies in correct package.json
## Contributing
Contributions to the Cline extension are welcome! Please follow these guidelines:
When adding new tools or API providers, follow the existing patterns in the `src/integrations/` and `src/api/providers/` directories, respectively. Ensure that your code is well-documented and includes appropriate error handling.
The `.clineignore` file allows users to specify files and directories that Cline should not access. When implementing new features, respect the `.clineignore` rules and ensure that your code does not attempt to read or modify ignored files.
+3 -1
View File
@@ -7,4 +7,6 @@ tmp
.DS_Store
pnpm-lock.yaml
pnpm-lock.yaml
.clineignore
+8
View File
@@ -1,5 +1,13 @@
# Changelog
## [3.4.10]
- Add support for GPT-4.5 preview model
## [3.4.9]
- Add toggle to let users opt-in to anonymous telemetry and error reporting
## [3.4.6]
- Add support for Claude 3.7 Sonnet
+1 -1
View File
@@ -32,7 +32,7 @@ English | <a href="https://github.com/cline/cline/blob/main/locales/es/README.md
Meet Cline, an AI assistant that can use your **CLI** a**N**d **E**ditor.
Thanks to [Claude 3.5 Sonnet's agentic coding capabilities](https://www-cdn.anthropic.com/fed9cc193a14b84131812372d8d5857f8f304c52/Model_Card_Claude_3_Addendum.pdf), Cline can handle complex software development tasks step-by-step. With tools that let him create & edit files, explore large projects, use the browser, and execute terminal commands (after you grant permission), he can assist you in ways that go beyond code completion or tech support. Cline can even use the Model Context Protocol (MCP) to create new tools and extend his own capabilities. While autonomous AI scripts traditionally run in sandboxed environments, this extension provides a human-in-the-loop GUI to approve every file change and terminal command, providing a safe and accessible way to explore the potential of agentic AI.
Thanks to [Claude 3.7 Sonnet's agentic coding capabilities](https://www.anthropic.com/claude/sonnet), Cline can handle complex software development tasks step-by-step. With tools that let him create & edit files, explore large projects, use the browser, and execute terminal commands (after you grant permission), he can assist you in ways that go beyond code completion or tech support. Cline can even use the Model Context Protocol (MCP) to create new tools and extend his own capabilities. While autonomous AI scripts traditionally run in sandboxed environments, this extension provides a human-in-the-loop GUI to approve every file change and terminal command, providing a safe and accessible way to explore the potential of agentic AI.
1. Enter your task and add images to convert mockups into functional apps or fix bugs with screenshots.
2. Cline starts by analyzing your file structure & source code ASTs, running regex searches, and reading relevant files to get up to speed in existing projects. By carefully managing what information is added to context, Cline can provide valuable assistance even for large, complex projects without overwhelming the context window.
+20 -3
View File
@@ -42,8 +42,8 @@ Cline functions solely as a client-side VS Code extension that facilitates commu
1. **Local-Only Processing**:
- All operations happen on your local machine
- No central servers or data collection
- No telemetry or usage statistics gathered
- No central servers or data collection by default
- Anonymous telemetry and usage statistics are only collected if you explicitly opt in
- No account creation required
2. **API Key Security**:
@@ -73,7 +73,8 @@ When you request assistance:
- Error logs are processed locally
- No automatic error reporting to Cline
- You control what information to include when reporting issues
- Optional anonymous telemetry and error reporting via PostHog if you opt in
- You control what information to include when manually reporting issues
## Children's Privacy
@@ -90,6 +91,22 @@ We will post any changes to this policy on our GitHub repository. Significant ch
- You can inspect exactly what data is being sent to AI providers
- Enterprise users can implement additional access controls through VS Code
## Telemetry & Usage Statistics
If you choose to opt in to anonymous telemetry:
- Basic usage statistics and error reports are collected via PostHog
- A stable, anonymous identifier (VS Code's `machineId`) is used to understand unique usage patterns
- This identifier is not linked to any personal information
- It helps us understand how features are used across sessions
- It cannot be used to identify you personally
- All data is anonymized and cannot be linked to individual users
- No code content or sensitive information is ever included
- You can opt out at any time through:
- VS Code Settings > Cline > Enable Telemetry
- VS Code Settings > Telemetry > Telemetry Level (setting this to anything other than "all" will disable Cline's telemetry)
- Collected data helps us improve the extension's functionality and stability
## Contact Us
For privacy-related questions or concerns:
+43
View File
@@ -0,0 +1,43 @@
# Cline Extension Architecture
This directory contains architectural documentation for the Cline VSCode extension.
## Extension Architecture Diagram
The [extension-architecture.mmd](./extension-architecture.mmd) file contains a Mermaid diagram showing the high-level architecture of the Cline extension. The diagram illustrates:
1. **Core Extension**
- Extension entry point and main classes
- State management through VSCode's global state and secrets storage
- Core business logic in the Cline class
2. **Webview UI**
- React-based user interface
- State management through ExtensionStateContext
- Component hierarchy
3. **Storage**
- Task-specific storage for history and state
- Git-based checkpoint system for file changes
4. **Data Flow**
- Core extension data flow between components
- Webview UI data flow
- Bidirectional communication between core and webview
## Viewing the Diagram
To view the diagram:
1. Install a Mermaid diagram viewer extension in VSCode
2. Open extension-architecture.mmd
3. Use the extension's preview feature to render the diagram
You can also view the diagram on GitHub, which has built-in Mermaid rendering support.
## Color Scheme
The diagram uses a high-contrast color scheme for better visibility:
- Pink (#ff0066): Global state and secrets storage components
- Blue (#0066ff): Extension state context
- Green (#00cc66): Cline provider
- All components use white text for maximum readability
@@ -0,0 +1,41 @@
graph TB
subgraph VSCode Extension Host
subgraph Core Extension
ExtensionEntry[Extension Entry<br/>src/extension.ts]
ClineProvider[ClineProvider<br/>src/core/webview/ClineProvider.ts]
ClineClass[Cline Class<br/>src/core/Cline.ts]
GlobalState[VSCode Global State]
SecretsStorage[VSCode Secrets Storage]
end
subgraph Webview UI
WebviewApp[React App<br/>webview-ui/src/App.tsx]
ExtStateContext[ExtensionStateContext<br/>webview-ui/src/context/ExtensionStateContext.tsx]
ReactComponents[React Components]
end
subgraph Storage
TaskStorage[Task Storage<br/>Per-Task Files & History]
CheckpointSystem[Git-based Checkpoints]
end
end
%% Core Extension Data Flow
ExtensionEntry --> ClineProvider
ClineProvider --> ClineClass
ClineClass --> GlobalState
ClineClass --> SecretsStorage
ClineClass --> TaskStorage
ClineClass --> CheckpointSystem
%% Webview Data Flow
WebviewApp --> ExtStateContext
ExtStateContext --> ReactComponents
%% Bidirectional Communication
ClineProvider <-->|postMessage| ExtStateContext
style GlobalState fill:#ff0066,stroke:#333,stroke-width:2px,color:#ffffff
style SecretsStorage fill:#ff0066,stroke:#333,stroke-width:2px,color:#ffffff
style ExtStateContext fill:#0066ff,stroke:#333,stroke-width:2px,color:#ffffff
style ClineProvider fill:#00cc66,stroke:#333,stroke-width:2px,color:#ffffff
+1 -1
View File
@@ -32,7 +32,7 @@
التقى Cline، مساعد الذكاء الاصطناعي الذي يمكنه استخدام **سطر الأوامر** و **محرر النصوص** الخاص بك.
بفضل [قدرات Claude 3.5 Sonnet على التعليمات البرمجية الوكيلة](https://www-cdn.anthropic.com/fed9cc193a14b84131812372d8d5857f8f304c52/Model_Card_Claude_3_Addendum.pdf)، يمكن لـ Cline التعامل مع مهام تطوير البرامج المعقدة خطوة بخطوة. مع الأدوات التي تسمح له بإنشاء وتعديل الملفات، واستكشاف المشاريع الكبيرة، واستخدام المتصفح، وتنفيذ أوامر الطرفية (بعد منحك الإذن)، يمكنه مساعدتك بطرق تتجاوز إكمال الكود أو الدعم الفني. يمكن لـ Cline أيضًا استخدام بروتوكول سياق النموذج (MCP) لإنشاء أدوات جديدة وتوسيع قدراته الخاصة. في حين تعمل النصوص البرمجية الآلية المستقلة تقليديًا في بيئات محاصرة، توفر هذه الإضافة واجهة رسومية لموافقة المستخدم على كل تغيير في الملف وأمر طرفية، مما يوفر طريقة آمنة وسهلة الاستخدام لاستكشاف إمكانات الذكاء الاصطناعي الوكيل.
بفضل [قدرات Claude 3.7 Sonnet على التعليمات البرمجية الوكيلة](https://www.anthropic.com/claude/sonnet)، يمكن لـ Cline التعامل مع مهام تطوير البرامج المعقدة خطوة بخطوة. مع الأدوات التي تسمح له بإنشاء وتعديل الملفات، واستكشاف المشاريع الكبيرة، واستخدام المتصفح، وتنفيذ أوامر الطرفية (بعد منحك الإذن)، يمكنه مساعدتك بطرق تتجاوز إكمال الكود أو الدعم الفني. يمكن لـ Cline أيضًا استخدام بروتوكول سياق النموذج (MCP) لإنشاء أدوات جديدة وتوسيع قدراته الخاصة. في حين تعمل النصوص البرمجية الآلية المستقلة تقليديًا في بيئات محاصرة، توفر هذه الإضافة واجهة رسومية لموافقة المستخدم على كل تغيير في الملف وأمر طرفية، مما يوفر طريقة آمنة وسهلة الاستخدام لاستكشاف إمكانات الذكاء الاصطناعي الوكيل.
1. أدخل مهمتك وأضف الصور لتحويل المحاكاة إلى تطبيقات وظيفية أو إصلاح الأخطاء مع لقطات الشاشة.
2. يبدأ Cline بتحليل هيكل الملفات الخاصة بك وشجرة التعريف المصدرية، وإجراء عمليات بحث regex، وقراءة الملفات ذات الصلة للاطلاع على المشاريع الحالية. من خلال إدارة المعلومات التي يتم إضافتها إلى السياق بعناية، يمكن لـ Cline تقديم مساعدة قيمة حتى للمشاريع الكبيرة والمعقدة دون إرهاق نافذة السياق.
+1 -1
View File
@@ -28,7 +28,7 @@
Lernen Sie Cline kennen, einen KI-Assistenten, der Ihre **CLI** u**N**d **E**ditor nutzen kann.
Dank der [agentischen Codierungsfähigkeiten von Claude 3.5 Sonnet](https://www-cdn.anthropic.com/fed9cc193a14b84131812372d8d5857f8f304c52/Model_Card_Claude_3_Addendum.pdf) kann Cline komplexe Softwareentwicklungsaufgaben Schritt für Schritt bewältigen. Mit Werkzeugen, die ihm das Erstellen und Bearbeiten von Dateien, das Erkunden großer Projekte, die Nutzung des Browsers und das Ausführen von Terminalbefehlen (nach Ihrer Genehmigung) ermöglichen, kann er Ihnen auf eine Weise helfen, die über die Codevervollständigung oder technischen Support hinausgeht. Cline kann sogar das Model Context Protocol (MCP) verwenden, um neue Werkzeuge zu erstellen und seine eigenen Fähigkeiten zu erweitern. Während autonome KI-Skripte traditionell in sandboxed Umgebungen laufen, bietet diese Erweiterung eine Mensch-in-der-Schleife-GUI, um jede Dateiänderung und jeden Terminalbefehl zu genehmigen, was eine sichere und zugängliche Möglichkeit bietet, das Potenzial agentischer KI zu erkunden.
Dank der [agentischen Codierungsfähigkeiten von Claude 3.7 Sonnet](https://www.anthropic.com/claude/sonnet) kann Cline komplexe Softwareentwicklungsaufgaben Schritt für Schritt bewältigen. Mit Werkzeugen, die ihm das Erstellen und Bearbeiten von Dateien, das Erkunden großer Projekte, die Nutzung des Browsers und das Ausführen von Terminalbefehlen (nach Ihrer Genehmigung) ermöglichen, kann er Ihnen auf eine Weise helfen, die über die Codevervollständigung oder technischen Support hinausgeht. Cline kann sogar das Model Context Protocol (MCP) verwenden, um neue Werkzeuge zu erstellen und seine eigenen Fähigkeiten zu erweitern. Während autonome KI-Skripte traditionell in sandboxed Umgebungen laufen, bietet diese Erweiterung eine Mensch-in-der-Schleife-GUI, um jede Dateiänderung und jeden Terminalbefehl zu genehmigen, was eine sichere und zugängliche Möglichkeit bietet, das Potenzial agentischer KI zu erkunden.
1. Geben Sie Ihre Aufgabe ein und fügen Sie Bilder hinzu, um Mockups in funktionale Apps zu konvertieren oder Fehler mit Screenshots zu beheben.
2. Cline beginnt mit der Analyse Ihrer Dateistruktur und Quellcode-ASTs, führt Regex-Suchen durch und liest relevante Dateien, um sich in bestehenden Projekten zurechtzufinden. Durch sorgfältiges Management der hinzugefügten Informationen kann Cline wertvolle Unterstützung auch bei großen, komplexen Projekten bieten, ohne das Kontextfenster zu überladen.
+1 -1
View File
@@ -28,7 +28,7 @@
Conozca a Cline, un asistente de IA que puede usar su **CLI** y **E**ditor.
Gracias a las [habilidades de codificación agencial de Claude 3.5 Sonnet](https://www-cdn.anthropic.com/fed9cc193a14b84131812372d8d5857f8f304c52/Model_Card_Claude_3_Addendum.pdf), Cline puede abordar tareas complejas de desarrollo de software paso a paso. Con herramientas que le permiten crear y editar archivos, explorar grandes proyectos, usar el navegador y ejecutar comandos de terminal (con su aprobación), puede ayudarle de una manera que va más allá de la autocompletación de código o el soporte técnico. Cline incluso puede usar el Model Context Protocol (MCP) para crear nuevas herramientas y expandir sus propias capacidades. Mientras que los scripts de IA autónomos tradicionalmente se ejecutan en entornos aislados, esta extensión ofrece una GUI con un humano en el bucle para aprobar cada cambio de archivo y comando de terminal, proporcionando una forma segura y accesible de explorar el potencial de la IA agencial.
Gracias a las [habilidades de codificación agencial de Claude 3.7 Sonnet](https://www.anthropic.com/claude/sonnet), Cline puede abordar tareas complejas de desarrollo de software paso a paso. Con herramientas que le permiten crear y editar archivos, explorar grandes proyectos, usar el navegador y ejecutar comandos de terminal (con su aprobación), puede ayudarle de una manera que va más allá de la autocompletación de código o el soporte técnico. Cline incluso puede usar el Model Context Protocol (MCP) para crear nuevas herramientas y expandir sus propias capacidades. Mientras que los scripts de IA autónomos tradicionalmente se ejecutan en entornos aislados, esta extensión ofrece una GUI con un humano en el bucle para aprobar cada cambio de archivo y comando de terminal, proporcionando una forma segura y accesible de explorar el potencial de la IA agencial.
1. Ingrese su tarea y agregue imágenes para convertir maquetas en aplicaciones funcionales o solucionar errores con capturas de pantalla.
2. Cline comenzará analizando su estructura de archivos y ASTs de código fuente, realizando búsquedas Regex y leyendo archivos relevantes para orientarse en proyectos existentes. Al gestionar cuidadosamente la información agregada, Cline puede proporcionar asistencia valiosa incluso en proyectos grandes y complejos sin sobrecargar la ventana de contexto.
+1 -1
View File
@@ -28,7 +28,7 @@
Clineは、**CLI**と**エディター**を使用できるAIアシスタントです。
[Claude 3.5 Sonnetのエージェント的コーディング機能](https://www-cdn.anthropic.com/fed9cc193a14b84131812372d8d5857f8f304c52/Model_Card_Claude_3_Addendum.pdf)のおかげで、Clineは複雑なソフトウェア開発タスクをステップバイステップで処理できます。ファイルの作成と編集、大規模プロジェクトの探索、ブラウザの使用、ターミナルコマンドの実行(許可後)などのツールを使用して、コード補完や技術サポートを超えた支援を提供します。Clineは、Model Context Protocol (MCP)を使用して新しいツールを作成し、自身の機能を拡張することもできます。自律的なAIスクリプトは通常サンドボックス環境で実行されますが、この拡張機能はファイル変更やターミナルコマンドを承認するための人間インターフェースを提供し、エージェント的AIの可能性を安全かつアクセスしやすい方法で探求できます。
[Claude 3.7 Sonnetのエージェント的コーディング機能](https://www.anthropic.com/claude/sonnet)のおかげで、Clineは複雑なソフトウェア開発タスクをステップバイステップで処理できます。ファイルの作成と編集、大規模プロジェクトの探索、ブラウザの使用、ターミナルコマンドの実行(許可後)などのツールを使用して、コード補完や技術サポートを超えた支援を提供します。Clineは、Model Context Protocol (MCP)を使用して新しいツールを作成し、自身の機能を拡張することもできます。自律的なAIスクリプトは通常サンドボックス環境で実行されますが、この拡張機能はファイル変更やターミナルコマンドを承認するための人間インターフェースを提供し、エージェント的AIの可能性を安全かつアクセスしやすい方法で探求できます。
1. タスクを入力し、モックアップを機能するアプリに変換したり、スクリーンショットでバグを修正したりします。
2. Clineは、ファイル構造とソースコードASTの分析、正規表現検索の実行、関連ファイルの読み取りから始め、既存プロジェクトに精通します。コンテキストに追加される情報を慎重に管理することで、大規模で複雑なプロジェクトでもコンテキストウィンドウを圧倒することなく貴重な支援を提供できます。
+1 -1
View File
@@ -28,7 +28,7 @@
Conheça o Cline: um assistente de IA que pode usar seu **CLI** e **Editor**.
Graças às [habilidades avançadas do Claude 3.5 Sonnet](https://www-cdn.anthropic.com/fed9cc193a14b84131812372d8d5857f8f304c52/Model_Card_Claude_3_Addendum.pdf), o Cline pode lidar com tarefas complexas de desenvolvimento de software passo a passo. Com ferramentas que permitem criar e editar arquivos, explorar grandes projetos, usar o navegador e executar comandos no terminal (com sua aprovação), ele pode ajudar você de maneiras que vão além da inclusão de código ou suporte técnico. O Cline pode é capaz inclusive de usar o Model Context Protocol (MCP) para criar novas ferramentas e expandir seus próprios recursos. Embora os scripts de IA autônomas tradicionalmente sejam executados em ambientes isolados, esta extensão oferece uma GUI com um humano no circuito para aprovar cada alteração de arquivo e comando de terminal, fornecendo uma maneira segura e acessível de explorar todo o potencial da IA.
Graças às [habilidades avançadas do Claude 3.7 Sonnet](https://www.anthropic.com/claude/sonnet), o Cline pode lidar com tarefas complexas de desenvolvimento de software passo a passo. Com ferramentas que permitem criar e editar arquivos, explorar grandes projetos, usar o navegador e executar comandos no terminal (com sua aprovação), ele pode ajudar você de maneiras que vão além da inclusão de código ou suporte técnico. O Cline pode é capaz inclusive de usar o Model Context Protocol (MCP) para criar novas ferramentas e expandir seus próprios recursos. Embora os scripts de IA autônomas tradicionalmente sejam executados em ambientes isolados, esta extensão oferece uma GUI com um humano no circuito para aprovar cada alteração de arquivo e comando de terminal, fornecendo uma maneira segura e acessível de explorar todo o potencial da IA.
1. Insira sua tarefa e adicione imagens para transformar mockups em aplicativos funcionais ou corrigir erros através de capturas de tela.
+1 -1
View File
@@ -28,7 +28,7 @@
认识 Cline,一个可以使用你的 **CLI****编辑器** 的 AI 助手。
感谢 [Claude 3.5 Sonnet 的代理编码能力](https://www-cdn.anthropic.com/fed9cc193a14b84131812372d8d5857f8f304c52/Model_Card_Claude_3_Addendum.pdf),Cline 可以一步步处理复杂的软件开发任务。通过允许他创建和编辑文件、探索大型项目、使用浏览器和执行终端命令(在你授予权限后),他可以提供超越代码完成或技术支持的帮助。Cline 甚至可以使用 Model Context Protocol (MCP) 创建新工具并扩展自己的能力。虽然自主 AI 脚本传统上在沙盒环境中运行,但此扩展提供了一个人机交互的 GUI 来批准每个文件更改和终端命令,提供了一种安全且可访问的方式来探索代理 AI 的潜力。
感谢 [Claude 3.7 Sonnet 的代理编码能力](https://www.anthropic.com/claude/sonnet),Cline 可以一步步处理复杂的软件开发任务。通过允许他创建和编辑文件、探索大型项目、使用浏览器和执行终端命令(在你授予权限后),他可以提供超越代码完成或技术支持的帮助。Cline 甚至可以使用 Model Context Protocol (MCP) 创建新工具并扩展自己的能力。虽然自主 AI 脚本传统上在沙盒环境中运行,但此扩展提供了一个人机交互的 GUI 来批准每个文件更改和终端命令,提供了一种安全且可访问的方式来探索代理 AI 的潜力。
1. 输入你的任务并添加图像,将模型转换为功能应用程序或通过截图修复错误。
2. Cline 首先分析你的文件结构和源代码 AST,运行正则表达式搜索,并阅读相关文件以了解现有项目。通过仔细管理添加到上下文中的信息,Cline 即使在大型复杂项目中也能提供有价值的帮助,而不会使上下文窗口过载。
+1 -1
View File
@@ -28,7 +28,7 @@
認識 Cline,一個可以使用你的 **CLI****編輯器** 的 AI 助手。
感謝 [Claude 3.5 Sonnet 的代理編碼能力](https://www-cdn.anthropic.com/fed9cc193a14b84131812372d8d5857f8f304c52/Model_Card_Claude_3_Addendum.pdf),Cline 可以一步步處理複雜的軟件開發任務。通過允許他創建和編輯文件、探索大型項目、使用瀏覽器和執行終端命令(在你授予權限後),他可以提供超越代碼完成或技術支持的幫助。Cline 甚至可以使用 Model Context Protocol (MCP) 創建新工具並擴展自己的能力。雖然自主 AI 腳本傳統上在沙盒環境中運行,但此擴展提供了一個人機交互的 GUI 來批准每個文件更改和終端命令,提供了一種安全且可訪問的方式來探索代理 AI 的潛力。
感謝 [Claude 3.7 Sonnet 的代理編碼能力](https://www.anthropic.com/claude/sonnet),Cline 可以一步步處理複雜的軟件開發任務。通過允許他創建和編輯文件、探索大型項目、使用瀏覽器和執行終端命令(在你授予權限後),他可以提供超越代碼完成或技術支持的幫助。Cline 甚至可以使用 Model Context Protocol (MCP) 創建新工具並擴展自己的能力。雖然自主 AI 腳本傳統上在沙盒環境中運行,但此擴展提供了一個人機交互的 GUI 來批准每個文件更改和終端命令,提供了一種安全且可訪問的方式來探索代理 AI 的潛力。
1. 輸入你的任務並添加圖像,將模型轉換為功能應用程序或通過截圖修復錯誤。
2. Cline 首先分析你的文件結構和源代碼 AST,運行正則表達式搜索,並閱讀相關文件以了解現有項目。通過仔細管理添加到上下文中的信息,Cline 即使在大型複雜項目中也能提供有價值的幫助,而不會使上下文窗口過載。
+59 -20
View File
@@ -1,17 +1,17 @@
{
"name": "claude-dev",
"version": "3.4.8",
"version": "3.4.10",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "claude-dev",
"version": "3.4.8",
"version": "3.4.10",
"license": "Apache-2.0",
"dependencies": {
"@anthropic-ai/bedrock-sdk": "^0.10.2",
"@anthropic-ai/sdk": "^0.26.0",
"@anthropic-ai/vertex-sdk": "^0.4.1",
"@anthropic-ai/bedrock-sdk": "^0.12.4",
"@anthropic-ai/sdk": "^0.37.0",
"@anthropic-ai/vertex-sdk": "^0.6.4",
"@google/generative-ai": "^0.18.0",
"@mistralai/mistralai": "^1.5.0",
"@modelcontextprotocol/sdk": "^1.0.1",
@@ -41,6 +41,7 @@
"os-name": "^6.0.0",
"p-wait-for": "^5.0.2",
"pdf-parse": "^1.1.1",
"posthog-node": "^4.8.1",
"puppeteer-chromium-resolver": "^23.0.0",
"puppeteer-core": "^23.4.0",
"serialize-error": "^11.0.3",
@@ -77,11 +78,12 @@
}
},
"node_modules/@anthropic-ai/bedrock-sdk": {
"version": "0.10.2",
"resolved": "https://registry.npmjs.org/@anthropic-ai/bedrock-sdk/-/bedrock-sdk-0.10.2.tgz",
"integrity": "sha512-sGmTzKJQHVwfXexe+yfzPU3rJmUMCygC+GNPkmMsPX/Jr+WKtJ0M71nGyHONr6vcHwUpUWA6o0MRH/oHaE54KA==",
"version": "0.12.4",
"resolved": "https://registry.npmjs.org/@anthropic-ai/bedrock-sdk/-/bedrock-sdk-0.12.4.tgz",
"integrity": "sha512-kraOgWWyVO/Wef3wYbpws77pCZubg/hCzXQ7RGrLRJsRpbTIT+ms+MigGu/0b1qn3o5WuIrumlT3Qtu3esHpzw==",
"license": "MIT",
"dependencies": {
"@anthropic-ai/sdk": "^0",
"@anthropic-ai/sdk": ">=0.36 <1",
"@aws-crypto/sha256-js": "^4.0.0",
"@aws-sdk/client-bedrock-runtime": "^3.423.0",
"@aws-sdk/credential-providers": "^3.341.0",
@@ -95,9 +97,10 @@
}
},
"node_modules/@anthropic-ai/sdk": {
"version": "0.26.0",
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.26.0.tgz",
"integrity": "sha512-vNbZ2rnnMfk8Bf4OdeVy6GA4EXao8tGC0tLEoSAl1NZrip9oOxnEGUkXl3FsPQgeBM5hmpGE1tSLuu9HEVJiHg==",
"version": "0.37.0",
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.37.0.tgz",
"integrity": "sha512-tHjX2YbkUBwEgg0JZU3EFSSAQPoK4qQR/NFYa8Vtzd5UAyXzZksCw2In69Rml4R/TyHPBfRYaLK35XiOe33pjw==",
"license": "MIT",
"dependencies": {
"@types/node": "^18.11.18",
"@types/node-fetch": "^2.6.4",
@@ -109,23 +112,48 @@
}
},
"node_modules/@anthropic-ai/sdk/node_modules/@types/node": {
"version": "18.19.39",
"resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.39.tgz",
"integrity": "sha512-nPwTRDKUctxw3di5b4TfT3I0sWDiWoPQCZjXhvdkINntwr8lcoVCKsTgnXeRubKIlfnV+eN/HYk6Jb40tbcEAQ==",
"version": "18.19.76",
"resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.76.tgz",
"integrity": "sha512-yvR7Q9LdPz2vGpmpJX5LolrgRdWvB67MJKDPSgIIzpFbaf9a1j/f5DnLp5VDyHGMR0QZHlTr1afsD87QCXFHKw==",
"license": "MIT",
"dependencies": {
"undici-types": "~5.26.4"
}
},
"node_modules/@anthropic-ai/vertex-sdk": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/@anthropic-ai/vertex-sdk/-/vertex-sdk-0.4.1.tgz",
"integrity": "sha512-RT/2CWzqyAcJDZWxnNc1mXa7XiiHDaQ9aknfW4mIDw6zE+Zj/R2vCKpTb0dIwrmHYNOyKQNaD7Z1ynDt9oXFWA==",
"version": "0.6.4",
"resolved": "https://registry.npmjs.org/@anthropic-ai/vertex-sdk/-/vertex-sdk-0.6.4.tgz",
"integrity": "sha512-rMBlO2jF53TfMRmsQMm1bPO2JRUh4jYddjq/OJLj8DSAkfbCrNWhc0yhDed6oLYJg5s+VpDbvlPzMggqHhTfMw==",
"license": "MIT",
"dependencies": {
"@anthropic-ai/sdk": ">=0.14 <1",
"@anthropic-ai/sdk": ">=0.35 <1",
"google-auth-library": "^9.4.2"
}
},
"node_modules/@anthropic-ai/vertex-sdk/node_modules/@anthropic-ai/sdk": {
"version": "0.38.0",
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.38.0.tgz",
"integrity": "sha512-ZUYjadEEeb1wwKd9MEM+plSfl7zQjYixhjHRtyPsjO7MtzRmbZvBb1n1ofo2kHwb0aUiIdb3aAEbAwS9Bcbm/A==",
"license": "MIT",
"dependencies": {
"@types/node": "^18.11.18",
"@types/node-fetch": "^2.6.4",
"abort-controller": "^3.0.0",
"agentkeepalive": "^4.2.1",
"form-data-encoder": "1.7.2",
"formdata-node": "^4.3.2",
"node-fetch": "^2.6.7"
}
},
"node_modules/@anthropic-ai/vertex-sdk/node_modules/@types/node": {
"version": "18.19.76",
"resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.76.tgz",
"integrity": "sha512-yvR7Q9LdPz2vGpmpJX5LolrgRdWvB67MJKDPSgIIzpFbaf9a1j/f5DnLp5VDyHGMR0QZHlTr1afsD87QCXFHKw==",
"license": "MIT",
"dependencies": {
"undici-types": "~5.26.4"
}
},
"node_modules/@aws-crypto/crc32": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/@aws-crypto/crc32/-/crc32-5.2.0.tgz",
@@ -3763,7 +3791,6 @@
"version": "0.18.0",
"resolved": "https://registry.npmjs.org/@google/generative-ai/-/generative-ai-0.18.0.tgz",
"integrity": "sha512-AhaIWSpk2tuhYHrBhUqC0xrWWznmYEja1/TRDIb+5kruBU5kUzMlFsXCQNO9PzyTZ4clUJ3CX/Rvy+Xm9x+w3g==",
"license": "Apache-2.0",
"engines": {
"node": ">=18.0.0"
}
@@ -11628,6 +11655,18 @@
"node": ">= 0.4"
}
},
"node_modules/posthog-node": {
"version": "4.8.1",
"resolved": "https://registry.npmjs.org/posthog-node/-/posthog-node-4.8.1.tgz",
"integrity": "sha512-ApMEC1+DbctP/88+VhaCl8SRKpIoReibMf7Mb3rxw3yMthr1rKaM4opbHdZJ0buLhwS5zX8B2ckqLjpwpSjRPg==",
"license": "MIT",
"dependencies": {
"axios": "^1.7.4"
},
"engines": {
"node": ">=15.0.0"
}
},
"node_modules/prelude-ls": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
+30 -4
View File
@@ -2,7 +2,7 @@
"name": "claude-dev",
"displayName": "Cline",
"description": "Autonomous coding agent right in your IDE, capable of creating/editing files, running commands, using the browser, and more with your permission every step of the way.",
"version": "3.4.8",
"version": "3.4.10",
"icon": "assets/icons/icon.png",
"galleryBanner": {
"color": "#617A91",
@@ -182,6 +182,31 @@
"default": null,
"description": "Path to Chrome executable for browser use functionality. If not set, the extension will attempt to find or download it automatically."
},
"cline.preferredLanguage": {
"type": "string",
"enum": [
"English",
"Arabic - العربية",
"Portuguese - Português (Brasil)",
"Czech - Čeština",
"French - Français",
"German - Deutsch",
"Hindi - हिन्दी",
"Hungarian - Magyar",
"Italian - Italiano",
"Japanese - 日本語",
"Korean - 한국어",
"Polish - Polski",
"Portuguese - Português (Portugal)",
"Russian - Русский",
"Simplified Chinese - 简体中文",
"Spanish - Español",
"Traditional Chinese - 繁體中文",
"Turkish - Türkçe"
],
"default": "English",
"description": "The language that Cline should use for communication."
},
"cline.mcpMarketplace.enabled": {
"type": "boolean",
"default": true,
@@ -237,9 +262,9 @@
"typescript": "^5.4.5"
},
"dependencies": {
"@anthropic-ai/bedrock-sdk": "^0.10.2",
"@anthropic-ai/sdk": "^0.26.0",
"@anthropic-ai/vertex-sdk": "^0.4.1",
"@anthropic-ai/bedrock-sdk": "^0.12.4",
"@anthropic-ai/sdk": "^0.37.0",
"@anthropic-ai/vertex-sdk": "^0.6.4",
"@google/generative-ai": "^0.18.0",
"@mistralai/mistralai": "^1.5.0",
"@modelcontextprotocol/sdk": "^1.0.1",
@@ -269,6 +294,7 @@
"os-name": "^6.0.0",
"p-wait-for": "^5.0.2",
"pdf-parse": "^1.1.1",
"posthog-node": "^4.8.1",
"puppeteer-chromium-resolver": "^23.0.0",
"puppeteer-core": "^23.4.0",
"serialize-error": "^11.0.3",
+3
View File
@@ -17,6 +17,7 @@ import { QwenHandler } from "./providers/qwen"
import { MistralHandler } from "./providers/mistral"
import { VsCodeLmHandler } from "./providers/vscode-lm"
import { LiteLlmHandler } from "./providers/litellm"
import { XAIHandler } from "./providers/xai"
export interface ApiHandler {
createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream
@@ -62,6 +63,8 @@ export function buildApiHandler(configuration: ApiConfiguration): ApiHandler {
return new VsCodeLmHandler(options)
case "litellm":
return new LiteLlmHandler(options)
case "xai":
return new XAIHandler(options)
default:
return new AnthropicHandler(options)
}
+2 -2
View File
@@ -20,7 +20,7 @@ export class AnthropicHandler implements ApiHandler {
@withRetry()
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
const model = this.getModel()
let stream: AnthropicStream<Anthropic.Beta.PromptCaching.Messages.RawPromptCachingBetaMessageStreamEvent>
let stream: AnthropicStream<Anthropic.RawMessageStreamEvent>
const modelId = model.id
switch (modelId) {
// 'latest' alias does not support cache_control
@@ -38,7 +38,7 @@ export class AnthropicHandler implements ApiHandler {
)
const lastUserMsgIndex = userMsgIndices[userMsgIndices.length - 1] ?? -1
const secondLastMsgUserIndex = userMsgIndices[userMsgIndices.length - 2] ?? -1
stream = await this.client.beta.promptCaching.messages.create(
stream = await this.client.messages.create(
{
model: modelId,
max_tokens: model.info.maxTokens || 8192,
+33 -9
View File
@@ -3,6 +3,7 @@ import OpenAI from "openai"
import { withRetry } from "../retry"
import { ApiHandler } from "../"
import { ApiHandlerOptions, DeepSeekModelId, ModelInfo, deepSeekDefaultModelId, deepSeekModels } from "../../shared/api"
import { calculateApiCostOpenAI } from "../../utils/cost"
import { convertToOpenAiMessages } from "../transform/openai-format"
import { ApiStream } from "../transform/stream"
import { convertToR1Format } from "../transform/r1-format"
@@ -19,6 +20,37 @@ export class DeepSeekHandler implements ApiHandler {
})
}
private async *yieldUsage(info: ModelInfo, usage: OpenAI.Completions.CompletionUsage | undefined): ApiStream {
// Deepseek reports total input AND cache reads/writes,
// see context caching: https://api-docs.deepseek.com/guides/kv_cache)
// where the input tokens is the sum of the cache hits/misses, just like OpenAI.
// This affects:
// 1) context management truncation algorithm, and
// 2) cost calculation
// Deepseek usage includes extra fields.
// Safely cast the prompt token details section to the appropriate structure.
interface DeepSeekUsage extends OpenAI.CompletionUsage {
prompt_cache_hit_tokens?: number
prompt_cache_miss_tokens?: number
}
const deepUsage = usage as DeepSeekUsage
const inputTokens = deepUsage?.prompt_tokens || 0
const outputTokens = deepUsage?.completion_tokens || 0
const cacheReadTokens = deepUsage?.prompt_cache_hit_tokens || 0
const cacheWriteTokens = deepUsage?.prompt_cache_miss_tokens || 0
const totalCost = calculateApiCostOpenAI(info, inputTokens, outputTokens, cacheWriteTokens, cacheReadTokens)
yield {
type: "usage",
inputTokens: inputTokens,
outputTokens: outputTokens,
cacheWriteTokens: cacheWriteTokens,
cacheReadTokens: cacheReadTokens,
totalCost: totalCost,
}
}
@withRetry()
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
const model = this.getModel()
@@ -61,15 +93,7 @@ export class DeepSeekHandler implements ApiHandler {
}
if (chunk.usage) {
yield {
type: "usage",
inputTokens: chunk.usage.prompt_tokens || 0, // (deepseek reports total input AND cache reads/writes, see context caching: https://api-docs.deepseek.com/guides/kv_cache) where the input tokens is the sum of the cache hits/misses, while anthropic reports them as separate tokens. This is important to know for 1) context management truncation algorithm, and 2) cost calculation (NOTE: we report both input and cache stats but for now set input price to 0 since all the cost calculation will be done using cache hits/misses)
outputTokens: chunk.usage.completion_tokens || 0,
// @ts-ignore-next-line
cacheReadTokens: chunk.usage.prompt_cache_hit_tokens || 0,
// @ts-ignore-next-line
cacheWriteTokens: chunk.usage.prompt_cache_miss_tokens || 0,
}
yield* this.yieldUsage(model.info, chunk.usage)
}
}
}
+30 -21
View File
@@ -10,6 +10,7 @@ import {
openAiNativeModels,
} from "../../shared/api"
import { convertToOpenAiMessages } from "../transform/openai-format"
import { calculateApiCostOpenAI } from "../../utils/cost"
import { ApiStream } from "../transform/stream"
import { ChatCompletionReasoningEffort } from "openai/resources/chat/completions.mjs"
@@ -24,31 +25,47 @@ export class OpenAiNativeHandler implements ApiHandler {
})
}
private async *yieldUsage(info: ModelInfo, usage: OpenAI.Completions.CompletionUsage | undefined): ApiStream {
const inputTokens = usage?.prompt_tokens || 0
const outputTokens = usage?.completion_tokens || 0
const cacheReadTokens = usage?.prompt_tokens_details?.cached_tokens || 0
const cacheWriteTokens = 0
const totalCost = calculateApiCostOpenAI(info, inputTokens, outputTokens, cacheWriteTokens, cacheReadTokens)
yield {
type: "usage",
inputTokens: inputTokens,
outputTokens: outputTokens,
cacheWriteTokens: cacheWriteTokens,
cacheReadTokens: cacheReadTokens,
totalCost: totalCost,
}
}
@withRetry()
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
switch (this.getModel().id) {
const model = this.getModel()
switch (model.id) {
case "o1":
case "o1-preview":
case "o1-mini": {
// o1 doesnt support streaming, non-1 temp, or system prompt
const response = await this.client.chat.completions.create({
model: this.getModel().id,
model: model.id,
messages: [{ role: "user", content: systemPrompt }, ...convertToOpenAiMessages(messages)],
})
yield {
type: "text",
text: response.choices[0]?.message.content || "",
}
yield {
type: "usage",
inputTokens: response.usage?.prompt_tokens || 0,
outputTokens: response.usage?.completion_tokens || 0,
}
yield* this.yieldUsage(model.info, response.usage)
break
}
case "o3-mini": {
const stream = await this.client.chat.completions.create({
model: this.getModel().id,
model: model.id,
messages: [{ role: "developer", content: systemPrompt }, ...convertToOpenAiMessages(messages)],
stream: true,
stream_options: { include_usage: true },
@@ -63,18 +80,15 @@ export class OpenAiNativeHandler implements ApiHandler {
}
}
if (chunk.usage) {
yield {
type: "usage",
inputTokens: chunk.usage.prompt_tokens || 0,
outputTokens: chunk.usage.completion_tokens || 0,
}
// Only last chunk contains usage
yield* this.yieldUsage(model.info, chunk.usage)
}
}
break
}
default: {
const stream = await this.client.chat.completions.create({
model: this.getModel().id,
model: model.id,
// max_completion_tokens: this.getModel().info.maxTokens,
temperature: 0,
messages: [{ role: "system", content: systemPrompt }, ...convertToOpenAiMessages(messages)],
@@ -90,14 +104,9 @@ export class OpenAiNativeHandler implements ApiHandler {
text: delta.content,
}
}
// contains a null value except for the last chunk which contains the token usage statistics for the entire request
if (chunk.usage) {
yield {
type: "usage",
inputTokens: chunk.usage.prompt_tokens || 0,
outputTokens: chunk.usage.completion_tokens || 0,
}
// Only last chunk contains usage
yield* this.yieldUsage(model.info, chunk.usage)
}
}
}
+81 -21
View File
@@ -28,6 +28,65 @@ export class OpenAiHandler implements ApiHandler {
}
}
private async diagnoseRequestProblem(
modelId: string,
messages: OpenAI.Chat.ChatCompletionMessageParam[],
apiKey: string,
baseURL: string,
) {
const url = `${baseURL}/chat/completions`
try {
const response = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify({
model: modelId,
messages: messages,
temperature: 0,
stream: true,
}),
})
if (!response.ok) {
return `HTTP error! status: ${response.status}, statusText: ${response.statusText}`
}
const responseData = await response.json()
return responseData
} catch (error) {
return error instanceof Error ? error.message : String(error)
}
}
private async *handleChunk(chunk: OpenAI.Chat.Completions.ChatCompletionChunk): ApiStream {
const delta = chunk.choices[0]?.delta
if (delta?.content) {
yield {
type: "text",
text: delta.content,
}
}
if (delta && "reasoning_content" in delta && delta.reasoning_content) {
yield {
type: "reasoning",
reasoning: (delta.reasoning_content as string | undefined) || "",
}
}
if (chunk.usage) {
yield {
type: "usage",
inputTokens: chunk.usage.prompt_tokens || 0,
outputTokens: chunk.usage.completion_tokens || 0,
}
}
}
@withRetry()
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
const modelId = this.options.openAiModelId ?? ""
@@ -49,29 +108,30 @@ export class OpenAiHandler implements ApiHandler {
stream: true,
stream_options: { include_usage: true },
})
for await (const chunk of stream) {
const delta = chunk.choices[0]?.delta
if (delta?.content) {
yield {
type: "text",
text: delta.content,
}
}
if (delta && "reasoning_content" in delta && delta.reasoning_content) {
yield {
type: "reasoning",
reasoning: (delta.reasoning_content as string | undefined) || "",
}
}
const [validationStream, contentStream] = stream.tee()
if (chunk.usage) {
yield {
type: "usage",
inputTokens: chunk.usage.prompt_tokens || 0,
outputTokens: chunk.usage.completion_tokens || 0,
}
}
// Check the first chunk to detect potential stream issues early
// This helps to provide better error messages for cases like:
// https://github.com/cline/cline/issues/1662
// where the stream appears valid but contains no actual data
const firstChunk = await validationStream[Symbol.asyncIterator]().next()
if (firstChunk.done || !firstChunk.value) {
// Make an additional request to get detailed error information
// This gives us more context about what went wrong with the API call
const errorResponse = await this.diagnoseRequestProblem(
modelId,
openAiMessages,
this.client.apiKey,
this.client.baseURL,
)
throw new Error(`Stream empty. Error details: ${JSON.stringify(errorResponse)}`)
}
yield* this.handleChunk(firstChunk.value)
for await (const chunk of contentStream) {
yield* this.handleChunk(chunk)
}
}
+28 -12
View File
@@ -1,7 +1,14 @@
import { Anthropic } from "@anthropic-ai/sdk"
import OpenAI from "openai"
import { withRetry } from "../retry"
import { ApiHandlerOptions, ModelInfo, openAiModelInfoSaneDefaults } from "../../shared/api"
import { calculateApiCostOpenAI } from "../../utils/cost"
import {
ApiHandlerOptions,
ModelInfo,
openAiModelInfoSaneDefaults,
requestyDefaultModelId,
requestyDefaultModelInfo,
} from "../../shared/api"
import { ApiHandler } from "../index"
import { convertToOpenAiMessages } from "../transform/openai-format"
import { ApiStream } from "../transform/stream"
@@ -24,7 +31,7 @@ export class RequestyHandler implements ApiHandler {
@withRetry()
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
const modelId = this.options.requestyModelId ?? ""
const model = this.getModel()
let openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [
{ role: "system", content: systemPrompt },
@@ -33,12 +40,13 @@ export class RequestyHandler implements ApiHandler {
// @ts-ignore-next-line
const stream = await this.client.chat.completions.create({
model: modelId,
model: model.id,
max_tokens: model.info.maxTokens || undefined,
messages: openAiMessages,
temperature: 0,
stream: true,
stream_options: { include_usage: true },
...(modelId === "openai/o3-mini" ? { reasoning_effort: this.options.o3MiniReasoningEffort || "medium" } : {}),
...(model.id === "openai/o3-mini" ? { reasoning_effort: this.options.o3MiniReasoningEffort || "medium" } : {}),
})
for await (const chunk of stream) {
@@ -69,22 +77,30 @@ export class RequestyHandler implements ApiHandler {
if (chunk.usage) {
const usage = chunk.usage as RequestyUsage
const inputTokens = usage.prompt_tokens || 0
const outputTokens = usage.completion_tokens || 0
const cacheWriteTokens = usage.prompt_tokens_details?.caching_tokens || undefined
const cacheReadTokens = usage.prompt_tokens_details?.cached_tokens || undefined
const totalCost = 0 // TODO: Replace with calculateApiCostOpenAI(model.info, inputTokens, outputTokens, cacheWriteTokens, cacheReadTokens)
yield {
type: "usage",
inputTokens: usage.prompt_tokens || 0,
outputTokens: usage.completion_tokens || 0,
cacheWriteTokens: usage.prompt_tokens_details?.caching_tokens || undefined,
cacheReadTokens: usage.prompt_tokens_details?.cached_tokens || undefined,
totalCost: usage.total_cost || undefined,
inputTokens: inputTokens,
outputTokens: outputTokens,
cacheWriteTokens: cacheWriteTokens,
cacheReadTokens: cacheReadTokens,
totalCost: totalCost,
}
}
}
}
getModel(): { id: string; info: ModelInfo } {
return {
id: this.options.requestyModelId ?? "",
info: openAiModelInfoSaneDefaults,
const modelId = this.options.requestyModelId
const modelInfo = this.options.requestyModelInfo
if (modelId && modelInfo) {
return { id: modelId, info: modelInfo }
}
return { id: requestyDefaultModelId, info: requestyDefaultModelInfo }
}
}
+113 -9
View File
@@ -21,14 +21,113 @@ export class VertexHandler implements ApiHandler {
@withRetry()
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
const stream = await this.client.messages.create({
model: this.getModel().id,
max_tokens: this.getModel().info.maxTokens || 8192,
temperature: 0,
system: systemPrompt,
messages,
stream: true,
})
const model = this.getModel()
const modelId = model.id
let stream
switch (modelId) {
case "claude-3-7-sonnet@20250219":
case "claude-3-5-sonnet-v2@20241022":
case "claude-3-5-sonnet@20240620":
case "claude-3-5-haiku@20241022":
case "claude-3-opus@20240229":
case "claude-3-haiku@20240307": {
// Find indices of user messages for cache control
const userMsgIndices = messages.reduce(
(acc, msg, index) => (msg.role === "user" ? [...acc, index] : acc),
[] as number[],
)
const lastUserMsgIndex = userMsgIndices[userMsgIndices.length - 1] ?? -1
const secondLastMsgUserIndex = userMsgIndices[userMsgIndices.length - 2] ?? -1
stream = await this.client.beta.messages.create(
{
model: modelId,
max_tokens: model.info.maxTokens || 8192,
temperature: 0,
system: [
{
text: systemPrompt,
type: "text",
cache_control: { type: "ephemeral" },
},
],
messages: messages.map((message, index) => {
if (index === lastUserMsgIndex || index === secondLastMsgUserIndex) {
return {
...message,
content:
typeof message.content === "string"
? [
{
type: "text",
text: message.content,
cache_control: {
type: "ephemeral",
},
},
]
: message.content.map((content, contentIndex) =>
contentIndex === message.content.length - 1
? {
...content,
cache_control: {
type: "ephemeral",
},
}
: content,
),
}
}
return {
...message,
content:
typeof message.content === "string"
? [
{
type: "text",
text: message.content,
},
]
: message.content,
}
}),
stream: true,
},
{
headers: {},
},
)
break
}
default: {
stream = await this.client.beta.messages.create({
model: modelId,
max_tokens: model.info.maxTokens || 8192,
temperature: 0,
system: [
{
text: systemPrompt,
type: "text",
},
],
messages: messages.map((message) => ({
...message,
content:
typeof message.content === "string"
? [
{
type: "text",
text: message.content,
},
]
: message.content,
})),
stream: true,
})
break
}
}
for await (const chunk of stream) {
switch (chunk.type) {
case "message_start":
@@ -37,6 +136,8 @@ export class VertexHandler implements ApiHandler {
type: "usage",
inputTokens: usage.input_tokens || 0,
outputTokens: usage.output_tokens || 0,
cacheWriteTokens: usage.cache_creation_input_tokens || undefined,
cacheReadTokens: usage.cache_read_input_tokens || undefined,
}
break
case "message_delta":
@@ -46,7 +147,8 @@ export class VertexHandler implements ApiHandler {
outputTokens: chunk.usage.output_tokens || 0,
}
break
case "message_stop":
break
case "content_block_start":
switch (chunk.content_block.type) {
case "text":
@@ -73,6 +175,8 @@ export class VertexHandler implements ApiHandler {
break
}
break
case "content_block_stop":
break
}
}
}
+2 -2
View File
@@ -1,7 +1,7 @@
import { Anthropic } from "@anthropic-ai/sdk"
import * as vscode from "vscode"
import { ApiHandler, SingleCompletionHandler } from "../"
import { calculateApiCost } from "../../utils/cost"
import { calculateApiCostAnthropic } from "../../utils/cost"
import { ApiStream } from "../transform/stream"
import { convertToVsCodeLmMessages } from "../transform/vscode-lm-format"
import { SELECTOR_SEPARATOR, stringifyVsCodeLmModelSelector } from "../../shared/vsCodeSelectorUtils"
@@ -525,7 +525,7 @@ export class VsCodeLmHandler implements ApiHandler, SingleCompletionHandler {
type: "usage",
inputTokens: totalInputTokens,
outputTokens: totalOutputTokens,
totalCost: calculateApiCost(this.getModel().info, totalInputTokens, totalOutputTokens),
totalCost: calculateApiCostAnthropic(this.getModel().info, totalInputTokens, totalOutputTokens),
}
} catch (error: unknown) {
this.ensureCleanState()
+64
View File
@@ -0,0 +1,64 @@
import { Anthropic } from "@anthropic-ai/sdk"
import OpenAI from "openai"
import { ApiHandler } from "../"
import { ApiHandlerOptions, XAIModelId, ModelInfo, xaiDefaultModelId, xaiModels } from "../../shared/api"
import { convertToOpenAiMessages } from "../transform/openai-format"
import { ApiStream } from "../transform/stream"
export class XAIHandler implements ApiHandler {
private options: ApiHandlerOptions
private client: OpenAI
constructor(options: ApiHandlerOptions) {
this.options = options
this.client = new OpenAI({
baseURL: "https://api.x.ai/v1",
apiKey: this.options.xaiApiKey,
})
}
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
const stream = await this.client.chat.completions.create({
model: this.getModel().id,
max_completion_tokens: this.getModel().info.maxTokens,
temperature: 0,
messages: [{ role: "system", content: systemPrompt }, ...convertToOpenAiMessages(messages)],
stream: true,
stream_options: { include_usage: true },
})
for await (const chunk of stream) {
const delta = chunk.choices[0]?.delta
if (delta?.content) {
yield {
type: "text",
text: delta.content,
}
}
if (chunk.usage) {
yield {
type: "usage",
inputTokens: 0,
outputTokens: chunk.usage.completion_tokens || 0,
// @ts-ignore-next-line
cacheReadTokens: chunk.usage.prompt_cache_hit_tokens || 0,
// @ts-ignore-next-line
cacheWriteTokens: chunk.usage.prompt_cache_miss_tokens || 0,
}
}
}
}
getModel(): { id: XAIModelId; info: ModelInfo } {
const modelId = this.options.apiModelId
if (modelId && modelId in xaiModels) {
const id = modelId as XAIModelId
return { id, info: xaiModels[id] }
}
return {
id: xaiDefaultModelId,
info: xaiModels[xaiDefaultModelId],
}
}
}
+4 -11
View File
@@ -11,16 +11,7 @@ import {
TextPart,
} from "@google/generative-ai"
export function convertAnthropicContentToGemini(
content:
| string
| Array<
| Anthropic.Messages.TextBlockParam
| Anthropic.Messages.ImageBlockParam
| Anthropic.Messages.ToolUseBlockParam
| Anthropic.Messages.ToolResultBlockParam
>,
): Part[] {
export function convertAnthropicContentToGemini(content: string | Anthropic.ContentBlockParam[]): Part[] {
if (typeof content === "string") {
return [{ text: content } as TextPart]
}
@@ -133,7 +124,7 @@ export function convertGeminiResponseToAnthropic(response: EnhancedGenerateConte
// Add the main text response
const text = response.text()
if (text) {
content.push({ type: "text", text })
content.push({ type: "text", text, citations: null })
}
// Add function calls as tool_use blocks
@@ -183,6 +174,8 @@ export function convertGeminiResponseToAnthropic(response: EnhancedGenerateConte
usage: {
input_tokens: response.usageMetadata?.promptTokenCount ?? 0,
output_tokens: response.usageMetadata?.candidatesTokenCount ?? 0,
cache_creation_input_tokens: null,
cache_read_input_tokens: null,
},
}
}
+3
View File
@@ -376,6 +376,7 @@ export function convertO1ResponseToAnthropicMessage(
{
type: "text",
text: normalText,
citations: null,
},
],
model: completion.model,
@@ -396,6 +397,8 @@ export function convertO1ResponseToAnthropicMessage(
usage: {
input_tokens: completion.usage?.prompt_tokens || 0,
output_tokens: completion.usage?.completion_tokens || 0,
cache_creation_input_tokens: null,
cache_read_input_tokens: null,
},
}
+3
View File
@@ -161,6 +161,7 @@ export function convertToAnthropicMessage(completion: OpenAI.Chat.Completions.Ch
{
type: "text",
text: openAiMessage.content || "",
citations: null,
},
],
model: completion.model,
@@ -181,6 +182,8 @@ export function convertToAnthropicMessage(completion: OpenAI.Chat.Completions.Ch
usage: {
input_tokens: completion.usage?.prompt_tokens || 0,
output_tokens: completion.usage?.completion_tokens || 0,
cache_creation_input_tokens: null,
cache_read_input_tokens: null,
},
}
+3
View File
@@ -175,6 +175,7 @@ export async function convertToAnthropicMessage(
return {
type: "text",
text: part.value,
citations: null,
}
}
@@ -195,6 +196,8 @@ export async function convertToAnthropicMessage(
usage: {
input_tokens: 0,
output_tokens: 0,
cache_creation_input_tokens: null,
cache_read_input_tokens: null,
},
}
}
+66 -12
View File
@@ -47,7 +47,7 @@ import {
import { getApiMetrics } from "../shared/getApiMetrics"
import { HistoryItem } from "../shared/HistoryItem"
import { ClineAskResponse, ClineCheckpointRestore } from "../shared/WebviewMessage"
import { calculateApiCost } from "../utils/cost"
import { calculateApiCostAnthropic } from "../utils/cost"
import { fileExistsAtPath } from "../utils/fs"
import { arePathsEqual, getReadablePath } from "../utils/path"
import { fixModelHtmlEscaping, removeInvalidChars } from "../utils/string"
@@ -59,13 +59,13 @@ import { formatResponse } from "./prompts/responses"
import { addUserInstructions, SYSTEM_PROMPT } from "./prompts/system"
import { getNextTruncationRange, getTruncatedMessages } from "./sliding-window"
import { ClineProvider, GlobalFileNames } from "./webview/ClineProvider"
import { DEFAULT_LANGUAGE_SETTINGS, getLanguageKey, LanguageDisplay, LanguageKey } from "../shared/Languages"
import { telemetryService } from "../services/telemetry/TelemetryService"
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
type ToolResponse = string | Array<Anthropic.TextBlockParam | Anthropic.ImageBlockParam>
type UserContent = Array<
Anthropic.TextBlockParam | Anthropic.ImageBlockParam | Anthropic.ToolUseBlockParam | Anthropic.ToolResultBlockParam
>
type UserContent = Array<Anthropic.ContentBlockParam>
export class Cline {
readonly taskId: string
@@ -75,6 +75,7 @@ export class Cline {
browserSession: BrowserSession
private didEditFile: boolean = false
customInstructions?: string
preferredLanguage?: LanguageKey
autoApprovalSettings: AutoApprovalSettings
private browserSettings: BrowserSettings
private chatSettings: ChatSettings
@@ -135,6 +136,9 @@ export class Cline {
this.browserSession = new BrowserSession(provider.context, browserSettings)
this.diffViewProvider = new DiffViewProvider(cwd)
this.customInstructions = customInstructions
this.preferredLanguage = getLanguageKey(
vscode.workspace.getConfiguration("cline").get<LanguageDisplay>("preferredLanguage"),
)
this.autoApprovalSettings = autoApprovalSettings
this.browserSettings = browserSettings
this.chatSettings = chatSettings
@@ -148,6 +152,16 @@ export class Cline {
} else {
throw new Error("Either historyItem or task/images must be provided")
}
// capture start of thread with the state at the beginning
telemetryService.capture({
event: "cline created",
properties: {
taskId: this.taskId,
isHistory: !!historyItem,
chatMode: this.chatSettings.mode,
hasImages: !!images,
},
})
}
updateBrowserSettings(browserSettings: BrowserSettings) {
@@ -285,7 +299,10 @@ export class Cline {
case "workspace":
if (!this.checkpointTracker) {
try {
this.checkpointTracker = await CheckpointTracker.create(this.taskId, this.providerRef.deref())
this.checkpointTracker = await CheckpointTracker.create(
this.taskId,
this.providerRef.deref()?.context.globalStorageUri.fsPath,
)
this.checkpointTrackerErrorMessage = undefined
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "Unknown error"
@@ -398,7 +415,10 @@ export class Cline {
// TODO: handle if this is called from outside original workspace, in which case we need to show user error message we cant show diff outside of workspace?
if (!this.checkpointTracker) {
try {
this.checkpointTracker = await CheckpointTracker.create(this.taskId, this.providerRef.deref())
this.checkpointTracker = await CheckpointTracker.create(
this.taskId,
this.providerRef.deref()?.context.globalStorageUri.fsPath,
)
this.checkpointTrackerErrorMessage = undefined
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "Unknown error"
@@ -502,7 +522,10 @@ export class Cline {
if (!this.checkpointTracker) {
try {
this.checkpointTracker = await CheckpointTracker.create(this.taskId, this.providerRef.deref())
this.checkpointTracker = await CheckpointTracker.create(
this.taskId,
this.providerRef.deref()?.context.globalStorageUri.fsPath,
)
this.checkpointTrackerErrorMessage = undefined
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "Unknown error"
@@ -1269,6 +1292,10 @@ export class Cline {
let systemPrompt = await SYSTEM_PROMPT(cwd, supportsComputerUse, mcpHub, this.browserSettings)
let settingsCustomInstructions = this.customInstructions?.trim()
const preferredLanguageInstructions =
this.preferredLanguage && this.preferredLanguage !== DEFAULT_LANGUAGE_SETTINGS
? `# Preferred Language\n\nSpeak in ${this.preferredLanguage}.`
: ""
const clineRulesFilePath = path.resolve(cwd, GlobalFileNames.clineRules)
let clineRulesFileInstructions: string | undefined
if (await fileExistsAtPath(clineRulesFilePath)) {
@@ -1288,9 +1315,19 @@ export class Cline {
clineIgnoreInstructions = `# .clineignore\n\n(The following is provided by a root-level .clineignore file where the user has specified files and directories that should not be accessed. When using list_files, you'll notice a ${LOCK_TEXT_SYMBOL} next to files that are blocked. Attempting to access the file's contents e.g. through read_file will result in an error.)\n\n${clineIgnoreContent}\n.clineignore`
}
if (settingsCustomInstructions || clineRulesFileInstructions) {
if (
settingsCustomInstructions ||
clineRulesFileInstructions ||
preferredLanguageInstructions ||
clineIgnoreInstructions
) {
// altering the system prompt mid-task will break the prompt cache, but in the grand scheme this will not change often so it's better to not pollute user messages with it the way we have to with <potentially relevant details>
systemPrompt += addUserInstructions(settingsCustomInstructions, clineRulesFileInstructions, clineIgnoreInstructions)
systemPrompt += addUserInstructions(
settingsCustomInstructions,
clineRulesFileInstructions,
clineIgnoreInstructions,
preferredLanguageInstructions,
)
}
// 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
@@ -2910,7 +2947,7 @@ export class Cline {
}
/*
Seeing out of bounds is fine, it means that the next too call is being built up and ready to add to assistantMessageContent to present.
Seeing out of bounds is fine, it means that the next too call is being built up and ready to add to assistantMessageContent to present.
When you see the UI inactive during this, it means that a tool is breaking without presenting any UI. For example the write_to_file tool was breaking when relpath was undefined, and for invalid relpath it never presented UI.
*/
this.presentAssistantMessageLocked = false // this needs to be placed here, if not then calling this.presentAssistantMessage below would fail (sometimes) since it's locked
@@ -3017,7 +3054,10 @@ export class Cline {
// isNewTask &&
if (!this.checkpointTracker) {
try {
this.checkpointTracker = await CheckpointTracker.create(this.taskId, this.providerRef.deref())
this.checkpointTracker = await CheckpointTracker.create(
this.taskId,
this.providerRef.deref()?.context.globalStorageUri.fsPath,
)
this.checkpointTrackerErrorMessage = undefined
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "Unknown error"
@@ -3073,7 +3113,13 @@ export class Cline {
cacheReads: cacheReadTokens,
cost:
totalCost ??
calculateApiCost(this.api.getModel().info, inputTokens, outputTokens, cacheWriteTokens, cacheReadTokens),
calculateApiCostAnthropic(
this.api.getModel().info,
inputTokens,
outputTokens,
cacheWriteTokens,
cacheReadTokens,
),
cancelReason,
streamingFailedMessage,
} satisfies ClineApiReqInfo)
@@ -3265,6 +3311,14 @@ export class Cline {
this.consecutiveMistakeCount++
}
telemetryService.capture({
event: "message sent",
properties: {
taskId: this.taskId,
chatMode: this.chatSettings.mode,
},
})
const recDidEndLoop = await this.recursivelyMakeClineRequests(this.userMessageContent)
didEndLoop = recDidEndLoop
} else {
+4
View File
@@ -979,8 +979,12 @@ export function addUserInstructions(
settingsCustomInstructions?: string,
clineRulesFileInstructions?: string,
clineIgnoreInstructions?: string,
preferredLanguageInstructions?: string,
) {
let customInstructions = ""
if (preferredLanguageInstructions) {
customInstructions += preferredLanguageInstructions + "\n\n"
}
if (settingsCustomInstructions) {
customInstructions += settingsCustomInstructions + "\n\n"
}
+190 -43
View File
@@ -1,39 +1,40 @@
import { Anthropic } from "@anthropic-ai/sdk"
import axios from "axios"
import fs from "fs/promises"
import os from "os"
import crypto from "crypto"
import { execa } from "execa"
import fs from "fs/promises"
import os from "os"
import pWaitFor from "p-wait-for"
import * as path from "path"
import * as vscode from "vscode"
import { buildApiHandler } from "../../api"
import CheckpointTracker from "../../integrations/checkpoints/CheckpointTracker"
import { downloadTask } from "../../integrations/misc/export-markdown"
import { openFile, openImage } from "../../integrations/misc/open-file"
import { fetchOpenGraphData, isImageUrl } from "../../integrations/misc/link-preview"
import { selectImages } from "../../integrations/misc/process-images"
import { getTheme } from "../../integrations/theme/getTheme"
import WorkspaceTracker from "../../integrations/workspace/WorkspaceTracker"
import { McpHub } from "../../services/mcp/McpHub"
import { McpDownloadResponse, McpMarketplaceCatalog, McpMarketplaceItem, McpServer } from "../../shared/mcp"
import { FirebaseAuthManager, UserInfo } from "../../services/auth/FirebaseAuthManager"
import { McpHub } from "../../services/mcp/McpHub"
import { ApiProvider, ModelInfo } from "../../shared/api"
import { findLast } from "../../shared/array"
import { AutoApprovalSettings, DEFAULT_AUTO_APPROVAL_SETTINGS } from "../../shared/AutoApprovalSettings"
import { BrowserSettings, DEFAULT_BROWSER_SETTINGS } from "../../shared/BrowserSettings"
import { ChatContent } from "../../shared/ChatContent"
import { ChatSettings, DEFAULT_CHAT_SETTINGS } from "../../shared/ChatSettings"
import { ExtensionMessage, ExtensionState, Platform } from "../../shared/ExtensionMessage"
import { HistoryItem } from "../../shared/HistoryItem"
import { McpDownloadResponse, McpMarketplaceCatalog, McpServer } from "../../shared/mcp"
import { ClineCheckpointRestore, WebviewMessage } from "../../shared/WebviewMessage"
import { fileExistsAtPath } from "../../utils/fs"
import { searchCommits } from "../../utils/git"
import { Cline } from "../Cline"
import { openMention } from "../mentions"
import { getNonce } from "./getNonce"
import { getUri } from "./getUri"
import { AutoApprovalSettings, DEFAULT_AUTO_APPROVAL_SETTINGS } from "../../shared/AutoApprovalSettings"
import { BrowserSettings, DEFAULT_BROWSER_SETTINGS } from "../../shared/BrowserSettings"
import { ChatSettings, DEFAULT_CHAT_SETTINGS } from "../../shared/ChatSettings"
import { DIFF_VIEW_URI_SCHEME } from "../../integrations/editor/DiffViewProvider"
import { searchCommits } from "../../utils/git"
import { ChatContent } from "../../shared/ChatContent"
import { getShell } from "../../utils/shell"
import { telemetryService } from "../../services/telemetry/TelemetryService"
import { TelemetrySetting } from "../../shared/TelemetrySetting"
/*
https://github.com/microsoft/vscode-webview-ui-toolkit-samples/blob/main/default/weather-webview/src/providers/WeatherViewProvider.ts
@@ -58,6 +59,7 @@ type SecretKey =
| "liteLlmApiKey"
| "authToken"
| "authNonce"
| "xaiApiKey"
type GlobalStateKey =
| "apiProvider"
| "apiModelId"
@@ -93,13 +95,16 @@ type GlobalStateKey =
| "liteLlmModelId"
| "qwenApiLine"
| "requestyModelId"
| "requestyModelInfo"
| "togetherModelId"
| "mcpMarketplaceCatalog"
| "telemetrySetting"
export const GlobalFileNames = {
apiConversationHistory: "api_conversation_history.json",
uiMessages: "ui_messages.json",
openRouterModels: "openrouter_models.json",
requestyModels: "requesty_models.json",
mcpSettings: "cline_mcp_settings.json",
clineRules: ".clinerules",
}
@@ -366,9 +371,9 @@ export class ClineProvider implements vscode.WebviewViewProvider {
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,shrink-to-fit=no">
<meta name="theme-color" content="#000000">
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; font-src ${webview.cspSource}; style-src ${webview.cspSource} 'unsafe-inline'; img-src ${webview.cspSource} https: data:; script-src 'nonce-${nonce}';">
<link rel="stylesheet" type="text/css" href="${stylesUri}">
<link href="${codiconsUri}" rel="stylesheet" />
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; connect-src https://*.posthog.com; font-src ${webview.cspSource}; style-src ${webview.cspSource} 'unsafe-inline'; img-src ${webview.cspSource} https: data:; script-src 'nonce-${nonce}' https://*.posthog.com;">
<title>Cline</title>
</head>
<body>
@@ -443,7 +448,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
<meta http-equiv="Content-Security-Policy" content="${csp.join("; ")}">
<link rel="stylesheet" type="text/css" href="${stylesUri}">
<link href="${codiconsUri}" rel="stylesheet" />
<title>Roo Code</title>
<title>Cline</title>
</head>
<body>
<div id="root"></div>
@@ -474,7 +479,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
}),
)
// post last cached models in case the call to endpoint fails
this.readOpenRouterModels().then((openRouterModels) => {
this.readDynamicProviderModels(GlobalFileNames.openRouterModels).then((openRouterModels) => {
if (openRouterModels) {
this.postMessageToWebview({
type: "openRouterModels",
@@ -510,6 +515,40 @@ export class ClineProvider implements vscode.WebviewViewProvider {
}
})
// If user already opted in to telemetry, enable telemetry service
this.getStateToPostToWebview().then((state) => {
const { telemetrySetting } = state
const isOptedIn = telemetrySetting === "enabled"
telemetryService.updateTelemetryState(isOptedIn)
})
// post last cached models in case the call to endpoint fails
this.readDynamicProviderModels(GlobalFileNames.requestyModels).then((requestyModels) => {
if (requestyModels) {
this.postMessageToWebview({
type: "requestyModels",
requestyModels,
})
}
})
// gui relies on model info to be up-to-date to provide the most accurate pricing, so we need to fetch the latest details on launch.
// we do this for all users since many users switch between api providers and if they were to switch back to openrouter it would be showing outdated model info if we hadn't retrieved the latest at this point
// (see normalizeApiConfiguration > openrouter)
this.refreshRequestyModels().then(async (requestyModels) => {
if (requestyModels) {
// update model info in state (this needs to be done here since we don't want to update state while settings is open, and we may refresh models there)
const { apiConfiguration } = await this.getState()
if (apiConfiguration.requestyModelId) {
await this.updateGlobalState(
"requestyModelInfo",
requestyModels[apiConfiguration.requestyModelId],
)
await this.postStateToWebview()
}
}
})
break
case "newTask":
// Code that should run in response to the hello message command
@@ -552,6 +591,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
deepSeekApiKey,
requestyApiKey,
requestyModelId,
requestyModelInfo,
togetherApiKey,
togetherModelId,
qwenApiKey,
@@ -564,6 +604,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
liteLlmModelId,
liteLlmApiKey,
qwenApiLine,
xaiApiKey,
} = message.apiConfiguration
await this.updateGlobalState("apiProvider", apiProvider)
await this.updateGlobalState("apiModelId", apiModelId)
@@ -595,6 +636,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
await this.storeSecret("qwenApiKey", qwenApiKey)
await this.storeSecret("mistralApiKey", mistralApiKey)
await this.storeSecret("liteLlmApiKey", liteLlmApiKey)
await this.storeSecret("xaiApiKey", xaiApiKey)
await this.updateGlobalState("azureApiVersion", azureApiVersion)
await this.updateGlobalState("openRouterModelId", openRouterModelId)
await this.updateGlobalState("openRouterModelInfo", openRouterModelInfo)
@@ -603,6 +645,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
await this.updateGlobalState("liteLlmModelId", liteLlmModelId)
await this.updateGlobalState("qwenApiLine", qwenApiLine)
await this.updateGlobalState("requestyModelId", requestyModelId)
await this.updateGlobalState("requestyModelInfo", requestyModelInfo)
await this.updateGlobalState("togetherModelId", togetherModelId)
if (this.cline) {
this.cline.api = buildApiHandler(message.apiConfiguration)
@@ -699,6 +742,9 @@ export class ClineProvider implements vscode.WebviewViewProvider {
case "refreshOpenRouterModels":
await this.refreshOpenRouterModels()
break
case "refreshRequestyModels":
await this.refreshRequestyModels()
break
case "refreshOpenAiModels":
const { apiConfiguration } = await this.getState()
const openAiModels = await this.getOpenAiModels(
@@ -915,6 +961,22 @@ export class ClineProvider implements vscode.WebviewViewProvider {
)
break
}
// telemetry
case "openSettings": {
await this.postMessageToWebview({
type: "action",
action: "settingsButtonClicked",
})
break
}
case "telemetrySetting": {
const telemetrySetting = message.text as TelemetrySetting
await this.updateGlobalState("telemetrySetting", telemetrySetting)
const isOptedIn = telemetrySetting === "enabled"
telemetryService.updateTelemetryState(isOptedIn)
await this.postStateToWebview()
break
}
// Add more switch case statements here as more webview message commands
// are created within the webview context (i.e. inside media/main.js)
}
@@ -948,6 +1010,10 @@ export class ClineProvider implements vscode.WebviewViewProvider {
await this.updateGlobalState("previousModeModelId", apiConfiguration.openRouterModelId)
await this.updateGlobalState("previousModeModelInfo", apiConfiguration.openRouterModelInfo)
break
case "requesty":
await this.updateGlobalState("previousModeModelId", apiConfiguration.requestyModelId)
await this.updateGlobalState("previousModeModelInfo", apiConfiguration.requestyModelInfo)
break
case "vscode-lm":
await this.updateGlobalState("previousModeModelId", apiConfiguration.vsCodeLmModelSelector)
break
@@ -980,6 +1046,10 @@ export class ClineProvider implements vscode.WebviewViewProvider {
await this.updateGlobalState("openRouterModelId", newModelId)
await this.updateGlobalState("openRouterModelInfo", newModelInfo)
break
case "requesty":
await this.updateGlobalState("requestyModelId", newModelId)
await this.updateGlobalState("requestyModelInfo", newModelInfo)
break
case "vscode-lm":
await this.updateGlobalState("vsCodeLmModelSelector", newModelId)
break
@@ -1096,21 +1166,38 @@ export class ClineProvider implements vscode.WebviewViewProvider {
async getDocumentsPath(): Promise<string> {
if (process.platform === "win32") {
// If the user is running Win 7/Win Server 2008 r2+, we want to get the correct path to their Documents directory.
try {
const { stdout: docsPath } = await execa("powershell", [
"-NoProfile", // Ignore user's PowerShell profile(s)
"-Command",
"[System.Environment]::GetFolderPath([System.Environment+SpecialFolder]::MyDocuments)",
])
return docsPath.trim()
const trimmedPath = docsPath.trim()
if (trimmedPath) {
return trimmedPath
}
} catch (err) {
console.error("Failed to retrieve Windows Documents path. Falling back to homedir/Documents.")
return path.join(os.homedir(), "Documents")
}
} else {
return path.join(os.homedir(), "Documents") // On POSIX (macOS, Linux, etc.), assume ~/Documents by default (existing behavior, but may want to implement similar logic here)
} else if (process.platform === "linux") {
try {
// First check if xdg-user-dir exists
await execa("which", ["xdg-user-dir"])
// If it exists, try to get XDG documents path
const { stdout } = await execa("xdg-user-dir", ["DOCUMENTS"])
const trimmedPath = stdout.trim()
if (trimmedPath) {
return trimmedPath
}
} catch {
// Log error but continue to fallback
console.error("Failed to retrieve XDG Documents path. Falling back to homedir/Documents.")
}
}
// Default fallback for all platforms
return path.join(os.homedir(), "Documents")
}
async ensureMcpServersDirectoryExists(): Promise<string> {
@@ -1435,16 +1522,61 @@ Here is the project's README to help you get started:\n\n${mcpDetails.readmeCont
return cacheDir
}
async readOpenRouterModels(): Promise<Record<string, ModelInfo> | undefined> {
const openRouterModelsFilePath = path.join(await this.ensureCacheDirectoryExists(), GlobalFileNames.openRouterModels)
const fileExists = await fileExistsAtPath(openRouterModelsFilePath)
async readDynamicProviderModels(filename: string): Promise<Record<string, ModelInfo> | undefined> {
const filePath = path.join(await this.ensureCacheDirectoryExists(), filename)
const fileExists = await fileExistsAtPath(filePath)
if (fileExists) {
const fileContents = await fs.readFile(openRouterModelsFilePath, "utf8")
const fileContents = await fs.readFile(filePath, "utf8")
return JSON.parse(fileContents)
}
return undefined
}
adjustPriceToMillionTokens(price: any) {
if (price) {
return parseFloat(price) * 1_000_000
}
return undefined
}
async refreshRequestyModels() {
const requestyModelsFilePath = path.join(await this.ensureCacheDirectoryExists(), GlobalFileNames.requestyModels)
let models: Record<string, ModelInfo> = {}
try {
const response = await axios.get("https://router.requesty.ai/v1/models")
if (response.data?.data) {
for (const model of response.data.data) {
const modelInfo: ModelInfo = {
maxTokens: model.max_output_tokens,
contextWindow: model.context_window,
supportsImages: model.supports_images || undefined,
supportsComputerUse: model.supports_computer_use || undefined,
supportsPromptCache: model.supports_caching || undefined,
inputPrice: this.adjustPriceToMillionTokens(model.input_price),
outputPrice: this.adjustPriceToMillionTokens(model.output_price),
cacheWritesPrice: this.adjustPriceToMillionTokens(model.caching_price),
cacheReadsPrice: this.adjustPriceToMillionTokens(model.cached_price),
description: model.description,
}
models[model.id] = modelInfo
}
await fs.writeFile(requestyModelsFilePath, JSON.stringify(models))
console.log("Requesty models fetched and saved", models)
} else {
console.error("Invalid response from Requesty API")
}
} catch (error) {
console.error("Error fetching Requesty models:", error)
}
await this.postMessageToWebview({
type: "requestyModels",
requestyModels: models,
})
return models
}
async refreshOpenRouterModels() {
const openRouterModelsFilePath = path.join(await this.ensureCacheDirectoryExists(), GlobalFileNames.openRouterModels)
@@ -1479,20 +1611,14 @@ Here is the project's README to help you get started:\n\n${mcpDetails.readmeCont
*/
if (response.data?.data) {
const rawModels = response.data.data
const parsePrice = (price: any) => {
if (price) {
return parseFloat(price) * 1_000_000
}
return undefined
}
for (const rawModel of rawModels) {
const modelInfo: ModelInfo = {
maxTokens: rawModel.top_provider?.max_completion_tokens,
contextWindow: rawModel.context_length,
supportsImages: rawModel.architecture?.modality?.includes("image"),
supportsPromptCache: false,
inputPrice: parsePrice(rawModel.pricing?.prompt),
outputPrice: parsePrice(rawModel.pricing?.completion),
inputPrice: this.adjustPriceToMillionTokens(rawModel.pricing?.prompt),
outputPrice: this.adjustPriceToMillionTokens(rawModel.pricing?.completion),
description: rawModel.description,
}
@@ -1617,12 +1743,29 @@ Here is the project's README to help you get started:\n\n${mcpDetails.readmeCont
}
async deleteTaskWithId(id: string) {
console.info("deleteTaskWithId: ", id)
if (id === this.cline?.taskId) {
await this.clearTask()
console.debug("cleared task")
}
const { taskDirPath, apiConversationHistoryFilePath, uiMessagesFilePath } = await this.getTaskWithId(id)
// Delete checkpoints
// deleteCheckpoints will determine if the task has legacy checkpoints or not and handle it accordingly
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
@@ -1639,21 +1782,12 @@ Here is the project's README to help you get started:\n\n${mcpDetails.readmeCont
await fs.unlink(legacyMessagesFilePath)
}
// Delete the checkpoints directory if it exists
const checkpointsDir = path.join(taskDirPath, "checkpoints")
if (await fileExistsAtPath(checkpointsDir)) {
try {
await fs.rm(checkpointsDir, { recursive: true, force: true })
} catch (error) {
console.error(`Failed to delete checkpoints directory for task ${id}:`, error)
// Continue with deletion of task directory - don't throw since this is a cleanup operation
}
}
await fs.rmdir(taskDirPath) // succeeds if the dir is empty
}
async deleteTaskFromState(id: string) {
console.log("deleteTaskFromState: ", id)
// Remove the task from history
const taskHistory = ((await this.getGlobalState("taskHistory")) as HistoryItem[] | undefined) || []
const updatedTaskHistory = taskHistory.filter((task) => task.id !== id)
@@ -1680,6 +1814,7 @@ Here is the project's README to help you get started:\n\n${mcpDetails.readmeCont
userInfo,
authToken,
mcpMarketplaceEnabled,
telemetrySetting,
} = await this.getState()
return {
@@ -1699,6 +1834,8 @@ Here is the project's README to help you get started:\n\n${mcpDetails.readmeCont
isLoggedIn: !!authToken,
userInfo,
mcpMarketplaceEnabled,
telemetrySetting,
vscMachineId: vscode.env.machineId,
}
}
@@ -1721,7 +1858,7 @@ Here is the project's README to help you get started:\n\n${mcpDetails.readmeCont
/*
It seems that some API messages do not comply with vscode state requirements. Either the Anthropic library is manipulating these values somehow in the backend in a way thats creating cyclic references, or the API returns a function or a Symbol as part of the message content.
VSCode docs about state: "The value must be JSON-stringifyable ... value A value. MUST not contain cyclic references."
VSCode docs about state: "The value must be JSON-stringifyable ... value  A value. MUST not contain cyclic references."
For now we'll store the conversation history in memory, and if we need to store in state directly we'd need to do a manual conversion to ensure proper json stringification.
*/
@@ -1782,6 +1919,7 @@ Here is the project's README to help you get started:\n\n${mcpDetails.readmeCont
deepSeekApiKey,
requestyApiKey,
requestyModelId,
requestyModelInfo,
togetherApiKey,
togetherModelId,
qwenApiKey,
@@ -1805,6 +1943,8 @@ Here is the project's README to help you get started:\n\n${mcpDetails.readmeCont
previousModeModelInfo,
qwenApiLine,
liteLlmApiKey,
telemetrySetting,
xaiApiKey,
] = await Promise.all([
this.getGlobalState("apiProvider") as Promise<ApiProvider | undefined>,
this.getGlobalState("apiModelId") as Promise<string | undefined>,
@@ -1833,6 +1973,7 @@ Here is the project's README to help you get started:\n\n${mcpDetails.readmeCont
this.getSecret("deepSeekApiKey") as Promise<string | undefined>,
this.getSecret("requestyApiKey") as Promise<string | undefined>,
this.getGlobalState("requestyModelId") as Promise<string | undefined>,
this.getGlobalState("requestyModelInfo") as Promise<ModelInfo | undefined>,
this.getSecret("togetherApiKey") as Promise<string | undefined>,
this.getGlobalState("togetherModelId") as Promise<string | undefined>,
this.getSecret("qwenApiKey") as Promise<string | undefined>,
@@ -1856,6 +1997,8 @@ Here is the project's README to help you get started:\n\n${mcpDetails.readmeCont
this.getGlobalState("previousModeModelInfo") as Promise<ModelInfo | undefined>,
this.getGlobalState("qwenApiLine") as Promise<string | undefined>,
this.getSecret("liteLlmApiKey") as Promise<string | undefined>,
this.getGlobalState("telemetrySetting") as Promise<TelemetrySetting | undefined>,
this.getSecret("xaiApiKey") as Promise<string | undefined>,
])
let apiProvider: ApiProvider
@@ -1907,6 +2050,7 @@ Here is the project's README to help you get started:\n\n${mcpDetails.readmeCont
deepSeekApiKey,
requestyApiKey,
requestyModelId,
requestyModelInfo,
togetherApiKey,
togetherModelId,
qwenApiKey,
@@ -1920,6 +2064,7 @@ Here is the project's README to help you get started:\n\n${mcpDetails.readmeCont
liteLlmBaseUrl,
liteLlmModelId,
liteLlmApiKey,
xaiApiKey,
},
lastShownAnnouncementId,
customInstructions,
@@ -1933,6 +2078,7 @@ Here is the project's README to help you get started:\n\n${mcpDetails.readmeCont
previousModeModelId,
previousModeModelInfo,
mcpMarketplaceEnabled,
telemetrySetting: telemetrySetting || "unset",
}
}
@@ -2062,6 +2208,7 @@ Here is the project's README to help you get started:\n\n${mcpDetails.readmeCont
"mistralApiKey",
"liteLlmApiKey",
"authToken",
"xaiApiKey",
]
for (const key of secretKeys) {
await this.storeSecret(key, undefined)
+2
View File
@@ -8,6 +8,7 @@ import { createClineAPI } from "./exports"
import "./utils/path" // necessary to have access to String.prototype.toPosix
import { DIFF_VIEW_URI_SCHEME } from "./integrations/editor/DiffViewProvider"
import assert from "node:assert"
import { telemetryService } from "./services/telemetry/TelemetryService"
/*
Built using https://github.com/microsoft/vscode-webview-ui-toolkit
@@ -189,6 +190,7 @@ export function activate(context: vscode.ExtensionContext) {
// This method is called when your extension is deactivated
export function deactivate() {
telemetryService.shutdown()
Logger.log("Cline extension deactivated")
}
@@ -0,0 +1,73 @@
import * as vscode from "vscode"
import fs from "fs/promises"
import path from "path"
import os from "os"
import CheckpointTracker from "./CheckpointTracker"
export async function createTestEnvironment() {
// Create temp directory structure
const tempDir = path.join(os.tmpdir(), `checkpoint-test-${Date.now()}`)
await fs.mkdir(tempDir, { recursive: true })
// Create storage path outside of working directory to avoid submodule issues
const globalStoragePath = path.join(os.tmpdir(), `storage-${Date.now()}`)
await fs.mkdir(globalStoragePath, { recursive: true })
// Create test file in a subdirectory
const testDir = path.join(tempDir, "src")
await fs.mkdir(testDir, { recursive: true })
const testFilePath = path.join(testDir, "test.txt")
// Create .gitignore to prevent git from treating directories as submodules
await fs.writeFile(path.join(tempDir, ".gitignore"), "storage/\n")
// Mock VS Code workspace
const mockWorkspaceFolders = [
{
uri: { fsPath: tempDir },
name: "test",
index: 0,
},
]
const originalDescriptor = Object.getOwnPropertyDescriptor(vscode.workspace, "workspaceFolders")
Object.defineProperty(vscode.workspace, "workspaceFolders", {
get: () => mockWorkspaceFolders,
})
// Mock findFiles to return no nested git repos
const originalFindFiles = vscode.workspace.findFiles
vscode.workspace.findFiles = async () => []
// Mock VS Code configuration
const originalGetConfiguration = vscode.workspace.getConfiguration
vscode.workspace.getConfiguration = () =>
({
get: (key: string) => (key === "enableCheckpoints" ? true : undefined),
}) as any
return {
tempDir,
globalStoragePath,
testFilePath,
originalDescriptor,
originalFindFiles,
originalGetConfiguration,
cleanup: async () => {
// Restore VS Code mocks
if (originalDescriptor) {
Object.defineProperty(vscode.workspace, "workspaceFolders", originalDescriptor)
}
vscode.workspace.getConfiguration = originalGetConfiguration
vscode.workspace.findFiles = originalFindFiles
// Clean up temp directories
await fs.rm(tempDir, { recursive: true, force: true })
await fs.rm(globalStoragePath, { recursive: true, force: true })
}
}
}
export async function createTestTracker(globalStoragePath?: string, taskId = "test-task-1") {
return await CheckpointTracker.create(taskId, globalStoragePath)
}
@@ -0,0 +1,153 @@
import { expect } from "chai"
import { describe, it, beforeEach, afterEach } from "mocha"
import fs from "fs/promises"
import path from "path"
import { createTestEnvironment, createTestTracker } from "./Checkpoint-test-utils"
describe("Checkpoint Commit Operations", () => {
let env: Awaited<ReturnType<typeof createTestEnvironment>>
beforeEach(async () => {
env = await createTestEnvironment()
})
afterEach(async () => {
await env.cleanup()
})
it("should create commit with single file changes", async () => {
const tracker = await createTestTracker(env.globalStoragePath)
if (!tracker) {throw new Error("Failed to create tracker")}
// Create initial file
await fs.writeFile(env.testFilePath, "initial content")
// Create first commit
const firstCommit = await tracker.commit()
expect(firstCommit).to.be.a("string").and.not.empty
// Modify file
await fs.writeFile(env.testFilePath, "modified content")
// Create second commit
const secondCommit = await tracker.commit()
expect(secondCommit).to.be.a("string").and.not.empty
expect(secondCommit).to.not.equal(firstCommit)
// Verify commits are different
const diffSet = await tracker.getDiffSet(firstCommit, secondCommit)
expect(diffSet).to.have.lengthOf(1)
expect(diffSet[0].before).to.equal("initial content")
expect(diffSet[0].after).to.equal("modified content")
})
it("should create commit with multiple file changes", async () => {
const tracker = await createTestTracker(env.globalStoragePath)
if (!tracker) {throw new Error("Failed to create tracker")}
// Create initial files with newlines
const testFile2Path = path.join(env.tempDir, "src", "test2.txt")
await fs.writeFile(env.testFilePath, "file1 initial\n")
await fs.writeFile(testFile2Path, "file2 initial\n")
// Create first commit
const firstCommit = await tracker.commit()
expect(firstCommit).to.be.a("string").and.not.empty
// Modify both files with newlines
await fs.writeFile(env.testFilePath, "file1 modified\n")
await fs.writeFile(testFile2Path, "file2 modified\n")
// Create second commit
const secondCommit = await tracker.commit()
expect(secondCommit).to.be.a("string").and.not.empty
expect(secondCommit).to.not.equal(firstCommit)
// Get diff between commits
const diffSet = await tracker.getDiffSet(firstCommit, secondCommit)
expect(diffSet).to.have.lengthOf(2)
// Sort diffSet by path for consistent ordering
const sortedDiffs = diffSet.sort((a, b) => a.relativePath.localeCompare(b.relativePath))
// Verify file paths
expect(sortedDiffs[0].relativePath).to.equal("src/test.txt")
expect(sortedDiffs[1].relativePath).to.equal("src/test2.txt")
// Verify file contents
expect(sortedDiffs[0].before).to.equal("file1 initial\nfile2 initial\n")
expect(sortedDiffs[0].after).to.equal("file1 modified\nfile2 modified\n")
})
it("should create commit when files are deleted", async () => {
const tracker = await createTestTracker(env.globalStoragePath)
if (!tracker) {throw new Error("Failed to create tracker")}
// Create and commit initial file
await fs.writeFile(env.testFilePath, "initial content")
const firstCommit = await tracker.commit()
expect(firstCommit).to.be.a("string").and.not.empty
// Delete file
await fs.unlink(env.testFilePath)
// Create second commit
const secondCommit = await tracker.commit()
expect(secondCommit).to.be.a("string").and.not.empty
expect(secondCommit).to.not.equal(firstCommit)
// Verify file deletion was committed
const diffSet = await tracker.getDiffSet(firstCommit, secondCommit)
expect(diffSet).to.have.lengthOf(1)
expect(diffSet[0].before).to.equal("initial content")
expect(diffSet[0].after).to.equal("")
})
it("should create empty commit when no changes", async () => {
const tracker = await createTestTracker(env.globalStoragePath)
if (!tracker) {throw new Error("Failed to create tracker")}
// Create and commit initial file
await fs.writeFile(env.testFilePath, "test content")
const firstCommit = await tracker.commit()
expect(firstCommit).to.be.a("string").and.not.empty
// Create commit without changes
const secondCommit = await tracker.commit()
expect(secondCommit).to.be.a("string").and.not.empty
expect(secondCommit).to.not.equal(firstCommit)
// Verify no changes between commits
const diffSet = await tracker.getDiffSet(firstCommit, secondCommit)
expect(diffSet).to.have.lengthOf(0)
})
it("should handle files in nested directories", async () => {
const tracker = await createTestTracker(env.globalStoragePath)
if (!tracker) {throw new Error("Failed to create tracker")}
// Create nested directory structure
const nestedDir = path.join(env.tempDir, "src", "deep", "nested")
await fs.mkdir(nestedDir, { recursive: true })
const nestedFilePath = path.join(nestedDir, "nested.txt")
// Create and commit file in nested directory
await fs.writeFile(nestedFilePath, "nested content")
const firstCommit = await tracker.commit()
expect(firstCommit).to.be.a("string").and.not.empty
// Modify nested file
await fs.writeFile(nestedFilePath, "modified nested content")
// Create second commit
const secondCommit = await tracker.commit()
expect(secondCommit).to.be.a("string").and.not.empty
// Verify changes were committed
const diffSet = await tracker.getDiffSet(firstCommit, secondCommit)
expect(diffSet).to.have.lengthOf(1)
expect(diffSet[0].relativePath).to.equal("src/deep/nested/nested.txt")
expect(diffSet[0].before).to.equal("nested content")
expect(diffSet[0].after).to.equal("modified nested content")
})
})
@@ -0,0 +1,35 @@
import { expect } from "chai"
import { describe, it, beforeEach, afterEach } from "mocha"
import { createTestEnvironment, createTestTracker } from "./Checkpoint-test-utils"
import CheckpointTracker from "./CheckpointTracker"
describe("Checkpoint Creation", () => {
let env: Awaited<ReturnType<typeof createTestEnvironment>>
beforeEach(async () => {
env = await createTestEnvironment()
})
afterEach(async () => {
await env.cleanup()
})
it("should create a new checkpoint tracker", async () => {
const tracker = await createTestTracker(env.globalStoragePath)
expect(tracker).to.not.be.undefined
expect(tracker).to.be.instanceOf(CheckpointTracker)
// Verify shadow git config
const configWorkTree = await tracker?.getShadowGitConfigWorkTree()
expect(configWorkTree).to.not.be.undefined
})
it("should throw error when globalStoragePath is missing", async () => {
try {
await createTestTracker(undefined)
expect.fail("Expected error was not thrown")
} catch (error: any) {
expect(error.message).to.equal("Global storage path is required to create a checkpoint tracker")
}
})
})
@@ -0,0 +1,68 @@
import { expect } from "chai"
import { describe, it, beforeEach, afterEach } from "mocha"
import fs from "fs/promises"
import { createTestEnvironment, createTestTracker } from "./Checkpoint-test-utils"
describe("Checkpoint Diff Operations", () => {
let env: Awaited<ReturnType<typeof createTestEnvironment>>
beforeEach(async () => {
env = await createTestEnvironment()
})
afterEach(async () => {
await env.cleanup()
})
it("should detect file changes between commits", async () => {
const tracker = await createTestTracker(env.globalStoragePath)
if (!tracker) {throw new Error("Failed to create tracker")}
// Create initial file
await fs.writeFile(env.testFilePath, "initial content")
// Create first checkpoint
const firstCommit = await tracker.commit()
expect(firstCommit).to.not.be.undefined
// Modify file
await fs.writeFile(env.testFilePath, "modified content")
// Create second checkpoint
const secondCommit = await tracker.commit()
expect(secondCommit).to.not.be.undefined
// Get diff between commits
const diffSet = await tracker.getDiffSet(firstCommit, secondCommit)
// Verify diff results
expect(diffSet).to.have.lengthOf(1)
expect(diffSet[0].relativePath).to.equal("src/test.txt")
expect(diffSet[0].before).to.equal("initial content")
expect(diffSet[0].after).to.equal("modified content")
})
it("should detect changes between commit and working directory", async () => {
const tracker = await createTestTracker(env.globalStoragePath)
if (!tracker) {throw new Error("Failed to create tracker")}
// Create initial file
await fs.writeFile(env.testFilePath, "initial content")
// Create checkpoint
const commit = await tracker.commit()
expect(commit).to.not.be.undefined
// Modify file without committing
await fs.writeFile(env.testFilePath, "working directory changes")
// Get diff between commit and working directory
const diffSet = await tracker.getDiffSet(commit)
// Verify diff results
expect(diffSet).to.have.lengthOf(1)
expect(diffSet[0].relativePath).to.equal("src/test.txt")
expect(diffSet[0].before).to.equal("initial content")
expect(diffSet[0].after).to.equal("working directory changes")
})
})
@@ -0,0 +1,94 @@
import { expect } from "chai"
import { describe, it, beforeEach, afterEach } from "mocha"
import fs from "fs/promises"
import * as vscode from "vscode"
import { createTestEnvironment, createTestTracker } from "./Checkpoint-test-utils"
describe("Checkpoint Disabled State", () => {
let env: Awaited<ReturnType<typeof createTestEnvironment>>
let originalGetConfiguration: typeof vscode.workspace.getConfiguration
beforeEach(async () => {
env = await createTestEnvironment()
originalGetConfiguration = vscode.workspace.getConfiguration
// Mock VS Code configuration to disable checkpoints
vscode.workspace.getConfiguration = () =>
({
get: (key: string) => (key === "enableCheckpoints" ? false : undefined),
}) as any
})
afterEach(async () => {
await env.cleanup()
// Restore original configuration
vscode.workspace.getConfiguration = originalGetConfiguration
})
it("should return undefined when creating tracker", async () => {
const tracker = await createTestTracker(env.globalStoragePath)
expect(tracker).to.be.undefined
})
it("should allow re-enabling checkpoints", async () => {
// First verify disabled state
const disabledTracker = await createTestTracker(env.globalStoragePath)
expect(disabledTracker).to.be.undefined
// Mock configuration to enable checkpoints
vscode.workspace.getConfiguration = () =>
({
get: (key: string) => (key === "enableCheckpoints" ? true : undefined),
}) as any
// Verify tracker can be created when enabled
const enabledTracker = await createTestTracker(env.globalStoragePath)
expect(enabledTracker).to.not.be.undefined
// Verify operations work
if (!enabledTracker) {throw new Error("Failed to create tracker")}
await fs.writeFile(env.testFilePath, "test content")
const commit = await enabledTracker.commit()
expect(commit).to.be.a("string").and.not.empty
})
it("should prevent operations when disabled mid-session", async () => {
// Start with checkpoints enabled
vscode.workspace.getConfiguration = () =>
({
get: (key: string) => (key === "enableCheckpoints" ? true : undefined),
}) as any
// Create tracker and initial commit
const tracker = await createTestTracker(env.globalStoragePath)
expect(tracker).to.not.be.undefined
if (!tracker) {throw new Error("Failed to create tracker")}
await fs.writeFile(env.testFilePath, "initial content")
const firstCommit = await tracker.commit()
expect(firstCommit).to.be.a("string").and.not.empty
// Disable checkpoints
vscode.workspace.getConfiguration = () =>
({
get: (key: string) => (key === "enableCheckpoints" ? false : undefined),
}) as any
// Verify new tracker cannot be created
const disabledTracker = await createTestTracker(env.globalStoragePath)
expect(disabledTracker).to.be.undefined
// Verify existing tracker still works
// This is expected behavior since the tracker was created when enabled
await fs.writeFile(env.testFilePath, "modified content")
const secondCommit = await tracker.commit()
expect(secondCommit).to.be.a("string").and.not.empty
expect(secondCommit).to.not.equal(firstCommit)
// Verify diffs still work on existing tracker
const diffSet = await tracker.getDiffSet(firstCommit, secondCommit)
expect(diffSet).to.have.lengthOf(1)
expect(diffSet[0].before).to.equal("initial content")
expect(diffSet[0].after).to.equal("modified content")
})
})
@@ -0,0 +1,335 @@
import fs from "fs/promises"
import { join } from "path"
import { fileExistsAtPath } from "../../utils/fs"
import { GIT_DISABLED_SUFFIX } from "./CheckpointGitOperations"
/**
* CheckpointExclusions Module
*
* A specialized module within Cline's Checkpoints system that manages file exclusion rules
* for the checkpoint tracking process. It provides:
*
* File Filtering:
* - File types (build artifacts, media, cache files, etc.)
* - Git LFS patterns from workspace
* - Environment and configuration files
* - Temporary and cache files
*
* Pattern Management:
* - Extensible category-based pattern system
* - Comprehensive file type coverage
* - Easy pattern updates and maintenance
*
* Git Integration:
* - Seamless integration with Git's exclude mechanism
* - Support for workspace-specific LFS patterns
* - Automatic pattern updates during checkpoints
*
* The module ensures efficient checkpoint creation by preventing unnecessary tracking
* of large files, binary files, and temporary artifacts while maintaining a clean
* and organized checkpoint history.
*/
/**
* Interface representing the result of a file exclusion check
*/
interface ExclusionResult {
/** Whether the file should be excluded */
excluded: boolean
/** Optional reason for exclusion */
reason?: string
}
/**
* Returns the default list of file and directory patterns to exclude from checkpoints.
* Combines built-in patterns with workspace-specific LFS patterns.
*
* @param lfsPatterns - Optional array of Git LFS patterns from workspace
* @returns Array of glob patterns to exclude
* @todo Make this configurable by the user
*/
export const getDefaultExclusions = (lfsPatterns: string[] = []): string[] => [
// Build and Development Artifacts
".git/",
`.git${GIT_DISABLED_SUFFIX}/`,
...getBuildArtifactPatterns(),
// Media Files
...getMediaFilePatterns(),
// Cache and Temporary Files
...getCacheFilePatterns(),
// Environment and Config Files
...getConfigFilePatterns(),
// Large Data Files
...getLargeDataFilePatterns(),
// Database Files
...getDatabaseFilePatterns(),
// Geospatial Datasets
...getGeospatialPatterns(),
// Log Files
...getLogFilePatterns(),
...lfsPatterns,
]
/**
* Returns patterns for common build and development artifact directories
* @returns Array of glob patterns for build artifacts
*/
function getBuildArtifactPatterns(): string[] {
return [
".gradle/",
".idea/",
".parcel-cache/",
".pytest_cache/",
".next/",
".nuxt/",
".sass-cache/",
".vs/",
".vscode/",
"Pods/",
"__pycache__/",
"bin/",
"build/",
"bundle/",
"coverage/",
"deps/",
"dist/",
"env/",
"node_modules/",
"obj/",
"out/",
"pkg/",
"pycache/",
"target/dependency/",
"temp/",
"vendor/",
"venv/",
]
}
/**
* Returns patterns for common media and image file types
* @returns Array of glob patterns for media files
*/
function getMediaFilePatterns(): string[] {
return [
"*.jpg",
"*.jpeg",
"*.png",
"*.gif",
"*.bmp",
"*.ico",
"*.webp",
"*.tiff",
"*.tif",
"*.svg",
"*.raw",
"*.heic",
"*.avif",
"*.eps",
"*.psd",
"*.3gp",
"*.aac",
"*.aiff",
"*.asf",
"*.avi",
"*.divx",
"*.flac",
"*.m4a",
"*.m4v",
"*.mkv",
"*.mov",
"*.mp3",
"*.mp4",
"*.mpeg",
"*.mpg",
"*.ogg",
"*.opus",
"*.rm",
"*.rmvb",
"*.vob",
"*.wav",
"*.webm",
"*.wma",
"*.wmv",
]
}
/**
* Returns patterns for cache, temporary, and system files
* @returns Array of glob patterns for cache files
*/
function getCacheFilePatterns(): string[] {
return [
"*.DS_Store",
"*.bak",
"*.cache",
"*.crdownload",
"*.dmp",
"*.dump",
"*.eslintcache",
"*.lock",
"*.log",
"*.old",
"*.part",
"*.partial",
"*.pyc",
"*.pyo",
"*.stackdump",
"*.swo",
"*.swp",
"*.temp",
"*.tmp",
"*.Thumbs.db",
]
}
/**
* Returns patterns for environment and configuration files
* @returns Array of glob patterns for config files
*/
function getConfigFilePatterns(): string[] {
return ["*.env*", "*.local", "*.development", "*.production"]
}
/**
* Returns patterns for common large binary and archive files
* @returns Array of glob patterns for large data files
*/
function getLargeDataFilePatterns(): string[] {
return [
"*.zip",
"*.tar",
"*.gz",
"*.rar",
"*.7z",
"*.iso",
"*.bin",
"*.exe",
"*.dll",
"*.so",
"*.dylib",
"*.dat",
"*.dmg",
"*.msi",
]
}
/**
* Returns patterns for database and data storage files
* @returns Array of glob patterns for database files
*/
function getDatabaseFilePatterns(): string[] {
return [
"*.arrow",
"*.accdb",
"*.aof",
"*.avro",
"*.bak",
"*.bson",
"*.csv",
"*.db",
"*.dbf",
"*.dmp",
"*.frm",
"*.ibd",
"*.mdb",
"*.myd",
"*.myi",
"*.orc",
"*.parquet",
"*.pdb",
"*.rdb",
"*.sql",
"*.sqlite",
]
}
/**
* Returns patterns for geospatial and mapping data files
* @returns Array of glob patterns for geospatial files
*/
function getGeospatialPatterns(): string[] {
return [
"*.shp",
"*.shx",
"*.dbf",
"*.prj",
"*.sbn",
"*.sbx",
"*.shp.xml",
"*.cpg",
"*.gdb",
"*.mdb",
"*.gpkg",
"*.kml",
"*.kmz",
"*.gml",
"*.geojson",
"*.dem",
"*.asc",
"*.img",
"*.ecw",
"*.las",
"*.laz",
"*.mxd",
"*.qgs",
"*.grd",
"*.csv",
"*.dwg",
"*.dxf",
]
}
/**
* Returns patterns for log and debug output files
* @returns Array of glob patterns for log files
*/
function getLogFilePatterns(): string[] {
return ["*.error", "*.log", "*.logs", "*.npm-debug.log*", "*.out", "*.stdout", "yarn-debug.log*", "yarn-error.log*"]
}
/**
* Writes the combined exclusion patterns to Git's exclude file.
* Creates the info directory if it doesn't exist.
*
* @param gitPath - Path to the .git directory
* @param lfsPatterns - Optional array of Git LFS patterns to include
*/
export const writeExcludesFile = async (gitPath: string, lfsPatterns: string[] = []): Promise<void> => {
const excludesPath = join(gitPath, "info", "exclude")
await fs.mkdir(join(gitPath, "info"), { recursive: true })
const patterns = getDefaultExclusions(lfsPatterns)
await fs.writeFile(excludesPath, patterns.join("\n"))
}
/**
* Retrieves Git LFS patterns from the workspace's .gitattributes file.
* Returns an empty array if no patterns found or file doesn't exist.
*
* @param workspacePath - Path to the workspace root
* @returns Array of Git LFS patterns found in .gitattributes
*/
export const getLfsPatterns = async (workspacePath: string): Promise<string[]> => {
try {
const attributesPath = join(workspacePath, ".gitattributes")
if (await fileExistsAtPath(attributesPath)) {
const attributesContent = await fs.readFile(attributesPath, "utf8")
return attributesContent
.split("\n")
.filter((line) => line.includes("filter=lfs"))
.map((line) => line.split(" ")[0].trim())
}
} catch (error) {
console.warn("Failed to read .gitattributes:", error)
}
return []
}
@@ -0,0 +1,482 @@
import simpleGit, { SimpleGit } from "simple-git"
import { getLfsPatterns, writeExcludesFile } from "./CheckpointExclusions"
import fs from "fs/promises"
import * as path from "path"
import { fileExistsAtPath } from "../../utils/fs"
import * as vscode from "vscode"
import { getWorkingDirectory, hashWorkingDir } from "./CheckpointUtils"
import { HistoryItem } from "../../shared/HistoryItem"
interface StorageProvider {
context: {
globalStorageUri: { fsPath: string }
}
}
interface CheckpointAddResult {
success: boolean
fileCount: number
}
/**
* GitOperations Class
*
* Handles git-specific operations for Cline's Checkpoints system.
*
* Key responsibilities:
* - Git repository initialization and configuration
* - Git settings management (user, LFS, etc.)
* - Worktree configuration and management
* - Task-specific branch management (creation, switching, deletion)
* - Handling of both legacy and branch-per-task checkpoint structures
* - Managing nested git repositories during checkpoint operations
* - File staging and checkpoint creation
* - Shadow git repository maintenance and cleanup
*/
export class GitOperations {
private cwd: string
private isLegacyCheckpoint: boolean
/**
* Creates a new GitOperations instance.
*
* @param cwd - The current working directory for git operations
* @param isLegacyCheckpoint - Whether this is operating in legacy checkpoint mode
*/
constructor(cwd: string, isLegacyCheckpoint: boolean) {
this.cwd = cwd
this.isLegacyCheckpoint = isLegacyCheckpoint
}
/**
* Initializes or verifies a shadow Git repository for checkpoint tracking.
* Creates a new repository if one doesn't exist, or verifies the worktree
* configuration if it does.
*
* Key operations:
* - Creates/verifies shadow git repository
* - Configures git settings (user, LFS, etc.)
* - Sets up worktree to point to workspace
* - Creates initial empty commit
* - Handles both legacy and branch-per-task checkpoint structures
*
* @param gitPath - Path to the .git directory
* @param cwd - The current working directory for git operations
* @param isLegacyCheckpoint - Whether this is operating in legacy checkpoint mode
* @returns Promise<string> Path to the initialized .git directory
* @throws Error if:
* - Worktree verification fails for existing repository
* - Git initialization or configuration fails
* - Unable to create initial commit
* - LFS pattern setup fails
*/
public static async initShadowGit(gitPath: string, cwd: string, isLegacyCheckpoint: boolean): Promise<string> {
console.info(`Initializing ${isLegacyCheckpoint ? "legacy" : "branch-per-task"} shadow git`)
// If repo exists, just verify worktree
if (await fileExistsAtPath(gitPath)) {
const git = simpleGit(path.dirname(gitPath))
const worktree = await git.getConfig("core.worktree")
if (worktree.value !== cwd) {
throw new Error("Checkpoints can only be used in the original workspace: " + worktree.value)
}
console.warn(`Using existing ${isLegacyCheckpoint ? "legacy" : "branch-per-task"} shadow git at ${gitPath}`)
return gitPath
}
// Initialize new repo
const checkpointsDir = path.dirname(gitPath)
console.warn(`Creating new ${isLegacyCheckpoint ? "legacy" : "branch-per-task"} shadow git in ${checkpointsDir}`)
const git = simpleGit(checkpointsDir)
await git.init()
// Configure repo
await git.addConfig("core.worktree", cwd)
await git.addConfig("commit.gpgSign", "false")
await git.addConfig("user.name", "Cline Checkpoint")
await git.addConfig("user.email", "checkpoint@cline.bot")
await git.addConfig("core.quotePath", "false")
await git.addConfig("core.precomposeunicode", "true")
// Set up LFS patterns
const lfsPatterns = await getLfsPatterns(cwd)
await writeExcludesFile(gitPath, lfsPatterns)
// Initial commit only on first repo creation
await git.commit("initial commit", { "--allow-empty": null })
console.warn(`${isLegacyCheckpoint ? "Legacy" : "New"} shadow git initialization completed`)
return gitPath
}
/**
* Retrieves the worktree path from the shadow git configuration.
* The worktree path indicates where the shadow git repository is tracking files,
* which should match the current workspace directory.
*
* @param gitPath - Path to the .git directory
* @returns Promise<string | undefined> The worktree path or undefined if not found
* @throws Error if unable to get worktree path
*/
public async getShadowGitConfigWorkTree(gitPath: string): Promise<string | undefined> {
try {
const git = simpleGit(path.dirname(gitPath))
const worktree = await git.getConfig("core.worktree")
return worktree.value || undefined
} catch (error) {
console.error("Failed to get shadow git config worktree:", error)
return undefined
}
}
/**
* Checks if a shadow Git repository exists for the given task and workspace.
* Checks both legacy checkpoint paths (tasks/{taskId}/checkpoints/.git) and
* branch-per-task paths (checkpoints/{workspaceHash}/.git).
*
* @param taskId - The ID of the task whose shadow git to check
* @param provider - The ClineProvider instance for accessing VS Code functionality
* @returns Promise<boolean> True if either a legacy or branch-per-task shadow git exists, false otherwise
*/
public static async doesShadowGitExist(taskId: string, provider?: StorageProvider): Promise<boolean> {
const globalStoragePath = provider?.context.globalStorageUri.fsPath
if (!globalStoragePath) {
return false
}
// Check legacy checkpoint path to see if this is a legacy task
const legacyGitPath = path.join(globalStoragePath, "tasks", taskId, "checkpoints", ".git")
if (await fileExistsAtPath(legacyGitPath)) {
console.info("Found legacy shadow git")
return true
}
// Check branch-per-task path for newer tasks
const workingDir = await getWorkingDirectory()
const cwdHash = hashWorkingDir(workingDir)
const gitPath = path.join(globalStoragePath, "checkpoints", cwdHash, ".git")
const exists = await fileExistsAtPath(gitPath)
if (exists) {
console.info("Found branch-per-task shadow git")
}
return exists
}
/**
* Deletes a branch in the git repository, handling cases where the branch is currently checked out.
* If the branch to be deleted is currently checked out, the method will:
* 1. Save the current worktree configuration
* 2. Temporarily unset the worktree to prevent workspace modifications
* 3. Force switch to master/main branch
* 4. Delete the target branch
* 5. Restore the worktree configuration
*
* @param git - SimpleGit instance to use for operations
* @param branchName - Name of the branch to delete
* @param checkpointsDir - Directory containing the git repository
* @throws Error if:
* - Branch deletion fails
* - Unable to switch to master/main branch after 3 retries
* - Git operations fail during the process
*/
public static async deleteBranchForGit(git: SimpleGit, branchName: string, checkpointsDir: string): Promise<void> {
// Check if branch exists
const branches = await git.branchLocal()
if (!branches.all.includes(branchName)) {
console.error(`Task branch ${branchName} does not exist, nothing to delete`)
return // Branch doesn't exist, nothing to delete
}
// First, if we're on the branch to be deleted, switch to master/main
const currentBranch = await git.revparse(["--abbrev-ref", "HEAD"])
console.info(`Current branch: ${currentBranch}, target branch to delete: ${branchName}`)
if (currentBranch === branchName) {
console.debug("Currently on branch to be deleted, switching to master/main first")
// Save the current worktree config
const worktree = await git.getConfig("core.worktree")
console.debug(`Saved current worktree config: ${worktree.value}`)
try {
// Temporarily unset worktree to prevent workspace modifications
console.debug("Temporarily unsetting worktree config")
await git.raw(["config", "--unset", "core.worktree"])
// Force discard all changes
console.debug("Discarding all changes")
await git.reset(["--hard"])
await git.clean("f", ["-d"]) // Clean mode 'f' for force, -d for directories
// Determine default branch (master or main)
const defaultBranch = branches.all.includes("main") ? "main" : "master"
console.debug(`Using ${defaultBranch} as default branch`)
// Switch to default branch and delete branch
console.debug(`Attempting to force switch to ${defaultBranch} branch`)
await git.checkout([defaultBranch, "--force"])
// Verify the switch completed
let retries = 3
while (retries > 0) {
const newBranch = await git.revparse(["--abbrev-ref", "HEAD"])
console.debug(`Verifying branch switch - current branch: ${newBranch}, attempts left: ${retries}`)
if (newBranch === defaultBranch) {
console.debug(`Successfully switched to ${defaultBranch} branch`)
break
}
retries--
if (retries === 0) {
throw new Error(`Failed to switch to ${defaultBranch} branch`)
}
}
console.info(`Deleting branch: ${branchName}`)
await git.raw(["branch", "-D", branchName])
console.debug(`Successfully deleted branch: ${branchName}`)
} finally {
// Restore the worktree config
if (worktree.value) {
console.debug(`Restoring worktree config to: ${worktree.value}`)
await git.addConfig("core.worktree", worktree.value)
}
}
} else {
// If we're not on the branch, we can safely delete it
console.info(`Directly deleting branch ${branchName} since we're not on it`)
await git.raw(["branch", "-D", branchName])
console.debug(`Successfully deleted branch: ${branchName}`)
}
}
/**
* Static method to delete a task's branch using stored workspace path.
* Handles both branch-per-task and legacy checkpoint formats:
* 1. First attempts to delete branch-per-task checkpoint if it exists
* 2. Falls back to deleting legacy checkpoint directory if found
*
* @param taskId - The ID of the task whose branch should be deleted
* @param historyItem - The history item containing the shadow git config
* @param globalStoragePath - Path to VS Code's global storage
* @throws Error if:
* - Global storage path is invalid
* - Branch deletion fails
* - Legacy checkpoint directory deletion fails
*/
public static async deleteTaskBranchStatic(
taskId: string,
historyItem: HistoryItem,
globalStoragePath: string,
): Promise<void> {
try {
console.debug("Starting static task branch deletion process...")
if (!globalStoragePath) {
throw new Error("Global storage uri is invalid")
}
// First try to handle branch-per-task checkpoint
let workingDir: string
if (historyItem.shadowGitConfigWorkTree) {
workingDir = historyItem.shadowGitConfigWorkTree
} else {
// Try to determine working directory from current state
workingDir = await getWorkingDirectory()
}
const cwdHash = hashWorkingDir(workingDir)
const checkpointsDir = path.join(globalStoragePath, "checkpoints", cwdHash)
const gitPath = path.join(checkpointsDir, ".git")
if (await fileExistsAtPath(gitPath)) {
console.debug(`Found branch-per-task git repository at ${gitPath}`)
const git = simpleGit(path.dirname(gitPath))
const branchName = `task-${taskId}`
// Check if the branch exists
const branches = await git.branchLocal()
if (branches.all.includes(branchName)) {
console.info(`Found branch ${branchName} to delete`)
await GitOperations.deleteBranchForGit(git, branchName, checkpointsDir)
return
}
console.warn(`Branch ${branchName} not found in branch-per-task repository`)
}
// Only check legacy checkpoint if we didn't find/delete a branch-per-task branch
const legacyCheckpointsDir = path.join(globalStoragePath, "tasks", taskId, "checkpoints")
const legacyGitPath = path.join(legacyCheckpointsDir, ".git")
if (await fileExistsAtPath(legacyGitPath)) {
console.info("Found legacy checkpoint, deleting directory")
try {
await fs.rm(legacyCheckpointsDir, { recursive: true, force: true })
console.debug("Successfully deleted legacy checkpoint directory")
return
} catch (error) {
console.error("Failed to delete legacy checkpoint directory:", error)
throw error
}
}
console.info("No checkpoints found to delete")
} catch (error) {
console.error("Failed to delete task branch:", error)
throw new Error(`Failed to delete task branch: ${error instanceof Error ? error.message : String(error)}`)
}
}
/**
* Since we use git to track checkpoints, we need to temporarily disable nested git repos to work around git's
* requirement of using submodules for nested repos.
*
* This method renames nested .git directories by adding/removing a suffix to temporarily disable/enable them.
* The root .git directory is preserved. Uses VS Code's workspace API to find nested .git directories and
* only processes actual directories (not files named .git).
*
* @param disable - If true, adds suffix to disable nested git repos. If false, removes suffix to re-enable them.
* @throws Error if renaming any .git directory fails
*/
public async renameNestedGitRepos(disable: boolean): Promise<void> {
// Find all .git directories that are not at the root level using VS Code API
const gitFiles = await vscode.workspace.findFiles(
new vscode.RelativePattern(this.cwd, "**/.git" + (disable ? "" : GIT_DISABLED_SUFFIX)),
new vscode.RelativePattern(this.cwd, ".git/**"), // Exclude root .git
)
// Filter to only include directories
const gitPaths: string[] = []
for (const file of gitFiles) {
const relativePath = path.relative(this.cwd, file.fsPath)
try {
const stats = await fs.stat(path.join(this.cwd, relativePath))
if (stats.isDirectory()) {
gitPaths.push(relativePath)
}
} catch {
// Skip if stat fails
continue
}
}
// For each nested .git directory, rename it based on the disable flag
for (const gitPath of gitPaths) {
const fullPath = path.join(this.cwd, gitPath)
let newPath: string
if (disable) {
newPath = fullPath + GIT_DISABLED_SUFFIX
} else {
newPath = fullPath.endsWith(GIT_DISABLED_SUFFIX) ? fullPath.slice(0, -GIT_DISABLED_SUFFIX.length) : fullPath
}
try {
await fs.rename(fullPath, newPath)
console.info(`${disable ? "Disabled" : "Enabled"} nested git repo ${gitPath}`)
} catch (error) {
console.error(`Failed to ${disable ? "disable" : "enable"} nested git repo ${gitPath}:`, error)
}
}
}
/**
* Switches to or creates a task-specific branch in the shadow Git repository.
* For legacy checkpoints, this is a no-op since they use separate repositories.
* For branch-per-task checkpoints, this ensures we're on the correct task branch before operations.
*
* The method performs the following:
* 1. Gets the shadow git path and initializes simple-git
* 2. Constructs the branch name using the task ID
* 3. Checks if the branch exists:
* - If not, creates a new branch
* - If yes, switches to the existing branch
* 4. Verifies the branch switch completed successfully
*
* Branch naming convention:
* task-{taskId}
*
* @param taskId - The ID of the task whose branch to switch to
* @param gitPath - Path to the .git directory
* @returns Promise<void>
* @throws Error if branch operations fail or git commands error
*/
public async switchToTaskBranch(taskId: string, gitPath: string): Promise<void> {
const git = simpleGit(path.dirname(gitPath))
const branchName = `task-${taskId}`
// Create new task-specific branch, or switch to one if it already exists.
const branches = await git.branchLocal()
if (!branches.all.includes(branchName)) {
console.info(`Creating new task branch: ${branchName}`)
await git.checkoutLocalBranch(branchName)
} else {
console.info(`Switching to existing task branch: ${branchName}`)
await git.checkout(branchName)
}
const currentBranch = await git.revparse(["--abbrev-ref", "HEAD"])
console.info(`Current Checkpoint branch after switch: ${currentBranch}`)
}
/**
* Adds files to the shadow git repository while handling nested git repos.
* Uses git commands to list files and stages them for commit.
* Respects .gitignore and handles LFS patterns.
*
* Process:
* 1. Updates exclude patterns from LFS config
* 2. Temporarily disables nested git repos
* 3. Gets list of tracked and untracked files from git (respecting .gitignore)
* 4. Adds all files to git staging
* 5. Re-enables nested git repos
*
* @param git - SimpleGit instance configured for the shadow git repo
* @param gitPath - Path to the .git directory
* @returns Promise<CheckpointAddResult> Object containing success status, message, and file count
* @throws Error if:
* - File operations fail
* - Git commands error
* - LFS pattern updates fail
* - Nested git repo handling fails
*/
public async addCheckpointFiles(git: SimpleGit, gitPath: string): Promise<CheckpointAddResult> {
try {
// Update exclude patterns before each commit
await writeExcludesFile(gitPath, await getLfsPatterns(this.cwd))
await this.renameNestedGitRepos(true)
//console.info("Starting checkpoint add operation...")
// Get list of all files git would track (respects .gitignore)
await git.addConfig("core.quotePath", "false")
await git.addConfig("core.precomposeunicode", "true")
const gitFiles = (await git.raw(["ls-files", "--others", "--exclude-standard", "--cached"]))
.split("\n")
.filter(Boolean)
// Add filtered files
if (gitFiles.length === 0) {
console.info("No files to add to checkpoint")
return { success: true, fileCount: 0 }
}
try {
console.info(`Adding ${gitFiles.length} files to checkpoint`)
await git.addConfig("core.quotePath", "false")
await git.addConfig("core.precomposeunicode", "true")
await git.add(gitFiles)
console.info("Checkpoint add operation completed successfully")
return { success: true, fileCount: gitFiles.length }
} catch (error) {
console.error("Checkpoint add operation failed:", error)
throw error
}
} catch (error) {
console.error("Failed to add files to checkpoint", error)
throw error
} finally {
await this.renameNestedGitRepos(false)
}
}
}
export const GIT_DISABLED_SUFFIX = "_disabled"
@@ -0,0 +1,95 @@
import { expect } from "chai"
import { describe, it, beforeEach, afterEach } from "mocha"
import fs from "fs/promises"
import path from "path"
import { createTestEnvironment, createTestTracker } from "./Checkpoint-test-utils"
describe("Checkpoint Revert Operations", () => {
let env: Awaited<ReturnType<typeof createTestEnvironment>>
beforeEach(async () => {
env = await createTestEnvironment()
})
afterEach(async () => {
await env.cleanup()
})
it("should revert working directory to a previous checkpoint state", async () => {
const tracker = await createTestTracker(env.globalStoragePath)
if (!tracker) {throw new Error("Failed to create tracker")}
// Create and commit initial state
await fs.writeFile(env.testFilePath, "initial content")
const firstCommit = await tracker.commit()
expect(firstCommit).to.not.be.undefined
// Create and commit changes
await fs.writeFile(env.testFilePath, "modified content")
const secondCommit = await tracker.commit()
expect(secondCommit).to.not.be.undefined
// Make more changes without committing
await fs.writeFile(env.testFilePath, "uncommitted changes")
// Revert to first commit
await tracker.resetHead(firstCommit!)
// Verify file content matches initial state
const resetContent = await fs.readFile(env.testFilePath, "utf8")
expect(resetContent).to.equal("initial content")
})
it("should handle reverting with multiple files", async () => {
const tracker = await createTestTracker(env.globalStoragePath)
if (!tracker) {throw new Error("Failed to create tracker")}
// Create and commit initial state with multiple files
const testFile2Path = path.join(env.tempDir, "src", "test2.txt")
await fs.writeFile(env.testFilePath, "file1 initial")
await fs.writeFile(testFile2Path, "file2 initial")
const firstCommit = await tracker.commit()
expect(firstCommit).to.not.be.undefined
// Modify both files and commit
await fs.writeFile(env.testFilePath, "file1 modified")
await fs.writeFile(testFile2Path, "file2 modified")
const secondCommit = await tracker.commit()
expect(secondCommit).to.not.be.undefined
// Make more changes
await fs.writeFile(env.testFilePath, "file1 uncommitted")
await fs.writeFile(testFile2Path, "file2 uncommitted")
// Reset to first commit
await tracker.resetHead(firstCommit!)
// Verify both files match initial state
const file1Content = await fs.readFile(env.testFilePath, "utf8")
const file2Content = await fs.readFile(testFile2Path, "utf8")
expect(file1Content).to.equal("file1 initial")
expect(file2Content).to.equal("file2 initial")
})
it("should handle reverting when files are deleted", async () => {
const tracker = await createTestTracker(env.globalStoragePath)
if (!tracker) {throw new Error("Failed to create tracker")}
// Create and commit initial state
await fs.writeFile(env.testFilePath, "initial content")
const firstCommit = await tracker.commit()
expect(firstCommit).to.not.be.undefined
// Delete file and commit
await fs.unlink(env.testFilePath)
const secondCommit = await tracker.commit()
expect(secondCommit).to.not.be.undefined
// Revert to first commit
await tracker.resetHead(firstCommit!)
// Verify file is restored with original content
const resetContent = await fs.readFile(env.testFilePath, "utf8")
expect(resetContent).to.equal("initial content")
})
})
@@ -0,0 +1,120 @@
import { expect } from "chai"
import { describe, it, beforeEach, afterEach } from "mocha"
import fs from "fs/promises"
import { createTestEnvironment, createTestTracker } from "./Checkpoint-test-utils"
import { HistoryItem } from "../../shared/HistoryItem"
import CheckpointTracker from "./CheckpointTracker"
describe("Checkpoint Task Switching", () => {
let env: Awaited<ReturnType<typeof createTestEnvironment>>
let taskId1: string
let taskId2: string
let tracker1: CheckpointTracker | undefined
let tracker2: CheckpointTracker | undefined
beforeEach(async () => {
env = await createTestEnvironment()
taskId1 = "task-1"
taskId2 = "task-2"
tracker1 = await createTestTracker(env.globalStoragePath, taskId1)
if (!tracker1) {throw new Error("Failed to create tracker1")}
})
afterEach(async () => {
await env.cleanup()
})
it("should maintain separate history for each task", async () => {
if (!tracker1) {throw new Error("Failed to create tracker1")}
// Create and commit file in first task
await fs.writeFile(env.testFilePath, "task1 initial")
const task1Commit1 = await tracker1.commit()
expect(task1Commit1).to.be.a("string").and.not.empty
// Modify and commit again in first task
await fs.writeFile(env.testFilePath, "task1 modified")
const task1Commit2 = await tracker1.commit()
expect(task1Commit2).to.be.a("string").and.not.empty
// Create second task tracker
tracker2 = await createTestTracker(env.globalStoragePath, taskId2)
if (!tracker2) {throw new Error("Failed to create tracker2")}
// Create and commit file in second task
await fs.writeFile(env.testFilePath, "task2 initial")
const task2Commit1 = await tracker2.commit()
expect(task2Commit1).to.be.a("string").and.not.empty
// Create another commit to establish history
await fs.writeFile(env.testFilePath, "task2 modified")
const task2Commit2 = await tracker2.commit()
expect(task2Commit2).to.be.a("string").and.not.empty
// Verify second task's history
const task2Diff = await tracker2.getDiffSet(task2Commit1, task2Commit2)
expect(task2Diff).to.have.lengthOf(1)
expect(task2Diff[0].before).to.equal("task2 initial")
expect(task2Diff[0].after).to.equal("task2 modified")
// Switch back to first task by creating new tracker
const tracker1Again = await createTestTracker(env.globalStoragePath, taskId1)
if (!tracker1Again) {throw new Error("Failed to create tracker1Again")}
// Verify first task's history is preserved
const task1Diff = await tracker1Again.getDiffSet(task1Commit1, task1Commit2)
expect(task1Diff[0].before).to.equal("task1 initial")
expect(task1Diff[0].after).to.equal("task1 modified")
// Reset first task to initial state
if (!task1Commit1) {throw new Error("Failed to create initial commit")}
await tracker1Again.resetHead(task1Commit1)
const resetContent = await fs.readFile(env.testFilePath, "utf8")
expect(resetContent).to.equal("task1 initial")
})
it("should handle task deletion and recreation", async () => {
if (!tracker1) {throw new Error("Failed to create tracker1")}
// Create and commit file in first task
await fs.writeFile(env.testFilePath, "task1 content")
const task1Commit = await tracker1.commit()
expect(task1Commit).to.be.a("string").and.not.empty
// Create second task
tracker2 = await createTestTracker(env.globalStoragePath, taskId2)
if (!tracker2) {throw new Error("Failed to create tracker2")}
await fs.writeFile(env.testFilePath, "task2 content")
const task2Commit = await tracker2.commit()
expect(task2Commit).to.be.a("string").and.not.empty
// Delete second task's checkpoints
const historyItem: HistoryItem = {
id: `test-${Date.now()}`,
ts: Date.now(),
task: taskId2,
shadowGitConfigWorkTree: env.tempDir,
tokensIn: 0,
tokensOut: 0,
totalCost: 0,
}
await CheckpointTracker.deleteCheckpoints(taskId2, historyItem, env.globalStoragePath)
// Recreate second task
const tracker2Again = await createTestTracker(env.globalStoragePath, taskId2)
if (!tracker2Again) {throw new Error("Failed to create tracker2Again")}
// Create new commit in recreated task
await fs.writeFile(env.testFilePath, "task2 new content")
const newCommit = await tracker2Again.commit()
expect(newCommit).to.be.a("string").and.not.empty
// Switch back to first task and verify its history is intact
const tracker1Again = await createTestTracker(env.globalStoragePath, taskId1)
if (!tracker1Again) {throw new Error("Failed to create tracker1Again")}
if (!task1Commit) {throw new Error("Failed to create initial commit")}
await tracker1Again.resetHead(task1Commit)
const resetContent = await fs.readFile(env.testFilePath, "utf8")
expect(resetContent).to.equal("task1 content")
})
})
+331 -298
View File
@@ -1,31 +1,100 @@
import fs from "fs/promises"
import os from "os"
import * as path from "path"
import simpleGit, { SimpleGit } from "simple-git"
import * as vscode from "vscode"
import { ClineProvider } from "../../core/webview/ClineProvider"
import { fileExistsAtPath } from "../../utils/fs"
import { globby } from "globby"
import { HistoryItem } from "../../shared/HistoryItem"
import { GitOperations } from "./CheckpointGitOperations"
import { getShadowGitPath, hashWorkingDir, getWorkingDirectory, detectLegacyCheckpoint } from "./CheckpointUtils"
/**
* CheckpointTracker Module
*
* Core implementation of Cline's Checkpoints system that provides version control
* capabilities without interfering with the user's main Git repository. Key features:
*
* Shadow Git Repository:
* - Creates and manages an isolated Git repository for tracking checkpoints
* - Handles nested Git repositories by temporarily disabling them
* - Configures Git settings automatically (identity, LFS, etc.)
*
* File Management:
* - Integrates with CheckpointExclusions for file filtering
* - Handles workspace validation and path resolution
* - Manages Git worktree configuration
*
* Checkpoint Operations:
* - Creates checkpoints (commits) of the current state
* - Provides diff capabilities between checkpoints
* - Supports resetting to previous checkpoints
*
* Safety Features:
* - Prevents usage in sensitive directories (home, desktop, etc.)
* - Validates workspace configuration
* - Handles cleanup and resource disposal
*
* Checkpoint Architecture:
* - Uses a branch-per-task model to consolidate shadow git repositories
* - Each task gets its own branch within a single shadow git per workspace
* - Maintains backward compatibility with legacy checkpoint structure
* - Automatically cleans up by deleting task branches when tasks are removed
*/
class CheckpointTracker {
private providerRef: WeakRef<ClineProvider>
private globalStoragePath: string
private taskId: string
private disposables: vscode.Disposable[] = []
private cwd: string
private cwdHash: string
private lastRetrievedShadowGitConfigWorkTree?: string
lastCheckpointHash?: string
private lastCheckpointHash?: string
private isLegacyCheckpoint: boolean = false
private gitOperations: GitOperations
private constructor(provider: ClineProvider, taskId: string, cwd: string) {
this.providerRef = new WeakRef(provider)
/**
* Creates a new CheckpointTracker instance to manage checkpoints for a specific task.
* The constructor is private - use the static create() method to instantiate.
*
* @param taskId - Unique identifier for the task being tracked
* @param cwd - The current working directory to track files in
* @param cwdHash - Hash of the working directory path for shadow git organization
*/
private constructor(globalStoragePath: string, taskId: string, cwd: string, cwdHash: string) {
this.globalStoragePath = globalStoragePath
this.taskId = taskId
this.cwd = cwd
this.cwdHash = cwdHash
this.gitOperations = new GitOperations(cwd, false) // Initialize with non-legacy mode
}
public static async create(taskId: string, provider?: ClineProvider): Promise<CheckpointTracker | undefined> {
/**
* Creates a new CheckpointTracker instance for tracking changes in a task.
* Handles initialization of the shadow git repository and branch setup.
*
* @param taskId - Unique identifier for the task to track
* @param globalStoragePath - the globalStorage path
* @returns Promise resolving to new CheckpointTracker instance, or undefined if checkpoints are disabled
* @throws Error if:
* - globalStoragePath is not supplied
* - Git is not installed
* - Working directory is invalid or in a protected location
* - Shadow git initialization fails
*
* Key operations:
* - Validates git installation and settings
* - Creates/initializes shadow git repository
* - Detects and handles legacy checkpoint structure
* - Sets up task-specific branch for new checkpoints
*
* Configuration:
* - Respects 'cline.enableCheckpoints' VS Code setting
* - Uses branch-per-task architecture for new checkpoints
* - Maintains backwards compatibility with legacy structure
*/
public static async create(taskId: string, globalStoragePath: string | undefined): Promise<CheckpointTracker | undefined> {
if (!globalStoragePath) {
throw new Error("Global storage path is required to create a checkpoint tracker")
}
try {
if (!provider) {
throw new Error("Provider is required to create a checkpoint tracker")
}
console.info(`Creating new CheckpointTracker for task ${taskId}`)
// Check if checkpoints are disabled in VS Code settings
const enableCheckpoints = vscode.workspace.getConfiguration("cline").get<boolean>("enableCheckpoints") ?? true
@@ -40,9 +109,36 @@ class CheckpointTracker {
throw new Error("Git must be installed to use checkpoints.") // FIXME: must match what we check for in TaskHeader to show link
}
const cwd = await CheckpointTracker.getWorkingDirectory()
const newTracker = new CheckpointTracker(provider, taskId, cwd)
await newTracker.initShadowGit()
const workingDir = await getWorkingDirectory()
const cwdHash = hashWorkingDir(workingDir)
console.debug(`Repository ID (cwdHash): ${cwdHash}`)
const newTracker = new CheckpointTracker(globalStoragePath, taskId, workingDir, cwdHash)
// Check if this is a legacy task
newTracker.isLegacyCheckpoint = await detectLegacyCheckpoint(newTracker.globalStoragePath, newTracker.taskId)
if (newTracker.isLegacyCheckpoint) {
console.debug("Using legacy checkpoint path structure")
const gitPath = await getShadowGitPath(
newTracker.globalStoragePath,
newTracker.taskId,
newTracker.cwdHash,
newTracker.isLegacyCheckpoint,
)
await GitOperations.initShadowGit(gitPath, workingDir, newTracker.isLegacyCheckpoint)
await newTracker.gitOperations.switchToTaskBranch(newTracker.taskId, gitPath)
return newTracker
}
// Branch-per-task structure
const gitPath = await getShadowGitPath(
newTracker.globalStoragePath,
newTracker.taskId,
newTracker.cwdHash,
newTracker.isLegacyCheckpoint,
)
await GitOperations.initShadowGit(gitPath, workingDir, newTracker.isLegacyCheckpoint)
await newTracker.gitOperations.switchToTaskBranch(newTracker.taskId, gitPath)
return newTracker
} catch (error) {
console.error("Failed to create CheckpointTracker:", error)
@@ -50,203 +146,110 @@ class CheckpointTracker {
}
}
private static async getWorkingDirectory(): Promise<string> {
const cwd = vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath).at(0)
if (!cwd) {
throw new Error("No workspace detected. Please open Cline in a workspace to use checkpoints.")
}
const homedir = os.homedir()
const desktopPath = path.join(homedir, "Desktop")
const documentsPath = path.join(homedir, "Documents")
const downloadsPath = path.join(homedir, "Downloads")
/**
* Creates a new checkpoint commit in the shadow git repository.
*
* Key behaviors:
* - Creates commit with checkpoint files in shadow git repo
* - Handles both legacy and branch-per-task checkpoint structures
* - For new tasks, switches to task-specific branch first
* - Caches the created commit hash
*
* Commit structure:
* - Legacy: Simple "checkpoint" message
* - Branch-per-task: "checkpoint-{cwdHash}-{taskId}"
* - Always allows empty commits
*
* Dependencies:
* - Requires initialized shadow git (getShadowGitPath)
* - For new checkpoints, requires task branch setup
* - Uses addCheckpointFiles to stage changes
*
* @returns Promise<string | undefined> The created commit hash, or undefined if:
* - Shadow git access fails
* - Branch switch fails
* - Staging files fails
* - Commit creation fails
* @throws Error if unable to:
* - Access shadow git path
* - Initialize simple-git
* - Switch branches
* - Stage or commit files
*/
public async commit(): Promise<string | undefined> {
try {
console.info(`Creating new checkpoint commit for task ${this.taskId}`)
const gitPath = await getShadowGitPath(this.globalStoragePath, this.taskId, this.cwdHash, this.isLegacyCheckpoint)
const git = simpleGit(path.dirname(gitPath))
switch (cwd) {
case homedir:
throw new Error("Cannot use checkpoints in home directory")
case desktopPath:
throw new Error("Cannot use checkpoints in Desktop directory")
case documentsPath:
throw new Error("Cannot use checkpoints in Documents directory")
case downloadsPath:
throw new Error("Cannot use checkpoints in Downloads directory")
default:
return cwd
}
}
console.info(`Using shadow git at: ${gitPath}`)
private async getShadowGitPath(): Promise<string> {
const globalStoragePath = this.providerRef.deref()?.context.globalStorageUri.fsPath
if (!globalStoragePath) {
throw new Error("Global storage uri is invalid")
}
const checkpointsDir = path.join(globalStoragePath, "tasks", this.taskId, "checkpoints")
await fs.mkdir(checkpointsDir, { recursive: true })
const gitPath = path.join(checkpointsDir, ".git")
return gitPath
}
// Disable nested git repos before any operations
await this.gitOperations.renameNestedGitRepos(true)
public static async doesShadowGitExist(taskId: string, provider?: ClineProvider): Promise<boolean> {
const globalStoragePath = provider?.context.globalStorageUri.fsPath
if (!globalStoragePath) {
return false
}
const gitPath = path.join(globalStoragePath, "tasks", taskId, "checkpoints", ".git")
return await fileExistsAtPath(gitPath)
}
public async initShadowGit(): Promise<string> {
const gitPath = await this.getShadowGitPath()
if (await fileExistsAtPath(gitPath)) {
// Make sure it's the same cwd as the configured worktree
const worktree = await this.getShadowGitConfigWorkTree()
if (worktree !== this.cwd) {
throw new Error("Checkpoints can only be used in the original workspace: " + worktree)
}
return gitPath
} else {
const checkpointsDir = path.dirname(gitPath)
const git = simpleGit(checkpointsDir)
await git.init()
await git.addConfig("core.worktree", this.cwd) // sets the working tree to the current workspace
// Disable commit signing for shadow repo
await git.addConfig("commit.gpgSign", "false")
// Get LFS patterns from workspace if they exist
let lfsPatterns: string[] = []
try {
const attributesPath = path.join(this.cwd, ".gitattributes")
if (await fileExistsAtPath(attributesPath)) {
const attributesContent = await fs.readFile(attributesPath, "utf8")
lfsPatterns = attributesContent
.split("\n")
.filter((line) => line.includes("filter=lfs"))
.map((line) => line.split(" ")[0].trim())
if (!this.isLegacyCheckpoint) {
await this.gitOperations.switchToTaskBranch(this.taskId, gitPath)
}
} catch (error) {
console.warn("Failed to read .gitattributes:", error)
await this.gitOperations.addCheckpointFiles(git, gitPath)
const commitMessage = this.isLegacyCheckpoint ? "checkpoint" : "checkpoint-" + this.cwdHash + "-" + this.taskId
console.info(
`Creating ${this.isLegacyCheckpoint ? "legacy" : "new"} checkpoint commit with message: ${commitMessage}`,
)
const result = await git.commit(commitMessage, {
"--allow-empty": null,
})
const commitHash = result.commit || ""
this.lastCheckpointHash = commitHash
console.warn(`Checkpoint commit created.`)
return commitHash
} finally {
// Always re-enable nested git repos
await this.gitOperations.renameNestedGitRepos(false)
}
// Add basic excludes directly in git config, while respecting any .gitignore in the workspace
// .git/info/exclude is local to the shadow git repo, so it's not shared with the main repo - and won't conflict with user's .gitignore
// TODO: let user customize these
const excludesPath = path.join(gitPath, "info", "exclude")
await fs.mkdir(path.join(gitPath, "info"), { recursive: true })
await fs.writeFile(
excludesPath,
[
".git/", // ignore the user's .git
`.git${GIT_DISABLED_SUFFIX}/`, // ignore the disabled nested git repos
".DS_Store",
"*.log",
"node_modules/",
"__pycache__/",
"env/",
"venv/",
"target/dependency/",
"build/dependencies/",
"dist/",
"out/",
"bundle/",
"vendor/",
"tmp/",
"temp/",
"deps/",
"pkg/",
"Pods/",
// Media files
"*.jpg",
"*.jpeg",
"*.png",
"*.gif",
"*.bmp",
"*.ico",
// "*.svg",
"*.mp3",
"*.mp4",
"*.wav",
"*.avi",
"*.mov",
"*.wmv",
"*.webm",
"*.webp",
"*.m4a",
"*.flac",
// Build and dependency directories
"build/",
"bin/",
"obj/",
".gradle/",
".idea/",
".vscode/",
".vs/",
"coverage/",
".next/",
".nuxt/",
// Cache and temporary files
"*.cache",
"*.tmp",
"*.temp",
"*.swp",
"*.swo",
"*.pyc",
"*.pyo",
".pytest_cache/",
".eslintcache",
// Environment and config files
".env*",
"*.local",
"*.development",
"*.production",
// Large data files
"*.zip",
"*.tar",
"*.gz",
"*.rar",
"*.7z",
"*.iso",
"*.bin",
"*.exe",
"*.dll",
"*.so",
"*.dylib",
// Database files
"*.sqlite",
"*.db",
"*.sql",
// Log files
"*.logs",
"*.error",
"npm-debug.log*",
"yarn-debug.log*",
"yarn-error.log*",
...lfsPatterns,
].join("\n"),
)
// Set up git identity (git throws an error if user.name or user.email is not set)
await git.addConfig("user.name", "Cline Checkpoint")
await git.addConfig("user.email", "noreply@example.com")
await this.addAllFiles(git)
// Initial commit (--allow-empty ensures it works even with no files)
await git.commit("initial commit", { "--allow-empty": null })
return gitPath
} catch (error) {
console.error("Failed to create checkpoint:", {
taskId: this.taskId,
error,
isLegacyCheckpoint: this.isLegacyCheckpoint,
})
throw new Error(`Failed to create checkpoint: ${error instanceof Error ? error.message : String(error)}`)
}
}
/**
* Retrieves the worktree path from the shadow git configuration.
* The worktree path indicates where the shadow git repository is tracking files,
* which should match the current workspace directory.
*
* Key behaviors:
* - Caches result in lastRetrievedShadowGitConfigWorkTree to avoid repeated reads
* - Returns cached value if available
* - Reads git config if no cached value exists
* - Handles both legacy and new checkpoint structures
*
* Configuration read:
* - Uses simple-git to read core.worktree config
* - Operates on shadow git at path from getShadowGitPath()
*
* @returns Promise<string | undefined> The configured worktree path, or undefined if:
* - Shadow git repository doesn't exist
* - Config read fails
* - No worktree is configured
* @throws Error if unable to:
* - Access shadow git path
* - Initialize simple-git
* - Read git configuration
*/
public async getShadowGitConfigWorkTree(): Promise<string | undefined> {
if (this.lastRetrievedShadowGitConfigWorkTree) {
return this.lastRetrievedShadowGitConfigWorkTree
}
try {
const gitPath = await this.getShadowGitPath()
const git = simpleGit(path.dirname(gitPath))
const worktree = await git.getConfig("core.worktree")
this.lastRetrievedShadowGitConfigWorkTree = worktree.value || undefined
const gitPath = await getShadowGitPath(this.globalStoragePath, this.taskId, this.cwdHash, this.isLegacyCheckpoint)
this.lastRetrievedShadowGitConfigWorkTree = await this.gitOperations.getShadowGitConfigWorkTree(gitPath)
return this.lastRetrievedShadowGitConfigWorkTree
} catch (error) {
console.error("Failed to get shadow git config worktree:", error)
@@ -254,36 +257,32 @@ class CheckpointTracker {
}
}
public async commit(): Promise<string | undefined> {
try {
const gitPath = await this.getShadowGitPath()
const git = simpleGit(path.dirname(gitPath))
await this.addAllFiles(git)
const result = await git.commit("checkpoint", {
"--allow-empty": null,
})
const commitHash = result.commit || ""
this.lastCheckpointHash = commitHash
return commitHash
} catch (error) {
console.error("Failed to create checkpoint:", error)
return undefined
}
}
/**
* Resets the shadow git repository's HEAD to a specific checkpoint commit.
* This will discard all changes after the target commit and restore the
* working directory to that checkpoint's state.
*
* Dependencies:
* - Requires initialized shadow git (getShadowGitPath)
* - For new checkpoints, requires task branch setup
* - Must be called with a valid commit hash from this task's history
*
* @param commitHash - The hash of the checkpoint commit to reset to
* @returns Promise<void> Resolves when reset is complete
* @throws Error if unable to:
* - Access shadow git path
* - Initialize simple-git
* - Switch to task branch
* - Reset to target commit
*/
public async resetHead(commitHash: string): Promise<void> {
const gitPath = await this.getShadowGitPath()
console.info(`Resetting to checkpoint: ${commitHash}`)
const gitPath = await getShadowGitPath(this.globalStoragePath, this.taskId, this.cwdHash, this.isLegacyCheckpoint)
const git = simpleGit(path.dirname(gitPath))
// Clean working directory and force reset
// This ensures that the operation will succeed regardless of:
// - Untracked files in the workspace
// - Staged changes
// - Unstaged changes
// - Partial commits
// - Merge conflicts
await git.clean("f", ["-d", "-f"]) // Remove untracked files and directories
console.debug(`Using shadow git at: ${gitPath}`)
await this.gitOperations.switchToTaskBranch(this.taskId, gitPath)
await git.reset(["--hard", commitHash]) // Hard reset to target commit
console.debug(`Successfully reset to checkpoint: ${commitHash}`)
}
/**
@@ -310,111 +309,145 @@ class CheckpointTracker {
after: string
}>
> {
const gitPath = await this.getShadowGitPath()
const gitPath = await getShadowGitPath(this.globalStoragePath, this.taskId, this.cwdHash, this.isLegacyCheckpoint)
const git = simpleGit(path.dirname(gitPath))
if (!this.isLegacyCheckpoint) {
await this.gitOperations.switchToTaskBranch(this.taskId, gitPath)
}
console.info(`Getting diff between commits: ${lhsHash || "initial"} -> ${rhsHash || "working directory"}`)
// If lhsHash is missing, use the initial commit of the repo
let baseHash = lhsHash
if (!baseHash) {
const rootCommit = await git.raw(["rev-list", "--max-parents=0", "HEAD"])
baseHash = rootCommit.trim()
console.debug(`Using root commit as base: ${baseHash}`)
}
// Stage all changes so that untracked files appear in diff summary
await this.addAllFiles(git)
await this.gitOperations.addCheckpointFiles(git, gitPath)
const diffSummary = rhsHash ? await git.diffSummary([`${baseHash}..${rhsHash}`]) : await git.diffSummary([baseHash])
console.info(`Found ${diffSummary.files.length} changed files`)
// For each changed file, gather before/after content
const result = []
const cwdPath = (await this.getShadowGitConfigWorkTree()) || this.cwd || ""
const files = diffSummary.files.map((f) => f.file)
const batchSize = 50
for (const file of diffSummary.files) {
const filePath = file.file
const absolutePath = path.join(cwdPath, filePath)
// Get list of files that exist in base commit
const existingFiles = await this.getExistingFiles(git, baseHash, files)
let beforeContent = ""
try {
beforeContent = await git.show([`${baseHash}:${filePath}`])
} catch (_) {
// file didn't exist in older commit => remains empty
// Process files in batches
for (let i = 0; i < files.length; i += batchSize) {
const batch = files.slice(i, i + batchSize)
// Split batch into existing and new files
const existingBatch = batch.filter((file) => existingFiles.has(file))
const newBatch = batch.filter((file) => !existingFiles.has(file))
// Get before contents for existing files
let beforeContents: string[] = new Array(batch.length).fill("")
if (existingBatch.length > 0) {
await git.addConfig("core.quotePath", "false")
await git.addConfig("core.precomposeunicode", "true")
const args = ["show", "--format="]
existingBatch.forEach((file) => {
args.push(`${baseHash}:${file}`)
})
const beforeResult = await git.raw(args)
const existingContents = beforeResult.split("\n\0\n")
// Map contents back to original batch positions
existingBatch.forEach((file, index) => {
const batchIndex = batch.indexOf(file)
if (batchIndex !== -1) {
beforeContents[batchIndex] = existingContents[index] || ""
}
})
}
let afterContent = ""
// Get after contents
let afterContents: string[] = []
if (rhsHash) {
// if user provided a newer commit, use git.show at that commit
try {
afterContent = await git.show([`${rhsHash}:${filePath}`])
} catch (_) {
// file didn't exist in newer commit => remains empty
// Split after files into existing and new in target commit
const afterExistingFiles = await this.getExistingFiles(git, rhsHash, batch)
const afterExistingBatch = batch.filter((file) => afterExistingFiles.has(file))
if (afterExistingBatch.length > 0) {
const args = ["show", "--format="]
afterExistingBatch.forEach((file) => {
args.push(`${rhsHash}:${file}`)
})
const afterResult = await git.raw(args)
const existingContents = afterResult.split("\n\0\n")
afterContents = new Array(batch.length).fill("")
afterExistingBatch.forEach((file, index) => {
const batchIndex = batch.indexOf(file)
if (batchIndex !== -1) {
afterContents[batchIndex] = existingContents[index] || ""
}
})
}
} else {
// otherwise, read from disk (includes uncommitted changes)
try {
afterContent = await fs.readFile(absolutePath, "utf8")
} catch (_) {
// file might be deleted => remains empty
}
// Read from disk for working directory changes
afterContents = await Promise.all(
batch.map(async (filePath) => {
try {
return await fs.readFile(path.join(cwdPath, filePath), "utf8")
} catch (_) {
return ""
}
}),
)
}
result.push({
relativePath: filePath,
absolutePath,
before: beforeContent,
after: afterContent,
})
// Add results for this batch
for (let j = 0; j < batch.length; j++) {
const filePath = batch[j]
const absolutePath = path.join(cwdPath, filePath)
result.push({
relativePath: filePath,
absolutePath,
before: beforeContents[j] || "",
after: afterContents[j] || "",
})
}
}
return result
}
private async addAllFiles(git: SimpleGit) {
await this.renameNestedGitRepos(true)
/**
* Deletes all checkpoint data for a given task.
* Handles both legacy checkpoints and branch-per-task checkpoints.
*
* @param taskId - The ID of the task whose checkpoints should be deleted
* @param historyItem - The history item containing the shadow git config for this task
* @param globalStoragePath - the globalStorage path
* @throws Error if deletion fails
*/
public static async deleteCheckpoints(taskId: string, historyItem: HistoryItem, globalStoragePath: string): Promise<void> {
if (!globalStoragePath) {
throw new Error("Global storage uri is invalid")
}
await GitOperations.deleteTaskBranchStatic(taskId, historyItem, globalStoragePath)
}
/**
* Helper function to get a set of files that exist in a given commit
*/
private async getExistingFiles(git: SimpleGit, commitHash: string, files: string[]): Promise<Set<string>> {
try {
await git.add(".")
const result = await git.raw(["ls-tree", "-r", "--name-only", commitHash])
const existingFiles = new Set<string>(result.split("\n"))
return existingFiles
} catch (error) {
console.error("Failed to add files to git:", error)
} finally {
await this.renameNestedGitRepos(false)
console.error("Error getting existing files:", error)
return new Set()
}
}
// Since we use git to track checkpoints, we need to temporarily disable nested git repos to work around git's requirement of using submodules for nested repos.
private async renameNestedGitRepos(disable: boolean) {
// Find all .git directories that are not at the root level
const gitPaths = await globby("**/.git" + (disable ? "" : GIT_DISABLED_SUFFIX), {
cwd: this.cwd,
onlyDirectories: true,
ignore: [".git"], // Ignore root level .git
dot: true,
markDirectories: false,
})
// For each nested .git directory, rename it based on operation
for (const gitPath of gitPaths) {
const fullPath = path.join(this.cwd, gitPath)
let newPath: string
if (disable) {
newPath = fullPath + GIT_DISABLED_SUFFIX
} else {
newPath = fullPath.endsWith(GIT_DISABLED_SUFFIX) ? fullPath.slice(0, -GIT_DISABLED_SUFFIX.length) : fullPath
}
try {
await fs.rename(fullPath, newPath)
console.log(`CheckpointTracker ${disable ? "disabled" : "enabled"} nested git repo ${gitPath}`)
} catch (error) {
console.error(`CheckpointTracker failed to ${disable ? "disable" : "enable"} nested git repo ${gitPath}:`, error)
}
}
}
public dispose() {
this.disposables.forEach((d) => d.dispose())
this.disposables = []
}
}
const GIT_DISABLED_SUFFIX = "_disabled"
export default CheckpointTracker
@@ -0,0 +1,159 @@
import { mkdir } from "fs/promises"
import * as path from "path"
import * as vscode from "vscode"
import os from "os"
import { fileExistsAtPath } from "../../utils/fs"
/**
* Gets the path to the legacy shadow Git repository in globalStorage.
* Legacy checkpoints stored each task's checkpoints in a separate git repository
* under the tasks/{taskId}/checkpoints directory.
*
* Legacy path structure:
* globalStorage/
* tasks/
* {taskId}/
* checkpoints/
* .git/
*
* @param globalStoragePath - The VS Code global storage path
* @param taskId - The ID of the task
* @returns Promise<string> The absolute path to the legacy shadow git directory
* @throws Error if global storage path is invalid
*/
export async function getLegacyShadowGitPath(globalStoragePath: string, taskId: string): Promise<string> {
if (!globalStoragePath) {
throw new Error("Global storage uri is invalid")
}
const checkpointsDir = path.join(globalStoragePath, "tasks", taskId, "checkpoints")
await mkdir(checkpointsDir, { recursive: true })
const gitPath = path.join(checkpointsDir, ".git")
console.info(`Legacy shadow git path: ${gitPath}`)
return gitPath
}
/**
* Gets the path to the shadow Git repository in globalStorage.
* For legacy checkpoints, delegates to getLegacyShadowGitPath().
* For new checkpoints, uses the consolidated branch-per-task structure.
*
* Branch-per-task path structure:
* globalStorage/
* checkpoints/
* {cwdHash}/
* .git/
*
* @param globalStoragePath - The VS Code global storage path
* @param taskId - The ID of the task
* @param cwdHash - Hash of the working directory path
* @param isLegacyCheckpoint - Whether this is a legacy checkpoint
* @returns Promise<string> The absolute path to the shadow git directory
* @throws Error if global storage path is invalid
*/
export async function getShadowGitPath(
globalStoragePath: string,
taskId: string,
cwdHash: string,
isLegacyCheckpoint: boolean,
): Promise<string> {
if (isLegacyCheckpoint) {
return getLegacyShadowGitPath(globalStoragePath, taskId)
}
if (!globalStoragePath) {
throw new Error("Global storage uri is invalid")
}
const checkpointsDir = path.join(globalStoragePath, "checkpoints", cwdHash)
await mkdir(checkpointsDir, { recursive: true })
const gitPath = path.join(checkpointsDir, ".git")
return gitPath
}
/**
* Gets the current working directory from the VS Code workspace.
* Validates that checkpoints are not being used in protected directories
* like home, Desktop, Documents, or Downloads.
*
* Protected directories:
* - User's home directory
* - Desktop
* - Documents
* - Downloads
*
* @returns Promise<string> The absolute path to the current working directory
* @throws Error if no workspace is detected or if in a protected directory
*/
export async function getWorkingDirectory(): Promise<string> {
const cwd = vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath).at(0)
if (!cwd) {
throw new Error("No workspace detected. Please open Cline in a workspace to use checkpoints.")
}
const homedir = os.homedir()
const desktopPath = path.join(homedir, "Desktop")
const documentsPath = path.join(homedir, "Documents")
const downloadsPath = path.join(homedir, "Downloads")
switch (cwd) {
case homedir:
throw new Error("Cannot use checkpoints in home directory")
case desktopPath:
throw new Error("Cannot use checkpoints in Desktop directory")
case documentsPath:
throw new Error("Cannot use checkpoints in Documents directory")
case downloadsPath:
throw new Error("Cannot use checkpoints in Downloads directory")
default:
return cwd
}
}
/**
* Hashes the current working directory to a 13-character numeric hash.
* @param workingDir - The absolute path to the working directory
* @returns A 13-character numeric hash string used to identify the workspace
* @throws {Error} If the working directory path is empty or invalid
*/
export function hashWorkingDir(workingDir: string): string {
if (!workingDir) {
throw new Error("Working directory path cannot be empty")
}
let hash = 0
for (let i = 0; i < workingDir.length; i++) {
hash = (hash * 31 + workingDir.charCodeAt(i)) >>> 0
}
const bigHash = BigInt(hash)
const numericHash = bigHash.toString().slice(0, 13)
return numericHash
}
/**
* Detects if a task uses the legacy checkpoint structure.
* Legacy checkpoints stored each task's checkpoints in a separate git repository
* under the tasks/{taskId}/checkpoints directory. New checkpoints use a single
* repository with branches per task.
*
* @param globalStoragePath - The VS Code global storage path
* @param taskId - The ID of the task to check
* @returns Promise<boolean> True if task uses legacy checkpoint structure, false otherwise
*
* Legacy path structure:
* globalStorage/
* tasks/
* {taskId}/
* checkpoints/
* .git/
*
* Branch-per-task structure:
* globalStorage/
* checkpoints/
* {cwdHash}/
* .git/
*/
export async function detectLegacyCheckpoint(globalStoragePath: string | undefined, taskId: string): Promise<boolean> {
if (!globalStoragePath) {
return false
}
const legacyGitPath = path.join(globalStoragePath, "tasks", taskId, "checkpoints", ".git")
const isLegacy = await fileExistsAtPath(legacyGitPath)
console.info(`Legacy checkpoint detection result: ${isLegacy}`)
return isLegacy
}
+12 -4
View File
@@ -35,14 +35,20 @@ export async function downloadTask(dateTs: number, conversationHistory: Anthropi
})
if (saveUri) {
// Write content to the selected location
await vscode.workspace.fs.writeFile(saveUri, Buffer.from(markdownContent))
vscode.window.showTextDocument(saveUri, { preview: true })
try {
// Write content to the selected location
await vscode.workspace.fs.writeFile(saveUri, new TextEncoder().encode(markdownContent))
vscode.window.showTextDocument(saveUri, { preview: true })
} catch (error) {
vscode.window.showErrorMessage(
`Failed to save markdown file: ${error instanceof Error ? error.message : String(error)}`,
)
}
}
}
export function formatContentBlockToMarkdown(
block: Anthropic.TextBlockParam | Anthropic.ImageBlockParam | Anthropic.ToolUseBlockParam | Anthropic.ToolResultBlockParam,
block: Anthropic.ContentBlockParam,
// messages: Anthropic.MessageParam[]
): string {
switch (block.type) {
@@ -50,6 +56,8 @@ export function formatContentBlockToMarkdown(
return block.text
case "image":
return `[Image]`
case "document":
return `[Document]`
case "tool_use":
let input: string
if (typeof block.input === "object" && block.input !== null) {
@@ -0,0 +1,65 @@
import { PostHog } from "posthog-node"
import * as vscode from "vscode"
class PostHogClient {
private static instance: PostHogClient
private client: PostHog
private distinctId: string = vscode.env.machineId
private telemetryEnabled: boolean = false
private constructor() {
this.client = new PostHog("phc_uY24EJXNBcc9kwO1K8TJUl5hPQntGM6LL1Mtrz0CBD4", {
host: "https://us.i.posthog.com",
enableExceptionAutocapture: false,
})
}
public updateTelemetryState(didUserOptIn: boolean): void {
this.telemetryEnabled = false
// 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 globalTelemetryEnabled = telemetryLevel === "all"
// We only enable telemetry if global vscode telemetry is enabled
if (globalTelemetryEnabled) {
this.telemetryEnabled = didUserOptIn
}
// Update PostHog client state based on telemetry preference and use machineId to tie it to the webview
if (this.telemetryEnabled) {
this.client.optIn()
this.client.identify({ distinctId: this.distinctId })
// console.log("Telemetry enabled")
} else {
this.client.optOut()
// console.log("Telemetry disabled")
}
}
public static getInstance(): PostHogClient {
if (!PostHogClient.instance) {
PostHogClient.instance = new PostHogClient()
}
return PostHogClient.instance
}
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 })
// console.log("Captured event", { distinctId: this.distinctId, event: event.event, properties: event.properties })
}
}
public isTelemetryEnabled(): boolean {
return this.telemetryEnabled
}
public async shutdown(): Promise<void> {
await this.client.shutdown()
}
}
// Export a single instance
export const telemetryService = PostHogClient.getInstance()
+5
View File
@@ -7,6 +7,7 @@ import { BrowserSettings } from "./BrowserSettings"
import { ChatSettings } from "./ChatSettings"
import { HistoryItem } from "./HistoryItem"
import { McpServer, McpMarketplaceCatalog, McpMarketplaceItem, McpDownloadResponse } from "./mcp"
import { TelemetrySetting } from "./TelemetrySetting"
// webview will hold state
export interface ExtensionMessage {
@@ -21,6 +22,7 @@ export interface ExtensionMessage {
| "invoke"
| "partialMessage"
| "openRouterModels"
| "requestyModels"
| "openAiModels"
| "mcpServers"
| "relinquishControl"
@@ -50,6 +52,7 @@ export interface ExtensionMessage {
filePaths?: string[]
partialMessage?: ClineMessage
openRouterModels?: Record<string, ModelInfo>
requestyModels?: Record<string, ModelInfo>
openAiModels?: string[]
mcpServers?: McpServer[]
mcpMarketplaceCatalog?: McpMarketplaceCatalog
@@ -93,6 +96,8 @@ export interface ExtensionState {
photoURL: string | null
}
mcpMarketplaceEnabled?: boolean
telemetrySetting: TelemetrySetting
vscMachineId: string
}
export interface ClineMessage {
+73
View File
@@ -0,0 +1,73 @@
export type LanguageKey =
| "en"
| "ar"
| "pt-BR"
| "cs"
| "fr"
| "de"
| "hi"
| "hu"
| "it"
| "ja"
| "ko"
| "pl"
| "pt-PT"
| "ru"
| "zh-CN"
| "es"
| "zh-TW"
| "tr"
export type LanguageDisplay =
| "English"
| "Arabic - العربية"
| "Portuguese - Português (Brasil)"
| "Czech - Čeština"
| "French - Français"
| "German - Deutsch"
| "Hindi - हिन्दी"
| "Hungarian - Magyar"
| "Italian - Italiano"
| "Japanese - 日本語"
| "Korean - 한국어"
| "Polish - Polski"
| "Portuguese - Português (Portugal)"
| "Russian - Русский"
| "Simplified Chinese - 简体中文"
| "Spanish - Español"
| "Traditional Chinese - 繁體中文"
| "Turkish - Türkçe"
export const DEFAULT_LANGUAGE_SETTINGS: LanguageKey = "en"
export const languageOptions: { key: LanguageKey; display: LanguageDisplay }[] = [
{ key: "en", display: "English" },
{ key: "ar", display: "Arabic - العربية" },
{ key: "pt-BR", display: "Portuguese - Português (Brasil)" },
{ key: "cs", display: "Czech - Čeština" },
{ key: "fr", display: "French - Français" },
{ key: "de", display: "German - Deutsch" },
{ key: "hi", display: "Hindi - हिन्दी" },
{ key: "hu", display: "Hungarian - Magyar" },
{ key: "it", display: "Italian - Italiano" },
{ key: "ja", display: "Japanese - 日本語" },
{ key: "ko", display: "Korean - 한국어" },
{ key: "pl", display: "Polish - Polski" },
{ key: "pt-PT", display: "Portuguese - Português (Portugal)" },
{ key: "ru", display: "Russian - Русский" },
{ key: "zh-CN", display: "Simplified Chinese - 简体中文" },
{ key: "es", display: "Spanish - Español" },
{ key: "zh-TW", display: "Traditional Chinese - 繁體中文" },
{ key: "tr", display: "Turkish - Türkçe" },
]
export function getLanguageKey(display: LanguageDisplay | undefined): LanguageKey {
if (!display) {
return DEFAULT_LANGUAGE_SETTINGS
}
const languageOption = languageOptions.find((option) => option.display === display)
if (languageOption) {
return languageOption.key
}
return DEFAULT_LANGUAGE_SETTINGS
}
+1
View File
@@ -0,0 +1 @@
export type TelemetrySetting = "unset" | "enabled" | "disabled"
+3
View File
@@ -27,6 +27,7 @@ export interface WebviewMessage {
| "openMention"
| "cancelTask"
| "refreshOpenRouterModels"
| "refreshRequestyModels"
| "refreshOpenAiModels"
| "openMcpSettings"
| "restartMcpServer"
@@ -51,6 +52,8 @@ export interface WebviewMessage {
| "searchCommits"
| "showMcpView"
| "fetchLatestMcpServersFromHub"
| "telemetrySetting"
| "openSettings"
| "updateMcpTimeout"
| "fetchOpenGraphData"
| "checkIsImageUrl"
+143 -21
View File
@@ -15,6 +15,7 @@ export type ApiProvider =
| "mistral"
| "vscode-lm"
| "litellm"
| "xai"
export interface ApiHandlerOptions {
apiModelId?: string
@@ -48,6 +49,7 @@ export interface ApiHandlerOptions {
deepSeekApiKey?: string
requestyApiKey?: string
requestyModelId?: string
requestyModelInfo?: ModelInfo
togetherApiKey?: string
togetherModelId?: string
qwenApiKey?: string
@@ -56,6 +58,7 @@ export interface ApiHandlerOptions {
vsCodeLmModelSelector?: any
o3MiniReasoningEffort?: string
qwenApiLine?: string
xaiApiKey?: string
}
export type ApiConfiguration = ApiHandlerOptions & {
@@ -139,7 +142,7 @@ export const anthropicModels = {
// AWS Bedrock
// https://docs.aws.amazon.com/bedrock/latest/userguide/conversation-inference.html
export type BedrockModelId = keyof typeof bedrockModels
export const bedrockDefaultModelId: BedrockModelId = "anthropic.claude-3-5-sonnet-20241022-v2:0"
export const bedrockDefaultModelId: BedrockModelId = "anthropic.claude-3-7-sonnet-20250219-v1:0"
export const bedrockModels = {
"anthropic.claude-3-7-sonnet-20250219-v1:0": {
maxTokens: 8192,
@@ -219,18 +222,18 @@ export const openRouterDefaultModelInfo: ModelInfo = {
description:
"Claude 3.7 Sonnet is an advanced large language model with improved reasoning, coding, and problem-solving capabilities. It introduces a hybrid reasoning approach, allowing users to choose between rapid responses and extended, step-by-step processing for complex tasks. The model demonstrates notable improvements in coding, particularly in front-end development and full-stack updates, and excels in agentic workflows, where it can autonomously navigate multi-step processes. \n\nClaude 3.7 Sonnet maintains performance parity with its predecessor in standard mode while offering an extended reasoning mode for enhanced accuracy in math, coding, and instruction-following tasks.\n\nRead more at the [blog post here](https://www.anthropic.com/news/claude-3-7-sonnet)",
}
// Vertex AI
// https://cloud.google.com/vertex-ai/generative-ai/docs/partner-models/use-claude
// https://cloud.google.com/vertex-ai/generative-ai/pricing#partner-models
export type VertexModelId = keyof typeof vertexModels
export const vertexDefaultModelId: VertexModelId = "claude-3-5-sonnet-v2@20241022"
export const vertexDefaultModelId: VertexModelId = "claude-3-7-sonnet@20250219"
export const vertexModels = {
"claude-3-7-sonnet@20250219": {
maxTokens: 8192,
contextWindow: 200_000,
supportsImages: true,
supportsComputerUse: true,
supportsPromptCache: false,
supportsPromptCache: true,
inputPrice: 3.0,
outputPrice: 15.0,
},
@@ -239,41 +242,51 @@ export const vertexModels = {
contextWindow: 200_000,
supportsImages: true,
supportsComputerUse: true,
supportsPromptCache: false,
supportsPromptCache: true,
inputPrice: 3.0,
outputPrice: 15.0,
cacheWritesPrice: 3.75,
cacheReadsPrice: 0.3,
},
"claude-3-5-sonnet@20240620": {
maxTokens: 8192,
contextWindow: 200_000,
supportsImages: true,
supportsPromptCache: false,
supportsPromptCache: true,
inputPrice: 3.0,
outputPrice: 15.0,
cacheWritesPrice: 3.75,
cacheReadsPrice: 0.3,
},
"claude-3-5-haiku@20241022": {
maxTokens: 8192,
contextWindow: 200_000,
supportsImages: false,
supportsPromptCache: false,
supportsPromptCache: true,
inputPrice: 1.0,
outputPrice: 5.0,
cacheWritesPrice: 1.25,
cacheReadsPrice: 0.1,
},
"claude-3-opus@20240229": {
maxTokens: 4096,
contextWindow: 200_000,
supportsImages: true,
supportsPromptCache: false,
supportsPromptCache: true,
inputPrice: 15.0,
outputPrice: 75.0,
cacheWritesPrice: 18.75,
cacheReadsPrice: 1.5,
},
"claude-3-haiku@20240307": {
maxTokens: 4096,
contextWindow: 200_000,
supportsImages: true,
supportsPromptCache: false,
supportsPromptCache: true,
inputPrice: 0.25,
outputPrice: 1.25,
cacheWritesPrice: 0.3,
cacheReadsPrice: 0.03,
},
} as const satisfies Record<string, ModelInfo>
@@ -398,9 +411,10 @@ export const openAiNativeModels = {
maxTokens: 100_000,
contextWindow: 200_000,
supportsImages: false,
supportsPromptCache: false,
supportsPromptCache: true,
inputPrice: 1.1,
outputPrice: 4.4,
cacheReadsPrice: 0.55,
},
// don't support tool use yet
o1: {
@@ -410,38 +424,51 @@ export const openAiNativeModels = {
supportsPromptCache: false,
inputPrice: 15,
outputPrice: 60,
cacheReadsPrice: 7.5,
},
"o1-preview": {
maxTokens: 32_768,
contextWindow: 128_000,
supportsImages: true,
supportsPromptCache: false,
supportsPromptCache: true,
inputPrice: 15,
outputPrice: 60,
cacheReadsPrice: 7.5,
},
"o1-mini": {
maxTokens: 65_536,
contextWindow: 128_000,
supportsImages: true,
supportsPromptCache: false,
supportsPromptCache: true,
inputPrice: 1.1,
outputPrice: 4.4,
cacheReadsPrice: 0.55,
},
"gpt-4o": {
maxTokens: 4_096,
contextWindow: 128_000,
supportsImages: true,
supportsPromptCache: false,
supportsPromptCache: true,
inputPrice: 2.5,
outputPrice: 10,
cacheReadsPrice: 1.25,
},
"gpt-4o-mini": {
maxTokens: 16_384,
contextWindow: 128_000,
supportsImages: true,
supportsPromptCache: false,
supportsPromptCache: true,
inputPrice: 0.15,
outputPrice: 0.6,
cacheReadsPrice: 0.075,
},
"gpt-4.5-preview": {
maxTokens: 16_384,
contextWindow: 128_000,
supportsImages: true,
supportsPromptCache: false,
inputPrice: 75,
outputPrice: 150,
},
} as const satisfies Record<string, ModelInfo>
@@ -459,18 +486,18 @@ export const deepSeekModels = {
maxTokens: 8_000,
contextWindow: 64_000,
supportsImages: false,
supportsPromptCache: true, // supports context caching, but not in the way anthropic does it (deepseek reports input tokens and reads/writes in the same usage report) FIXME: we need to show users cache stats how deepseek does it
inputPrice: 0, // technically there is no input price, it's all either a cache hit or miss (ApiOptions will not show this)
outputPrice: 0.28,
cacheWritesPrice: 0.14,
cacheReadsPrice: 0.014,
supportsPromptCache: true,
inputPrice: 0.27,
outputPrice: 1.1,
cacheWritesPrice: 0.27,
cacheReadsPrice: 0.07,
},
"deepseek-reasoner": {
maxTokens: 8_000,
contextWindow: 64_000,
supportsImages: false,
supportsPromptCache: true, // supports context caching, but not in the way anthropic does it (deepseek reports input tokens and reads/writes in the same usage report) FIXME: we need to show users cache stats how deepseek does it
inputPrice: 0, // technically there is no input price, it's all either a cache hit or miss (ApiOptions will not show this)
supportsPromptCache: true,
inputPrice: 0.55,
outputPrice: 2.19,
cacheWritesPrice: 0.55,
cacheReadsPrice: 0.14,
@@ -775,3 +802,98 @@ export const liteLlmModelInfoSaneDefaults: ModelInfo = {
inputPrice: 0,
outputPrice: 0,
}
// Requesty
// https://requesty.ai/models
export const requestyDefaultModelId = "anthropic/claude-3-5-sonnet-latest"
export const requestyDefaultModelInfo: ModelInfo = {
maxTokens: 8192,
contextWindow: 200_000,
supportsImages: true,
supportsComputerUse: false,
supportsPromptCache: true,
inputPrice: 3.0,
outputPrice: 15.0,
cacheWritesPrice: 3.75,
cacheReadsPrice: 0.3,
description: "Anthropic's most intelligent model. Highest level of intelligence and capability.",
}
// X AI
// https://docs.x.ai/docs/api-reference
export type XAIModelId = keyof typeof xaiModels
export const xaiDefaultModelId: XAIModelId = "grok-2-latest"
export const xaiModels = {
"grok-2-latest": {
maxTokens: 8192,
contextWindow: 131072,
supportsImages: false,
supportsPromptCache: false,
inputPrice: 2.0,
outputPrice: 10.0,
description: "X AI's Grok-2 model - latest version with 131K context window",
},
"grok-2": {
maxTokens: 8192,
contextWindow: 131072,
supportsImages: false,
supportsPromptCache: false,
inputPrice: 2.0,
outputPrice: 10.0,
description: "X AI's Grok-2 model with 131K context window",
},
"grok-2-1212": {
maxTokens: 8192,
contextWindow: 131072,
supportsImages: false,
supportsPromptCache: false,
inputPrice: 2.0,
outputPrice: 10.0,
description: "X AI's Grok-2 model (version 1212) with 131K context window",
},
"grok-2-vision-latest": {
maxTokens: 8192,
contextWindow: 32768,
supportsImages: true,
supportsPromptCache: false,
inputPrice: 2.0,
outputPrice: 10.0,
description: "X AI's Grok-2 Vision model - latest version with image support and 32K context window",
},
"grok-2-vision": {
maxTokens: 8192,
contextWindow: 32768,
supportsImages: true,
supportsPromptCache: false,
inputPrice: 2.0,
outputPrice: 10.0,
description: "X AI's Grok-2 Vision model with image support and 32K context window",
},
"grok-2-vision-1212": {
maxTokens: 8192,
contextWindow: 32768,
supportsImages: true,
supportsPromptCache: false,
inputPrice: 2.0,
outputPrice: 10.0,
description: "X AI's Grok-2 Vision model (version 1212) with image support and 32K context window",
},
"grok-vision-beta": {
maxTokens: 8192,
contextWindow: 8192,
supportsImages: true,
supportsPromptCache: false,
inputPrice: 5.0,
outputPrice: 15.0,
description: "X AI's Grok Vision Beta model with image support and 8K context window",
},
"grok-beta": {
maxTokens: 8192,
contextWindow: 131072,
supportsImages: false,
supportsPromptCache: false,
inputPrice: 5.0,
outputPrice: 15.0,
description: "X AI's Grok Beta model (legacy) with 131K context window",
},
} as const satisfies Record<string, ModelInfo>
+19
View File
@@ -4,6 +4,25 @@ const vscode = require("vscode")
describe("Extension Tests", function () {
this.timeout(60000) // Increased timeout for extension operations
let originalGetConfiguration
beforeEach(() => {
// Save original configuration
originalGetConfiguration = vscode.workspace.getConfiguration
// Setup mock configuration
const mockUpdate = async () => Promise.resolve()
const mockConfig = {
get: () => true,
update: mockUpdate,
}
vscode.workspace.getConfiguration = () => mockConfig
})
afterEach(() => {
// Restore original configuration
vscode.workspace.getConfiguration = originalGetConfiguration
})
it("should activate extension successfully", async () => {
// Get the extension
const extension = vscode.extensions.getExtension("saoudrizwan.claude-dev")
+67 -6
View File
@@ -1,10 +1,10 @@
import { describe, it } from "mocha"
import "should"
import { calculateApiCost } from "./cost"
import { calculateApiCostAnthropic, calculateApiCostOpenAI } from "./cost"
import { ModelInfo } from "../shared/api"
describe("Cost Utilities", () => {
describe("calculateApiCost", () => {
describe("calculateApiCostAnthropic", () => {
it("should calculate basic input/output costs", () => {
const modelInfo: ModelInfo = {
supportsPromptCache: false,
@@ -12,7 +12,7 @@ describe("Cost Utilities", () => {
outputPrice: 15.0, // $15 per million tokens
}
const cost = calculateApiCost(modelInfo, 1000, 500)
const cost = calculateApiCostAnthropic(modelInfo, 1000, 500)
// Input: (3.0 / 1_000_000) * 1000 = 0.003
// Output: (15.0 / 1_000_000) * 500 = 0.0075
// Total: 0.003 + 0.0075 = 0.0105
@@ -25,7 +25,7 @@ describe("Cost Utilities", () => {
// No prices specified
}
const cost = calculateApiCost(modelInfo, 1000, 500)
const cost = calculateApiCostAnthropic(modelInfo, 1000, 500)
cost.should.equal(0)
})
@@ -42,7 +42,7 @@ describe("Cost Utilities", () => {
cacheReadsPrice: 0.3,
}
const cost = calculateApiCost(modelInfo, 2000, 1000, 1500, 500)
const cost = calculateApiCostAnthropic(modelInfo, 2000, 1000, 1500, 500)
// Cache writes: (3.75 / 1_000_000) * 1500 = 0.005625
// Cache reads: (0.3 / 1_000_000) * 500 = 0.00015
// Input: (3.0 / 1_000_000) * 2000 = 0.006
@@ -60,7 +60,68 @@ describe("Cost Utilities", () => {
cacheReadsPrice: 0.3,
}
const cost = calculateApiCost(modelInfo, 0, 0, 0, 0)
const cost = calculateApiCostAnthropic(modelInfo, 0, 0, 0, 0)
cost.should.equal(0)
})
})
describe("calculateApiCostOpenAI", () => {
it("should calculate basic input/output costs", () => {
const modelInfo: ModelInfo = {
supportsPromptCache: false,
inputPrice: 3.0, // $3 per million tokens
outputPrice: 15.0, // $15 per million tokens
}
const cost = calculateApiCostOpenAI(modelInfo, 1000, 500)
// Input: (3.0 / 1_000_000) * 1000 = 0.003
// Output: (15.0 / 1_000_000) * 500 = 0.0075
// Total: 0.003 + 0.0075 = 0.0105
cost.should.equal(0.0105)
})
it("should handle missing prices", () => {
const modelInfo: ModelInfo = {
supportsPromptCache: true,
// No prices specified
}
const cost = calculateApiCostOpenAI(modelInfo, 1000, 500)
cost.should.equal(0)
})
it("should use real model configuration (Claude 3.5 Sonnet)", () => {
const modelInfo: ModelInfo = {
maxTokens: 8192,
contextWindow: 200_000,
supportsImages: true,
supportsComputerUse: true,
supportsPromptCache: true,
inputPrice: 3.0,
outputPrice: 15.0,
cacheWritesPrice: 3.75,
cacheReadsPrice: 0.3,
}
const cost = calculateApiCostOpenAI(modelInfo, 2100, 1000, 1500, 500)
// Cache writes: (3.75 / 1_000_000) * 1500 = 0.005625
// Cache reads: (0.3 / 1_000_000) * 500 = 0.00015
// Input: (3.0 / 1_000_000) * (2100 - 1500 - 500) = 0.0003
// Output: (15.0 / 1_000_000) * 1000 = 0.015
// Total: 0.005625 + 0.00015 + 0.0003 + 0.015 = 0.021075
cost.should.equal(0.021075)
})
it("should handle zero token counts", () => {
const modelInfo: ModelInfo = {
supportsPromptCache: true,
inputPrice: 3.0,
outputPrice: 15.0,
cacheWritesPrice: 3.75,
cacheReadsPrice: 0.3,
}
const cost = calculateApiCostOpenAI(modelInfo, 0, 0, 0, 0)
cost.should.equal(0)
})
})
+40 -15
View File
@@ -1,24 +1,49 @@
import { ModelInfo } from "../shared/api"
export function calculateApiCost(
function calculateApiCostInternal(
modelInfo: ModelInfo,
inputTokens: number,
outputTokens: number,
cacheCreationInputTokens: number,
cacheReadInputTokens: number,
): number {
const cacheWritesCost = ((modelInfo.cacheWritesPrice || 0) / 1_000_000) * cacheCreationInputTokens
const cacheReadsCost = ((modelInfo.cacheReadsPrice || 0) / 1_000_000) * cacheReadInputTokens
const baseInputCost = ((modelInfo.inputPrice || 0) / 1_000_000) * inputTokens
const outputCost = ((modelInfo.outputPrice || 0) / 1_000_000) * outputTokens
const totalCost = cacheWritesCost + cacheReadsCost + baseInputCost + outputCost
return totalCost
}
// For Anthropic compliant usage, the input tokens count does NOT include the cached tokens
export function calculateApiCostAnthropic(
modelInfo: ModelInfo,
inputTokens: number,
outputTokens: number,
cacheCreationInputTokens?: number,
cacheReadInputTokens?: number,
): number {
const modelCacheWritesPrice = modelInfo.cacheWritesPrice
let cacheWritesCost = 0
if (cacheCreationInputTokens && modelCacheWritesPrice) {
cacheWritesCost = (modelCacheWritesPrice / 1_000_000) * cacheCreationInputTokens
}
const modelCacheReadsPrice = modelInfo.cacheReadsPrice
let cacheReadsCost = 0
if (cacheReadInputTokens && modelCacheReadsPrice) {
cacheReadsCost = (modelCacheReadsPrice / 1_000_000) * cacheReadInputTokens
}
const baseInputCost = ((modelInfo.inputPrice || 0) / 1_000_000) * inputTokens
const outputCost = ((modelInfo.outputPrice || 0) / 1_000_000) * outputTokens
const totalCost = cacheWritesCost + cacheReadsCost + baseInputCost + outputCost
return totalCost
const cacheCreationInputTokensNum = cacheCreationInputTokens || 0
const cacheReadInputTokensNum = cacheReadInputTokens || 0
return calculateApiCostInternal(modelInfo, inputTokens, outputTokens, cacheCreationInputTokensNum, cacheReadInputTokensNum)
}
// For OpenAI compliant usage, the input tokens count INCLUDES the cached tokens
export function calculateApiCostOpenAI(
modelInfo: ModelInfo,
inputTokens: number,
outputTokens: number,
cacheCreationInputTokens?: number,
cacheReadInputTokens?: number,
): number {
const cacheCreationInputTokensNum = cacheCreationInputTokens || 0
const cacheReadInputTokensNum = cacheReadInputTokens || 0
const nonCachedInputTokens = Math.max(0, inputTokens - cacheCreationInputTokensNum - cacheReadInputTokensNum)
return calculateApiCostInternal(
modelInfo,
nonCachedInputTokens,
outputTokens,
cacheCreationInputTokensNum,
cacheReadInputTokensNum,
)
}
+69 -1
View File
@@ -17,6 +17,7 @@
"fuse.js": "^7.0.0",
"fzf": "^0.5.2",
"mermaid": "^11.4.1",
"posthog-js": "^1.224.0",
"pretty-bytes": "^6.1.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
@@ -1468,6 +1469,16 @@
"win32"
]
},
"node_modules/@rrweb/types": {
"version": "2.0.0-alpha.17",
"resolved": "https://registry.npmjs.org/@rrweb/types/-/types-2.0.0-alpha.17.tgz",
"integrity": "sha512-AfDTVUuCyCaIG0lTSqYtrZqJX39ZEYzs4fYKnexhQ+id+kbZIpIJtaut5cto6dWZbB3SEe4fW0o90Po3LvTmfg==",
"license": "MIT",
"peer": true,
"dependencies": {
"rrweb-snapshot": "^2.0.0-alpha.17"
}
},
"node_modules/@sinclair/typebox": {
"version": "0.27.8",
"resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz",
@@ -3024,6 +3035,17 @@
"toggle-selection": "^1.0.6"
}
},
"node_modules/core-js": {
"version": "3.40.0",
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.40.0.tgz",
"integrity": "sha512-7vsMc/Lty6AGnn7uFpYT56QesI5D2Y/UkgKounk87OP9Z2H9Z8kj6jzcSGAxFmUtDOS0ntK6lbQz+Nsa0Jj6mQ==",
"hasInstallScript": true,
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/core-js"
}
},
"node_modules/cose-base": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/cose-base/-/cose-base-1.0.3.tgz",
@@ -4201,6 +4223,12 @@
"reusify": "^1.0.4"
}
},
"node_modules/fflate": {
"version": "0.4.8",
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.4.8.tgz",
"integrity": "sha512-FJqqoDBR00Mdj9ppamLa/Y7vxm+PRmNWA67N846RvsoYVMKB4q3y/de5PA7gUmRMYK/8CMz2GDZQmCRN1wBcWA==",
"license": "MIT"
},
"node_modules/file-entry-cache": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
@@ -5799,7 +5827,6 @@
"version": "8.5.3",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.3.tgz",
"integrity": "sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==",
"dev": true,
"funding": [
{
"type": "opencollective",
@@ -5830,6 +5857,37 @@
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
"license": "MIT"
},
"node_modules/posthog-js": {
"version": "1.224.0",
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.224.0.tgz",
"integrity": "sha512-JT1XQQeYs0CKb4lU2vujmeLTDLWc61I5lT7d6oG/H/cnCpXAqBi5rMuCFFeotHeMy3hqJ/Tpu3eAPFE2p5ErHA==",
"license": "MIT",
"dependencies": {
"core-js": "^3.38.1",
"fflate": "^0.4.8",
"preact": "^10.19.3",
"web-vitals": "^4.2.0"
},
"peerDependencies": {
"@rrweb/types": "2.0.0-alpha.17"
}
},
"node_modules/posthog-js/node_modules/web-vitals": {
"version": "4.2.4",
"resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-4.2.4.tgz",
"integrity": "sha512-r4DIlprAGwJ7YM11VZp4R884m0Vmgr6EAKe3P+kO0PPj3Unqyvv59rczf6UiGcb9Z8QxZVcqKNwv/g0WNdWwsw==",
"license": "Apache-2.0"
},
"node_modules/preact": {
"version": "10.26.2",
"resolved": "https://registry.npmjs.org/preact/-/preact-10.26.2.tgz",
"integrity": "sha512-0gNmv4qpS9HaN3+40CLBAnKe0ZfyE4ZWo5xKlC1rVrr0ckkEvJvAQqKaHANdFKsGstoxrY4AItZ7kZSGVoVjgg==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/preact"
}
},
"node_modules/prelude-ls": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
@@ -6214,6 +6272,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/rrweb-snapshot": {
"version": "2.0.0-alpha.18",
"resolved": "https://registry.npmjs.org/rrweb-snapshot/-/rrweb-snapshot-2.0.0-alpha.18.tgz",
"integrity": "sha512-hBHZL/NfgQX6wO1D9mpwqFu1NJPpim+moIcKhFEjVTZVRUfCln+LOugRc4teVTCISYHN8Cw5e2iNTWCSm+SkoA==",
"license": "MIT",
"peer": true,
"dependencies": {
"postcss": "^8.4.38"
}
},
"node_modules/rtl-css-js": {
"version": "1.16.1",
"resolved": "https://registry.npmjs.org/rtl-css-js/-/rtl-css-js-1.16.1.tgz",
+1
View File
@@ -21,6 +21,7 @@
"fuse.js": "^7.0.0",
"fzf": "^0.5.2",
"mermaid": "^11.4.1",
"posthog-js": "^1.224.0",
"pretty-bytes": "^6.1.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
+11 -1
View File
@@ -9,9 +9,10 @@ import AccountView from "./components/account/AccountView"
import { ExtensionStateContextProvider, useExtensionState } from "./context/ExtensionStateContext"
import { vscode } from "./utils/vscode"
import McpView from "./components/mcp/McpView"
import posthog from "posthog-js"
const AppContent = () => {
const { didHydrateState, showWelcome, shouldShowAnnouncement } = useExtensionState()
const { didHydrateState, showWelcome, shouldShowAnnouncement, telemetrySetting, vscMachineId } = useExtensionState()
const [showSettings, setShowSettings] = useState(false)
const [showHistory, setShowHistory] = useState(false)
const [showMcp, setShowMcp] = useState(false)
@@ -60,6 +61,15 @@ const AppContent = () => {
useEvent("message", handleMessage)
useEffect(() => {
if (telemetrySetting === "enabled") {
posthog.identify(vscMachineId)
posthog.opt_in_capturing()
} else {
posthog.opt_out_capturing()
}
}, [telemetrySetting, vscMachineId])
useEffect(() => {
if (shouldShowAnnouncement) {
setShowAnnouncement(true)
@@ -214,7 +214,7 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
},
ref,
) => {
const { filePaths, chatSettings, apiConfiguration, openRouterModels, platform } = useExtensionState()
const { filePaths, chatSettings, apiConfiguration, openRouterModels, requestyModels, platform } = useExtensionState()
const [isTextAreaFocused, setIsTextAreaFocused] = useState(false)
const [gitCommits, setGitCommits] = useState<any[]>([])
@@ -635,14 +635,14 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
// Separate the API config submission logic
const submitApiConfig = useCallback(() => {
const apiValidationResult = validateApiConfiguration(apiConfiguration)
const modelIdValidationResult = validateModelId(apiConfiguration, openRouterModels)
const modelIdValidationResult = validateModelId(apiConfiguration, openRouterModels, requestyModels)
if (!apiValidationResult && !modelIdValidationResult) {
vscode.postMessage({ type: "apiConfiguration", apiConfiguration })
} else {
vscode.postMessage({ type: "getLatestState" })
}
}, [apiConfiguration, openRouterModels])
}, [apiConfiguration, openRouterModels, requestyModels])
const onModeToggle = useCallback(() => {
// if (textAreaDisabled) return
@@ -742,9 +742,6 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
const unknownModel = "unknown"
if (!apiConfiguration) return unknownModel
switch (selectedProvider) {
case "anthropic":
case "openrouter":
return `${selectedProvider}:${selectedModelId}`
case "openai":
return `openai-compat:${selectedModelId}`
case "vscode-lm":
@@ -758,7 +755,8 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
case "litellm":
return `${selectedProvider}:${apiConfiguration.liteLlmModelId}`
case "requesty":
return `${selectedProvider}:${apiConfiguration.requestyModelId}`
case "anthropic":
case "openrouter":
default:
return `${selectedProvider}:${selectedModelId}`
}
+6 -2
View File
@@ -1,4 +1,4 @@
import { VSCodeButton, VSCodeLink } from "@vscode/webview-ui-toolkit/react"
import { VSCodeButton } from "@vscode/webview-ui-toolkit/react"
import debounce from "debounce"
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import { useDeepCompareEffect, useEvent, useMount } from "react-use"
@@ -26,6 +26,7 @@ import BrowserSessionRow from "./BrowserSessionRow"
import ChatRow from "./ChatRow"
import ChatTextArea from "./ChatTextArea"
import TaskHeader from "./TaskHeader"
import TelemetryBanner from "../common/TelemetryBanner"
interface ChatViewProps {
isHidden: boolean
@@ -37,7 +38,7 @@ interface ChatViewProps {
export const MAX_IMAGES_PER_MESSAGE = 20 // Anthropic limits to 20 images
const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryView }: ChatViewProps) => {
const { version, clineMessages: messages, taskHistory, apiConfiguration } = useExtensionState()
const { version, clineMessages: messages, taskHistory, apiConfiguration, telemetrySetting } = useExtensionState()
//const task = messages.length > 0 ? (messages[0].say === "task" ? messages[0] : undefined) : undefined) : undefined
const task = useMemo(() => messages.at(0), [messages]) // leaving this less safe version here since if the first message is not a task, then the extension is in a bad state and needs to be debugged (see Cline.abort)
@@ -789,7 +790,10 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
flexDirection: "column",
paddingBottom: "10px",
}}>
{telemetrySetting === "unset" && <TelemetryBanner />}
{showAnnouncement && <Announcement version={version} hideAnnouncement={hideAnnouncement} />}
<div style={{ padding: "0 20px", flexShrink: 0 }}>
<h2>What can I do for you?</h2>
<p>
@@ -0,0 +1,72 @@
import { VSCodeButton, VSCodeLink } from "@vscode/webview-ui-toolkit/react"
import { memo, useState } from "react"
import styled from "styled-components"
import { vscode } from "../../utils/vscode"
import { TelemetrySetting } from "../../../../src/shared/TelemetrySetting"
const BannerContainer = styled.div`
background-color: var(--vscode-banner-background);
padding: 12px 20px;
display: flex;
flex-direction: column;
gap: 10px;
flex-shrink: 0;
margin-bottom: 6px;
`
const ButtonContainer = styled.div`
display: flex;
gap: 8px;
width: 100%;
& > vscode-button {
flex: 1;
}
`
const TelemetryBanner = () => {
const [hasChosen, setHasChosen] = useState(false)
const handleAllow = () => {
setHasChosen(true)
vscode.postMessage({ type: "telemetrySetting", text: "enabled" satisfies TelemetrySetting })
}
const handleDeny = () => {
setHasChosen(true)
vscode.postMessage({ type: "telemetrySetting", text: "disabled" satisfies TelemetrySetting })
}
const handleOpenSettings = () => {
vscode.postMessage({ type: "openSettings" })
}
return (
<BannerContainer>
<div>
<strong>Help Improve Cline</strong>
<div style={{ marginTop: 4 }}>
Send anonymous error and usage data to help us fix bugs and improve the extension. No code, prompts, or
personal information is ever sent.
<div style={{ marginTop: 4 }}>
You can always change this in{" "}
<VSCodeLink href="#" onClick={handleOpenSettings}>
settings
</VSCodeLink>
.
</div>
</div>
</div>
<ButtonContainer>
<VSCodeButton appearance="primary" onClick={handleAllow} disabled={hasChosen}>
Allow
</VSCodeButton>
<VSCodeButton appearance="secondary" onClick={handleDeny} disabled={hasChosen}>
Deny
</VSCodeButton>
</ButtonContainer>
</BannerContainer>
)
}
export default memo(TelemetryBanner)
@@ -92,6 +92,7 @@ const HistoryPreview = ({ showHistoryView }: HistoryPreviewProps) => {
</span>
</div>
<div
className="ph-no-capture"
style={{
fontSize: "var(--vscode-font-size)",
color: "var(--vscode-descriptionForeground)",
@@ -223,8 +223,16 @@ const McpMarketplaceCard = ({ item, installedServers }: McpMarketplaceCardProps)
display: "flex",
gap: "6px",
flexWrap: "nowrap",
overflow: "hidden",
overflowX: "auto",
scrollbarWidth: "none",
position: "relative",
}}
onScroll={(e) => {
const target = e.currentTarget
const gradient = target.querySelector(".tags-gradient") as HTMLElement
if (gradient) {
gradient.style.visibility = target.scrollLeft > 0 ? "hidden" : "visible"
}
}}>
<span
style={{
@@ -254,6 +262,7 @@ const McpMarketplaceCard = ({ item, installedServers }: McpMarketplaceCardProps)
</span>
))}
<div
className="tags-gradient"
style={{
position: "absolute",
right: 0,
@@ -31,14 +31,20 @@ import {
openAiNativeModels,
openRouterDefaultModelId,
openRouterDefaultModelInfo,
requestyDefaultModelId,
requestyDefaultModelInfo,
vertexDefaultModelId,
vertexModels,
xaiDefaultModelId,
xaiModels,
} from "../../../../src/shared/api"
import { ExtensionMessage } from "../../../../src/shared/ExtensionMessage"
import { useExtensionState } from "../../context/ExtensionStateContext"
import { vscode } from "../../utils/vscode"
import VSCodeButtonLink from "../common/VSCodeButtonLink"
import OpenRouterModelPicker, { ModelDescriptionMarkdown } from "./OpenRouterModelPicker"
import OpenRouterModelPicker from "./OpenRouterModelPicker"
import RequestyModelPicker from "./RequestyModelPicker"
import ModelDescriptionMarkdown from "./ModelDescriptionMarkdown"
import styled from "styled-components"
import * as vscodemodels from "vscode"
import { getAsVar, VSC_DESCRIPTION_FOREGROUND } from "../../utils/vscStyles"
@@ -51,7 +57,7 @@ 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 = 1001 // Higher than the Requesty/OpenRouterModelPicker's and ModelSelectorTooltip's z-index
const DropdownContainer = styled.div<{ zIndex?: number }>`
position: relative;
@@ -195,6 +201,7 @@ const ApiOptions = ({ showModelOptions, apiErrorMessage, modelIdErrorMessage, is
<VSCodeOption value="lmstudio">LM Studio</VSCodeOption>
<VSCodeOption value="ollama">Ollama</VSCodeOption>
<VSCodeOption value="litellm">LiteLLM</VSCodeOption>
<VSCodeOption value="xai">X AI</VSCodeOption>
</VSCodeDropdown>
</DropdownContainer>
@@ -721,7 +728,7 @@ const ApiOptions = ({ showModelOptions, apiErrorMessage, modelIdErrorMessage, is
{modelConfigurationSelected && (
<>
<VSCodeCheckbox
checked={apiConfiguration?.openAiModelInfo?.supportsImages}
checked={!!apiConfiguration?.openAiModelInfo?.supportsImages}
onChange={(e: any) => {
const isChecked = e.target.checked === true
let modelInfo = apiConfiguration?.openAiModelInfo
@@ -841,24 +848,7 @@ const ApiOptions = ({ showModelOptions, apiErrorMessage, modelIdErrorMessage, is
placeholder="Enter API Key...">
<span style={{ fontWeight: 500 }}>API Key</span>
</VSCodeTextField>
<VSCodeTextField
value={apiConfiguration?.requestyModelId || ""}
style={{ width: "100%" }}
onInput={handleInputChange("requestyModelId")}
placeholder={"Enter Model ID..."}>
<span style={{ fontWeight: 500 }}>Model ID</span>
</VSCodeTextField>
<p
style={{
fontSize: "12px",
marginTop: 3,
color: "var(--vscode-descriptionForeground)",
}}>
<span style={{ color: "var(--vscode-errorForeground)" }}>
(<span style={{ fontWeight: 500 }}>Note:</span> Cline uses complex prompts and works best with Claude
models. Less capable models may not work as expected.)
</span>
</p>
{!apiConfiguration?.requestyApiKey && <a href="https://app.requesty.ai/manage-api">Get API Key</a>}
</div>
)}
@@ -1122,6 +1112,46 @@ const ApiOptions = ({ showModelOptions, apiErrorMessage, modelIdErrorMessage, is
</div>
)}
{selectedProvider === "xai" && (
<div>
<VSCodeTextField
value={apiConfiguration?.xaiApiKey || ""}
style={{ width: "100%" }}
type="password"
onInput={handleInputChange("xaiApiKey")}
placeholder="Enter API Key...">
<span style={{ fontWeight: 500 }}>X AI API Key</span>
</VSCodeTextField>
<p
style={{
fontSize: "12px",
marginTop: 3,
color: "var(--vscode-descriptionForeground)",
}}>
This key is stored locally and only used to make API requests from this extension.
{!apiConfiguration?.xaiApiKey && (
<VSCodeLink href="https://x.ai" style={{ display: "inline", fontSize: "inherit" }}>
You can get an X AI API key by signing up here.
</VSCodeLink>
)}
</p>
{/* Note: To fully implement this, you would need to add a handler in ClineProvider.ts */}
{/* {apiConfiguration?.xaiApiKey && (
<button
onClick={() => {
vscode.postMessage({
type: "requestXAIModels",
text: apiConfiguration?.xaiApiKey,
})
}}
style={{ margin: "5px 0 0 0" }}
className="vscode-button">
Fetch Available Models
</button>
)} */}
</div>
)}
{apiErrorMessage && (
<p
style={{
@@ -1134,6 +1164,7 @@ const ApiOptions = ({ showModelOptions, apiErrorMessage, modelIdErrorMessage, is
)}
{selectedProvider !== "openrouter" &&
selectedProvider !== "requesty" &&
selectedProvider !== "openai" &&
selectedProvider !== "ollama" &&
selectedProvider !== "lmstudio" &&
@@ -1152,6 +1183,7 @@ const ApiOptions = ({ showModelOptions, apiErrorMessage, modelIdErrorMessage, is
{selectedProvider === "deepseek" && createDropdown(deepSeekModels)}
{selectedProvider === "qwen" && createDropdown(qwenModels)}
{selectedProvider === "mistral" && createDropdown(mistralModels)}
{selectedProvider === "xai" && createDropdown(xaiModels)}
</DropdownContainer>
<ModelInfoView
@@ -1165,6 +1197,7 @@ const ApiOptions = ({ showModelOptions, apiErrorMessage, modelIdErrorMessage, is
)}
{selectedProvider === "openrouter" && showModelOptions && <OpenRouterModelPicker isPopup={isPopup} />}
{selectedProvider === "requesty" && showModelOptions && <RequestyModelPicker isPopup={isPopup} />}
{modelIdErrorMessage && (
<p
@@ -1368,6 +1401,12 @@ export function normalizeApiConfiguration(apiConfiguration?: ApiConfiguration):
selectedModelId: apiConfiguration?.openRouterModelId || openRouterDefaultModelId,
selectedModelInfo: apiConfiguration?.openRouterModelInfo || openRouterDefaultModelInfo,
}
case "requesty":
return {
selectedProvider: provider,
selectedModelId: apiConfiguration?.requestyModelId || requestyDefaultModelId,
selectedModelInfo: apiConfiguration?.requestyModelInfo || requestyDefaultModelInfo,
}
case "openai":
return {
selectedProvider: provider,
@@ -1403,6 +1442,8 @@ export function normalizeApiConfiguration(apiConfiguration?: ApiConfiguration):
selectedModelId: apiConfiguration?.liteLlmModelId || "",
selectedModelInfo: openAiModelInfoSaneDefaults,
}
case "xai":
return getProviderData(xaiModels, xaiDefaultModelId)
default:
return getProviderData(anthropicModels, anthropicDefaultModelId)
}
@@ -0,0 +1,138 @@
import { VSCodeLink } from "@vscode/webview-ui-toolkit/react"
import { memo, useEffect, useRef, useState } from "react"
import { useRemark } from "react-remark"
import styled from "styled-components"
import { CODE_BLOCK_BG_COLOR } from "../common/CodeBlock"
const StyledMarkdown = styled.div`
font-family:
var(--vscode-font-family),
system-ui,
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
Roboto,
Oxygen,
Ubuntu,
Cantarell,
"Open Sans",
"Helvetica Neue",
sans-serif;
font-size: 12px;
color: var(--vscode-descriptionForeground);
p,
li,
ol,
ul {
line-height: 1.25;
margin: 0;
}
ol,
ul {
padding-left: 1.5em;
margin-left: 0;
}
p {
white-space: pre-wrap;
}
a {
text-decoration: none;
}
a {
&:hover {
text-decoration: underline;
}
}
`
export const ModelDescriptionMarkdown = memo(
({
markdown,
key,
isExpanded,
setIsExpanded,
isPopup,
}: {
markdown?: string
key: string
isExpanded: boolean
setIsExpanded: (isExpanded: boolean) => void
isPopup?: boolean
}) => {
const [reactContent, setMarkdown] = useRemark()
const [showSeeMore, setShowSeeMore] = useState(false)
const textContainerRef = useRef<HTMLDivElement>(null)
const textRef = useRef<HTMLDivElement>(null)
useEffect(() => {
setMarkdown(markdown || "")
}, [markdown, setMarkdown])
useEffect(() => {
if (textRef.current && textContainerRef.current) {
const { scrollHeight } = textRef.current
const { clientHeight } = textContainerRef.current
const isOverflowing = scrollHeight > clientHeight
setShowSeeMore(isOverflowing)
}
}, [reactContent, setIsExpanded])
return (
<StyledMarkdown key={key} style={{ display: "inline-block", marginBottom: 0 }}>
<div
ref={textContainerRef}
style={{
overflowY: isExpanded ? "auto" : "hidden",
position: "relative",
wordBreak: "break-word",
overflowWrap: "anywhere",
}}>
<div
ref={textRef}
style={{
display: "-webkit-box",
WebkitLineClamp: isExpanded ? "unset" : 3,
WebkitBoxOrient: "vertical",
overflow: "hidden",
}}>
{reactContent}
</div>
{!isExpanded && showSeeMore && (
<div
style={{
position: "absolute",
right: 0,
bottom: 0,
display: "flex",
alignItems: "center",
}}>
<div
style={{
width: 30,
height: "1.2em",
background: "linear-gradient(to right, transparent, var(--vscode-sideBar-background))",
}}
/>
<VSCodeLink
style={{
fontSize: "inherit",
paddingRight: 0,
paddingLeft: 3,
backgroundColor: isPopup ? CODE_BLOCK_BG_COLOR : "var(--vscode-sideBar-background)",
}}
onClick={() => setIsExpanded(true)}>
See more
</VSCodeLink>
</div>
)}
</div>
</StyledMarkdown>
)
},
)
export default ModelDescriptionMarkdown
@@ -1,7 +1,6 @@
import { VSCodeLink, 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"
import React, { KeyboardEvent, useEffect, useMemo, useRef, useState } from "react"
import { useMount } from "react-use"
import styled from "styled-components"
import { openRouterDefaultModelId } from "../../../../src/shared/api"
@@ -9,7 +8,6 @@ import { useExtensionState } from "../../context/ExtensionStateContext"
import { vscode } from "../../utils/vscode"
import { highlight } from "../history/HistoryView"
import { ModelInfoView, normalizeApiConfiguration } from "./ApiOptions"
import { CODE_BLOCK_BG_COLOR } from "../common/CodeBlock"
export interface OpenRouterModelPickerProps {
isPopup?: boolean
@@ -276,158 +274,3 @@ const DropdownItem = styled.div<{ isSelected: boolean }>`
background-color: var(--vscode-list-activeSelectionBackground);
}
`
// Markdown
const StyledMarkdown = styled.div`
font-family:
var(--vscode-font-family),
system-ui,
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
Roboto,
Oxygen,
Ubuntu,
Cantarell,
"Open Sans",
"Helvetica Neue",
sans-serif;
font-size: 12px;
color: var(--vscode-descriptionForeground);
p,
li,
ol,
ul {
line-height: 1.25;
margin: 0;
}
ol,
ul {
padding-left: 1.5em;
margin-left: 0;
}
p {
white-space: pre-wrap;
}
a {
text-decoration: none;
}
a {
&:hover {
text-decoration: underline;
}
}
`
export const ModelDescriptionMarkdown = memo(
({
markdown,
key,
isExpanded,
setIsExpanded,
isPopup,
}: {
markdown?: string
key: string
isExpanded: boolean
setIsExpanded: (isExpanded: boolean) => void
isPopup?: boolean
}) => {
const [reactContent, setMarkdown] = useRemark()
// const [isExpanded, setIsExpanded] = useState(false)
const [showSeeMore, setShowSeeMore] = useState(false)
const textContainerRef = useRef<HTMLDivElement>(null)
const textRef = useRef<HTMLDivElement>(null)
useEffect(() => {
setMarkdown(markdown || "")
}, [markdown, setMarkdown])
useEffect(() => {
if (textRef.current && textContainerRef.current) {
const { scrollHeight } = textRef.current
const { clientHeight } = textContainerRef.current
const isOverflowing = scrollHeight > clientHeight
setShowSeeMore(isOverflowing)
// if (!isOverflowing) {
// setIsExpanded(false)
// }
}
}, [reactContent, setIsExpanded])
return (
<StyledMarkdown key={key} style={{ display: "inline-block", marginBottom: 0 }}>
<div
ref={textContainerRef}
style={{
overflowY: isExpanded ? "auto" : "hidden",
position: "relative",
wordBreak: "break-word",
overflowWrap: "anywhere",
}}>
<div
ref={textRef}
style={{
display: "-webkit-box",
WebkitLineClamp: isExpanded ? "unset" : 3,
WebkitBoxOrient: "vertical",
overflow: "hidden",
// whiteSpace: "pre-wrap",
// wordBreak: "break-word",
// overflowWrap: "anywhere",
}}>
{reactContent}
</div>
{!isExpanded && showSeeMore && (
<div
style={{
position: "absolute",
right: 0,
bottom: 0,
display: "flex",
alignItems: "center",
}}>
<div
style={{
width: 30,
height: "1.2em",
background: "linear-gradient(to right, transparent, var(--vscode-sideBar-background))",
}}
/>
<VSCodeLink
style={{
// cursor: "pointer",
// color: "var(--vscode-textLink-foreground)",
fontSize: "inherit",
paddingRight: 0,
paddingLeft: 3,
backgroundColor: isPopup ? CODE_BLOCK_BG_COLOR : "var(--vscode-sideBar-background)",
}}
onClick={() => setIsExpanded(true)}>
See more
</VSCodeLink>
</div>
)}
</div>
{/* {isExpanded && showSeeMore && (
<div
style={{
cursor: "pointer",
color: "var(--vscode-textLink-foreground)",
marginLeft: "auto",
textAlign: "right",
paddingRight: 2,
}}
onClick={() => setIsExpanded(false)}>
See less
</div>
)} */}
</StyledMarkdown>
)
},
)
@@ -0,0 +1,274 @@
import { VSCodeLink, VSCodeTextField } from "@vscode/webview-ui-toolkit/react"
import Fuse from "fuse.js"
import React, { KeyboardEvent, useEffect, useMemo, useRef, useState } from "react"
import { useMount } from "react-use"
import styled from "styled-components"
import { requestyDefaultModelId } from "../../../../src/shared/api"
import { useExtensionState } from "../../context/ExtensionStateContext"
import { vscode } from "../../utils/vscode"
import { highlight } from "../history/HistoryView"
import { ModelInfoView, normalizeApiConfiguration } from "./ApiOptions"
export interface RequestyModelPickerProps {
isPopup?: boolean
}
const RequestyModelPicker: React.FC<RequestyModelPickerProps> = ({ isPopup }) => {
const { apiConfiguration, setApiConfiguration, requestyModels } = useExtensionState()
const [searchTerm, setSearchTerm] = useState(apiConfiguration?.requestyModelId || requestyDefaultModelId)
const [isDropdownVisible, setIsDropdownVisible] = useState(false)
const [selectedIndex, setSelectedIndex] = useState(-1)
const dropdownRef = useRef<HTMLDivElement>(null)
const itemRefs = useRef<(HTMLDivElement | null)[]>([])
const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false)
const dropdownListRef = useRef<HTMLDivElement>(null)
const handleModelChange = (newModelId: string) => {
// could be setting invalid model id/undefined info but validation will catch it
setApiConfiguration({
...apiConfiguration,
...{
requestyModelId: newModelId,
requestyModelInfo: requestyModels[newModelId],
},
})
setSearchTerm(newModelId)
}
const { selectedModelId, selectedModelInfo } = useMemo(() => {
return normalizeApiConfiguration(apiConfiguration)
}, [apiConfiguration])
useMount(() => {
vscode.postMessage({ type: "refreshRequestyModels" })
})
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsDropdownVisible(false)
}
}
document.addEventListener("mousedown", handleClickOutside)
return () => {
document.removeEventListener("mousedown", handleClickOutside)
}
}, [])
const modelIds = useMemo(() => {
return Object.keys(requestyModels).sort((a, b) => a.localeCompare(b))
}, [requestyModels])
const searchableItems = useMemo(() => {
return modelIds.map((id) => ({
id,
html: id,
}))
}, [modelIds])
const fuse = useMemo(() => {
return new Fuse(searchableItems, {
keys: ["html"], // highlight function will update this
threshold: 0.6,
shouldSort: true,
isCaseSensitive: false,
ignoreLocation: false,
includeMatches: true,
minMatchCharLength: 1,
})
}, [searchableItems])
const modelSearchResults = useMemo(() => {
let results: { id: string; html: string }[] = searchTerm
? highlight(fuse.search(searchTerm), "model-item-highlight")
: searchableItems
return results
}, [searchableItems, searchTerm, fuse])
const handleKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
if (!isDropdownVisible) return
switch (event.key) {
case "ArrowDown":
event.preventDefault()
setSelectedIndex((prev) => (prev < modelSearchResults.length - 1 ? prev + 1 : prev))
break
case "ArrowUp":
event.preventDefault()
setSelectedIndex((prev) => (prev > 0 ? prev - 1 : prev))
break
case "Enter":
event.preventDefault()
if (selectedIndex >= 0 && selectedIndex < modelSearchResults.length) {
handleModelChange(modelSearchResults[selectedIndex].id)
setIsDropdownVisible(false)
}
break
case "Escape":
setIsDropdownVisible(false)
setSelectedIndex(-1)
break
}
}
const hasInfo = useMemo(() => {
return modelIds.some((id) => id.toLowerCase() === searchTerm.toLowerCase())
}, [modelIds, searchTerm])
useEffect(() => {
setSelectedIndex(-1)
if (dropdownListRef.current) {
dropdownListRef.current.scrollTop = 0
}
}, [searchTerm])
useEffect(() => {
if (selectedIndex >= 0 && itemRefs.current[selectedIndex]) {
itemRefs.current[selectedIndex]?.scrollIntoView({
block: "nearest",
behavior: "smooth",
})
}
}, [selectedIndex])
return (
<div style={{ width: "100%" }}>
<style>
{`
.model-item-highlight {
background-color: var(--vscode-editor-findMatchHighlightBackground);
color: inherit;
}
`}
</style>
<div style={{ display: "flex", flexDirection: "column" }}>
<label htmlFor="model-search">
<span style={{ fontWeight: 500 }}>Model</span>
</label>
<DropdownWrapper ref={dropdownRef}>
<VSCodeTextField
id="model-search"
placeholder="Search and select a model..."
value={searchTerm}
onInput={(e) => {
handleModelChange((e.target as HTMLInputElement)?.value?.toLowerCase())
setIsDropdownVisible(true)
}}
onFocus={() => setIsDropdownVisible(true)}
onKeyDown={handleKeyDown}
style={{
width: "100%",
zIndex: REQUESTY_MODEL_PICKER_Z_INDEX,
position: "relative",
}}>
{searchTerm && (
<div
className="input-icon-button codicon codicon-close"
aria-label="Clear search"
onClick={() => {
handleModelChange("")
setIsDropdownVisible(true)
}}
slot="end"
style={{
display: "flex",
justifyContent: "center",
alignItems: "center",
height: "100%",
}}
/>
)}
</VSCodeTextField>
{isDropdownVisible && (
<DropdownList ref={dropdownListRef}>
{modelSearchResults.map((item, index) => (
<DropdownItem
key={item.id}
ref={(el) => (itemRefs.current[index] = el)}
isSelected={index === selectedIndex}
onMouseEnter={() => setSelectedIndex(index)}
onClick={() => {
handleModelChange(item.id)
setIsDropdownVisible(false)
}}
dangerouslySetInnerHTML={{
__html: item.html,
}}
/>
))}
</DropdownList>
)}
</DropdownWrapper>
</div>
{hasInfo ? (
<ModelInfoView
selectedModelId={selectedModelId}
modelInfo={selectedModelInfo}
isDescriptionExpanded={isDescriptionExpanded}
setIsDescriptionExpanded={setIsDescriptionExpanded}
isPopup={isPopup}
/>
) : (
<p
style={{
fontSize: "12px",
marginTop: 0,
color: "var(--vscode-descriptionForeground)",
}}>
<>
The extension automatically fetches the latest list of models available on{" "}
<VSCodeLink style={{ display: "inline", fontSize: "inherit" }} href="https://app.requesty.ai/router/list">
Requesty.
</VSCodeLink>
If you're unsure which model to choose, Cline works best with{" "}
<VSCodeLink
style={{ display: "inline", fontSize: "inherit" }}
onClick={() => handleModelChange("anthropic/claude-3-5-sonnet-latest")}>
anthropic/claude-3-5-sonnet-latest.
</VSCodeLink>
</>
</p>
)}
</div>
)
}
export default RequestyModelPicker
// Dropdown
const DropdownWrapper = styled.div`
position: relative;
width: 100%;
`
export const REQUESTY_MODEL_PICKER_Z_INDEX = 1_000
const DropdownList = styled.div`
position: absolute;
top: calc(100% - 3px);
left: 0;
width: calc(100% - 2px);
max-height: 200px;
overflow-y: auto;
background-color: var(--vscode-dropdown-background);
border: 1px solid var(--vscode-list-activeSelectionBackground);
z-index: ${REQUESTY_MODEL_PICKER_Z_INDEX - 1};
border-bottom-left-radius: 3px;
border-bottom-right-radius: 3px;
`
const DropdownItem = styled.div<{ isSelected: boolean }>`
padding: 5px 10px;
cursor: pointer;
word-break: break-all;
white-space: normal;
background-color: ${({ isSelected }) => (isSelected ? "var(--vscode-list-activeSelectionBackground)" : "inherit")};
&:hover {
background-color: var(--vscode-list-activeSelectionBackground);
}
`
@@ -1,10 +1,10 @@
import { VSCodeButton, VSCodeLink, VSCodeTextArea } from "@vscode/webview-ui-toolkit/react"
import { VSCodeButton, VSCodeCheckbox, VSCodeLink, VSCodeTextArea } from "@vscode/webview-ui-toolkit/react"
import { memo, useEffect, useState } from "react"
import { useExtensionState } from "../../context/ExtensionStateContext"
import { validateApiConfiguration, validateModelId } from "../../utils/validate"
import { vscode } from "../../utils/vscode"
import ApiOptions from "./ApiOptions"
import SettingsButton from "../common/SettingsButton"
import ApiOptions from "./ApiOptions"
const { IS_DEV } = process.env
type SettingsViewProps = {
@@ -12,13 +12,22 @@ type SettingsViewProps = {
}
const SettingsView = ({ onDone }: SettingsViewProps) => {
const { apiConfiguration, version, customInstructions, setCustomInstructions, openRouterModels } = useExtensionState()
const {
apiConfiguration,
version,
customInstructions,
setCustomInstructions,
openRouterModels,
requestyModels,
telemetrySetting,
setTelemetrySetting,
} = useExtensionState()
const [apiErrorMessage, setApiErrorMessage] = useState<string | undefined>(undefined)
const [modelIdErrorMessage, setModelIdErrorMessage] = useState<string | undefined>(undefined)
const handleSubmit = () => {
const apiValidationResult = validateApiConfiguration(apiConfiguration)
const modelIdValidationResult = validateModelId(apiConfiguration, openRouterModels)
const modelIdValidationResult = validateModelId(apiConfiguration, openRouterModels, requestyModels)
setApiErrorMessage(apiValidationResult)
setModelIdErrorMessage(modelIdValidationResult)
@@ -29,6 +38,10 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
type: "customInstructions",
text: customInstructions,
})
vscode.postMessage({
type: "telemetrySetting",
text: telemetrySetting,
})
onDone()
}
}
@@ -114,6 +127,33 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
</p>
</div>
<div style={{ marginBottom: 5 }}>
<VSCodeCheckbox
style={{ marginBottom: "5px" }}
checked={telemetrySetting === "enabled"}
onChange={(e: any) => {
const checked = e.target.checked === true
setTelemetrySetting(checked ? "enabled" : "disabled")
}}>
Allow anonymous error and usage reporting
</VSCodeCheckbox>
<p
style={{
fontSize: "12px",
marginTop: "5px",
color: "var(--vscode-descriptionForeground)",
}}>
Help improve Cline by sending anonymous usage data and error reports. No code, prompts, or personal
information is ever sent. See our{" "}
<VSCodeLink
href="https://github.com/cline/cline/blob/main/docs/PRIVACY.md"
style={{ fontSize: "inherit" }}>
privacy policy
</VSCodeLink>{" "}
for more details.
</p>
</div>
{IS_DEV && (
<>
<div style={{ marginTop: "10px", marginBottom: "4px" }}>Debug</div>
@@ -126,6 +126,7 @@ describe("OpenApiInfoOptions", () => {
<ApiOptions showModelOptions={true} />
</ExtensionStateContextProvider>,
)
fireEvent.click(screen.getByText("Model Configuration"))
const apiKeyInput = screen.getByText("Supports Images")
expect(apiKeyInput).toBeInTheDocument()
})
@@ -136,6 +137,7 @@ describe("OpenApiInfoOptions", () => {
<ApiOptions showModelOptions={true} />
</ExtensionStateContextProvider>,
)
fireEvent.click(screen.getByText("Model Configuration"))
const orgIdInput = screen.getByText("Context Window Size")
expect(orgIdInput).toBeInTheDocument()
})
@@ -146,6 +148,7 @@ describe("OpenApiInfoOptions", () => {
<ApiOptions showModelOptions={true} />
</ExtensionStateContextProvider>,
)
fireEvent.click(screen.getByText("Model Configuration"))
const modelInput = screen.getByText("Max Output Tokens")
expect(modelInput).toBeInTheDocument()
})
@@ -2,25 +2,35 @@ import React, { createContext, useCallback, useContext, useEffect, useState } fr
import { useEvent } from "react-use"
import { DEFAULT_AUTO_APPROVAL_SETTINGS } from "../../../src/shared/AutoApprovalSettings"
import { ExtensionMessage, ExtensionState, DEFAULT_PLATFORM } from "../../../src/shared/ExtensionMessage"
import { ApiConfiguration, ModelInfo, openRouterDefaultModelId, openRouterDefaultModelInfo } from "../../../src/shared/api"
import {
ApiConfiguration,
ModelInfo,
openRouterDefaultModelId,
openRouterDefaultModelInfo,
requestyDefaultModelId,
requestyDefaultModelInfo,
} from "../../../src/shared/api"
import { findLastIndex } from "../../../src/shared/array"
import { McpMarketplaceCatalog, McpServer } from "../../../src/shared/mcp"
import { convertTextMateToHljs } from "../utils/textMateToHljs"
import { vscode } from "../utils/vscode"
import { DEFAULT_BROWSER_SETTINGS } from "../../../src/shared/BrowserSettings"
import { DEFAULT_CHAT_SETTINGS } from "../../../src/shared/ChatSettings"
import { TelemetrySetting } from "../../../src/shared/TelemetrySetting"
interface ExtensionStateContextType extends ExtensionState {
didHydrateState: boolean
showWelcome: boolean
theme: any
openRouterModels: Record<string, ModelInfo>
requestyModels: Record<string, ModelInfo>
openAiModels: string[]
mcpServers: McpServer[]
mcpMarketplaceCatalog: McpMarketplaceCatalog
filePaths: string[]
setApiConfiguration: (config: ApiConfiguration) => void
setCustomInstructions: (value?: string) => void
setTelemetrySetting: (value: TelemetrySetting) => void
setShowAnnouncement: (value: boolean) => void
}
@@ -39,6 +49,8 @@ export const ExtensionStateContextProvider: React.FC<{
chatSettings: DEFAULT_CHAT_SETTINGS,
isLoggedIn: false,
platform: DEFAULT_PLATFORM,
telemetrySetting: "unset",
vscMachineId: "",
})
const [didHydrateState, setDidHydrateState] = useState(false)
const [showWelcome, setShowWelcome] = useState(false)
@@ -47,6 +59,9 @@ export const ExtensionStateContextProvider: React.FC<{
const [openRouterModels, setOpenRouterModels] = useState<Record<string, ModelInfo>>({
[openRouterDefaultModelId]: openRouterDefaultModelInfo,
})
const [requestyModels, setRequestyModels] = useState<Record<string, ModelInfo>>({
[requestyDefaultModelId]: requestyDefaultModelInfo,
})
const [openAiModels, setOpenAiModels] = useState<string[]>([])
const [mcpServers, setMcpServers] = useState<McpServer[]>([])
@@ -61,6 +76,7 @@ export const ExtensionStateContextProvider: React.FC<{
? [
config.apiKey,
config.openRouterApiKey,
config.requestyApiKey,
config.awsRegion,
config.vertexProjectId,
config.openAiApiKey,
@@ -75,6 +91,7 @@ export const ExtensionStateContextProvider: React.FC<{
config.qwenApiKey,
config.mistralApiKey,
config.vsCodeLmModelSelector,
config.xaiApiKey,
].some((key) => key !== undefined)
: false
setShowWelcome(!hasKey)
@@ -105,6 +122,14 @@ export const ExtensionStateContextProvider: React.FC<{
})
break
}
case "requestyModels": {
const updatedModels = message.requestyModels ?? {}
setRequestyModels({
[requestyDefaultModelId]: requestyDefaultModelInfo, // in case the extension sent a model list without the default model
...updatedModels,
})
break
}
case "openRouterModels": {
const updatedModels = message.openRouterModels ?? {}
setOpenRouterModels({
@@ -143,6 +168,7 @@ export const ExtensionStateContextProvider: React.FC<{
showWelcome,
theme,
openRouterModels,
requestyModels,
openAiModels,
mcpServers,
mcpMarketplaceCatalog,
@@ -157,6 +183,11 @@ export const ExtensionStateContextProvider: React.FC<{
...prevState,
customInstructions: value,
})),
setTelemetrySetting: (value) =>
setState((prevState) => ({
...prevState,
telemetrySetting: value,
})),
setShowAnnouncement: (value) =>
setState((prevState) => ({
...prevState,
+7 -2
View File
@@ -1,12 +1,17 @@
import { StrictMode } from "react"
import { createRoot } from "react-dom/client"
import { PostHogProvider } from "posthog-js/react"
import "./index.css"
import App from "./App.tsx"
import "../../node_modules/@vscode/codicons/dist/codicon.css"
const apiKey = "phc_uY24EJXNBcc9kwO1K8TJUl5hPQntGM6LL1Mtrz0CBD4"
const apiHost = "https://us.i.posthog.com"
createRoot(document.getElementById("root")!).render(
<StrictMode>
<App />
<PostHogProvider apiKey={apiKey} options={{ api_host: apiHost }}>
<App />
</PostHogProvider>
</StrictMode>,
)
+20 -4
View File
@@ -1,4 +1,4 @@
import { ApiConfiguration, openRouterDefaultModelId } from "../../../src/shared/api"
import { ApiConfiguration, openRouterDefaultModelId, requestyDefaultModelId } from "../../../src/shared/api"
import { ModelInfo } from "../../../src/shared/api"
export function validateApiConfiguration(apiConfiguration?: ApiConfiguration): string | undefined {
if (apiConfiguration) {
@@ -38,6 +38,11 @@ export function validateApiConfiguration(apiConfiguration?: ApiConfiguration): s
return "You must provide a valid API key or choose a different provider."
}
break
case "xai":
if (!apiConfiguration.xaiApiKey) {
return "You must provide a valid API key or choose a different provider."
}
break
case "qwen":
if (!apiConfiguration.qwenApiKey) {
return "You must provide a valid API key or choose a different provider."
@@ -86,15 +91,26 @@ export function validateApiConfiguration(apiConfiguration?: ApiConfiguration): s
export function validateModelId(
apiConfiguration?: ApiConfiguration,
openRouterModels?: Record<string, ModelInfo>,
requestyModels?: Record<string, ModelInfo>,
): string | undefined {
if (apiConfiguration) {
switch (apiConfiguration.apiProvider) {
case "openrouter":
const modelId = apiConfiguration.openRouterModelId || openRouterDefaultModelId // in case the user hasn't changed the model id, it will be undefined by default
if (!modelId) {
const openRouterModelId = apiConfiguration.openRouterModelId || openRouterDefaultModelId // in case the user hasn't changed the model id, it will be undefined by default
if (!openRouterModelId) {
return "You must provide a model ID."
}
if (openRouterModels && !Object.keys(openRouterModels).includes(modelId)) {
if (openRouterModels && !Object.keys(openRouterModels).includes(openRouterModelId)) {
// even if the model list endpoint failed, extensionstatecontext will always have the default model info
return "The model ID you provided is not available. Please choose a different model."
}
break
case "requesty":
const requestyModelId = apiConfiguration.requestyModelId || requestyDefaultModelId // in case the user hasn't changed the model id, it will be undefined by default
if (!requestyModelId) {
return "You must provide a model ID."
}
if (requestyModels && !Object.keys(requestyModels).includes(requestyModelId)) {
// even if the model list endpoint failed, extensionstatecontext will always have the default model info
return "The model ID you provided is not available. Please choose a different model."
}
+1
View File
@@ -1,4 +1,5 @@
export const VSC_INPUT_BACKGROUND = "--vscode-input-background"
export const VSC_INPUT_FOREGROUND = "--vscode-input-foreground"
export const VSC_SIDEBAR_BACKGROUND = "--vscode-sideBar-background"
export const VSC_FOREGROUND = "--vscode-foreground"
export const VSC_EDITOR_FOREGROUND = "--vscode-editor-foreground"