Compare commits

..

2 Commits

Author SHA1 Message Date
Saoud Rizwan e4f6783140 Create rude-vans-cry.md 2025-04-01 11:46:09 -07:00
Saoud Rizwan 174c098cdf Fix firebase session being inconsistent in popout tabs 2025-04-01 11:43:49 -07:00
127 changed files with 1654 additions and 6792 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
Fix issue with Cline accounts not showing user info in popout tabs
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": minor
---
add coverage tests to github workflows
+40 -286
View File
@@ -11,12 +11,10 @@ graph TB
subgraph VSCode Extension Host
subgraph Core Extension
ExtensionEntry[Extension Entry<br/>src/extension.ts]
WebviewProvider[WebviewProvider<br/>src/core/webview/index.ts]
Controller[Controller<br/>src/core/controller/index.ts]
Task[Task<br/>src/core/task/index.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]
McpHub[McpHub<br/>src/services/mcp/McpHub.ts]
end
subgraph Webview UI
@@ -29,101 +27,45 @@ graph TB
TaskStorage[Task Storage<br/>Per-Task Files & History]
CheckpointSystem[Git-based Checkpoints]
end
subgraph API Providers
AnthropicAPI[Anthropic]
OpenRouterAPI[OpenRouter]
BedrockAPI[AWS Bedrock]
OtherAPIs[Other Providers]
end
subgraph MCP Servers
ExternalMcpServers[External MCP Servers]
end
end
%% Core Extension Data Flow
ExtensionEntry --> WebviewProvider
WebviewProvider --> Controller
Controller --> Task
Controller --> McpHub
Task --> GlobalState
Task --> SecretsStorage
Task --> TaskStorage
Task --> CheckpointSystem
Task --> |API Requests| API Providers
McpHub --> |Connects to| ExternalMcpServers
Task --> |Uses| McpHub
ExtensionEntry --> ClineProvider
ClineProvider --> ClineClass
ClineClass --> GlobalState
ClineClass --> SecretsStorage
ClineClass --> TaskStorage
ClineClass --> CheckpointSystem
%% Webview Data Flow
WebviewApp --> ExtStateContext
ExtStateContext --> ReactComponents
%% Bidirectional Communication
WebviewProvider <-->|postMessage| ExtStateContext
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 WebviewProvider fill:#bfb,stroke:#333,stroke-width:2px
style McpHub fill:#bfb,stroke:#333,stroke-width:2px
style API Providers fill:#fdb,stroke:#333,stroke-width:2px
style ClineProvider fill:#bfb,stroke:#333,stroke-width:2px
```
## Definitions
- **Core Extension**: Anything inside the src folder, organized into modular components
- **Core Extension State**: Managed by the Controller class in src/core/controller/index.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 components
- **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 Architecture
The core extension follows a clear hierarchical structure:
1. **WebviewProvider** (src/core/webview/index.ts): Manages the webview lifecycle and communication
2. **Controller** (src/core/controller/index.ts): Handles webview messages and task management
3. **Task** (src/core/task/index.ts): Executes API requests and tool operations
This architecture provides clear separation of concerns:
- WebviewProvider focuses on VSCode webview integration
- Controller manages state and coordinates tasks
- Task handles the execution of AI requests and tool operations
### WebviewProvider Implementation
The WebviewProvider class in `src/core/webview/index.ts` is responsible for:
- Managing multiple active instances through a static set (`activeInstances`)
- Handling webview lifecycle events (creation, visibility changes, disposal)
- Implementing HTML content generation with proper CSP headers
- Supporting Hot Module Replacement (HMR) for development
- Setting up message listeners between the webview and extension
The WebviewProvider maintains a reference to the Controller and delegates message handling to it. It also handles the creation of both sidebar and tab panel webviews, allowing Cline to be used in different contexts within VSCode.
- 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 `Controller` class manages multiple types of persistent storage:
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 `Controller` handles the distribution of state to both the core extension and webview components. It also coordinates state across multiple extension instances, ensuring consistency.
State synchronization between instances is handled through:
- File-based storage for task history and conversation data
- VSCode's global state API for settings and configuration
- Secrets storage for sensitive information
- Event listeners for file changes and configuration updates
The Controller implements methods for:
- Saving and loading task state
- Managing API configurations
- Handling user authentication
- Coordinating MCP server connections
- Managing task history and checkpoints
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
@@ -140,65 +82,16 @@ The `ExtensionStateContext` in `webview-ui/src/context/ExtensionStateContext.tsx
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`).
The ExtensionStateContext handles:
- Real-time updates through message events
- Partial message updates for streaming content
- State modifications through setter methods
- Type-safe access to state through a custom hook
## Core Extension (Cline.ts)
## API Provider System
Cline supports multiple AI providers through a modular API provider system. Each provider is implemented as a separate module in the `src/api/providers/` directory and follows a common interface.
### API Provider Architecture
The API system consists of:
1. **API Handlers**: Provider-specific implementations in `src/api/providers/`
2. **API Transformers**: Stream transformation utilities in `src/api/transform/`
3. **API Configuration**: User settings for API keys and endpoints
4. **API Factory**: Builder function to create the appropriate handler
Key providers include:
- **Anthropic**: Direct integration with Claude models
- **OpenRouter**: Meta-provider supporting multiple model providers
- **AWS Bedrock**: Integration with Amazon's AI services
- **Gemini**: Google's AI models
- **Ollama**: Local model hosting
- **LM Studio**: Local model hosting
- **VSCode LM**: VSCode's built-in language models
### API Configuration Management
API configurations are stored securely:
- API keys are stored in VSCode's secrets storage
- Model selections and non-sensitive settings are stored in global state
- The Controller manages switching between providers and updating configurations
The system supports:
- Secure storage of API keys
- Model selection and configuration
- Automatic retry and error handling
- Token usage tracking and cost calculation
- Context window management
### Plan/Act Mode API Configuration
Cline supports separate model configurations for Plan and Act modes:
- Different models can be used for planning vs. execution
- The system preserves model selections when switching modes
- The Controller handles the transition between modes and updates the API configuration accordingly
## Task Execution System
The Task class is responsible for executing AI requests and tool operations. Each task runs in its own instance of the Task class, ensuring isolation and proper state management.
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 Task {
class Cline {
async initiateTaskLoop(userContent: UserContent, isNewTask: boolean) {
while (!this.abort) {
// 1. Make API request and stream response
@@ -233,7 +126,7 @@ class Task {
The streaming system handles real-time updates and partial content:
```typescript
class Task {
class Cline {
async presentAssistantMessage() {
// Handle streaming locks to prevent race conditions
if (this.presentAssistantMessageLocked) {
@@ -268,7 +161,7 @@ class Task {
Tools follow a strict execution pattern:
```typescript
class Task {
class Cline {
async executeToolWithApproval(block: ToolBlock) {
// 1. Check auto-approval settings
if (this.shouldAutoApproveTool(block.name)) {
@@ -300,7 +193,7 @@ class Task {
The system includes robust error handling:
```typescript
class Task {
class Cline {
async handleError(action: string, error: Error) {
// 1. Check if task was abandoned
if (this.abandoned) return
@@ -323,23 +216,23 @@ class Task {
### API Request & Token Management
The Task class handles API requests with built-in retry, streaming, and token management:
The Cline class handles API requests with built-in retry, streaming, and token management:
```typescript
class Task {
class Cline {
async *attemptApiRequest(previousApiReqIndex: number): ApiStream {
// 1. Wait for MCP servers to connect
await pWaitFor(() => this.controllerRef.deref()?.mcpHub?.isConnecting !== true)
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 { tokensIn, tokensOut } = JSON.parse(previousRequest.text)
const totalTokens = (tokensIn || 0) + (tokensOut || 0)
// Truncate conversation if approaching context limit
if (totalTokens >= maxAllowedSize) {
this.conversationHistoryDeletedRange = this.contextManager.getNextTruncationRange(
this.conversationHistoryDeletedRange = getNextTruncationRange(
this.apiConversationHistory,
this.conversationHistoryDeletedRange,
totalTokens / 2 > maxAllowedSize ? "quarter" : "half"
@@ -406,32 +299,16 @@ Key features:
- Cost calculation
- Cache hit monitoring
### Context Management System
The Context Management System handles conversation history truncation to prevent context window overflow errors. Implemented in the `ContextManager` class, it ensures long-running conversations remain within model context limits while preserving critical context.
Key features:
1. **Model-Aware Sizing**: Dynamically adjusts based on different model context windows (64K for DeepSeek, 128K for most models, 200K for Claude).
2. **Proactive Truncation**: Monitors token usage and preemptively truncates conversations when approaching limits, maintaining buffers of 27K-40K tokens depending on the model.
3. **Intelligent Preservation**: Always preserves the original task message and maintains the user-assistant conversation structure when truncating.
4. **Adaptive Strategies**: Uses different truncation strategies based on context pressure - removing half of the conversation for moderate pressure or three-quarters for severe pressure.
5. **Error Recovery**: Includes specialized detection for context window errors from different providers with automatic retry and more aggressive truncation when needed.
### Task State & Resumption
The Task class provides robust task state management and resumption capabilities:
The Cline class provides robust task state management and resumption capabilities:
```typescript
class Task {
class Cline {
async resumeTaskFromHistory() {
// 1. Load saved state
this.clineMessages = await getSavedClineMessages(this.getContext(), this.taskId)
this.apiConversationHistory = await getSavedApiConversationHistory(this.getContext(), this.taskId)
this.clineMessages = await this.getSavedClineMessages()
this.apiConversationHistory = await this.getSavedApiConversationHistory()
// 2. Handle interrupted tool executions
const lastMessage = this.apiConversationHistory[this.apiConversationHistory.length - 1]
@@ -461,14 +338,14 @@ class Task {
private async saveTaskState() {
// Save conversation history
await saveApiConversationHistory(this.getContext(), this.taskId, this.apiConversationHistory)
await saveClineMessages(this.getContext(), this.taskId, this.clineMessages)
await this.saveApiConversationHistory()
await this.saveClineMessages()
// Create checkpoint
const commitHash = await this.checkpointTracker?.commit()
// Update task history
await this.controllerRef.deref()?.updateTaskHistory({
await this.providerRef.deref()?.updateTaskHistory({
id: this.taskId,
ts: lastMessage.ts,
task: taskMessage.text,
@@ -504,54 +381,11 @@ Key aspects of task state management:
- Resources are cleaned up properly
- User is notified of state changes
## Plan/Act Mode System
Cline implements a dual-mode system that separates planning from execution:
### Mode Architecture
The Plan/Act mode system consists of:
1. **Mode State**: Stored in `chatSettings.mode` in the Controller's state
2. **Mode Switching**: Handled by `togglePlanActModeWithChatSettings` in the Controller
3. **Mode-specific Models**: Optional configuration to use different models for each mode
4. **Mode-specific Prompting**: Different system prompts for planning vs. execution
### Mode Switching Process
When switching between modes:
1. The current model configuration is saved to mode-specific state
2. The previous mode's model configuration is restored
3. The Task instance is updated with the new mode
4. The webview is notified of the mode change
5. Telemetry events are captured for analytics
### Plan Mode
Plan mode is designed for:
- Information gathering and context building
- Asking clarifying questions
- Creating detailed execution plans
- Discussing approaches with the user
In Plan mode, the AI uses the `plan_mode_respond` tool to engage in conversational planning without executing actions.
### Act Mode
Act mode is designed for:
- Executing the planned actions
- Using tools to modify files, run commands, etc.
- Implementing the solution
- Providing results and completion feedback
In Act mode, the AI has access to all tools except `plan_mode_respond` and focuses on implementation rather than discussion.
## Data Flow & State Management
### Core Extension Role
The Controller acts as the single source of truth for all persistent state. It:
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
@@ -560,10 +394,10 @@ The Controller acts as the single source of truth for all persistent state. It:
### Terminal Management
The Task class manages terminal instances and command execution:
The Cline class manages terminal instances and command execution:
```typescript
class Task {
class Cline {
async executeCommandTool(command: string): Promise<[boolean, ToolResponse]> {
// 1. Get or create terminal
const terminalInfo = await this.terminalManager.getOrCreateTerminal(cwd)
@@ -619,10 +453,10 @@ Key features:
### Browser Session Management
The Task class handles browser automation through Puppeteer:
The Cline class handles browser automation through Puppeteer:
```typescript
class Task {
class Cline {
async executeBrowserAction(action: BrowserAction): Promise<BrowserActionResult> {
switch (action) {
case "launch":
@@ -659,93 +493,13 @@ Key aspects:
- Screenshot capture
- Error recovery
## MCP (Model Context Protocol) Integration
### MCP Architecture
The MCP system consists of:
1. **McpHub Class**: Central manager in `src/services/mcp/McpHub.ts`
2. **MCP Connections**: Manages connections to external MCP servers
3. **MCP Settings**: Configuration stored in a JSON file
4. **MCP Marketplace**: Online catalog of available MCP servers
5. **MCP Tools & Resources**: Capabilities exposed by connected servers
The McpHub class:
- Manages the lifecycle of MCP server connections
- Handles server configuration through a settings file
- Provides methods for calling tools and accessing resources
- Implements auto-approval settings for MCP tools
- Monitors server health and handles reconnection
### MCP Server Types
Cline supports two types of MCP server connections:
- **Stdio**: Command-line based servers that communicate via standard I/O
- **SSE**: HTTP-based servers that communicate via Server-Sent Events
### MCP Server Management
The McpHub class provides methods for:
- Discovering and connecting to MCP servers
- Monitoring server health and status
- Restarting servers when needed
- Managing server configurations
- Setting timeouts and auto-approval rules
### MCP Tool Integration
MCP tools are integrated into the Task execution system:
- Tools are discovered and registered at connection time
- The Task class can call MCP tools through the McpHub
- Tool results are streamed back to the AI
- Auto-approval settings can be configured per tool
### MCP Marketplace
The MCP Marketplace provides:
- A catalog of available MCP servers
- One-click installation
- README previews
- Server status monitoring
The Controller class manages MCP servers through the McpHub service:
```typescript
class Controller {
mcpHub?: McpHub
constructor(context: vscode.ExtensionContext, outputChannel: vscode.OutputChannel, webviewProvider: WebviewProvider) {
this.mcpHub = new McpHub(this)
}
async downloadMcp(mcpId: string) {
// Fetch server details from marketplace
const response = await axios.post<McpDownloadResponse>(
"https://api.cline.bot/v1/mcp/download",
{ mcpId },
{
headers: { "Content-Type": "application/json" },
timeout: 10000,
}
)
// Create task with context from README
const task = `Set up the MCP server from ${mcpDetails.githubUrl}...`
// Initialize task and show chat view
await this.initClineWithTask(task)
}
}
```
## 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 follows a WebviewProvider -> Controller -> Task flow
- 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
-8
View File
@@ -31,14 +31,6 @@ body:
label: Relevant API REQUEST output
description: Please copy and paste any relevant output. This will be automatically formatted into code, so no need for backticks.
render: shell
- type: input
id: provider-model
attributes:
label: Provider/Model
description: What provider and model were you using when the issue occurred?
placeholder: "e.g., cline:anthropic/claude-3.7-sonnet, gemini:gemini-2.5-pro-exp-03-25"
validations:
required: true
- type: input
id: operating-system
attributes:
+5 -26
View File
@@ -2,7 +2,6 @@ name: Changeset Converter
run-name: Changeset Conversion
on:
workflow_dispatch:
pull_request:
types: [closed]
@@ -14,36 +13,16 @@ env:
jobs:
# Job 1: Create version bump PR when changesets are merged to main
changeset-pr-version-bump:
if: |
github.event_name == 'workflow_dispatch' ||
(
github.event_name == 'pull_request' &&
github.event.pull_request.merged == true &&
github.event.pull_request.base.ref == 'main' &&
github.actor != 'github-actions'
)
if: >
github.event_name == 'pull_request' &&
github.event.pull_request.merged == true &&
github.event.pull_request.base.ref == 'main' &&
github.actor != 'github-actions'
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
steps:
- name: Check user for team affiliation
id: team_check
if: github.event_name == 'workflow_dispatch'
uses: morfien101/actions-authorized-user@4a3cfbf0bcb3cafe4a71710a278920c5d94bb38b
with:
username: ${{ github.actor }}
team: "deployer"
github_token: ${{ secrets.GITHUB_TOKEN }}
- name: Check if user is authorized
if: github.event_name == 'workflow_dispatch'
run: |
if [ "${{ steps.team_check.outputs.authorized }}" != "true" ]; then
echo "User is not authorized to run this workflow."
exit 1
fi
- name: Git Checkout
uses: actions/checkout@v4
with:
-4
View File
@@ -73,10 +73,6 @@ jobs:
- name: Build Extension
run: npm run compile
# Disabling due to compatability with test framework and ESM modules
# - name: Unit Tests
# run: npm run test:unit
# Run extension tests with coverage
- name: Extension Tests with Coverage
id: extension_coverage
-6
View File
@@ -1,6 +0,0 @@
{
"extension": ["ts"],
"spec": "src/**/__tests__/*.ts",
"require": ["ts-node/register", "source-map-support/register"],
"recursive": true
}
+6 -45
View File
@@ -1,44 +1,5 @@
# Changelog
## [3.10.0]
- Add setting to let browser tool use local Chrome via remote debugging, enabling session-based browsing. Replaces sessionless Chromium, unlocking debugging and productivity workflows tied to your real browser state.
- Add new auto-approve option to approve *ALL* commands (use at your own risk!)
- Add modal in the chat area to more easily enable or disable MCP servers
- Add drag and drop of file/folders into cline chat (Thanks eljapi!)
- Add prompt caching for LiteLLM + Claude (Thanks sammcj!)
- Add Improved context management
- Reduce system prompt size by dynamically loading MCP documentation only when needed
- Fix MCP auto approve toggle issues being out of sync with settings
## [3.9.2]
- Add recommended models for Cline provider
- Add ability to detect when user edits files manually so Cline knows to re-read, leading to reduced diff edit errors
- Add improvements to file mention searching for faster searching
- Add scoring logic to file mentions to sort and exlcude results based on relevance
- Add Support for Bytedance Doubao (Thanks Tunixer!)
- Fix to prevent duplicate BOM (Thanks bamps53!)
## [3.9.1]
- Add Gemini 2.5 Pro Preview 03-25 to Google Provider
## [3.9.0]
- Add Enable extended thinking for LiteLLM provider (Thanks @jorgegarciarey!)
- Add a tab for configuring local MCP Servers
- Fix issue with DeepSeek API provider token counting + context management
- Fix issues with checkpoints hanging under certain conditions
## [3.8.6]
- Add UI for adding remote servers
- Add Mentions Feature Guide and update related documentation
- Fix bug where menu would open in sidebar and open tab
- Fix issue with Cline accounts not showing user info in popout tabs
- Fix bug where menu buttons wouldn't open view in sidebar
## [3.8.5]
- Add support for remote MCP Servers using SSE
@@ -261,8 +222,8 @@
## [3.1.0]
- Added checkpoints: Snapshots of workspace are automatically created whenever Cline uses a tool
- Compare changes: Hover over any tool use to see a diff between the snapshot and current workspace state
- Restore options: Choose to restore just the task state, just the workspace files, or both
- Compare changes: Hover over any tool use to see a diff between the snapshot and current workspace state
- Restore options: Choose to restore just the task state, just the workspace files, or both
- New 'See new changes' button appears after task completion, providing an overview of all workspace changes
- Task header now shows disk space usage with a delete button to help manage snapshot storage
@@ -444,10 +405,10 @@
## [1.8.0]
- You can now use '@' in the textarea to add context!
- @url: Paste in a URL for the extension to fetch and convert to markdown, useful when you want to give Claude the latest docs!
- @problems: Add workspace errors and warnings for Claude to fix, no more back-and-forth about debugging
- @file: Adds a file's contents so you don't have to waste API requests approving read file (+ type to search files)
- @folder: Adds folder's files all at once to speed up your workflow even more
- @url: Paste in a URL for the extension to fetch and convert to markdown, useful when you want to give Claude the latest docs!
- @problems: Add workspace errors and warnings for Claude to fix, no more back-and-forth about debugging
- @file: Adds a file's contents so you don't have to waste API requests approving read file (+ type to search files)
- @folder: Adds folder's files all at once to speed up your workflow even more
## [1.7.0]
+1 -1
View File
@@ -24,7 +24,7 @@ English | <a href="https://github.com/cline/cline/blob/main/locales/es/README.md
<a href="https://github.com/cline/cline/discussions/categories/feature-requests?discussions_q=is%3Aopen+category%3A%22Feature+Requests%22+sort%3Atop" target="_blank"><strong>Feature Requests</strong></a>
</td>
<td align="center">
<a href="https://docs.cline.bot/getting-started/for-new-coders" target="_blank"><strong>Getting Started</strong></a>
<a href="https://docs.cline.bot/getting-started/getting-started-new-coders" target="_blank"><strong>Getting Started</strong></a>
</td>
</tbody>
</table>
-1
View File
@@ -18,7 +18,6 @@ Welcome to the Cline documentation - your comprehensive guide to using and exten
- **Understand Cline's capabilities:**
- [Cline Tools Guide](tools/cline-tools-guide.md)
- [Mentions Feature Guide](tools/mentions-guide.md)
- **Extend Cline with MCP Servers:**
- [MCP Overview](mcp/README.md)
+26 -52
View File
@@ -1,67 +1,41 @@
flowchart TB
subgraph "VSCode Extension Host"
subgraph "Core Extension"
ExtensionEntry["Extension Entry<br/>src/extension.ts"]
WebviewProvider["WebviewProvider<br/>src/core/webview/index.ts"]
Controller["Controller<br/>src/core/controller/index.ts"]
Task["Task<br/>src/core/task/index.ts"]
GlobalState["VSCode Global State"]
SecretsStorage["VSCode Secrets Storage"]
McpHub["McpHub<br/>src/services/mcp/McpHub.ts"]
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"]
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
subgraph "API Providers"
AnthropicAPI["Anthropic"]
OpenRouterAPI["OpenRouter"]
BedrockAPI["AWS Bedrock"]
OtherAPIs["Other Providers"]
end
subgraph "MCP Servers"
ExternalMcpServers["External MCP Servers"]
subgraph Storage
TaskStorage[Task Storage<br/>Per-Task Files & History]
CheckpointSystem[Git-based Checkpoints]
end
end
%% Core Extension Data Flow
ExtensionEntry --> WebviewProvider
WebviewProvider --> Controller
Controller --> Task
Controller --> McpHub
Task --> GlobalState
Task --> SecretsStorage
Task --> TaskStorage
Task --> CheckpointSystem
Task --> |"API Requests"| AnthropicAPI
Task --> |"API Requests"| OpenRouterAPI
Task --> |"API Requests"| BedrockAPI
Task --> |"API Requests"| OtherAPIs
McpHub --> |"Connects to"| ExternalMcpServers
Task --> |"Uses"| McpHub
ExtensionEntry --> ClineProvider
ClineProvider --> ClineClass
ClineClass --> GlobalState
ClineClass --> SecretsStorage
ClineClass --> TaskStorage
ClineClass --> CheckpointSystem
%% Webview Data Flow
WebviewApp --> ExtStateContext
ExtStateContext --> ReactComponents
%% Bidirectional Communication
WebviewProvider <--> |"postMessage"| ExtStateContext
ClineProvider <-->|postMessage| ExtStateContext
classDef vscodeState fill:#f9f,stroke:#333,stroke-width:2px
classDef contextClass fill:#bbf,stroke:#333,stroke-width:2px
classDef providerClass fill:#bfb,stroke:#333,stroke-width:2px
classDef apiClass fill:#fdb,stroke:#333,stroke-width:2px
class GlobalState,SecretsStorage vscodeState
class ExtStateContext contextClass
class WebviewProvider,McpHub providerClass
class AnthropicAPI,OpenRouterAPI,BedrockAPI,OtherAPIs apiClass
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
+3 -4
View File
@@ -20,9 +20,8 @@ Cline is your AI assistant that can:
2. **Provide Context**
- Use @ mentions to add files, folders, URLs, diagnostics, terminal output, and more
- Example: "@/src/components/App.tsx"
- See the [Mentions Feature Guide](./mentions-guide.md) for details
- Use @ mentions to add files, folders, or URLs
- Example: "@file:src/components/App.tsx"
3. **Review Changes**
- Cline will show diffs before making changes
@@ -55,7 +54,7 @@ Cline is your AI assistant that can:
## Available Tools
For the most up-to-date implementation details, you can view the full source code in the [Cline repository](https://github.com/cline/cline/blob/main/src/core/task/index.ts).
For the most up-to-date implementation details, you can view the full source code in the [Cline repository](https://github.com/cline/cline/blob/main/src/core/Cline.ts).
Cline has access to the following tools for various tasks:
-205
View File
@@ -1,205 +0,0 @@
# Cline Mentions Feature Guide
## Overview
The mentions feature is a powerful capability that allows you to reference various resources in your conversations with Cline using the "@" symbol. This includes file contents, directory structures, webpage URLs, VSCode diagnostic information, terminal output, Git change status, and more - all easily incorporated into your conversations.
By using this feature, Cline can gain more accurate context and provide more relevant assistance for your tasks.
## Basic Syntax
Mentions always start with the "@" symbol, followed by the path or identifier of the resource you want to reference:
```
@resource_identifier
```
You can place mentions anywhere in your user messages, and Cline will automatically retrieve the referenced content.
## Supported Mention Types
### 1. File References
To reference file contents, use `@/` followed by the relative path within your project:
```
@/path/to/file.js
```
**Example:**
```
Please analyze the implementation in @/src/components/Button.tsx
```
In this example, Cline automatically retrieves the contents of Button.tsx and uses it to perform the analysis.
### 2. Directory References
To reference directory contents, use `@/` followed by the relative path of the directory, ending with a trailing `/`:
```
@/path/to/directory/
```
**Example:**
```
What components are available in the @/src/components/ directory?
```
In this example, Cline retrieves a listing of the components directory and its contents.
### 3. URL References
To reference web page contents, use `@` followed by the URL:
```
@https://example.com
```
**Example:**
```
Please parse the JSON response from @https://api.github.com/users/octocat
```
In this example, Cline fetches the response from the GitHub API and analyzes the JSON.
### 4. Diagnostic References
To reference VSCode diagnostic information (errors and warnings) in the current workspace, use `@problems`:
```
@problems
```
**Example:**
```
Check @problems and tell me which errors I should prioritize fixing
```
In this example, Cline retrieves the current errors and warnings from your workspace and identifies high-priority issues.
### 5. Terminal Output References
To reference the latest terminal output, use `@terminal`:
```
@terminal
```
**Example:**
```
Please identify the cause of the error in the @terminal output
```
In this example, Cline examines the latest terminal output and analyzes the error's cause.
### 6. Git Working Directory References
To reference the current Git working directory change status, use `@git-changes`:
```
@git-changes
```
**Example:**
```
Review the @git-changes and summarize the important changes that should be committed
```
In this example, Cline retrieves the list of changed files in the current Git working directory and identifies candidates for commit.
### 7. Git Commit References
To reference information about a specific Git commit, use `@` followed by the commit hash:
```
@commit_hash
```
**Example:**
```
Analyze the commit @abcd123 and explain what changes were made
```
In this example, Cline retrieves information about the specified commit hash and analyzes the changes made in that commit.
## Usage Scenarios
### Code Review
```
Check @/src/components/Form.jsx and suggest improvements from a performance perspective. Also, if there are any @problems, please suggest how to fix them.
```
### Debugging Assistance
```
My npm install failed. Please examine the @terminal output and suggest a solution to the problem.
```
### Project Analysis
```
Analyze the code in the @/src/models/ directory and explain the relationships between the data models. Also, tell me how the utility functions in @/src/utils/ are used with these models.
```
### Code Generation
```
Create a new Input.tsx component using the same design language as @/src/components/Button.tsx
```
### Version Control Integration
```
Review the @git-changes and suggest a commit message for the feature I'm working on.
```
## Combining Multiple Mentions
You can combine multiple mentions to provide more complex context:
```
There seems to be a bug in @/src/api/users.js. Please check @problems and @terminal to identify and fix the issue.
```
## Limitations and Considerations
1. **Large Files**: Referencing very large files may take time to process and could consume a significant amount of tokens.
2. **Binary Files**: Binary files (such as images) will not be properly processed and will show a "Binary file" message.
3. **Directory Structure**: Directory references will only show top-level files and directories, not recursively showing the contents of subdirectories.
4. **URL Limitations**: Some websites may block automated crawling, which could prevent accurate content retrieval.
5. **Path Syntax**: File paths or URLs with special characters (such as spaces) may not be recognized correctly.
## Troubleshooting
### Mentions Not Recognized
If your mentions aren't being recognized correctly, check that:
- There's no space after the `@` symbol
- File paths are accurate (case-sensitive)
- URLs include the full format (with `https://`)
### Content Not Retrieved
If the content of referenced resources can't be retrieved:
- Verify the file exists
- Ensure you have access permissions for the file
- Check that the file isn't too large or the URL too complex
### Performance Issues
If mention processing is slow:
- Reference smaller files or specific file sections
- Reduce the number of mentions used at once
## Conclusion
Mastering the mentions feature makes your communication with Cline more efficient. By providing appropriate context, Cline can deliver more accurate assistance, significantly improving your development workflow.
+6 -6
View File
@@ -1,4 +1,4 @@
# Cline OpenRouter 排名第一的 AI 工具
# Cline OpenRouter 排名第一
<p align="center">
<img src="https://media.githubusercontent.com/media/cline/cline/main/assets/docs/demo.gif" width="100%" />
@@ -20,7 +20,7 @@
<a href="https://github.com/cline/cline/discussions/categories/feature-requests?discussions_q=is%3Aopen+category%3A%22Feature+Requests%22+sort%3Atop" target="_blank"><strong>功能请求</strong></a>
</td>
<td align="center">
<a href="https://docs.cline.bot/getting-started/for-new-coders" target="_blank"><strong>新手上路</strong></a>
<a href="https://cline.bot/join-us" target="_blank"><strong>我们正在招聘!</strong></a>
</td>
</tbody>
</table>
@@ -33,12 +33,12 @@
1. 输入你的任务并添加图像,将模型转换为功能应用程序或通过截图修复错误。
2. Cline 首先分析你的文件结构和源代码 AST,运行正则表达式搜索,并阅读相关文件以了解现有项目。通过仔细管理添加到上下文中的信息,Cline 即使在大型复杂项目中也能提供有价值的帮助,而不会使上下文窗口过载。
3. 一旦 Cline 获得所需信息,他可以:
- 创建和编辑文件 + 监控 linter/编译器错误,从而主动修复诸如缺少导入和语法错误等问题。
- 直接在你的终端中执行命令并监控其输出,从而在编辑文件后对开发服务器问题做出反应。
- 对于 Web 开发任务,Cline 可以在无头浏览器中启动网站,点击、输入、滚动并捕获截图和控制台日志,从而修复运行时错误和视觉错误。
- 创建和编辑文件 + 监控 linter/编译器错误,从而主动修复诸如缺少导入和语法错误等问题。
- 直接在你的终端中执行命令并监控其输出,从而在编辑文件后对开发服务器问题做出反应。
- 对于 Web 开发任务,Cline 可以在无头浏览器中启动网站,点击、输入、滚动并捕获截图和控制台日志,从而修复运行时错误和视觉错误。
4. 当任务完成时,Cline 将通过终端命令如 `open -a "Google Chrome" index.html` 向你展示结果,你可以通过点击按钮运行该命令。
> [!TIP]
> [!提示]
> 使用 `CMD/CTRL + Shift + P` 快捷键打开命令面板并输入 "Cline: Open In New Tab" 将扩展作为标签在编辑器中打开。这让你可以与文件资源管理器并排使用 Cline,更清楚地看到他如何改变你的工作空间。
---
+9 -312
View File
@@ -1,12 +1,12 @@
{
"name": "claude-dev",
"version": "3.10.0",
"version": "3.8.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "claude-dev",
"version": "3.10.0",
"version": "3.8.4",
"license": "Apache-2.0",
"dependencies": {
"@anthropic-ai/bedrock-sdk": "^0.12.4",
@@ -27,14 +27,12 @@
"axios": "^1.8.2",
"cheerio": "^1.0.0",
"chokidar": "^4.0.1",
"chrome-launcher": "^1.1.2",
"clone-deep": "^4.0.1",
"default-shell": "^2.2.0",
"diff": "^5.2.0",
"execa": "^9.5.2",
"fast-deep-equal": "^3.1.3",
"firebase": "^11.2.0",
"fzf": "^0.5.2",
"get-folder-size": "^5.0.0",
"globby": "^14.0.2",
"ignore": "^7.0.3",
@@ -68,7 +66,6 @@
"@types/mocha": "^10.0.7",
"@types/node": "20.x",
"@types/pdf-parse": "^1.1.4",
"@types/proxyquire": "^1.3.31",
"@types/should": "^11.2.0",
"@types/sinon": "^17.0.4",
"@types/turndown": "^5.0.5",
@@ -83,10 +80,8 @@
"husky": "^9.1.7",
"npm-run-all": "^4.1.5",
"prettier": "^3.3.3",
"proxyquire": "^2.1.3",
"should": "^13.2.3",
"sinon": "^19.0.2",
"ts-node": "^10.9.2",
"typescript": "^5.4.5"
},
"engines": {
@@ -4291,30 +4286,6 @@
"url": "https://github.com/prettier/prettier?sponsor=1"
}
},
"node_modules/@cspotcode/source-map-support": {
"version": "0.8.1",
"resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz",
"integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/trace-mapping": "0.3.9"
},
"engines": {
"node": ">=12"
}
},
"node_modules/@cspotcode/source-map-support/node_modules/@jridgewell/trace-mapping": {
"version": "0.3.9",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz",
"integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/resolve-uri": "^3.0.3",
"@jridgewell/sourcemap-codec": "^1.4.10"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.25.0",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.0.tgz",
@@ -8598,34 +8569,6 @@
"integrity": "sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==",
"license": "MIT"
},
"node_modules/@tsconfig/node10": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.11.tgz",
"integrity": "sha512-DcRjDCujK/kCk/cUe8Xz8ZSpm8mS3mNNpta+jGCA6USEDfktlNvm1+IuZ9eTcDbNk41BHwpHHeW+N1lKCz4zOw==",
"dev": true,
"license": "MIT"
},
"node_modules/@tsconfig/node12": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz",
"integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==",
"dev": true,
"license": "MIT"
},
"node_modules/@tsconfig/node14": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz",
"integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==",
"dev": true,
"license": "MIT"
},
"node_modules/@tsconfig/node16": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz",
"integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/chai": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.0.1.tgz",
@@ -8705,13 +8648,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/proxyquire": {
"version": "1.3.31",
"resolved": "https://registry.npmjs.org/@types/proxyquire/-/proxyquire-1.3.31.tgz",
"integrity": "sha512-uALowNG2TSM1HNPMMOR0AJwv4aPYPhqB0xlEhkeRTMuto5hjoSPZkvgu1nbPUkz3gEPAHv4sy4DmKsurZiEfRQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/should": {
"version": "11.2.0",
"resolved": "https://registry.npmjs.org/@types/should/-/should-11.2.0.tgz",
@@ -9176,19 +9112,6 @@
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
}
},
"node_modules/acorn-walk": {
"version": "8.3.4",
"resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz",
"integrity": "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==",
"dev": true,
"license": "MIT",
"dependencies": {
"acorn": "^8.11.0"
},
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/agent-base": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.1.tgz",
@@ -9292,13 +9215,6 @@
"integrity": "sha512-lYe4Gx7QT+MKGbDsA+Z+he/Wtef0BiwDOlK/XkBrdfsh9J/jPPXbX0tE9x9cl27Tmu5gg3QUbUrQYa/y+KOHPQ==",
"license": "ISC"
},
"node_modules/arg": {
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz",
"integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==",
"dev": true,
"license": "MIT"
},
"node_modules/argparse": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
@@ -9967,24 +9883,6 @@
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/chrome-launcher": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/chrome-launcher/-/chrome-launcher-1.1.2.tgz",
"integrity": "sha512-YclTJey34KUm5jB1aEJCq807bSievi7Nb/TU4Gu504fUYi3jw3KCIaH6L7nFWQhdEgH3V+wCh+kKD1P5cXnfxw==",
"license": "Apache-2.0",
"dependencies": {
"@types/node": "*",
"escape-string-regexp": "^4.0.0",
"is-wsl": "^2.2.0",
"lighthouse-logger": "^2.0.1"
},
"bin": {
"print-chrome-path": "bin/print-chrome-path.js"
},
"engines": {
"node": ">=12.13.0"
}
},
"node_modules/chromium-bidi": {
"version": "0.6.5",
"resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-0.6.5.tgz",
@@ -10275,13 +10173,6 @@
"node": ">= 0.10"
}
},
"node_modules/create-require": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz",
"integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==",
"dev": true,
"license": "MIT"
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -10972,9 +10863,9 @@
}
},
"node_modules/escalade": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
"integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.2.tgz",
"integrity": "sha512-ErCHMCae19vR8vQGe50xIsVomy19rg6gFu3+r3jkEO46suLMWBksvVyoGgQV+jOfl84ZSOSlmv6Gxa89PmTGmA==",
"license": "MIT",
"engines": {
"node": ">=6"
@@ -10990,6 +10881,7 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
"integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10"
@@ -11698,30 +11590,6 @@
"node": "^10.12.0 || >=12.0.0"
}
},
"node_modules/fill-keys": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/fill-keys/-/fill-keys-1.0.2.tgz",
"integrity": "sha512-tcgI872xXjwFF4xgQmLxi76GnwJG3g/3isB1l4/G5Z4zrbddGpBjqZCO9oEAcB5wX0Hj/5iQB3toxfO7in1hHA==",
"dev": true,
"license": "MIT",
"dependencies": {
"is-object": "~1.0.1",
"merge-descriptors": "~1.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/fill-keys/node_modules/merge-descriptors": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
"integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/fill-range": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
@@ -12015,12 +11883,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/fzf": {
"version": "0.5.2",
"resolved": "https://registry.npmjs.org/fzf/-/fzf-0.5.2.tgz",
"integrity": "sha512-Tt4kuxLXFKHy8KT40zwsUPUkg1CrsgY25FxA2U/j/0WgEDCk3ddc/zLTCCcbSHX9FcKtLuVaDGtGE/STWC+j3Q==",
"license": "BSD-3-Clause"
},
"node_modules/gauge": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/gauge/-/gauge-5.0.2.tgz",
@@ -12915,21 +12777,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/is-docker": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz",
"integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==",
"license": "MIT",
"bin": {
"is-docker": "cli.js"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/is-extglob": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
@@ -13011,16 +12858,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/is-object": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/is-object/-/is-object-1.0.2.tgz",
"integrity": "sha512-2rRIahhZr2UWb45fIOuvZGpFtz0TyOZLf32KxBbSoUCeZR495zCKlWUKKUByk3geS2eAs7ZAABt0Y/Rx0GiQGA==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/is-path-inside": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz",
@@ -13200,18 +13037,6 @@
"node": ">=0.10.0"
}
},
"node_modules/is-wsl": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz",
"integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==",
"license": "MIT",
"dependencies": {
"is-docker": "^2.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/isarray": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
@@ -13483,31 +13308,6 @@
"immediate": "~3.0.5"
}
},
"node_modules/lighthouse-logger": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/lighthouse-logger/-/lighthouse-logger-2.0.1.tgz",
"integrity": "sha512-ioBrW3s2i97noEmnXxmUq7cjIcVRjT5HBpAYy8zE11CxU9HqlWHHeRxfeN1tn8F7OEMVPIC9x1f8t3Z7US9ehQ==",
"license": "Apache-2.0",
"dependencies": {
"debug": "^2.6.9",
"marky": "^1.2.2"
}
},
"node_modules/lighthouse-logger/node_modules/debug": {
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
"license": "MIT",
"dependencies": {
"ms": "2.0.0"
}
},
"node_modules/lighthouse-logger/node_modules/ms": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT"
},
"node_modules/load-json-file": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/load-json-file/-/load-json-file-4.0.0.tgz",
@@ -13646,13 +13446,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/make-error": {
"version": "1.3.6",
"resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz",
"integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==",
"dev": true,
"license": "ISC"
},
"node_modules/mammoth": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/mammoth/-/mammoth-1.8.0.tgz",
@@ -13686,12 +13479,6 @@
"sprintf-js": "~1.0.2"
}
},
"node_modules/marky": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/marky/-/marky-1.2.5.tgz",
"integrity": "sha512-q9JtQJKjpsVxCRVgQ+WapguSbKC3SQ5HEzFGPAJMStgh3QjCawp00UKv3MTTAArTmGmmPUvllHZoNbZ3gs0I+Q==",
"license": "Apache-2.0"
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -14064,13 +13851,6 @@
"integrity": "sha512-ySViT69/76t8VhE1xXHK6Ch4NcDd26gx0MzKXLO+F7NOtnqH68d9zF94nT8ZWSxXh8ELOERsnJO/sWt1xZYw5A==",
"license": "MIT"
},
"node_modules/module-not-found-error": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/module-not-found-error/-/module-not-found-error-1.0.1.tgz",
"integrity": "sha512-pEk4ECWQXV6z2zjhRZUongnLJNUeGQJ3w6OQ5ctGwD+i5o93qjRQUk2Rt6VdNeu3sEP0AB4LcfvdebpxBRVr4g==",
"dev": true,
"license": "MIT"
},
"node_modules/monaco-vscode-textmate-theme-converter": {
"version": "0.1.7",
"resolved": "https://registry.npmjs.org/monaco-vscode-textmate-theme-converter/-/monaco-vscode-textmate-theme-converter-0.1.7.tgz",
@@ -15336,18 +15116,6 @@
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="
},
"node_modules/proxyquire": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/proxyquire/-/proxyquire-2.1.3.tgz",
"integrity": "sha512-BQWfCqYM+QINd+yawJz23tbBM40VIGXOdDw3X344KcclI/gtBbdWF6SlQ4nK/bYhF9d27KYug9WzljHC6B9Ysg==",
"dev": true,
"license": "MIT",
"dependencies": {
"fill-keys": "^1.0.2",
"module-not-found-error": "^1.0.1",
"resolve": "^1.11.1"
}
},
"node_modules/pump": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.2.tgz",
@@ -15840,9 +15608,9 @@
"license": "MIT"
},
"node_modules/semver": {
"version": "7.7.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz",
"integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==",
"version": "7.6.3",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz",
"integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
@@ -16840,60 +16608,6 @@
"typescript": ">=4.2.0"
}
},
"node_modules/ts-node": {
"version": "10.9.2",
"resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz",
"integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@cspotcode/source-map-support": "^0.8.0",
"@tsconfig/node10": "^1.0.7",
"@tsconfig/node12": "^1.0.7",
"@tsconfig/node14": "^1.0.0",
"@tsconfig/node16": "^1.0.2",
"acorn": "^8.4.1",
"acorn-walk": "^8.1.1",
"arg": "^4.1.0",
"create-require": "^1.1.0",
"diff": "^4.0.1",
"make-error": "^1.1.1",
"v8-compile-cache-lib": "^3.0.1",
"yn": "3.1.1"
},
"bin": {
"ts-node": "dist/bin.js",
"ts-node-cwd": "dist/bin-cwd.js",
"ts-node-esm": "dist/bin-esm.js",
"ts-node-script": "dist/bin-script.js",
"ts-node-transpile-only": "dist/bin-transpile.js",
"ts-script": "dist/bin-script-deprecated.js"
},
"peerDependencies": {
"@swc/core": ">=1.2.50",
"@swc/wasm": ">=1.2.50",
"@types/node": "*",
"typescript": ">=2.7"
},
"peerDependenciesMeta": {
"@swc/core": {
"optional": true
},
"@swc/wasm": {
"optional": true
}
}
},
"node_modules/ts-node/node_modules/diff": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz",
"integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.3.1"
}
},
"node_modules/tslib": {
"version": "1.14.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz",
@@ -17218,13 +16932,6 @@
"uuid": "dist/bin/uuid"
}
},
"node_modules/v8-compile-cache-lib": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz",
"integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==",
"dev": true,
"license": "MIT"
},
"node_modules/v8-to-istanbul": {
"version": "9.3.0",
"resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz",
@@ -17779,16 +17486,6 @@
"fd-slicer": "~1.1.0"
}
},
"node_modules/yn": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz",
"integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/yocto-queue": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
+16 -7
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.10.0",
"version": "3.8.5",
"icon": "assets/icons/icon.png",
"engines": {
"vscode": "^1.84.0"
@@ -214,6 +214,21 @@
},
"description": "Settings for VSCode Language Model API"
},
"cline.mcp.mode": {
"type": "string",
"enum": [
"full",
"server-use-only",
"off"
],
"enumDescriptions": [
"Enable all MCP functionality (server use and build instructions)",
"Enable MCP server use only (excludes instructions about building MCP servers)",
"Disable all MCP functionality"
],
"default": "full",
"description": "Controls MCP inclusion in prompts, reduces token usage if you only need access to certain functionality."
},
"cline.enableCheckpoints": {
"type": "boolean",
"default": true,
@@ -288,7 +303,6 @@
"format:fix": "prettier . --write",
"test": "vscode-test",
"test:ci": "node scripts/test-ci.js",
"test:unit": "TS_NODE_PROJECT='./tsconfig.unit-test.json' mocha",
"test:coverage": "vscode-test --coverage",
"install:all": "npm install && cd webview-ui && npm install",
"dev:webview": "cd webview-ui && npm run dev",
@@ -309,7 +323,6 @@
"@types/mocha": "^10.0.7",
"@types/node": "20.x",
"@types/pdf-parse": "^1.1.4",
"@types/proxyquire": "^1.3.31",
"@types/should": "^11.2.0",
"@types/sinon": "^17.0.4",
"@types/turndown": "^5.0.5",
@@ -324,10 +337,8 @@
"husky": "^9.1.7",
"npm-run-all": "^4.1.5",
"prettier": "^3.3.3",
"proxyquire": "^2.1.3",
"should": "^13.2.3",
"sinon": "^19.0.2",
"ts-node": "^10.9.2",
"typescript": "^5.4.5"
},
"dependencies": {
@@ -349,14 +360,12 @@
"axios": "^1.8.2",
"cheerio": "^1.0.0",
"chokidar": "^4.0.1",
"chrome-launcher": "^1.1.2",
"clone-deep": "^4.0.1",
"default-shell": "^2.2.0",
"diff": "^5.2.0",
"execa": "^9.5.2",
"fast-deep-equal": "^3.1.3",
"firebase": "^11.2.0",
"fzf": "^0.5.2",
"get-folder-size": "^5.0.0",
"globby": "^14.0.2",
"ignore": "^7.0.3",
-3
View File
@@ -15,7 +15,6 @@ import { RequestyHandler } from "./providers/requesty"
import { TogetherHandler } from "./providers/together"
import { QwenHandler } from "./providers/qwen"
import { MistralHandler } from "./providers/mistral"
import { DoubaoHandler } from "./providers/doubao"
import { VsCodeLmHandler } from "./providers/vscode-lm"
import { ClineHandler } from "./providers/cline"
import { LiteLlmHandler } from "./providers/litellm"
@@ -62,8 +61,6 @@ export function buildApiHandler(configuration: ApiConfiguration): ApiHandler {
return new TogetherHandler(options)
case "qwen":
return new QwenHandler(options)
case "doubao":
return new DoubaoHandler(options)
case "mistral":
return new MistralHandler(options)
case "vscode-lm":
+2 -3
View File
@@ -36,15 +36,14 @@ export class DeepSeekHandler implements ApiHandler {
}
const deepUsage = usage as DeepSeekUsage
const inputTokens = deepUsage?.prompt_tokens || 0 // sum of cache hits and misses
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)
const nonCachedInputTokens = Math.max(0, inputTokens - cacheReadTokens - cacheWriteTokens) // this will always be 0
yield {
type: "usage",
inputTokens: nonCachedInputTokens,
inputTokens: inputTokens,
outputTokens: outputTokens,
cacheWriteTokens: cacheWriteTokens,
cacheReadTokens: cacheReadTokens,
-68
View File
@@ -1,68 +0,0 @@
import { ApiHandler } from ".."
import { ApiHandlerOptions, doubaoDefaultModelId, DoubaoModelId, doubaoModels, ModelInfo } from "../../shared/api"
import { Anthropic } from "@anthropic-ai/sdk"
import OpenAI from "openai"
import { convertToOpenAiMessages } from "../transform/openai-format"
import { ApiStream } from "../transform/stream"
export class DoubaoHandler implements ApiHandler {
private options: ApiHandlerOptions
private client: OpenAI
constructor(options: ApiHandlerOptions) {
this.options = options
this.client = new OpenAI({
baseURL: "https://ark.cn-beijing.volces.com/api/v3/",
apiKey: this.options.doubaoApiKey,
})
}
getModel(): { id: DoubaoModelId; info: ModelInfo } {
const modelId = this.options.apiModelId
if (modelId && modelId in doubaoModels) {
const id = modelId as DoubaoModelId
return { id, info: doubaoModels[id] }
}
return {
id: doubaoDefaultModelId,
info: doubaoModels[doubaoDefaultModelId],
}
}
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
const model = this.getModel()
let openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [
{ role: "system", content: systemPrompt },
...convertToOpenAiMessages(messages),
]
const stream = await this.client.chat.completions.create({
model: model.id,
max_completion_tokens: model.info.maxTokens,
messages: openAiMessages,
stream: true,
stream_options: { include_usage: true },
temperature: 0,
})
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: chunk.usage.prompt_tokens || 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,
}
}
}
}
}
+5 -73
View File
@@ -59,53 +59,18 @@ export class LiteLlmHandler implements ApiHandler {
}
const modelId = this.options.liteLlmModelId || liteLlmDefaultModelId
const isOminiModel = modelId.includes("o1-mini") || modelId.includes("o3-mini")
// Configuration for extended thinking
const budgetTokens = this.options.thinkingBudgetTokens || 0
const reasoningOn = budgetTokens !== 0 ? true : false
const thinkingConfig = reasoningOn ? { type: "enabled", budget_tokens: budgetTokens } : undefined
let temperature: number | undefined = 0
if (isOminiModel && reasoningOn) {
temperature = undefined // Thinking mode doesn't support temperature
if (isOminiModel) {
temperature = undefined // does not support temperature
}
// Define cache control object if prompt caching is enabled
const cacheControl = this.options.liteLlmUsePromptCache ? { cache_control: { type: "ephemeral" } } : undefined
// Add cache_control to system message if enabled
const enhancedSystemMessage = {
...systemMessage,
...(cacheControl && cacheControl),
}
// Find the last two user messages to apply caching
const userMsgIndices = formattedMessages.reduce(
(acc, msg, index) => (msg.role === "user" ? [...acc, index] : acc),
[] as number[],
)
const lastUserMsgIndex = userMsgIndices[userMsgIndices.length - 1] ?? -1
const secondLastUserMsgIndex = userMsgIndices[userMsgIndices.length - 2] ?? -1
// Apply cache_control to the last two user messages if enabled
const enhancedMessages = formattedMessages.map((message, index) => {
if ((index === lastUserMsgIndex || index === secondLastUserMsgIndex) && cacheControl) {
return {
...message,
...cacheControl,
}
}
return message
})
const stream = await this.client.chat.completions.create({
model: this.options.liteLlmModelId || liteLlmDefaultModelId,
messages: [enhancedSystemMessage, ...enhancedMessages],
messages: [systemMessage, ...formattedMessages],
temperature,
stream: true,
stream_options: { include_usage: true },
...(thinkingConfig && { thinking: thinkingConfig }), // Add thinking configuration when applicable
})
const inputCost = (await this.calculateCost(1e6, 0)) || 0
@@ -113,8 +78,6 @@ export class LiteLlmHandler implements ApiHandler {
for await (const chunk of stream) {
const delta = chunk.choices[0]?.delta
// Handle normal text content
if (delta?.content) {
yield {
type: "text",
@@ -122,44 +85,13 @@ export class LiteLlmHandler implements ApiHandler {
}
}
// Handle reasoning events (thinking)
// Thinking is not in the standard types but may be in the response
interface ThinkingDelta {
thinking?: string
}
if ((delta as ThinkingDelta)?.thinking) {
yield {
type: "reasoning",
reasoning: (delta as ThinkingDelta).thinking || "",
}
}
// Handle token usage information
if (chunk.usage) {
const totalCost =
(inputCost * chunk.usage.prompt_tokens) / 1e6 + (outputCost * chunk.usage.completion_tokens) / 1e6
// Extract cache-related information if available
// Need to use type assertion since these properties are not in the standard OpenAI types
const usage = chunk.usage as {
prompt_tokens: number
completion_tokens: number
cache_creation_input_tokens?: number
prompt_cache_miss_tokens?: number
cache_read_input_tokens?: number
prompt_cache_hit_tokens?: number
}
const cacheWriteTokens = usage.cache_creation_input_tokens || usage.prompt_cache_miss_tokens || 0
const cacheReadTokens = usage.cache_read_input_tokens || usage.prompt_cache_hit_tokens || 0
yield {
type: "usage",
inputTokens: usage.prompt_tokens || 0,
outputTokens: usage.completion_tokens || 0,
cacheWriteTokens: cacheWriteTokens > 0 ? cacheWriteTokens : undefined,
cacheReadTokens: cacheReadTokens > 0 ? cacheReadTokens : undefined,
inputTokens: chunk.usage.prompt_tokens || 0,
outputTokens: chunk.usage.completion_tokens || 0,
totalCost,
}
}
+2 -3
View File
@@ -26,15 +26,14 @@ export class OpenAiNativeHandler implements ApiHandler {
}
private async *yieldUsage(info: ModelInfo, usage: OpenAI.Completions.CompletionUsage | undefined): ApiStream {
const inputTokens = usage?.prompt_tokens || 0 // sum of cache hits and misses
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)
const nonCachedInputTokens = Math.max(0, inputTokens - cacheReadTokens - cacheWriteTokens)
yield {
type: "usage",
inputTokens: nonCachedInputTokens,
inputTokens: inputTokens,
outputTokens: outputTokens,
cacheWriteTokens: cacheWriteTokens,
cacheReadTokens: cacheReadTokens,
-1
View File
@@ -21,7 +21,6 @@ export const toolUseNames = [
"access_mcp_resource",
"ask_followup_question",
"plan_mode_respond",
"load_mcp_documentation",
"attempt_completion",
] as const
+20 -752
View File
@@ -2,119 +2,14 @@ import { Anthropic } from "@anthropic-ai/sdk"
import { ClineApiReqInfo, ClineMessage } from "../../shared/ExtensionMessage"
import { ApiHandler } from "../../api"
import { OpenAiHandler } from "../../api/providers/openai"
import { formatResponse } from "../prompts/responses"
import { GlobalFileNames } from "../storage/disk"
import { fileExistsAtPath } from "../../utils/fs"
import * as path from "path"
import fs from "fs/promises"
import cloneDeep from "clone-deep"
enum EditType {
UNDEFINED = 0,
NO_FILE_READ = 1,
READ_FILE_TOOL = 2,
ALTER_FILE_TOOL = 3,
FILE_MENTION = 4,
}
// array of string values allows us to cover all changes for message types currently supported
type MessageContent = string[]
type MessageMetadata = string[][]
// Type for a single context update
type ContextUpdate = [number, string, MessageContent, MessageMetadata] // [timestamp, updateType, update, metadata]
// Type for the serialized format of our nested maps
type SerializedContextHistory = Array<
[
number, // messageIndex
[
number, // EditType (message type)
Array<
[
number, // blockIndex
ContextUpdate[], // updates array (now with 4 elements including metadata)
]
>,
],
]
>
export class ContextManager {
// mapping from the apiMessages outer index to the inner message index to a list of actual changes, ordered by timestamp
// timestamp is required in order to support full checkpointing, where the changes we apply need to be able to be undone when
// moving to an earlier conversation history checkpoint - this ordering intuitively allows for binary search on truncation
// there is also a number stored for each (EditType) which defines which message type it is, for custom handling
// format: { outerIndex => [EditType, { innerIndex => [[timestamp, updateType, update], ...] }] }
// example: { 1 => { [0, 0 => [[<timestamp>, "text", "[NOTE] Some previous conversation history with the user has been removed ..."], ...] }] }
// the above example would be how we update the first assistant message to indicate we truncated text
private contextHistoryUpdates: Map<number, [number, Map<number, ContextUpdate[]>]>
constructor() {
this.contextHistoryUpdates = new Map()
}
/**
* public function for loading contextHistoryUpdates from disk, if it exists
*/
async initializeContextHistory(taskDirectory: string) {
this.contextHistoryUpdates = await this.getSavedContextHistory(taskDirectory)
}
/**
* get the stored context history updates from disk
*/
private async getSavedContextHistory(taskDirectory: string): Promise<Map<number, [number, Map<number, ContextUpdate[]>]>> {
try {
const filePath = path.join(taskDirectory, GlobalFileNames.contextHistory)
if (await fileExistsAtPath(filePath)) {
const data = await fs.readFile(filePath, "utf8")
const serializedUpdates = JSON.parse(data) as SerializedContextHistory
// Update to properly reconstruct the tuple structure
return new Map(
serializedUpdates.map(([messageIndex, [numberValue, innerMapArray]]) => [
messageIndex,
[numberValue, new Map(innerMapArray)],
]),
)
}
} catch (error) {
console.error("Failed to load context history:", error)
}
return new Map()
}
/**
* save the context history updates to disk
*/
private async saveContextHistory(taskDirectory: string) {
try {
const serializedUpdates: SerializedContextHistory = Array.from(this.contextHistoryUpdates.entries()).map(
([messageIndex, [numberValue, innerMap]]) => [messageIndex, [numberValue, Array.from(innerMap.entries())]],
)
await fs.writeFile(
path.join(taskDirectory, GlobalFileNames.contextHistory),
JSON.stringify(serializedUpdates),
"utf8",
)
} catch (error) {
console.error("Failed to save context history:", error)
}
}
/**
* primary entry point for getting up to date context & truncating when required
*/
async getNewContextMessagesAndMetadata(
getNewContextMessagesAndMetadata(
apiConversationHistory: Anthropic.Messages.MessageParam[],
clineMessages: ClineMessage[],
api: ApiHandler,
conversationHistoryDeletedRange: [number, number] | undefined,
previousApiReqIndex: number,
taskDirectory: string,
) {
let updatedConversationHistoryDeletedRange = false
@@ -122,7 +17,6 @@ export class ContextManager {
if (previousApiReqIndex >= 0) {
const previousRequest = clineMessages[previousApiReqIndex]
if (previousRequest && previousRequest.text) {
const timestamp = previousRequest.ts
const { tokensIn, tokensOut, cacheWrites, cacheReads }: ClineApiReqInfo = JSON.parse(previousRequest.text)
const totalTokens = (tokensIn || 0) + (tokensOut || 0) + (cacheWrites || 0) + (cacheReads || 0)
let contextWindow = api.getModel().info.contextWindow || 128_000
@@ -149,54 +43,23 @@ export class ContextManager {
if (totalTokens >= maxAllowedSize) {
// Since the user may switch between models with different context windows, truncating half may not be enough (ie if switching from claude 200k to deepseek 64k, half truncation will only remove 100k tokens, but we need to remove much more)
// So if totalTokens/2 is greater than maxAllowedSize, we truncate 3/4 instead of 1/2
// FIXME: truncating the conversation in a way that is optimal for prompt caching AND takes into account multi-context window complexity is something we need to improve
const keep = totalTokens / 2 > maxAllowedSize ? "quarter" : "half"
// we later check how many chars we trim to determine if we should still truncate history
let [anyContextUpdates, uniqueFileReadIndices] = this.applyContextOptimizations(
// NOTE: it's okay that we overwriteConversationHistory in resume task since we're only ever removing the last user message and not anything in the middle which would affect this range
conversationHistoryDeletedRange = this.getNextTruncationRange(
apiConversationHistory,
conversationHistoryDeletedRange ? conversationHistoryDeletedRange[1] + 1 : 2,
timestamp,
conversationHistoryDeletedRange,
keep,
)
let needToTruncate = true
if (anyContextUpdates) {
// determine whether we've saved enough chars to not truncate
const charactersSavedPercentage = this.calculateContextOptimizationMetrics(
apiConversationHistory,
conversationHistoryDeletedRange,
uniqueFileReadIndices,
)
if (charactersSavedPercentage >= 0.3) {
needToTruncate = false
}
}
if (needToTruncate) {
// go ahead with truncation
anyContextUpdates = this.applyStandardContextTruncationNoticeChange(timestamp) || anyContextUpdates
// NOTE: it's okay that we overwriteConversationHistory in resume task since we're only ever removing the last user message and not anything in the middle which would affect this range
conversationHistoryDeletedRange = this.getNextTruncationRange(
apiConversationHistory,
conversationHistoryDeletedRange,
keep,
)
updatedConversationHistoryDeletedRange = true
}
// if we alter the context history, save the updated version to disk
if (anyContextUpdates) {
await this.saveContextHistory(taskDirectory)
}
updatedConversationHistoryDeletedRange = true
}
}
}
const truncatedConversationHistory = this.getAndAlterTruncatedMessages(
apiConversationHistory,
conversationHistoryDeletedRange,
)
// conversationHistoryDeletedRange is updated only when we're close to hitting the context window, so we don't continuously break the prompt cache
const truncatedConversationHistory = this.getTruncatedMessages(apiConversationHistory, conversationHistoryDeletedRange)
return {
conversationHistoryDeletedRange: conversationHistoryDeletedRange,
@@ -205,17 +68,14 @@ export class ContextManager {
}
}
/**
* get truncation range
*/
public getNextTruncationRange(
apiMessages: Anthropic.Messages.MessageParam[],
currentDeletedRange: [number, number] | undefined,
keep: "half" | "quarter",
): [number, number] {
// We always keep the first user-assistant pairing, and truncate an even number of messages from there
const rangeStartIndex = 2 // index 0 and 1 are kept
const startOfRest = currentDeletedRange ? currentDeletedRange[1] + 1 : 2 // inclusive starting index
// Since we always keep the first message, currentDeletedRange[0] will always be 1 (for now until we have a smarter truncation algorithm)
const rangeStartIndex = 1
const startOfRest = currentDeletedRange ? currentDeletedRange[1] + 1 : 1
let messagesToRemove: number
if (keep === "half") {
@@ -232,11 +92,11 @@ export class ContextManager {
messagesToRemove = Math.floor(((apiMessages.length - startOfRest) * 3) / 4 / 2) * 2
}
let rangeEndIndex = startOfRest + messagesToRemove - 1 // inclusive ending index
let rangeEndIndex = startOfRest + messagesToRemove - 1
// Make sure that the last message being removed is a assistant message, so the next message after the initial user-assistant pair is an assistant message. This preserves the user-assistant-user-assistant structure.
// Make sure the last message being removed is a user message, so that the next message after the initial task message is an assistant message. This preservers the user-assistant-user-assistant structure.
// NOTE: anthropic format messages are always user-assistant-user-assistant, while openai format messages can have multiple user messages in a row (we use anthropic format throughout cline)
if (apiMessages[rangeEndIndex].role !== "assistant") {
if (apiMessages[rangeEndIndex].role !== "user") {
rangeEndIndex -= 1
}
@@ -244,609 +104,17 @@ export class ContextManager {
return [rangeStartIndex, rangeEndIndex]
}
/**
* external interface to support old calls
*/
public getTruncatedMessages(
messages: Anthropic.Messages.MessageParam[],
deletedRange: [number, number] | undefined,
): Anthropic.Messages.MessageParam[] {
return this.getAndAlterTruncatedMessages(messages, deletedRange)
}
/**
* apply all required truncation methods to the messages in context
*/
private getAndAlterTruncatedMessages(
messages: Anthropic.Messages.MessageParam[],
deletedRange: [number, number] | undefined,
): Anthropic.Messages.MessageParam[] {
if (messages.length <= 1) {
if (!deletedRange) {
return messages
}
const updatedMessages = this.applyContextHistoryUpdates(messages, deletedRange ? deletedRange[1] + 1 : 2)
// OLD NOTE: if you try to console log these, don't forget that logging a reference to an array may not provide the same result as logging a slice() snapshot of that array at that exact moment. The following DOES in fact include the latest assistant message.
return updatedMessages
}
/**
* applies deletedRange truncation and other alterations based on changes in this.contextHistoryUpdates
*/
private applyContextHistoryUpdates(
messages: Anthropic.Messages.MessageParam[],
startFromIndex: number,
): Anthropic.Messages.MessageParam[] {
// runtime is linear in length of user messages, if expecting a limited number of alterations, could be more optimal to loop over alterations
const firstChunk = messages.slice(0, 2) // get first user-assistant pair
const secondChunk = messages.slice(startFromIndex) // get remaining messages within context
const messagesToUpdate = [...firstChunk, ...secondChunk]
// we need the mapping from the local indices in messagesToUpdate to the global array of updates in this.contextHistoryUpdates
const originalIndices = [
...Array(2).keys(),
...Array(secondChunk.length)
.fill(0)
.map((_, i) => i + startFromIndex),
]
for (let arrayIndex = 0; arrayIndex < messagesToUpdate.length; arrayIndex++) {
const messageIndex = originalIndices[arrayIndex]
const innerTuple = this.contextHistoryUpdates.get(messageIndex)
if (!innerTuple) {
continue
}
// because we are altering this, we need a deep copy
messagesToUpdate[arrayIndex] = cloneDeep(messagesToUpdate[arrayIndex])
// Extract the map from the tuple
const innerMap = innerTuple[1]
for (const [blockIndex, changes] of innerMap) {
// apply the latest change among n changes - [timestamp, updateType, update]
const latestChange = changes[changes.length - 1]
if (latestChange[1] === "text") {
// only altering text for now
const message = messagesToUpdate[arrayIndex]
if (Array.isArray(message.content)) {
const block = message.content[blockIndex]
if (block && block.type === "text") {
block.text = latestChange[2][0]
}
}
}
}
}
return messagesToUpdate
}
/**
* removes all context history updates that occurred after the specified timestamp and saves to disk
*/
async truncateContextHistory(timestamp: number, taskDirectory: string): Promise<void> {
this.truncateContextHistoryAtTimestamp(this.contextHistoryUpdates, timestamp)
// save the modified context history to disk
await this.saveContextHistory(taskDirectory)
}
/**
* alters the context history to remove all alterations after a given timestamp
* removes the index if there are no alterations there anymore, both outer and inner indices
*/
private truncateContextHistoryAtTimestamp(
contextHistory: Map<number, [number, Map<number, ContextUpdate[]>]>,
timestamp: number,
): void {
for (const [messageIndex, [_, innerMap]] of contextHistory) {
// track which blockIndices to delete
const blockIndicesToDelete: number[] = []
// loop over the innerIndices of the messages in this block
for (const [blockIndex, updates] of innerMap) {
// updates ordered by timestamp, so find cutoff point by iterating from right to left
let cutoffIndex = updates.length - 1
while (cutoffIndex >= 0 && updates[cutoffIndex][0] > timestamp) {
cutoffIndex--
}
// If we found updates to remove
if (cutoffIndex < updates.length - 1) {
// Modify the array in place to keep only updates up to cutoffIndex
updates.length = cutoffIndex + 1
// If no updates left after truncation, mark this block for deletion
if (updates.length === 0) {
blockIndicesToDelete.push(blockIndex)
}
}
}
// Remove empty blocks from inner map
for (const blockIndex of blockIndicesToDelete) {
innerMap.delete(blockIndex)
}
// If inner map is now empty, remove the message index from outer map
if (innerMap.size === 0) {
contextHistory.delete(messageIndex)
}
}
}
/**
* applies the context optimization steps and returns whether any changes were made
*/
private applyContextOptimizations(
apiMessages: Anthropic.Messages.MessageParam[],
startFromIndex: number,
timestamp: number,
): [boolean, Set<number>] {
const [fileReadUpdatesBool, uniqueFileReadIndices] = this.findAndPotentiallySaveFileReadContextHistoryUpdates(
apiMessages,
startFromIndex,
timestamp,
)
// true if any context optimization steps alter state
const contextHistoryUpdated = fileReadUpdatesBool
return [contextHistoryUpdated, uniqueFileReadIndices]
}
/**
* if there is any truncation and there is no other alteration already set, alter the assistant message to indicate this occurred
*/
private applyStandardContextTruncationNoticeChange(timestamp: number): boolean {
if (!this.contextHistoryUpdates.has(1)) {
// first assistant message always at index 1
const innerMap = new Map<number, ContextUpdate[]>()
innerMap.set(0, [[timestamp, "text", [formatResponse.contextTruncationNotice()], []]])
this.contextHistoryUpdates.set(1, [0, innerMap]) // EditType is undefined for first assistant message
return true
}
return false
}
/**
* wraps the logic for determining file reads to overwrite, and altering state
* returns whether any updates were made (bool) and indices where updates were made
*/
private findAndPotentiallySaveFileReadContextHistoryUpdates(
apiMessages: Anthropic.Messages.MessageParam[],
startFromIndex: number,
timestamp: number,
): [boolean, Set<number>] {
const [fileReadIndices, messageFilePaths] = this.getPossibleDuplicateFileReads(apiMessages, startFromIndex)
return this.applyFileReadContextHistoryUpdates(fileReadIndices, messageFilePaths, apiMessages, timestamp)
}
/**
* generate a mapping from unique file reads from multiple tool calls to their outer index position(s)
* also return additional metadata to support multiple file reads in file mention text blocks
*/
private getPossibleDuplicateFileReads(
apiMessages: Anthropic.Messages.MessageParam[],
startFromIndex: number,
): [Map<string, [number, number, string, string][]>, Map<number, string[]>] {
// fileReadIndices: { fileName => [outerIndex, EditType, searchText, replaceText] }
// messageFilePaths: { outerIndex => [fileRead1, fileRead2, ..] }
// searchText in fileReadIndices is only required for file mention file-reads since there can be more than one file in the text
// searchText will be the empty string "" in the case that it's not required, for non-file mentions
// messageFilePaths is only used for file mentions as there can be multiple files read in the same text chunk
// for all text blocks per file, has info for updating the block
const fileReadIndices = new Map<string, [number, number, string, string][]>()
// for file mention text blocks, track all the unique files read
const messageFilePaths = new Map<number, string[]>()
for (let i = startFromIndex; i < apiMessages.length; i++) {
let thisExistingFileReads: string[] = []
if (this.contextHistoryUpdates.has(i)) {
const innerTuple = this.contextHistoryUpdates.get(i)
if (innerTuple) {
// safety check
const editType = innerTuple[0]
if (editType === EditType.FILE_MENTION) {
const innerMap = innerTuple[1]
const blockIndex = 1 // file mention blocks assumed to be at index 1
const blockUpdates = innerMap.get(blockIndex)
// if we have updated this text previously, we want to check whether the lists of files in the metadata are the same
if (blockUpdates && blockUpdates.length > 0) {
// the first list indicates the files we have replaced in this text, second list indicates all unique files in this text
// if they are equal then we have replaced all the files in this text already, and can ignore further processing
if (
blockUpdates[blockUpdates.length - 1][3][0].length ===
blockUpdates[blockUpdates.length - 1][3][1].length
) {
continue
}
// otherwise there are still file reads here we can overwrite, so still need to process this text chunk
// to do so we need to keep track of which files we've already replaced so we don't replace them again
else {
thisExistingFileReads = blockUpdates[blockUpdates.length - 1][3][0]
}
}
} else {
// for all other cases we can assume that we dont need to check this again
continue
}
}
}
const message = apiMessages[i]
if (message.role === "user" && Array.isArray(message.content) && message.content.length > 0) {
const firstBlock = message.content[0]
if (firstBlock.type === "text") {
const matchTup = this.parsePotentialToolCall(firstBlock.text)
let foundNormalFileRead = false
if (matchTup) {
if (matchTup[0] === "read_file") {
this.handleReadFileToolCall(i, matchTup[1], fileReadIndices)
foundNormalFileRead = true
} else if (matchTup[0] === "replace_in_file" || matchTup[0] === "write_to_file") {
if (message.content.length > 1) {
const secondBlock = message.content[1]
if (secondBlock.type === "text") {
this.handlePotentialFileChangeToolCalls(i, matchTup[1], secondBlock.text, fileReadIndices)
foundNormalFileRead = true
}
}
}
}
// file mentions can happen in most other user message blocks
if (!foundNormalFileRead) {
if (message.content.length > 1) {
const secondBlock = message.content[1]
if (secondBlock.type === "text") {
const [hasFileRead, filePaths] = this.handlePotentialFileMentionCalls(
i,
secondBlock.text,
fileReadIndices,
thisExistingFileReads, // file reads we've already replaced in this text in the latest version of this updated text
)
if (hasFileRead) {
messageFilePaths.set(i, filePaths) // all file paths in this string
}
}
}
}
}
}
}
return [fileReadIndices, messageFilePaths]
}
/**
* handles potential file content mentions in text blocks
* there will not be more than one of the same file read in a text block
*/
private handlePotentialFileMentionCalls(
i: number,
secondBlockText: string,
fileReadIndices: Map<string, [number, number, string, string][]>,
thisExistingFileReads: string[],
): [boolean, string[]] {
const pattern = new RegExp(`<file_content path="([^"]*)">([\\s\\S]*?)</file_content>`, "g")
let foundMatch = false
const filePaths: string[] = []
let match
while ((match = pattern.exec(secondBlockText)) !== null) {
foundMatch = true
const filePath = match[1]
filePaths.push(filePath) // we will record all unique paths from file mentions in this text
// we can assume that thisExistingFileReads does not have many entries
if (!thisExistingFileReads.includes(filePath)) {
// meaning we havent already replaced this file read
const entireMatch = match[0] // The entire matched string
// Create the replacement text - keep the tags but replace the content
const replacementText = `<file_content path="${filePath}">${formatResponse.duplicateFileReadNotice()}</file_content>`
const indices = fileReadIndices.get(filePath) || []
indices.push([i, EditType.FILE_MENTION, entireMatch, replacementText])
fileReadIndices.set(filePath, indices)
}
}
return [foundMatch, filePaths]
}
/**
* parses specific tool call formats, returns null if no acceptable format is found
*/
private parsePotentialToolCall(text: string): [string, string] | null {
const match = text.match(/^\[([^\s]+) for '([^']+)'\] Result:$/)
if (!match) {
return null
}
return [match[1], match[2]]
}
/**
* file_read tool call always pastes the file, so this is always a hit
*/
private handleReadFileToolCall(
i: number,
filePath: string,
fileReadIndices: Map<string, [number, number, string, string][]>,
) {
const indices = fileReadIndices.get(filePath) || []
indices.push([i, EditType.READ_FILE_TOOL, "", formatResponse.duplicateFileReadNotice()])
fileReadIndices.set(filePath, indices)
}
/**
* write_to_file and replace_in_file tool output are handled similarly
*/
private handlePotentialFileChangeToolCalls(
i: number,
filePath: string,
secondBlockText: string,
fileReadIndices: Map<string, [number, number, string, string][]>,
) {
const pattern = new RegExp(`(<final_file_content path="[^"]*">)[\\s\\S]*?(</final_file_content>)`)
// check if this exists in the text, it wont exist if the user rejects the file change for example
if (pattern.test(secondBlockText)) {
const replacementText = secondBlockText.replace(pattern, `$1 ${formatResponse.duplicateFileReadNotice()} $2`)
const indices = fileReadIndices.get(filePath) || []
indices.push([i, EditType.ALTER_FILE_TOOL, "", replacementText])
fileReadIndices.set(filePath, indices)
}
}
/**
* alter all occurrences of file read operations and track which messages were updated
* returns the outer index of messages we alter, to count number of changes
*/
private applyFileReadContextHistoryUpdates(
fileReadIndices: Map<string, [number, number, string, string][]>,
messageFilePaths: Map<number, string[]>,
apiMessages: Anthropic.Messages.MessageParam[],
timestamp: number,
): [boolean, Set<number>] {
let didUpdate = false
const updatedMessageIndices = new Set<number>() // track which messages we update on this round
const fileMentionUpdates = new Map<number, [string, string[]]>()
for (const [filePath, indices] of fileReadIndices.entries()) {
// Only process if there are multiple reads of the same file, else we will want to keep the latest read of the file
if (indices.length > 1) {
// Process all but the last index, as we will keep that instance of the file read
for (let i = 0; i < indices.length - 1; i++) {
const messageIndex = indices[i][0]
const messageType = indices[i][1] // EditType value
const searchText = indices[i][2] // search text (for file mentions, else empty string)
const messageString = indices[i][3] // what we will replace the string with
didUpdate = true
updatedMessageIndices.add(messageIndex)
// for single-fileread text we can set the updates here
// for potential multi-fileread text we need to determine all changes & iteratively update the text prior to saving the final change
if (messageType === EditType.FILE_MENTION) {
if (!fileMentionUpdates.has(messageIndex)) {
// Get base text either from existing updates or from apiMessages
let baseText = ""
let prevFilesReplaced: string[] = []
const innerTuple = this.contextHistoryUpdates.get(messageIndex)
if (innerTuple) {
const blockUpdates = innerTuple[1].get(1) // assumed index=1 for file mention filereads
if (blockUpdates && blockUpdates.length > 0) {
baseText = blockUpdates[blockUpdates.length - 1][2][0] // index 0 of MessageContent
prevFilesReplaced = blockUpdates[blockUpdates.length - 1][3][0] // previously overwritten file reads in this text
}
}
// can assume that this content will exist, otherwise it would not have been in fileReadIndices
const messageContent = apiMessages[messageIndex]?.content
if (!baseText && Array.isArray(messageContent) && messageContent.length > 1) {
const contentBlock = messageContent[1] // assume index=1 for all text to replace for file mention filereads
if (contentBlock.type === "text") {
baseText = contentBlock.text
}
}
// prevFilesReplaced keeps track of the previous file reads we've replace in this string, empty array if none
fileMentionUpdates.set(messageIndex, [baseText, prevFilesReplaced])
}
// Replace searchText with messageString for all file reads we need to replace in this text
if (searchText) {
const currentTuple = fileMentionUpdates.get(messageIndex) || ["", []]
if (currentTuple[0]) {
// safety check
// replace this text chunk
const updatedText = currentTuple[0].replace(searchText, messageString)
// add the newly added filePath read
const updatedFileReads = currentTuple[1]
updatedFileReads.push(filePath)
fileMentionUpdates.set(messageIndex, [updatedText, updatedFileReads])
}
}
} else {
let innerTuple = this.contextHistoryUpdates.get(messageIndex)
let innerMap: Map<number, ContextUpdate[]>
if (!innerTuple) {
innerMap = new Map<number, ContextUpdate[]>()
this.contextHistoryUpdates.set(messageIndex, [messageType, innerMap])
} else {
innerMap = innerTuple[1]
}
// block index for file reads from read_file, write_to_file, replace_in_file tools is 1
const blockIndex = 1
const updates = innerMap.get(blockIndex) || []
// metadata array is empty for non-file mention occurrences
updates.push([timestamp, "text", [messageString], []])
innerMap.set(blockIndex, updates)
}
}
}
}
// apply file mention updates to contextHistoryUpdates
// in fileMentionUpdates, filePathsUpdated includes all the file paths which are updated in the latest version of this altered text
for (const [messageIndex, [updatedText, filePathsUpdated]] of fileMentionUpdates.entries()) {
let innerTuple = this.contextHistoryUpdates.get(messageIndex)
let innerMap: Map<number, ContextUpdate[]>
if (!innerTuple) {
innerMap = new Map<number, ContextUpdate[]>()
this.contextHistoryUpdates.set(messageIndex, [EditType.FILE_MENTION, innerMap])
} else {
innerMap = innerTuple[1]
}
const blockIndex = 1 // we only consider the block index of 1 for file mentions
const updates = innerMap.get(blockIndex) || []
// filePathsUpdated includes changes done previously to this timestamp, and right now
if (messageFilePaths.has(messageIndex)) {
const allFileReads = messageFilePaths.get(messageIndex)
if (allFileReads) {
// safety check
// we gather all the file reads possible in this text from messageFilePaths
// filePathsUpdated from fileMentionUpdates stores all the files reads we have replaced now & previously
updates.push([timestamp, "text", [updatedText], [filePathsUpdated, allFileReads]])
innerMap.set(blockIndex, updates)
}
}
}
return [didUpdate, updatedMessageIndices]
}
/**
* count total characters in messages and total savings within this range
*/
private countCharactersAndSavingsInRange(
apiMessages: Anthropic.Messages.MessageParam[],
startIndex: number,
endIndex: number,
uniqueFileReadIndices: Set<number>,
): { totalCharacters: number; charactersSaved: number } {
let totalCharCount = 0
let totalCharactersSaved = 0
for (let i = startIndex; i < endIndex; i++) {
// looping over the outer indicies of messages
const message = apiMessages[i]
if (!message.content) {
continue
}
// hasExistingAlterations checks whether the outer idnex has any changes
// hasExistingAlterations will also include the alterations we just made
const hasExistingAlterations = this.contextHistoryUpdates.has(i)
const hasNewAlterations = uniqueFileReadIndices.has(i)
if (Array.isArray(message.content)) {
for (let blockIndex = 0; blockIndex < message.content.length; blockIndex++) {
// looping over inner indices of messages
const block = message.content[blockIndex]
if (block.type === "text" && block.text) {
// true if we just altered it, or it was altered before
if (hasExistingAlterations) {
const innerTuple = this.contextHistoryUpdates.get(i)
const updates = innerTuple?.[1].get(blockIndex) // updated text for this inner index
if (updates && updates.length > 0) {
// exists if we have an update for the message at this index
const latestUpdate = updates[updates.length - 1]
// if block was just altered, then calculate savings
if (hasNewAlterations) {
let originalTextLength
if (updates.length > 1) {
originalTextLength = updates[updates.length - 2][2][0].length // handles case if we have multiple updates for same text block
} else {
originalTextLength = block.text.length
}
const newTextLength = latestUpdate[2][0].length // replacement text
totalCharactersSaved += originalTextLength - newTextLength
totalCharCount += originalTextLength
} else {
// meaning there was an update to this text previously, but we didnt just alter it
totalCharCount += latestUpdate[2][0].length
}
} else {
// reach here if there was one inner index with an update, but now we are at a different index, so updates is not defined
totalCharCount += block.text.length
}
} else {
// reach here if there's no alterations for this outer index, meaning each inner index wont have any changes either
totalCharCount += block.text.length
}
} else if (block.type === "image" && block.source) {
if (block.source.type === "base64" && block.source.data) {
totalCharCount += block.source.data.length
}
}
}
}
}
return { totalCharacters: totalCharCount, charactersSaved: totalCharactersSaved }
}
/**
* count total percentage character savings across in-range conversation
*/
private calculateContextOptimizationMetrics(
apiMessages: Anthropic.Messages.MessageParam[],
conversationHistoryDeletedRange: [number, number] | undefined,
uniqueFileReadIndices: Set<number>,
): number {
// count for first user-assistant message pair
const firstChunkResult = this.countCharactersAndSavingsInRange(apiMessages, 0, 2, uniqueFileReadIndices)
// count for the remaining in-range messages
const secondChunkResult = this.countCharactersAndSavingsInRange(
apiMessages,
conversationHistoryDeletedRange ? conversationHistoryDeletedRange[1] + 1 : 2,
apiMessages.length,
uniqueFileReadIndices,
)
const totalCharacters = firstChunkResult.totalCharacters + secondChunkResult.totalCharacters
const totalCharactersSaved = firstChunkResult.charactersSaved + secondChunkResult.charactersSaved
const percentCharactersSaved = totalCharacters === 0 ? 0 : totalCharactersSaved / totalCharacters
return percentCharactersSaved
const [start, end] = deletedRange
// the range is inclusive - both start and end indices and everything in between will be removed from the final result.
// NOTE: if you try to console log these, don't forget that logging a reference to an array may not provide the same result as logging a slice() snapshot of that array at that exact moment. The following DOES in fact include the latest assistant message.
return [...messages.slice(0, start), ...messages.slice(end + 1)]
}
}
@@ -1,155 +0,0 @@
import { ContextManager } from "../ContextManager"
import { Anthropic } from "@anthropic-ai/sdk"
import { expect } from "chai"
describe("ContextManager", () => {
function createMessages(count: number): Anthropic.Messages.MessageParam[] {
const messages: Anthropic.Messages.MessageParam[] = []
messages.push({
role: "user",
content: "Initial task message",
})
let role: "user" | "assistant" = "assistant"
for (let i = 1; i < count; i++) {
messages.push({
role,
content: `Message ${i}`,
})
role = role === "user" ? "assistant" : "user"
}
return messages
}
describe("getNextTruncationRange", () => {
let contextManager: ContextManager
beforeEach(() => {
contextManager = new ContextManager()
})
it("first truncation with half keep", () => {
const messages = createMessages(11)
const result = contextManager.getNextTruncationRange(messages, undefined, "half")
expect(result).to.deep.equal([1, 4])
})
it("first truncation with quarter keep", () => {
const messages = createMessages(11)
const result = contextManager.getNextTruncationRange(messages, undefined, "quarter")
expect(result).to.deep.equal([1, 6])
})
it("sequential truncation with half keep", () => {
const messages = createMessages(21)
const firstRange = contextManager.getNextTruncationRange(messages, undefined, "half")
expect(firstRange).to.deep.equal([1, 10])
// Pass the previous range for sequential truncation
const secondRange = contextManager.getNextTruncationRange(messages, firstRange, "half")
expect(secondRange).to.deep.equal([1, 14])
})
it("sequential truncation with quarter keep", () => {
const messages = createMessages(41)
const firstRange = contextManager.getNextTruncationRange(messages, undefined, "quarter")
const secondRange = contextManager.getNextTruncationRange(messages, firstRange, "quarter")
expect(secondRange[0]).to.equal(1)
expect(secondRange[1]).to.be.greaterThan(firstRange[1])
})
it("ensures the last message in range is a user message", () => {
const messages = createMessages(14)
const result = contextManager.getNextTruncationRange(messages, undefined, "half")
// Check if the message at the end of range is a user message
const lastRemovedMessage = messages[result[1]]
expect(lastRemovedMessage.role).to.equal("user")
// Check if the next message after the range is an assistant message
const nextMessage = messages[result[1] + 1]
expect(nextMessage.role).to.equal("assistant")
})
it("handles small message arrays", () => {
const messages = createMessages(3)
const result = contextManager.getNextTruncationRange(messages, undefined, "half")
expect(result).to.deep.equal([1, 0])
})
it("preserves the message structure when truncating", () => {
const messages = createMessages(20)
const result = contextManager.getNextTruncationRange(messages, undefined, "half")
// Get messages after removing the range
const effectiveMessages = [...messages.slice(0, result[0]), ...messages.slice(result[1] + 1)]
// Check first message and alternating pattern
expect(effectiveMessages[0].role).to.equal("user")
for (let i = 1; i < effectiveMessages.length; i++) {
const expectedRole = i % 2 === 1 ? "assistant" : "user"
expect(effectiveMessages[i].role).to.equal(expectedRole)
}
})
})
describe("getTruncatedMessages", () => {
let contextManager: ContextManager
beforeEach(() => {
contextManager = new ContextManager()
})
it("returns original messages when no range is provided", () => {
const messages = createMessages(3)
const result = contextManager.getTruncatedMessages(messages, undefined)
expect(result).to.deep.equal(messages)
})
it("correctly removes messages in the specified range", () => {
const messages = createMessages(5)
const range: [number, number] = [1, 3]
const result = contextManager.getTruncatedMessages(messages, range)
expect(result).to.have.lengthOf(2)
expect(result[0]).to.deep.equal(messages[0])
expect(result[1]).to.deep.equal(messages[4])
})
it("works with a range that starts at the first message after task", () => {
const messages = createMessages(4)
const range: [number, number] = [1, 2]
const result = contextManager.getTruncatedMessages(messages, range)
expect(result).to.have.lengthOf(2)
expect(result[0]).to.deep.equal(messages[0])
expect(result[1]).to.deep.equal(messages[3])
})
it("correctly handles removing a range while preserving alternation pattern", () => {
const messages = createMessages(5)
const range: [number, number] = [1, 2]
const result = contextManager.getTruncatedMessages(messages, range)
expect(result).to.have.lengthOf(3)
expect(result[0]).to.deep.equal(messages[0])
expect(result[1]).to.deep.equal(messages[3])
expect(result[2]).to.deep.equal(messages[4])
expect(result[0].role).to.equal("user")
expect(result[1].role).to.equal("assistant")
expect(result[2].role).to.equal("user")
})
})
})
@@ -1,271 +0,0 @@
import { describe, it, beforeEach, afterEach } from "mocha"
import { expect } from "chai"
import * as sinon from "sinon"
import * as vscode from "vscode"
import * as path from "path"
import { FileContextTracker } from "./FileContextTracker"
import * as diskModule from "../storage/disk"
import type { TaskMetadata, ControllerLike, FileMetadataEntry } from "./FileContextTrackerTypes"
describe("FileContextTracker", () => {
let sandbox: sinon.SinonSandbox
let mockController: ControllerLike
let mockContext: vscode.ExtensionContext
let mockWorkspace: sinon.SinonStub
let mockFileSystemWatcher: any
let tracker: FileContextTracker
let taskId: string
let mockTaskMetadata: TaskMetadata
let getTaskMetadataStub: sinon.SinonStub
let saveTaskMetadataStub: sinon.SinonStub
beforeEach(() => {
sandbox = sinon.createSandbox()
// Mock vscode workspace
mockWorkspace = sandbox.stub(vscode.workspace, "workspaceFolders").value([
{
uri: {
fsPath: "/mock/workspace",
},
} as vscode.WorkspaceFolder,
])
// Mock file system watcher
mockFileSystemWatcher = {
dispose: sandbox.stub(),
onDidChange: sandbox.stub().returns({ dispose: () => {} }),
}
// Use a function replacement instead of a direct stub
const originalCreateFileSystemWatcher = vscode.workspace.createFileSystemWatcher
vscode.workspace.createFileSystemWatcher = function () {
return mockFileSystemWatcher
} as any
// Mock controller and context
mockContext = {
globalStorageUri: { fsPath: "/mock/storage" },
} as unknown as vscode.ExtensionContext
mockController = {
context: mockContext,
}
// Mock disk module functions
mockTaskMetadata = { files_in_context: [] }
getTaskMetadataStub = sandbox.stub(diskModule, "getTaskMetadata").resolves(mockTaskMetadata)
saveTaskMetadataStub = sandbox.stub(diskModule, "saveTaskMetadata").resolves()
// Create tracker instance
taskId = "test-task-id"
tracker = new FileContextTracker(mockController, taskId)
})
afterEach(() => {
sandbox.restore()
})
it("should add a record when a file is read by a tool", async () => {
const filePath = "src/test-file.ts"
await tracker.trackFileContext(filePath, "read_tool")
// Verify getTaskMetadata was called
expect(getTaskMetadataStub.calledOnce).to.be.true
expect(getTaskMetadataStub.firstCall.args[1]).to.equal(taskId)
// Verify saveTaskMetadata was called with the correct data
expect(saveTaskMetadataStub.calledOnce).to.be.true
const savedMetadata = saveTaskMetadataStub.firstCall.args[2]
expect(savedMetadata.files_in_context.length).to.equal(1)
const fileEntry = savedMetadata.files_in_context[0]
expect(fileEntry.path).to.equal(filePath)
expect(fileEntry.record_state).to.equal("active")
expect(fileEntry.record_source).to.equal("read_tool")
expect(fileEntry.cline_read_date).to.be.a("number")
expect(fileEntry.cline_edit_date).to.be.null
})
it("should add a record when a file is edited by Cline", async () => {
const filePath = "src/test-file.ts"
await tracker.trackFileContext(filePath, "cline_edited")
// Verify saveTaskMetadata was called with the correct data
expect(saveTaskMetadataStub.calledOnce).to.be.true
const savedMetadata = saveTaskMetadataStub.firstCall.args[2]
// Check that we have at least one entry in files_in_context
expect(savedMetadata.files_in_context).to.be.an("array").that.is.not.empty
// Find the active entry for this file
const activeEntry = savedMetadata.files_in_context.find(
(entry: FileMetadataEntry) => entry.path === filePath && entry.record_state === "active",
)
// Assert that we found an active entry
expect(activeEntry).to.exist
// Now check the properties of the active entry
expect(activeEntry.path).to.equal(filePath)
expect(activeEntry.record_state).to.equal("active")
expect(activeEntry.record_source).to.equal("cline_edited")
expect(activeEntry.cline_read_date).to.be.a("number")
expect(activeEntry.cline_edit_date).to.be.a("number")
})
it("should add a record when a file is mentioned", async () => {
const filePath = "src/test-file.ts"
await tracker.trackFileContext(filePath, "file_mentioned")
// Verify saveTaskMetadata was called with the correct data
const savedMetadata = saveTaskMetadataStub.firstCall.args[2]
const fileEntry = savedMetadata.files_in_context[0]
expect(fileEntry.path).to.equal(filePath)
expect(fileEntry.record_state).to.equal("active")
expect(fileEntry.record_source).to.equal("file_mentioned")
expect(fileEntry.cline_read_date).to.be.a("number")
expect(fileEntry.cline_edit_date).to.be.null
})
it("should add a record when a file is edited by the user", async () => {
const filePath = "src/test-file.ts"
await tracker.trackFileContext(filePath, "user_edited")
// Verify saveTaskMetadata was called with the correct data
const savedMetadata = saveTaskMetadataStub.firstCall.args[2]
const fileEntry = savedMetadata.files_in_context[0]
expect(fileEntry.path).to.equal(filePath)
expect(fileEntry.record_state).to.equal("active")
expect(fileEntry.record_source).to.equal("user_edited")
expect(fileEntry.user_edit_date).to.be.a("number")
// Verify the file was added to recentlyModifiedFiles
const modifiedFiles = tracker.getAndClearRecentlyModifiedFiles()
expect(modifiedFiles).to.include(filePath)
})
it("should mark existing entries as stale when adding a new entry for the same file", async () => {
const filePath = "src/test-file.ts"
// Add an initial entry
mockTaskMetadata.files_in_context = [
{
path: filePath,
record_state: "active",
record_source: "read_tool",
cline_read_date: Date.now() - 1000, // 1 second ago
cline_edit_date: null,
user_edit_date: null,
},
]
// Track a new operation on the same file
await tracker.trackFileContext(filePath, "cline_edited")
// Verify the metadata now has two entries - one stale and one active
const savedMetadata = saveTaskMetadataStub.firstCall.args[2]
expect(savedMetadata.files_in_context.length).to.equal(2)
// First entry should be marked as stale
expect(savedMetadata.files_in_context[0].record_state).to.equal("stale")
// New entry should be active
const newEntry = savedMetadata.files_in_context[1]
expect(newEntry.record_state).to.equal("active")
expect(newEntry.record_source).to.equal("cline_edited")
})
it("should setup a file watcher for tracked files", async () => {
const filePath = "src/test-file.ts"
// Create a spy to track if createFileSystemWatcher was called
const createWatcherSpy = sinon.spy(vscode.workspace, "createFileSystemWatcher")
await tracker.trackFileContext(filePath, "read_tool")
// Verify createFileSystemWatcher was called
expect(createWatcherSpy.called).to.be.true
createWatcherSpy.restore()
// Verify onDidChange was called to set up the change listener
expect(mockFileSystemWatcher.onDidChange.called).to.be.true
})
it("should track user edits when file watcher detects changes", async () => {
const filePath = "src/test-file.ts"
// First track the file to set up the watcher
await tracker.trackFileContext(filePath, "read_tool")
// Reset the stubs to check the next calls
getTaskMetadataStub.resetHistory()
saveTaskMetadataStub.resetHistory()
// Create a spy on trackFileContext to verify it's called with the right parameters
const trackFileContextSpy = sandbox.spy(tracker, "trackFileContext")
// Get the callback that was registered with onDidChange
const callback = mockFileSystemWatcher.onDidChange.firstCall.args[0]
// Directly call the callback to simulate a file change event
callback(vscode.Uri.file(path.resolve("/mock/workspace", filePath)))
// Verify trackFileContext was called with the right parameters
expect(trackFileContextSpy.calledWith(filePath, "user_edited")).to.be.true
// Verify the file was added to recentlyModifiedFiles
const modifiedFiles = tracker.getAndClearRecentlyModifiedFiles()
expect(modifiedFiles).to.include(filePath)
})
it("should not track Cline edits as user edits", async () => {
const filePath = "src/test-file.ts"
// First track the file to set up the watcher
await tracker.trackFileContext(filePath, "read_tool")
// Mark the file as edited by Cline
tracker.markFileAsEditedByCline(filePath)
// Reset the stubs to check the next calls
getTaskMetadataStub.resetHistory()
saveTaskMetadataStub.resetHistory()
// Create a spy on trackFileContext to verify it's not called
const trackFileContextSpy = sandbox.spy(tracker, "trackFileContext")
// Get the callback that was registered with onDidChange
const callback = mockFileSystemWatcher.onDidChange.firstCall.args[0]
// Directly call the callback to simulate a file change event
callback(vscode.Uri.file(path.resolve("/mock/workspace", filePath)))
// Verify trackFileContext was not called with user_edited
expect(trackFileContextSpy.calledWith(filePath, "user_edited")).to.be.false
// Verify the file was not added to recentlyModifiedFiles
const modifiedFiles = tracker.getAndClearRecentlyModifiedFiles()
expect(modifiedFiles).to.not.include(filePath)
})
it("should dispose file watchers when dispose is called", async () => {
const filePath = "src/test-file.ts"
// Track a file to set up the watcher
await tracker.trackFileContext(filePath, "read_tool")
// Call dispose
tracker.dispose()
// Verify the watcher was disposed
expect(mockFileSystemWatcher.dispose.called).to.be.true
})
})
@@ -1,187 +0,0 @@
import * as path from "path"
import * as vscode from "vscode"
import { getTaskMetadata, saveTaskMetadata } from "../storage/disk"
import type { FileMetadataEntry, ControllerLike } from "./FileContextTrackerTypes"
// This class is responsible for tracking file operations that may result in stale context.
// If a user modifies a file outside of Cline, the context may become stale and need to be updated.
// We do not want Cline to reload the context every time a file is modified, so we use this class merely
// to inform Cline that the change has occurred, and tell Cline to reload the file before making
// any changes to it. This fixes an issue with diff editing, where Cline was unable to complete a diff edit.
// a diff edit because the file was modified since Cline last read it.
// FileContextTracker
//
// This class is responsible for tracking file operations.
// If the full contents of a file are pass to Cline via a tool, mention, or edit, the file is marked as active.
// If a file is modified outside of Cline, we detect and track this change to prevent stale context.
export class FileContextTracker {
readonly taskId: string
private controllerRef: WeakRef<ControllerLike>
// File tracking and watching
private fileWatchers = new Map<string, vscode.FileSystemWatcher>()
private recentlyModifiedFiles = new Set<string>()
private recentlyEditedByCline = new Set<string>()
constructor(controller: ControllerLike, taskId: string) {
this.controllerRef = new WeakRef(controller)
this.taskId = taskId
}
// While a task is ref'd by a controller, it will always have access to the extension context
// This error is thrown if the controller derefs the task after e.g., aborting the task
private context(): vscode.ExtensionContext {
const context = this.controllerRef.deref()?.context
if (!context) {
throw new Error("Unable to access extension context")
}
return context
}
// Gets the current working directory or returns undefined if it cannot be determined
private getCwd(): string | undefined {
const cwd = vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath).at(0)
if (!cwd) {
console.info("No workspace folder available - cannot determine current working directory")
}
return cwd
}
// File watchers are set up for each file that is tracked in the task metadata.
async setupFileWatcher(filePath: string) {
// Only setup watcher if it doesn't already exist for this file
if (this.fileWatchers.has(filePath)) {
return
}
const cwd = this.getCwd()
if (!cwd) {
return
}
// Create a file system watcher for this specific file
const fileUri = vscode.Uri.file(path.resolve(cwd, filePath))
const watcher = vscode.workspace.createFileSystemWatcher(
new vscode.RelativePattern(path.dirname(fileUri.fsPath), path.basename(fileUri.fsPath)),
)
// Track file changes
watcher.onDidChange(() => {
if (this.recentlyEditedByCline.has(filePath)) {
this.recentlyEditedByCline.delete(filePath) // This was an edit by Cline, no need to inform Cline
} else {
this.recentlyModifiedFiles.add(filePath) // This was a user edit, we will inform Cline
this.trackFileContext(filePath, "user_edited") // Update the task metadata with file tracking
}
})
// Store the watcher so we can dispose it later
this.fileWatchers.set(filePath, watcher)
}
// Tracks a file operation in metadata and sets up a watcher for the file
// This is the main entry point for FileContextTracker and is called when a file is passed to Cline via a tool, mention, or edit.
async trackFileContext(filePath: string, operation: "read_tool" | "user_edited" | "cline_edited" | "file_mentioned") {
try {
const cwd = this.getCwd()
if (!cwd) {
return
}
const context = this.context()
// Add file to metadata
await this.addFileToFileContextTracker(context, this.taskId, filePath, operation)
// Set up file watcher for this file
await this.setupFileWatcher(filePath)
} catch (error) {
console.error("Failed to track file operation:", error)
}
}
// Adds a file to the metadata tracker
// This handles the business logic of determining if the file is new, stale, or active.
// It also updates the metadata with the latest read/edit dates.
async addFileToFileContextTracker(
context: vscode.ExtensionContext,
taskId: string,
filePath: string,
source: FileMetadataEntry["record_source"],
) {
try {
const metadata = await getTaskMetadata(context, taskId)
const now = Date.now()
// Mark existing entries for this file as stale
metadata.files_in_context.forEach((entry) => {
if (entry.path === filePath && entry.record_state === "active") {
entry.record_state = "stale"
}
})
// Helper to get the latest date for a specific field and file
const getLatestDateForField = (path: string, field: keyof FileMetadataEntry): number | null => {
const relevantEntries = metadata.files_in_context
.filter((entry) => entry.path === path && entry[field])
.sort((a, b) => (b[field] as number) - (a[field] as number))
return relevantEntries.length > 0 ? (relevantEntries[0][field] as number) : null
}
let newEntry: FileMetadataEntry = {
path: filePath,
record_state: "active",
record_source: source,
cline_read_date: getLatestDateForField(filePath, "cline_read_date"),
cline_edit_date: getLatestDateForField(filePath, "cline_edit_date"),
user_edit_date: getLatestDateForField(filePath, "user_edit_date"),
}
switch (source) {
// user_edited: The user has edited the file
case "user_edited":
newEntry.user_edit_date = now
this.recentlyModifiedFiles.add(filePath)
break
// cline_edited: Cline has edited the file
case "cline_edited":
newEntry.cline_read_date = now
newEntry.cline_edit_date = now
break
// read_tool/file_mentioned: Cline has read the file via a tool or file mention
case "read_tool":
case "file_mentioned":
newEntry.cline_read_date = now
break
}
metadata.files_in_context.push(newEntry)
await saveTaskMetadata(context, taskId, metadata)
} catch (error) {
console.error("Failed to add file to metadata:", error)
}
}
// Returns (and then clears) the set of recently modified files
getAndClearRecentlyModifiedFiles(): string[] {
const files = Array.from(this.recentlyModifiedFiles)
this.recentlyModifiedFiles.clear()
return files
}
// Marks a file as edited by Cline to prevent false positives in file watchers
markFileAsEditedByCline(filePath: string): void {
this.recentlyEditedByCline.add(filePath)
}
// Disposes all file watchers
dispose(): void {
for (const watcher of this.fileWatchers.values()) {
watcher.dispose()
}
this.fileWatchers.clear()
}
}
@@ -1,20 +0,0 @@
import * as vscode from "vscode"
// Type definitions for FileContextTracker
export interface FileMetadataEntry {
path: string
record_state: "active" | "stale"
record_source: "read_tool" | "user_edited" | "cline_edited" | "file_mentioned"
cline_read_date: number | null
cline_edit_date: number | null
user_edit_date?: number | null
}
export interface TaskMetadata {
files_in_context: FileMetadataEntry[]
}
// Interface for the controller to avoid direct dependency
export interface ControllerLike {
context: vscode.ExtensionContext
}
+16 -251
View File
@@ -43,11 +43,7 @@ import {
updateGlobalState,
} from "../storage/state"
import { WebviewProvider } from "../webview"
import { BrowserSession } from "../../services/browser/BrowserSession"
import { GlobalFileNames } from "../storage/disk"
import { discoverChromeInstances } from "../../services/browser/BrowserDiscovery"
import { searchWorkspaceFiles } from "../../services/search/file-search"
import { getWorkspacePath } from "../../utils/path"
/*
https://github.com/microsoft/vscode-webview-ui-toolkit-samples/blob/main/default/weather-webview/src/providers/WeatherViewProvider.ts
@@ -61,7 +57,7 @@ export class Controller {
workspaceTracker?: WorkspaceTracker
mcpHub?: McpHub
accountService?: ClineAccountService
private latestAnnouncementId = "april-7-2025" // update to some unique identifier when we add a new announcement
private latestAnnouncementId = "march-22-2025" // update to some unique identifier when we add a new announcement
private webviewProviderRef: WeakRef<WebviewProvider>
constructor(
@@ -173,22 +169,9 @@ export class Controller {
case "addRemoteServer": {
try {
await this.mcpHub?.addRemoteServer(message.serverName!, message.serverUrl!)
await this.postMessageToWebview({
type: "addRemoteServerResult",
addRemoteServerResult: {
success: true,
serverName: message.serverName!,
},
})
} catch (error) {
await this.postMessageToWebview({
type: "addRemoteServerResult",
addRemoteServerResult: {
success: false,
serverName: message.serverName!,
error: error.message,
},
})
// We handle the errorin McpHub.ts where the function is defined
console.error(`Failed to add remote server ${message.serverName}:`, error)
}
break
}
@@ -281,12 +264,6 @@ export class Controller {
break
case "browserSettings":
if (message.browserSettings) {
// remoteBrowserEnabled now means "enable remote browser connection"
// commenting out since this is being done in BrowserSettingsSection updateRemoteBrowserEnabled
// if (!message.browserSettings.remoteBrowserEnabled) {
// // If disabling remote browser connection, clear the remoteBrowserHost
// message.browserSettings.remoteBrowserHost = undefined
// }
await updateGlobalState(this.context, "browserSettings", message.browserSettings)
if (this.task) {
this.task.browserSettings = message.browserSettings
@@ -295,123 +272,6 @@ export class Controller {
await this.postStateToWebview()
}
break
case "getBrowserConnectionInfo":
try {
// Get the current browser session from Cline if it exists
if (this.task?.browserSession) {
const connectionInfo = this.task.browserSession.getConnectionInfo()
await this.postMessageToWebview({
type: "browserConnectionInfo",
isConnected: connectionInfo.isConnected,
isRemote: connectionInfo.isRemote,
host: connectionInfo.host,
})
} else {
// If no active browser session, just return the settings
const { browserSettings } = await getAllExtensionState(this.context)
await this.postMessageToWebview({
type: "browserConnectionInfo",
isConnected: false,
isRemote: !!browserSettings.remoteBrowserEnabled,
host: browserSettings.remoteBrowserHost,
})
}
} catch (error) {
console.error("Error getting browser connection info:", error)
await this.postMessageToWebview({
type: "browserConnectionInfo",
isConnected: false,
isRemote: false,
})
}
break
case "testBrowserConnection":
try {
const { browserSettings } = await getAllExtensionState(this.context)
const browserSession = new BrowserSession(this.context, browserSettings)
// If no text is provided, try auto-discovery
if (!message.text) {
try {
const discoveredHost = await discoverChromeInstances()
if (discoveredHost) {
// Test the connection to the discovered host
const result = await browserSession.testConnection(discoveredHost)
// Send the result back to the webview
await this.postMessageToWebview({
type: "browserConnectionResult",
success: result.success,
text: `Auto-discovered and tested connection to Chrome at ${discoveredHost}: ${result.message}`,
endpoint: result.endpoint,
})
} else {
await this.postMessageToWebview({
type: "browserConnectionResult",
success: false,
text: "No Chrome instances found on the network. Make sure Chrome is running with remote debugging enabled (--remote-debugging-port=9222).",
})
}
} catch (error) {
await this.postMessageToWebview({
type: "browserConnectionResult",
success: false,
text: `Error during auto-discovery: ${error instanceof Error ? error.message : String(error)}`,
})
}
} else {
// Test the provided URL
const result = await browserSession.testConnection(message.text)
// Send the result back to the webview
await this.postMessageToWebview({
type: "browserConnectionResult",
success: result.success,
text: result.message,
endpoint: result.endpoint,
})
}
} catch (error) {
await this.postMessageToWebview({
type: "browserConnectionResult",
success: false,
text: `Error testing connection: ${error instanceof Error ? error.message : String(error)}`,
})
}
break
case "discoverBrowser":
try {
const discoveredHost = await discoverChromeInstances()
if (discoveredHost) {
// Don't update the remoteBrowserHost state when auto-discovering
// This way we don't override the user's preference
// Test the connection to get the endpoint
const { browserSettings } = await getAllExtensionState(this.context)
const browserSession = new BrowserSession(this.context, browserSettings)
const result = await browserSession.testConnection(discoveredHost)
// Send the result back to the webview
await this.postMessageToWebview({
type: "browserConnectionResult",
success: true,
text: `Successfully discovered and connected to Chrome at ${discoveredHost}`,
endpoint: result.endpoint,
})
} else {
await this.postMessageToWebview({
type: "browserConnectionResult",
success: false,
text: "No Chrome instances found on the network. Make sure Chrome is running with remote debugging enabled (--remote-debugging-port=9222).",
})
}
} catch (error) {
await this.postMessageToWebview({
type: "browserConnectionResult",
success: false,
text: `Error discovering browser: ${error instanceof Error ? error.message : String(error)}`,
})
}
break
case "togglePlanActMode":
if (message.chatSettings) {
await this.togglePlanActModeWithChatSettings(message.chatSettings, message.chatContent)
@@ -424,11 +284,11 @@ export class Controller {
text: message.text,
})
break
case "relaunchChromeDebugMode":
const { browserSettings } = await getAllExtensionState(this.context)
const browserSession = new BrowserSession(this.context, browserSettings)
await browserSession.relaunchChromeDebugMode(this)
break
// case "relaunchChromeDebugMode":
// if (this.task) {
// this.task.browserSession.relaunchChromeDebugMode()
// }
// break
case "askResponse":
this.task?.handleWebviewAskResponse(message.askResponse!, message.text, message.images)
break
@@ -597,7 +457,14 @@ export class Controller {
await this.togglePlanActModeWithChatSettings({ mode: "act" })
}
// 2. download MCP
// 2. Enable MCP settings if disabled
// Enable MCP mode if disabled
const mcpConfig = vscode.workspace.getConfiguration("cline.mcp")
if (mcpConfig.get<string>("mode") !== "full") {
await mcpConfig.update("mode", "full", true)
}
// 3. download MCP
await this.downloadMcp(message.mcpId)
}
break
@@ -741,13 +608,6 @@ export class Controller {
})
break
}
case "scrollToSettings": {
await this.postMessageToWebview({
type: "scrollToSettings",
text: message.text,
})
break
}
case "telemetrySetting": {
if (message.telemetrySetting) {
await this.updateTelemetrySetting(message.telemetrySetting)
@@ -788,100 +648,6 @@ export class Controller {
this.postMessageToWebview({ type: "relinquishControl" })
break
}
case "getDetectedChromePath": {
try {
const { browserSettings } = await getAllExtensionState(this.context)
const browserSession = new BrowserSession(this.context, browserSettings)
const { path, isBundled } = await browserSession.getDetectedChromePath()
await this.postMessageToWebview({
type: "detectedChromePath",
text: path,
isBundled,
})
} catch (error) {
console.error("Error getting detected Chrome path:", error)
}
break
}
case "getRelativePaths": {
if (message.uris && message.uris.length > 0) {
const resolvedPaths = await Promise.all(
message.uris.map(async (uriString) => {
try {
const fileUri = vscode.Uri.parse(uriString, true)
const relativePath = vscode.workspace.asRelativePath(fileUri, false)
if (path.isAbsolute(relativePath)) {
console.warn(`Dropped file ${relativePath} is outside the workspace. Sending original path.`)
return fileUri.fsPath.replace(/\\/g, "/")
} else {
let finalPath = "/" + relativePath.replace(/\\/g, "/")
try {
const stat = await vscode.workspace.fs.stat(fileUri)
if (stat.type === vscode.FileType.Directory) {
finalPath += "/"
}
} catch (statError) {
console.error(`Error stating file ${fileUri.fsPath}:`, statError)
}
return finalPath
}
} catch (error) {
console.error(`Error calculating relative path for ${uriString}:`, error)
return null
}
}),
)
await this.postMessageToWebview({
type: "relativePathsResponse",
paths: resolvedPaths,
})
}
break
}
case "searchFiles": {
const workspacePath = getWorkspacePath()
if (!workspacePath) {
// Handle case where workspace path is not available
await this.postMessageToWebview({
type: "fileSearchResults",
results: [],
mentionsRequestId: message.mentionsRequestId,
error: "No workspace path available",
})
break
}
try {
// Call file search service with query from message
const results = await searchWorkspaceFiles(
message.query || "",
workspacePath,
20, // Use default limit, as filtering is now done in the backend
)
// debug logging to be removed
//console.log(`controller/index.ts: Search results: ${results.length}`)
// Send results back to webview
await this.postMessageToWebview({
type: "fileSearchResults",
results,
mentionsRequestId: message.mentionsRequestId,
})
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error)
// Send error response to webview
await this.postMessageToWebview({
type: "fileSearchResults",
results: [],
error: errorMessage,
mentionsRequestId: message.mentionsRequestId,
})
}
break
}
// Add more switch case statements here as more webview message commands
// are created within the webview context (i.e. inside media/main.js)
}
@@ -1359,7 +1125,6 @@ export class Controller {
// Create task with context from README and added guidelines for MCP server installation
const task = `Set up the MCP server from ${mcpDetails.githubUrl} while adhering to these MCP server installation rules:
- Start by loading the MCP documentation.
- Use "${mcpDetails.mcpId}" as the server name in cline_mcp_settings.json.
- Create the directory for the new MCP server before starting installation.
- Use commands aligned with the user's shell and operating system best practices.
+1 -11
View File
@@ -10,7 +10,6 @@ import { diagnosticsToProblemsString } from "../../integrations/diagnostics"
import { getLatestTerminalOutput } from "../../integrations/terminal/get-latest-output"
import { getCommitInfo } from "../../utils/git"
import { getWorkingState } from "../../utils/git"
import { FileContextTracker } from "../context-tracking/FileContextTracker"
export function openMention(mention?: string): void {
if (!mention) {
@@ -39,12 +38,7 @@ export function openMention(mention?: string): void {
}
}
export async function parseMentions(
text: string,
cwd: string,
urlContentFetcher: UrlContentFetcher,
fileContextTracker?: FileContextTracker,
): Promise<string> {
export async function parseMentions(text: string, cwd: string, urlContentFetcher: UrlContentFetcher): Promise<string> {
const mentions: Set<string> = new Set()
let parsedText = text.replace(mentionRegexGlobal, (match, mention) => {
mentions.add(mention)
@@ -104,10 +98,6 @@ export async function parseMentions(
parsedText += `\n\n<folder_content path="${mentionPath}">\n${content}\n</folder_content>`
} else {
parsedText += `\n\n<file_content path="${mentionPath}">\n${content}\n</file_content>`
// Track that this file was mentioned and its content was included
if (fileContextTracker) {
await fileContextTracker.trackFileContext(mentionPath, "file_mentioned")
}
}
} catch (error) {
if (mention.endsWith("/")) {
-361
View File
@@ -1,361 +0,0 @@
import { McpHub } from "../../services/mcp/McpHub"
export async function loadMcpDocumentation(mcpHub: McpHub) {
return `## Creating an MCP Server
When creating MCP servers, it's important to understand that they operate in a non-interactive environment. The server cannot initiate OAuth flows, open browser windows, or prompt for user input during runtime. All credentials and authentication tokens must be provided upfront through environment variables in the MCP settings configuration. For example, Spotify's API uses OAuth to get a refresh token for the user, but the MCP server cannot initiate this flow. While you can walk the user through obtaining an application client ID and secret, you may have to create a separate one-time setup script (like get-refresh-token.js) that captures and logs the final piece of the puzzle: the user's refresh token (i.e. you might run the script using execute_command which would open a browser for authentication, and then log the refresh token so that you can see it in the command output for you to use in the MCP settings configuration).
Unless the user specifies otherwise, new MCP servers should be created in: ${await mcpHub.getMcpServersPath()}
### Example MCP Server
For example, if the user wanted to give you the ability to retrieve weather information, you could create an MCP server that uses the OpenWeather API to get weather information, add it to the MCP settings configuration file, and then notice that you now have access to new tools and resources in the system prompt that you might use to show the user your new capabilities.
The following example demonstrates how to build an MCP server that provides weather data functionality. While this example shows how to implement resources, resource templates, and tools, in practice you should prefer using tools since they are more flexible and can handle dynamic parameters. The resource and resource template implementations are included here mainly for demonstration purposes of the different MCP capabilities, but a real weather server would likely just expose tools for fetching weather data. (The following steps are for macOS)
1. Use the \`create-typescript-server\` tool to bootstrap a new project in the default MCP servers directory:
\`\`\`bash
cd ${await mcpHub.getMcpServersPath()}
npx @modelcontextprotocol/create-server weather-server
cd weather-server
# Install dependencies
npm install axios
\`\`\`
This will create a new project with the following structure:
\`\`\`
weather-server/
├── package.json
{
...
"type": "module", // added by default, uses ES module syntax (import/export) rather than CommonJS (require/module.exports) (Important to know if you create additional scripts in this server repository like a get-refresh-token.js script)
"scripts": {
"build": "tsc && node -e \"require('fs').chmodSync('build/index.js', '755')\"",
...
}
...
}
├── tsconfig.json
└── src/
└── weather-server/
└── index.ts # Main server implementation
\`\`\`
2. Replace \`src/index.ts\` with the following:
\`\`\`typescript
#!/usr/bin/env node
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import {
CallToolRequestSchema,
ErrorCode,
ListResourcesRequestSchema,
ListResourceTemplatesRequestSchema,
ListToolsRequestSchema,
McpError,
ReadResourceRequestSchema,
} from '@modelcontextprotocol/sdk/types.js';
import axios from 'axios';
const API_KEY = process.env.OPENWEATHER_API_KEY; // provided by MCP config
if (!API_KEY) {
throw new Error('OPENWEATHER_API_KEY environment variable is required');
}
interface OpenWeatherResponse {
main: {
temp: number;
humidity: number;
};
weather: [{ description: string }];
wind: { speed: number };
dt_txt?: string;
}
const isValidForecastArgs = (
args: any
): args is { city: string; days?: number } =>
typeof args === 'object' &&
args !== null &&
typeof args.city === 'string' &&
(args.days === undefined || typeof args.days === 'number');
class WeatherServer {
private server: Server;
private axiosInstance;
constructor() {
this.server = new Server(
{
name: 'example-weather-server',
version: '0.1.0',
},
{
capabilities: {
resources: {},
tools: {},
},
}
);
this.axiosInstance = axios.create({
baseURL: 'http://api.openweathermap.org/data/2.5',
params: {
appid: API_KEY,
units: 'metric',
},
});
this.setupResourceHandlers();
this.setupToolHandlers();
// Error handling
this.server.onerror = (error) => console.error('[MCP Error]', error);
process.on('SIGINT', async () => {
await this.server.close();
process.exit(0);
});
}
// MCP Resources represent any kind of UTF-8 encoded data that an MCP server wants to make available to clients, such as database records, API responses, log files, and more. Servers define direct resources with a static URI or dynamic resources with a URI template that follows the format \`[protocol]://[host]/[path]\`.
private setupResourceHandlers() {
// For static resources, servers can expose a list of resources:
this.server.setRequestHandler(ListResourcesRequestSchema, async () => ({
resources: [
// This is a poor example since you could use the resource template to get the same information but this demonstrates how to define a static resource
{
uri: \`weather://San Francisco/current\`, // Unique identifier for San Francisco weather resource
name: \`Current weather in San Francisco\`, // Human-readable name
mimeType: 'application/json', // Optional MIME type
// Optional description
description:
'Real-time weather data for San Francisco including temperature, conditions, humidity, and wind speed',
},
],
}));
// For dynamic resources, servers can expose resource templates:
this.server.setRequestHandler(
ListResourceTemplatesRequestSchema,
async () => ({
resourceTemplates: [
{
uriTemplate: 'weather://{city}/current', // URI template (RFC 6570)
name: 'Current weather for a given city', // Human-readable name
mimeType: 'application/json', // Optional MIME type
description: 'Real-time weather data for a specified city', // Optional description
},
],
})
);
// ReadResourceRequestSchema is used for both static resources and dynamic resource templates
this.server.setRequestHandler(
ReadResourceRequestSchema,
async (request) => {
const match = request.params.uri.match(
/^weather:\/\/([^/]+)\/current$/
);
if (!match) {
throw new McpError(
ErrorCode.InvalidRequest,
\`Invalid URI format: \${request.params.uri}\`
);
}
const city = decodeURIComponent(match[1]);
try {
const response = await this.axiosInstance.get(
'weather', // current weather
{
params: { q: city },
}
);
return {
contents: [
{
uri: request.params.uri,
mimeType: 'application/json',
text: JSON.stringify(
{
temperature: response.data.main.temp,
conditions: response.data.weather[0].description,
humidity: response.data.main.humidity,
wind_speed: response.data.wind.speed,
timestamp: new Date().toISOString(),
},
null,
2
),
},
],
};
} catch (error) {
if (axios.isAxiosError(error)) {
throw new McpError(
ErrorCode.InternalError,
\`Weather API error: \${
error.response?.data.message ?? error.message
}\`
);
}
throw error;
}
}
);
}
/* MCP Tools enable servers to expose executable functionality to the system. Through these tools, you can interact with external systems, perform computations, and take actions in the real world.
* - Like resources, tools are identified by unique names and can include descriptions to guide their usage. However, unlike resources, tools represent dynamic operations that can modify state or interact with external systems.
* - While resources and tools are similar, you should prefer to create tools over resources when possible as they provide more flexibility.
*/
private setupToolHandlers() {
this.server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: [
{
name: 'get_forecast', // Unique identifier
description: 'Get weather forecast for a city', // Human-readable description
inputSchema: {
// JSON Schema for parameters
type: 'object',
properties: {
city: {
type: 'string',
description: 'City name',
},
days: {
type: 'number',
description: 'Number of days (1-5)',
minimum: 1,
maximum: 5,
},
},
required: ['city'], // Array of required property names
},
},
],
}));
this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
if (request.params.name !== 'get_forecast') {
throw new McpError(
ErrorCode.MethodNotFound,
\`Unknown tool: \${request.params.name}\`
);
}
if (!isValidForecastArgs(request.params.arguments)) {
throw new McpError(
ErrorCode.InvalidParams,
'Invalid forecast arguments'
);
}
const city = request.params.arguments.city;
const days = Math.min(request.params.arguments.days || 3, 5);
try {
const response = await this.axiosInstance.get<{
list: OpenWeatherResponse[];
}>('forecast', {
params: {
q: city,
cnt: days * 8,
},
});
return {
content: [
{
type: 'text',
text: JSON.stringify(response.data.list, null, 2),
},
],
};
} catch (error) {
if (axios.isAxiosError(error)) {
return {
content: [
{
type: 'text',
text: \`Weather API error: \${
error.response?.data.message ?? error.message
}\`,
},
],
isError: true,
};
}
throw error;
}
});
}
async run() {
const transport = new StdioServerTransport();
await this.server.connect(transport);
console.error('Weather MCP server running on stdio');
}
}
const server = new WeatherServer();
server.run().catch(console.error);
\`\`\`
(Remember: This is just an exampleyou may use different dependencies, break the implementation up into multiple files, etc.)
3. Build and compile the executable JavaScript file
\`\`\`bash
npm run build
\`\`\`
4. Whenever you need an environment variable such as an API key to configure the MCP server, walk the user through the process of getting the key. For example, they may need to create an account and go to a developer dashboard to generate the key. Provide step-by-step instructions and URLs to make it easy for the user to retrieve the necessary information. Then use the ask_followup_question tool to ask the user for the key, in this case the OpenWeather API key.
5. Install the MCP Server by adding the MCP server configuration to the settings file located at '${await mcpHub.getMcpSettingsFilePath()}'. The settings file may have other MCP servers already configured, so you would read it first and then add your new server to the existing \`mcpServers\` object.
IMPORTANT: Regardless of what else you see in the MCP settings file, you must default any new MCP servers you create to disabled=false and autoApprove=[].
\`\`\`json
{
"mcpServers": {
...,
"weather": {
"command": "node",
"args": ["/path/to/weather-server/build/index.js"],
"env": {
"OPENWEATHER_API_KEY": "user-provided-api-key"
}
},
}
}
\`\`\`
(Note: the user may also ask you to install the MCP server to the Claude desktop app, in which case you would read then modify \`~/Library/Application\ Support/Claude/claude_desktop_config.json\` on macOS for example. It follows the same format of a top level \`mcpServers\` object.)
6. After you have edited the MCP settings configuration file, the system will automatically run all the servers and expose the available tools and resources in the 'Connected MCP Servers' section. (Note: If you encounter a 'not connected' error when testing a newly installed mcp server, a common cause is an incorrect build path in your MCP settings configuration. Since compiled JavaScript files are commonly output to either 'dist/' or 'build/' directories, double-check that the build path in your MCP settings matches where your files are actually being compiled. E.g. If you assumed 'build' as the folder, check tsconfig.json to see if it's using 'dist' instead.)
7. Now that you have access to these new tools and resources, you may suggest ways the user can command you to invoke them - for example, with this new weather tool now available, you can invite the user to ask "what's the weather in San Francisco?"
## Editing MCP Servers
The user may ask to add tools or resources that may make sense to add to an existing MCP server (listed under 'Connected MCP Servers' below: ${
mcpHub
.getServers()
.filter((server) => server.status === "connected")
.map((server) => server.name)
.join(", ") || "(None running currently)"
}, e.g. if it would use the same API. This would be possible if you can locate the MCP server repository on the user's system by looking at the server arguments for a filepath. You might then use list_files and read_file to explore the files in the repository, and use replace_in_file to make changes to the files.
However some MCP servers may be running from installed packages rather than a local repository, in which case it may make more sense to create a new MCP server.
# MCP Servers Are Not Always Necessary
The user may not always request the use or creation of MCP servers. Instead, they might provide tasks that can be completed with existing tools. While using the MCP SDK to extend your capabilities can be useful, it's important to understand that this is just one specialized type of task you can accomplish. You should only implement MCP servers when the user explicitly requests it (e.g., "add a tool that...").
Remember: The MCP documentation and example provided above are to help you understand and work with existing MCP servers or create new ones when requested by the user. You already have access to tools and capabilities that can be used to accomplish a wide range of tasks.`
}
-6
View File
@@ -4,12 +4,6 @@ import * as path from "path"
import { ClineIgnoreController, LOCK_TEXT_SYMBOL } from "../ignore/ClineIgnoreController"
export const formatResponse = {
duplicateFileReadNotice: () =>
`[[NOTE] This file read has been removed to save space in the context window. Refer to the latest file read for the most up to date version of this file.]`,
contextTruncationNotice: () =>
`[NOTE] Some previous conversation history with the user has been removed to maintain optimal context window length. The initial user task and the most recent exchanges have been retained for continuity, while intermediate conversation history has been removed. Please keep this in mind as you continue assisting the user.`,
toolDenied: () => `The user denied this operation.`,
toolError: (error?: string) => `The tool execution failed with the following error:\n<error>\n${error}\n</error>`,
+404 -10
View File
@@ -177,6 +177,9 @@ Usage:
: ""
}
${
mcpHub.getMode() !== "off"
? `
## use_mcp_tool
Description: Request to use a tool provided by a connected MCP server. Each MCP server can provide multiple tools with different capabilities. Tools have defined input schemas that specify required and optional parameters.
Parameters:
@@ -205,6 +208,9 @@ Usage:
<server_name>server name here</server_name>
<uri>resource URI here</uri>
</access_mcp_resource>
`
: ""
}
## ask_followup_question
Description: Ask the user a question to gather additional information needed to complete the task. This tool should be used when you encounter ambiguities, need clarification, or require more details to proceed effectively. It allows for interactive problem-solving by enabling direct communication with the user. Use this tool judiciously to maintain a balance between gathering necessary information and avoiding excessive back-and-forth.
@@ -246,13 +252,6 @@ Array of options here (optional), e.g. ["Option 1", "Option 2", "Option 3"]
</options>
</plan_mode_respond>
## load_mcp_documentation
Description: Load documentation about creating MCP servers. This tool should be used when the user requests to create or install an MCP server (the user may ask you something along the lines of "add a tool" that does some function, in other words to create an MCP server that provides tools and resources that may connect to external APIs for example. You have the ability to create an MCP server and add it to a configuration file that will then expose the tools and resources for you to use with \`use_mcp_tool\` and \`access_mcp_resource\`). The documentation provides detailed information about the MCP server creation process, including setup instructions, best practices, and examples.
Parameters: None
Usage:
<load_mcp_documentation>
</load_mcp_documentation>
# Tool Use Examples
## Example 1: Requesting to execute a command
@@ -318,6 +317,9 @@ return (
>>>>>>> REPLACE
</diff>
</replace_in_file>
${
mcpHub.getMode() !== "off"
? `
## Example 4: Requesting to use an MCP tool
@@ -332,7 +334,14 @@ return (
</arguments>
</use_mcp_tool>
## Example 5: Another example of using an MCP tool (where the server name is a unique identifier such as a URL)
## Example 5: Requesting to access an MCP resource
<access_mcp_resource>
<server_name>weather-server</server_name>
<uri>weather://san-francisco/current</uri>
</access_mcp_resource>
## Example 6: Another example of using an MCP tool (where the server name is a unique identifier such as a URL)
<use_mcp_tool>
<server_name>github.com/modelcontextprotocol/servers/tree/main/src/github</server_name>
@@ -347,7 +356,9 @@ return (
"assignees": ["octocat"]
}
</arguments>
</use_mcp_tool>
</use_mcp_tool>`
: ""
}
# Tool Use Guidelines
@@ -370,6 +381,9 @@ It is crucial to proceed step-by-step, waiting for the user's message after each
By waiting for and carefully considering the user's response after each tool use, you can react accordingly and make informed decisions about how to proceed with the task. This iterative process helps ensure the overall success and accuracy of your work.
${
mcpHub.getMode() !== "off"
? `
====
MCP SERVERS
@@ -416,6 +430,374 @@ ${
})
.join("\n\n")}`
: "(No MCP servers currently connected)"
}`
: ""
}
${
mcpHub.getMode() === "full"
? `
## Creating an MCP Server
The user may ask you something along the lines of "add a tool" that does some function, in other words to create an MCP server that provides tools and resources that may connect to external APIs for example. You have the ability to create an MCP server and add it to a configuration file that will then expose the tools and resources for you to use with \`use_mcp_tool\` and \`access_mcp_resource\`.
When creating MCP servers, it's important to understand that they operate in a non-interactive environment. The server cannot initiate OAuth flows, open browser windows, or prompt for user input during runtime. All credentials and authentication tokens must be provided upfront through environment variables in the MCP settings configuration. For example, Spotify's API uses OAuth to get a refresh token for the user, but the MCP server cannot initiate this flow. While you can walk the user through obtaining an application client ID and secret, you may have to create a separate one-time setup script (like get-refresh-token.js) that captures and logs the final piece of the puzzle: the user's refresh token (i.e. you might run the script using execute_command which would open a browser for authentication, and then log the refresh token so that you can see it in the command output for you to use in the MCP settings configuration).
Unless the user specifies otherwise, new MCP servers should be created in: ${await mcpHub.getMcpServersPath()}
### Example MCP Server
For example, if the user wanted to give you the ability to retrieve weather information, you could create an MCP server that uses the OpenWeather API to get weather information, add it to the MCP settings configuration file, and then notice that you now have access to new tools and resources in the system prompt that you might use to show the user your new capabilities.
The following example demonstrates how to build an MCP server that provides weather data functionality. While this example shows how to implement resources, resource templates, and tools, in practice you should prefer using tools since they are more flexible and can handle dynamic parameters. The resource and resource template implementations are included here mainly for demonstration purposes of the different MCP capabilities, but a real weather server would likely just expose tools for fetching weather data. (The following steps are for macOS)
1. Use the \`create-typescript-server\` tool to bootstrap a new project in the default MCP servers directory:
\`\`\`bash
cd ${await mcpHub.getMcpServersPath()}
npx @modelcontextprotocol/create-server weather-server
cd weather-server
# Install dependencies
npm install axios
\`\`\`
This will create a new project with the following structure:
\`\`\`
weather-server/
├── package.json
{
...
"type": "module", // added by default, uses ES module syntax (import/export) rather than CommonJS (require/module.exports) (Important to know if you create additional scripts in this server repository like a get-refresh-token.js script)
"scripts": {
"build": "tsc && node -e \"require('fs').chmodSync('build/index.js', '755')\"",
...
}
...
}
├── tsconfig.json
└── src/
└── weather-server/
└── index.ts # Main server implementation
\`\`\`
2. Replace \`src/index.ts\` with the following:
\`\`\`typescript
#!/usr/bin/env node
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import {
CallToolRequestSchema,
ErrorCode,
ListResourcesRequestSchema,
ListResourceTemplatesRequestSchema,
ListToolsRequestSchema,
McpError,
ReadResourceRequestSchema,
} from '@modelcontextprotocol/sdk/types.js';
import axios from 'axios';
const API_KEY = process.env.OPENWEATHER_API_KEY; // provided by MCP config
if (!API_KEY) {
throw new Error('OPENWEATHER_API_KEY environment variable is required');
}
interface OpenWeatherResponse {
main: {
temp: number;
humidity: number;
};
weather: [{ description: string }];
wind: { speed: number };
dt_txt?: string;
}
const isValidForecastArgs = (
args: any
): args is { city: string; days?: number } =>
typeof args === 'object' &&
args !== null &&
typeof args.city === 'string' &&
(args.days === undefined || typeof args.days === 'number');
class WeatherServer {
private server: Server;
private axiosInstance;
constructor() {
this.server = new Server(
{
name: 'example-weather-server',
version: '0.1.0',
},
{
capabilities: {
resources: {},
tools: {},
},
}
);
this.axiosInstance = axios.create({
baseURL: 'http://api.openweathermap.org/data/2.5',
params: {
appid: API_KEY,
units: 'metric',
},
});
this.setupResourceHandlers();
this.setupToolHandlers();
// Error handling
this.server.onerror = (error) => console.error('[MCP Error]', error);
process.on('SIGINT', async () => {
await this.server.close();
process.exit(0);
});
}
// MCP Resources represent any kind of UTF-8 encoded data that an MCP server wants to make available to clients, such as database records, API responses, log files, and more. Servers define direct resources with a static URI or dynamic resources with a URI template that follows the format \`[protocol]://[host]/[path]\`.
private setupResourceHandlers() {
// For static resources, servers can expose a list of resources:
this.server.setRequestHandler(ListResourcesRequestSchema, async () => ({
resources: [
// This is a poor example since you could use the resource template to get the same information but this demonstrates how to define a static resource
{
uri: \`weather://San Francisco/current\`, // Unique identifier for San Francisco weather resource
name: \`Current weather in San Francisco\`, // Human-readable name
mimeType: 'application/json', // Optional MIME type
// Optional description
description:
'Real-time weather data for San Francisco including temperature, conditions, humidity, and wind speed',
},
],
}));
// For dynamic resources, servers can expose resource templates:
this.server.setRequestHandler(
ListResourceTemplatesRequestSchema,
async () => ({
resourceTemplates: [
{
uriTemplate: 'weather://{city}/current', // URI template (RFC 6570)
name: 'Current weather for a given city', // Human-readable name
mimeType: 'application/json', // Optional MIME type
description: 'Real-time weather data for a specified city', // Optional description
},
],
})
);
// ReadResourceRequestSchema is used for both static resources and dynamic resource templates
this.server.setRequestHandler(
ReadResourceRequestSchema,
async (request) => {
const match = request.params.uri.match(
/^weather:\/\/([^/]+)\/current$/
);
if (!match) {
throw new McpError(
ErrorCode.InvalidRequest,
\`Invalid URI format: \${request.params.uri}\`
);
}
const city = decodeURIComponent(match[1]);
try {
const response = await this.axiosInstance.get(
'weather', // current weather
{
params: { q: city },
}
);
return {
contents: [
{
uri: request.params.uri,
mimeType: 'application/json',
text: JSON.stringify(
{
temperature: response.data.main.temp,
conditions: response.data.weather[0].description,
humidity: response.data.main.humidity,
wind_speed: response.data.wind.speed,
timestamp: new Date().toISOString(),
},
null,
2
),
},
],
};
} catch (error) {
if (axios.isAxiosError(error)) {
throw new McpError(
ErrorCode.InternalError,
\`Weather API error: \${
error.response?.data.message ?? error.message
}\`
);
}
throw error;
}
}
);
}
/* MCP Tools enable servers to expose executable functionality to the system. Through these tools, you can interact with external systems, perform computations, and take actions in the real world.
* - Like resources, tools are identified by unique names and can include descriptions to guide their usage. However, unlike resources, tools represent dynamic operations that can modify state or interact with external systems.
* - While resources and tools are similar, you should prefer to create tools over resources when possible as they provide more flexibility.
*/
private setupToolHandlers() {
this.server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: [
{
name: 'get_forecast', // Unique identifier
description: 'Get weather forecast for a city', // Human-readable description
inputSchema: {
// JSON Schema for parameters
type: 'object',
properties: {
city: {
type: 'string',
description: 'City name',
},
days: {
type: 'number',
description: 'Number of days (1-5)',
minimum: 1,
maximum: 5,
},
},
required: ['city'], // Array of required property names
},
},
],
}));
this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
if (request.params.name !== 'get_forecast') {
throw new McpError(
ErrorCode.MethodNotFound,
\`Unknown tool: \${request.params.name}\`
);
}
if (!isValidForecastArgs(request.params.arguments)) {
throw new McpError(
ErrorCode.InvalidParams,
'Invalid forecast arguments'
);
}
const city = request.params.arguments.city;
const days = Math.min(request.params.arguments.days || 3, 5);
try {
const response = await this.axiosInstance.get<{
list: OpenWeatherResponse[];
}>('forecast', {
params: {
q: city,
cnt: days * 8,
},
});
return {
content: [
{
type: 'text',
text: JSON.stringify(response.data.list, null, 2),
},
],
};
} catch (error) {
if (axios.isAxiosError(error)) {
return {
content: [
{
type: 'text',
text: \`Weather API error: \${
error.response?.data.message ?? error.message
}\`,
},
],
isError: true,
};
}
throw error;
}
});
}
async run() {
const transport = new StdioServerTransport();
await this.server.connect(transport);
console.error('Weather MCP server running on stdio');
}
}
const server = new WeatherServer();
server.run().catch(console.error);
\`\`\`
(Remember: This is just an exampleyou may use different dependencies, break the implementation up into multiple files, etc.)
3. Build and compile the executable JavaScript file
\`\`\`bash
npm run build
\`\`\`
4. Whenever you need an environment variable such as an API key to configure the MCP server, walk the user through the process of getting the key. For example, they may need to create an account and go to a developer dashboard to generate the key. Provide step-by-step instructions and URLs to make it easy for the user to retrieve the necessary information. Then use the ask_followup_question tool to ask the user for the key, in this case the OpenWeather API key.
5. Install the MCP Server by adding the MCP server configuration to the settings file located at '${await mcpHub.getMcpSettingsFilePath()}'. The settings file may have other MCP servers already configured, so you would read it first and then add your new server to the existing \`mcpServers\` object.
IMPORTANT: Regardless of what else you see in the MCP settings file, you must default any new MCP servers you create to disabled=false and autoApprove=[].
\`\`\`json
{
"mcpServers": {
...,
"weather": {
"command": "node",
"args": ["/path/to/weather-server/build/index.js"],
"env": {
"OPENWEATHER_API_KEY": "user-provided-api-key"
}
},
}
}
\`\`\`
(Note: the user may also ask you to install the MCP server to the Claude desktop app, in which case you would read then modify \`~/Library/Application\ Support/Claude/claude_desktop_config.json\` on macOS for example. It follows the same format of a top level \`mcpServers\` object.)
6. After you have edited the MCP settings configuration file, the system will automatically run all the servers and expose the available tools and resources in the 'Connected MCP Servers' section. (Note: If you encounter a 'not connected' error when testing a newly installed mcp server, a common cause is an incorrect build path in your MCP settings configuration. Since compiled JavaScript files are commonly output to either 'dist/' or 'build/' directories, double-check that the build path in your MCP settings matches where your files are actually being compiled. E.g. If you assumed 'build' as the folder, check tsconfig.json to see if it's using 'dist' instead.)
7. Now that you have access to these new tools and resources, you may suggest ways the user can command you to invoke them - for example, with this new weather tool now available, you can invite the user to ask "what's the weather in San Francisco?"
## Editing MCP Servers
The user may ask to add tools or resources that may make sense to add to an existing MCP server (listed under 'Connected MCP Servers' below: ${
mcpHub
.getServers()
.filter((server) => server.status === "connected")
.map((server) => server.name)
.join(", ") || "(None running currently)"
}, e.g. if it would use the same API. This would be possible if you can locate the MCP server repository on the user's system by looking at the server arguments for a filepath. You might then use list_files and read_file to explore the files in the repository, and use replace_in_file to make changes to the files.
However some MCP servers may be running from installed packages rather than a local repository, in which case it may make more sense to create a new MCP server.
# MCP Servers Are Not Always Necessary
The user may not always request the use or creation of MCP servers. Instead, they might provide tasks that can be completed with existing tools. While using the MCP SDK to extend your capabilities can be useful, it's important to understand that this is just one specialized type of task you can accomplish. You should only implement MCP servers when the user explicitly requests it (e.g., "add a tool that...").
Remember: The MCP documentation and example provided above are to help you understand and work with existing MCP servers or create new ones when requested by the user. You already have access to tools and capabilities that can be used to accomplish a wide range of tasks.
`
: ""
}
====
@@ -530,7 +912,13 @@ CAPABILITIES
? "\n- You can use the browser_action tool to interact with websites (including html files and locally running development servers) through a Puppeteer-controlled browser when you feel it is necessary in accomplishing the user's task. This tool is particularly useful for web development tasks as it allows you to launch a browser, navigate to pages, interact with elements through clicks and keyboard input, and capture the results through screenshots and console logs. This tool may be useful at key stages of web development tasks-such as after implementing new features, making substantial changes, when troubleshooting issues, or to verify the result of your work. You can analyze the provided screenshots to ensure correct rendering or identify errors, and review console logs for runtime issues.\n - For example, if asked to add a component to a react website, you might create the necessary files, use execute_command to run the site locally, then use browser_action to launch the browser, navigate to the local server, and verify the component renders & functions correctly before closing the browser."
: ""
}
${
mcpHub.getMode() !== "off"
? `
- You have access to MCP servers that may provide additional tools and resources. Each server may provide different capabilities that you can use to accomplish tasks more effectively.
`
: ""
}
====
@@ -551,7 +939,7 @@ RULES
- The user may provide a file's contents directly in their message, in which case you shouldn't use the read_file tool to get the file contents again since you already have it.
- Your goal is to try to accomplish the user's task, NOT engage in a back and forth conversation.${
supportsComputerUse
? `\n- The user may ask generic non-development tasks, such as "what\'s the latest news" or "look up the weather in San Diego", in which case you might use the browser_action tool to complete the task if it makes sense to do so, rather than trying to create a website or using curl to answer the question. However, if an available MCP server tool or resource can be used instead, you should prefer to use it over browser_action.`
? `\n- The user may ask generic non-development tasks, such as "what\'s the latest news" or "look up the weather in San Diego", in which case you might use the browser_action tool to complete the task if it makes sense to do so, rather than trying to create a website or using curl to answer the question.${mcpHub.getMode() !== "off" ? "However, if an available MCP server tool or resource can be used instead, you should prefer to use it over browser_action." : ""}`
: ""
}
- NEVER end attempt_completion result with a question or request to engage in further conversation! Formulate the end of your result in a way that is final and does not require further input from the user.
@@ -566,7 +954,13 @@ RULES
? " Then if you want to test your work, you might use browser_action to launch the site, wait for the user's response confirming the site was launched along with a screenshot, then perhaps e.g., click a button to test functionality if needed, wait for the user's response confirming the button was clicked along with a screenshot of the new state, before finally closing the browser."
: ""
}
${
mcpHub.getMode() !== "off"
? `
- MCP operations should be used one at a time, similar to other tool usage. Wait for confirmation of success before proceeding with additional operations.
`
: ""
}
====
-37
View File
@@ -5,27 +5,12 @@ import { Anthropic } from "@anthropic-ai/sdk"
import { fileExistsAtPath } from "../../utils/fs"
import { ClineMessage } from "../../shared/ExtensionMessage"
export interface FileMetadataEntry {
path: string
record_state: "active" | "stale"
record_source: "read_tool" | "user_edited" | "cline_edited" | "file_mentioned"
cline_read_date: number | null
cline_edit_date: number | null
user_edit_date?: number | null
}
export interface TaskMetadata {
files_in_context: FileMetadataEntry[]
}
export const GlobalFileNames = {
apiConversationHistory: "api_conversation_history.json",
contextHistory: "context_history.json",
uiMessages: "ui_messages.json",
openRouterModels: "openrouter_models.json",
mcpSettings: "cline_mcp_settings.json",
clineRules: ".clinerules",
taskMetadata: "task_metadata.json",
}
export async function ensureTaskDirectoryExists(context: vscode.ExtensionContext, taskId: string): Promise<string> {
@@ -86,25 +71,3 @@ export async function saveClineMessages(context: vscode.ExtensionContext, taskId
console.error("Failed to save ui messages:", error)
}
}
export async function getTaskMetadata(context: vscode.ExtensionContext, taskId: string): Promise<TaskMetadata> {
const filePath = path.join(await ensureTaskDirectoryExists(context, taskId), GlobalFileNames.taskMetadata)
try {
if (await fileExistsAtPath(filePath)) {
return JSON.parse(await fs.readFile(filePath, "utf8"))
}
} catch (error) {
console.error("Failed to read task metadata:", error)
}
return { files_in_context: [] }
}
export async function saveTaskMetadata(context: vscode.ExtensionContext, taskId: string, metadata: TaskMetadata) {
try {
const taskDir = await ensureTaskDirectoryExists(context, taskId)
const filePath = path.join(taskDir, GlobalFileNames.taskMetadata)
await fs.writeFile(filePath, JSON.stringify(metadata, null, 2))
} catch (error) {
console.error("Failed to save task metadata:", error)
}
}
-2
View File
@@ -12,7 +12,6 @@ export type SecretKey =
| "requestyApiKey"
| "togetherApiKey"
| "qwenApiKey"
| "doubaoApiKey"
| "mistralApiKey"
| "liteLlmApiKey"
| "authNonce"
@@ -58,7 +57,6 @@ export type GlobalStateKey =
| "previousModeModelInfo"
| "liteLlmBaseUrl"
| "liteLlmModelId"
| "liteLlmUsePromptCache"
| "qwenApiLine"
| "requestyModelId"
| "togetherModelId"
+1 -12
View File
@@ -86,7 +86,6 @@ export async function getAllExtensionState(context: vscode.ExtensionContext) {
togetherApiKey,
togetherModelId,
qwenApiKey,
doubaoApiKey,
mistralApiKey,
azureApiVersion,
openRouterModelId,
@@ -101,7 +100,6 @@ export async function getAllExtensionState(context: vscode.ExtensionContext) {
vsCodeLmModelSelector,
liteLlmBaseUrl,
liteLlmModelId,
liteLlmUsePromptCache,
userInfo,
previousModeApiProvider,
previousModeModelId,
@@ -152,7 +150,6 @@ export async function getAllExtensionState(context: vscode.ExtensionContext) {
getSecret(context, "togetherApiKey") as Promise<string | undefined>,
getGlobalState(context, "togetherModelId") as Promise<string | undefined>,
getSecret(context, "qwenApiKey") as Promise<string | undefined>,
getSecret(context, "doubaoApiKey") as Promise<string | undefined>,
getSecret(context, "mistralApiKey") as Promise<string | undefined>,
getGlobalState(context, "azureApiVersion") as Promise<string | undefined>,
getGlobalState(context, "openRouterModelId") as Promise<string | undefined>,
@@ -167,7 +164,6 @@ export async function getAllExtensionState(context: vscode.ExtensionContext) {
getGlobalState(context, "vsCodeLmModelSelector") as Promise<vscode.LanguageModelChatSelector | undefined>,
getGlobalState(context, "liteLlmBaseUrl") as Promise<string | undefined>,
getGlobalState(context, "liteLlmModelId") as Promise<string | undefined>,
getGlobalState(context, "liteLlmUsePromptCache") as Promise<boolean | undefined>,
getGlobalState(context, "userInfo") as Promise<UserInfo | undefined>,
getGlobalState(context, "previousModeApiProvider") as Promise<ApiProvider | undefined>,
getGlobalState(context, "previousModeModelId") as Promise<string | undefined>,
@@ -258,7 +254,6 @@ export async function getAllExtensionState(context: vscode.ExtensionContext) {
togetherModelId,
qwenApiKey,
qwenApiLine,
doubaoApiKey,
mistralApiKey,
azureApiVersion,
openRouterModelId,
@@ -270,7 +265,6 @@ export async function getAllExtensionState(context: vscode.ExtensionContext) {
liteLlmBaseUrl,
liteLlmModelId,
liteLlmApiKey,
liteLlmUsePromptCache,
asksageApiKey,
asksageApiUrl,
xaiApiKey,
@@ -280,7 +274,7 @@ export async function getAllExtensionState(context: vscode.ExtensionContext) {
customInstructions,
taskHistory,
autoApprovalSettings: autoApprovalSettings || DEFAULT_AUTO_APPROVAL_SETTINGS, // default value can be 0 or empty string
browserSettings: { ...DEFAULT_BROWSER_SETTINGS, ...browserSettings }, // this will ensure that older versions of browserSettings (e.g. before remoteBrowserEnabled was added) are merged with the default values (false for remoteBrowserEnabled)
browserSettings: browserSettings || DEFAULT_BROWSER_SETTINGS,
chatSettings: chatSettings || DEFAULT_CHAT_SETTINGS,
userInfo,
previousModeApiProvider,
@@ -329,7 +323,6 @@ export async function updateApiConfiguration(context: vscode.ExtensionContext, a
togetherApiKey,
togetherModelId,
qwenApiKey,
doubaoApiKey,
mistralApiKey,
azureApiVersion,
openRouterModelId,
@@ -339,7 +332,6 @@ export async function updateApiConfiguration(context: vscode.ExtensionContext, a
liteLlmBaseUrl,
liteLlmModelId,
liteLlmApiKey,
liteLlmUsePromptCache,
qwenApiLine,
asksageApiKey,
asksageApiUrl,
@@ -379,7 +371,6 @@ export async function updateApiConfiguration(context: vscode.ExtensionContext, a
await storeSecret(context, "requestyApiKey", requestyApiKey)
await storeSecret(context, "togetherApiKey", togetherApiKey)
await storeSecret(context, "qwenApiKey", qwenApiKey)
await storeSecret(context, "doubaoApiKey", doubaoApiKey)
await storeSecret(context, "mistralApiKey", mistralApiKey)
await storeSecret(context, "liteLlmApiKey", liteLlmApiKey)
await storeSecret(context, "xaiApiKey", xaiApiKey)
@@ -390,7 +381,6 @@ export async function updateApiConfiguration(context: vscode.ExtensionContext, a
await updateGlobalState(context, "vsCodeLmModelSelector", vsCodeLmModelSelector)
await updateGlobalState(context, "liteLlmBaseUrl", liteLlmBaseUrl)
await updateGlobalState(context, "liteLlmModelId", liteLlmModelId)
await updateGlobalState(context, "liteLlmUsePromptCache", liteLlmUsePromptCache)
await updateGlobalState(context, "qwenApiLine", qwenApiLine)
await updateGlobalState(context, "requestyModelId", requestyModelId)
await updateGlobalState(context, "togetherModelId", togetherModelId)
@@ -418,7 +408,6 @@ export async function resetExtensionState(context: vscode.ExtensionContext) {
"requestyApiKey",
"togetherApiKey",
"qwenApiKey",
"doubaoApiKey",
"mistralApiKey",
"clineApiKey",
"liteLlmApiKey",
+10 -119
View File
@@ -14,6 +14,7 @@ import { AnthropicHandler } from "../../api/providers/anthropic"
import { ClineHandler } from "../../api/providers/cline"
import { OpenRouterHandler } from "../../api/providers/openrouter"
import { ApiStream } from "../../api/transform/stream"
import { GlobalFileNames } from "../storage/disk"
import CheckpointTracker from "../../integrations/checkpoints/CheckpointTracker"
import { DIFF_VIEW_URI_SCHEME, DiffViewProvider } from "../../integrations/editor/DiffViewProvider"
import { formatContentBlockToMarkdown } from "../../integrations/misc/export-markdown"
@@ -64,7 +65,6 @@ import { ClineIgnoreController } from ".././ignore/ClineIgnoreController"
import { parseMentions } from ".././mentions"
import { formatResponse } from ".././prompts/responses"
import { addUserInstructions, SYSTEM_PROMPT } from ".././prompts/system"
import { FileContextTracker } from "../context-tracking/FileContextTracker"
import {
checkIsAnthropicContextWindowError,
checkIsOpenRouterContextWindowError,
@@ -76,9 +76,7 @@ import {
getSavedClineMessages,
saveApiConversationHistory,
saveClineMessages,
GlobalFileNames,
} from "../storage/disk"
import { loadMcpDocumentation } from "../prompts/loadMcpDocumentation"
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
@@ -119,9 +117,6 @@ export class Task {
isAwaitingPlanResponse = false
didRespondToPlanAskBySwitchingMode = false
// File tracking
private fileContextTracker: FileContextTracker
// streaming
isWaitingForFirstChunk = false
isStreaming = false
@@ -173,18 +168,12 @@ export class Task {
throw new Error("Either historyItem or task/images must be provided")
}
// Initialize file context tracker
this.fileContextTracker = new FileContextTracker(controller, this.taskId)
// Now that taskId is initialized, we can build the API handler
this.api = buildApiHandler({
...apiConfiguration,
taskId: this.taskId,
})
// Set taskId on browserSession for telemetry tracking
this.browserSession.setTaskId(this.taskId)
// Continue with task initialization
if (historyItem) {
this.resumeTaskFromHistory()
@@ -212,6 +201,7 @@ export class Task {
}
// Storing task to disk for history
private async addToApiConversationHistory(message: Anthropic.MessageParam) {
this.apiConversationHistory.push(message)
await saveApiConversationHistory(this.getContext(), this.taskId, this.apiConversationHistory)
@@ -327,12 +317,6 @@ export class Task {
) // +1 since this index corresponds to the last user message, and another +1 since slice end index is exclusive
await this.overwriteApiConversationHistory(newConversationHistory)
// update the context history state
await this.contextManager.truncateContextHistory(
message.ts,
await ensureTaskDirectoryExists(this.getContext(), this.taskId),
)
// aggregate deleted api reqs info so we don't lose costs/tokens
const deletedMessages = this.clineMessages.slice(messageIndex + 1)
const deletedApiReqsMetrics = getApiMetrics(combineApiRequests(combineCommandSequences(deletedMessages)))
@@ -864,9 +848,6 @@ export class Task {
// This is important in case the user deletes messages without resuming the task first
this.apiConversationHistory = await getSavedApiConversationHistory(this.getContext(), this.taskId)
// load the context history state
await this.contextManager.initializeContextHistory(await ensureTaskDirectoryExists(this.getContext(), this.taskId))
const lastClineMessage = this.clineMessages
.slice()
.reverse()
@@ -996,9 +977,8 @@ export class Task {
this.abort = true // will stop any autonomously running promises
this.terminalManager.disposeAll()
this.urlContentFetcher.closeBrowser()
await this.browserSession.dispose()
this.browserSession.closeBrowser()
this.clineIgnoreController.dispose()
this.fileContextTracker.dispose()
await this.diffViewProvider.revertChanges() // need to await for when we want to make sure directories/files are reverted before re-starting the task from a checkpoint
}
@@ -1154,9 +1134,7 @@ export class Task {
}
}
// Check if the tool should be auto-approved based on the settings
// Returns bool for most tools, tuple for execute_command (and future nested auto appoved settings)
shouldAutoApproveTool(toolName: ToolUseName): boolean | [boolean, boolean] {
shouldAutoApproveTool(toolName: ToolUseName): boolean {
if (this.autoApprovalSettings.enabled) {
switch (toolName) {
case "read_file":
@@ -1168,10 +1146,7 @@ export class Task {
case "replace_in_file":
return this.autoApprovalSettings.actions.editFiles
case "execute_command":
return [
this.autoApprovalSettings.actions.executeSafeCommands,
this.autoApprovalSettings.actions.executeAllCommands,
]
return this.autoApprovalSettings.actions.executeCommands
case "browser_action":
return this.autoApprovalSettings.actions.useBrowser
case "access_mcp_resource":
@@ -1269,13 +1244,12 @@ export class Task {
preferredLanguageInstructions,
)
}
const contextManagementMetadata = await this.contextManager.getNewContextMessagesAndMetadata(
const contextManagementMetadata = this.contextManager.getNewContextMessagesAndMetadata(
this.apiConversationHistory,
this.clineMessages,
this.api,
this.conversationHistoryDeletedRange,
previousApiReqIndex,
await ensureTaskDirectoryExists(this.getContext(), this.taskId),
)
if (contextManagementMetadata.updatedConversationHistoryDeletedRange) {
@@ -1471,8 +1445,6 @@ export class Task {
return `[${block.name} for '${block.params.question}']`
case "plan_mode_respond":
return `[${block.name}]`
case "load_mcp_documentation":
return `[${block.name}]`
case "attempt_completion":
return `[${block.name}]`
}
@@ -1832,20 +1804,10 @@ export class Task {
}
}
// Mark the file as edited by Cline to prevent false "recently modified" warnings
this.fileContextTracker.markFileAsEditedByCline(relPath)
const { newProblemsMessage, userEdits, autoFormattingEdits, finalContent } =
await this.diffViewProvider.saveChanges()
this.didEditFile = true // used to determine if we should wait for busy terminal to update before sending api request
// Track file edit operation
await this.fileContextTracker.trackFileContext(relPath, "cline_edited")
if (userEdits) {
// Track file edit operation
await this.fileContextTracker.trackFileContext(relPath, "user_edited")
await this.say(
"user_feedback_diff",
JSON.stringify({
@@ -1953,10 +1915,6 @@ export class Task {
}
// now execute the tool like normal
const content = await extractTextFromFile(absolutePath)
// Track file read operation
await this.fileContextTracker.trackFileContext(relPath, "read_tool")
pushToolResult(content)
break
@@ -2264,14 +2222,6 @@ export class Task {
// await this.say("inspect_site_result", "") // no result, starts the loading spinner waiting for result
await this.say("browser_action_result", "") // starts loading spinner
// Re-make browserSession to make sure latest settings apply
const localContext = this.controllerRef.deref()?.context
if (localContext) {
await this.browserSession.dispose()
this.browserSession = new BrowserSession(localContext, this.browserSettings)
} else {
console.warn("no controller context available for browserSession")
}
await this.browserSession.launchBrowser()
browserActionResult = await this.browserSession.navigateToUrl(url)
} else {
@@ -2364,7 +2314,7 @@ export class Task {
case "execute_command": {
let command: string | undefined = block.params.command
const requiresApprovalRaw: string | undefined = block.params.requires_approval
const requiresApprovalPerLLM = requiresApprovalRaw?.toLowerCase() === "true"
const requiresApproval = requiresApprovalRaw?.toLowerCase() === "true"
try {
if (block.partial) {
@@ -2415,17 +2365,7 @@ export class Task {
let didAutoApprove = false
// If the model says this command is safe and auto aproval for safe commands is true, execute the command
// If the model says the command is risky, but *BOTH* auto approve settings are true, execute the command
const autoApproveResult = this.shouldAutoApproveTool(block.name)
const [autoApproveSafe, autoApproveAll] = Array.isArray(autoApproveResult)
? autoApproveResult
: [autoApproveResult, false]
if (
(!requiresApprovalPerLLM && autoApproveSafe) ||
(requiresApprovalPerLLM && autoApproveSafe && autoApproveAll)
) {
if (!requiresApproval && this.shouldAutoApproveTool(block.name)) {
this.removeLastPartialMessageIfExistsWithType("ask", "command")
await this.say("command", command, undefined, false)
this.consecutiveAutoApprovedRequestsCount++
@@ -2438,7 +2378,7 @@ export class Task {
const didApprove = await askApproval(
"command",
command +
`${this.shouldAutoApproveTool(block.name) && requiresApprovalPerLLM ? COMMAND_REQ_APP_STRING : ""}`, // ugly hack until we refactor combineCommandSequences
`${this.shouldAutoApproveTool(block.name) && requiresApproval ? COMMAND_REQ_APP_STRING : ""}`, // ugly hack until we refactor combineCommandSequences
)
if (!didApprove) {
break
@@ -2709,9 +2649,6 @@ export class Task {
})
}
// Store the number of options for telemetry
const options = parsePartialArrayString(optionsRaw || "[]")
const { text, images } = await this.ask("followup", JSON.stringify(sharedMessage), false)
// Check if options contains the text response
@@ -2725,11 +2662,9 @@ export class Task {
selected: text,
} satisfies ClineAskQuestion)
await this.saveClineMessagesAndUpdateHistory()
telemetryService.captureOptionSelected(this.taskId, options.length, "act")
}
} else {
// Option not selected, send user feedback
telemetryService.captureOptionsIgnored(this.taskId, options.length, "act")
await this.say("user_feedback", text ?? "", images)
}
@@ -2770,9 +2705,6 @@ export class Task {
// })
// }
// Store the number of options for telemetry
const options = parsePartialArrayString(optionsRaw || "[]")
this.isAwaitingPlanResponse = true
let { text, images } = await this.ask("plan_mode_respond", JSON.stringify(sharedMessage), false)
this.isAwaitingPlanResponse = false
@@ -2793,12 +2725,10 @@ export class Task {
selected: text,
} satisfies ClinePlanModeResponse)
await this.saveClineMessagesAndUpdateHistory()
telemetryService.captureOptionSelected(this.taskId, options.length, "plan")
}
} else {
// Option not selected, send user feedback
if (text || images?.length) {
telemetryService.captureOptionsIgnored(this.taskId, options.length, "plan")
await this.say("user_feedback", text ?? "", images)
}
}
@@ -2827,28 +2757,6 @@ export class Task {
break
}
}
case "load_mcp_documentation": {
try {
if (block.partial) {
// shouldn't happen
break
} else {
await this.say("load_mcp_documentation", "", undefined, false)
const mcpHub = this.controllerRef.deref()?.mcpHub
if (!mcpHub) {
throw new Error("MCP hub not available")
}
pushToolResult(await loadMcpDocumentation(mcpHub))
break
}
} catch (error) {
await handleError("loading MCP documentation", error)
break
}
}
case "attempt_completion": {
/*
this.consecutiveMistakeCount = 0
@@ -3443,16 +3351,9 @@ export class Task {
block.text.includes("<task>") ||
block.text.includes("<user_message>")
) {
const parsedText = await parseMentions(
block.text,
cwd,
this.urlContentFetcher,
this.fileContextTracker,
)
return {
...block,
text: parsedText,
text: await parseMentions(block.text, cwd, this.urlContentFetcher),
}
}
}
@@ -3589,16 +3490,6 @@ export class Task {
details += terminalDetails
}
// Add recently modified files section
const recentlyModifiedFiles = this.fileContextTracker.getAndClearRecentlyModifiedFiles()
if (recentlyModifiedFiles.length > 0) {
details +=
"\n\n# Recently Modified Files\nThese files have been modified since you last accessed them (file was just edited so you may need to re-read it before editing):"
for (const filePath of recentlyModifiedFiles) {
details += `\n${filePath}`
}
}
// Add current time information with timezone
const now = new Date()
const formatter = new Intl.DateTimeFormat(undefined, {
-12
View File
@@ -44,18 +44,6 @@ export class WebviewProvider implements vscode.WebviewViewProvider {
return findLast(Array.from(this.activeInstances), (instance) => instance.view?.visible === true)
}
public static getAllInstances(): WebviewProvider[] {
return Array.from(this.activeInstances)
}
public static getSidebarInstance() {
return Array.from(this.activeInstances).find((instance) => instance.view && "onDidChangeVisibility" in instance.view)
}
public static getTabInstances(): WebviewProvider[] {
return Array.from(this.activeInstances).filter((instance) => instance.view && "onDidChangeViewState" in instance.view)
}
async resolveWebviewView(webviewView: vscode.WebviewView | vscode.WebviewPanel) {
this.view = webviewView
+54 -67
View File
@@ -41,37 +41,35 @@ export function activate(context: vscode.ExtensionContext) {
)
context.subscriptions.push(
vscode.commands.registerCommand("cline.plusButtonClicked", async (webview: any) => {
const openChat = async (instance?: WebviewProvider) => {
await instance?.controller.clearTask()
await instance?.controller.postStateToWebview()
await instance?.controller.postMessageToWebview({
type: "action",
action: "chatButtonClicked",
})
}
const isSidebar = !webview
if (isSidebar) {
openChat(WebviewProvider.getSidebarInstance())
} else {
WebviewProvider.getTabInstances().forEach(openChat)
vscode.commands.registerCommand("cline.plusButtonClicked", async () => {
Logger.log("Plus button Clicked")
const visibleWebview = WebviewProvider.getVisibleInstance()
if (!visibleWebview) {
Logger.log("Cannot find any visible Cline instances.")
return
}
await visibleWebview.controller.clearTask()
await visibleWebview.controller.postStateToWebview()
await visibleWebview.controller.postMessageToWebview({
type: "action",
action: "chatButtonClicked",
})
}),
)
context.subscriptions.push(
vscode.commands.registerCommand("cline.mcpButtonClicked", (webview: any) => {
const openMcp = (instance?: WebviewProvider) =>
instance?.controller.postMessageToWebview({
type: "action",
action: "mcpButtonClicked",
})
const isSidebar = !webview
if (isSidebar) {
openMcp(WebviewProvider.getSidebarInstance())
} else {
WebviewProvider.getTabInstances().forEach(openMcp)
vscode.commands.registerCommand("cline.mcpButtonClicked", () => {
const visibleWebview = WebviewProvider.getVisibleInstance()
if (!visibleWebview) {
Logger.log("Cannot find any visible Cline instances.")
return
}
visibleWebview.controller.postMessageToWebview({
type: "action",
action: "mcpButtonClicked",
})
}),
)
@@ -112,58 +110,47 @@ export function activate(context: vscode.ExtensionContext) {
context.subscriptions.push(vscode.commands.registerCommand("cline.openInNewTab", openClineInNewTab))
context.subscriptions.push(
vscode.commands.registerCommand("cline.settingsButtonClicked", (webview: any) => {
WebviewProvider.getAllInstances().forEach((instance) => {
const openSettings = async (instance?: WebviewProvider) => {
instance?.controller.postMessageToWebview({
type: "action",
action: "settingsButtonClicked",
})
}
const isSidebar = !webview
if (isSidebar) {
openSettings(WebviewProvider.getSidebarInstance())
} else {
WebviewProvider.getTabInstances().forEach(openSettings)
}
vscode.commands.registerCommand("cline.settingsButtonClicked", () => {
//vscode.window.showInformationMessage(message)
const visibleWebview = WebviewProvider.getVisibleInstance()
if (!visibleWebview) {
Logger.log("Cannot find any visible Cline instances.")
return
}
visibleWebview.controller.postMessageToWebview({
type: "action",
action: "settingsButtonClicked",
})
}),
)
context.subscriptions.push(
vscode.commands.registerCommand("cline.historyButtonClicked", (webview: any) => {
WebviewProvider.getAllInstances().forEach((instance) => {
const openHistory = async (instance?: WebviewProvider) => {
instance?.controller.postMessageToWebview({
type: "action",
action: "historyButtonClicked",
})
}
const isSidebar = !webview
if (isSidebar) {
openHistory(WebviewProvider.getSidebarInstance())
} else {
WebviewProvider.getTabInstances().forEach(openHistory)
}
vscode.commands.registerCommand("cline.historyButtonClicked", () => {
const visibleWebview = WebviewProvider.getVisibleInstance()
if (!visibleWebview) {
Logger.log("Cannot find any visible Cline instances.")
return
}
visibleWebview.controller.postMessageToWebview({
type: "action",
action: "historyButtonClicked",
})
}),
)
context.subscriptions.push(
vscode.commands.registerCommand("cline.accountButtonClicked", (webview: any) => {
WebviewProvider.getAllInstances().forEach((instance) => {
const openAccount = async (instance?: WebviewProvider) => {
instance?.controller.postMessageToWebview({
type: "action",
action: "accountButtonClicked",
})
}
const isSidebar = !webview
if (isSidebar) {
openAccount(WebviewProvider.getSidebarInstance())
} else {
WebviewProvider.getTabInstances().forEach(openAccount)
}
vscode.commands.registerCommand("cline.accountButtonClicked", () => {
const visibleWebview = WebviewProvider.getVisibleInstance()
if (!visibleWebview) {
Logger.log("Cannot find any visible Cline instances.")
return
}
visibleWebview.controller.postMessageToWebview({
type: "action",
action: "accountButtonClicked",
})
}),
)
@@ -90,11 +90,7 @@ export class GitOperations {
const lfsPatterns = await getLfsPatterns(cwd)
await writeExcludesFile(gitPath, lfsPatterns)
const addFilesResult = await this.addCheckpointFiles(git)
if (!addFilesResult.success) {
console.error("Failed to add at least one file(s) to checkpoints shadow git")
throw new Error("Failed to add at least one file(s) to checkpoints shadow git")
}
await this.addCheckpointFiles(git)
// Initial commit only on first repo creation
await git.commit("initial commit", { "--allow-empty": null })
@@ -146,7 +142,6 @@ export class GitOperations {
ignore: [".git"], // Ignore root level .git
dot: true,
markDirectories: false,
suppressErrors: true,
})
// For each nested .git directory, rename it based on operation
@@ -195,18 +190,18 @@ export class GitOperations {
await this.renameNestedGitRepos(true)
console.info("Starting checkpoint add operation...")
// Attempt to add all files. Any files with permissions errors will not be added,
// but the process will proceed and add the rest (--ignore-errors).
try {
await git.add([".", "--ignore-errors"])
await git.add(".")
const durationMs = Math.round(performance.now() - startTime)
console.debug(`Checkpoint add operation completed in ${durationMs}ms`)
return { success: true }
} catch (error) {
return { success: false }
console.error("Checkpoint add operation failed:", error)
throw error
}
} catch (error) {
return { success: false }
console.error("Failed to add files to checkpoint", error)
throw error
} finally {
await this.renameNestedGitRepos(false)
}
@@ -165,10 +165,7 @@ class CheckpointTracker {
console.info(`Using shadow git at: ${gitPath}`)
const addFilesResult = await this.gitOperations.addCheckpointFiles(git)
if (!addFilesResult.success) {
console.error("Failed to add at least one file(s) to checkpoints shadow git")
}
await this.gitOperations.addCheckpointFiles(git)
const commitMessage = "checkpoint-" + this.cwdHash + "-" + this.taskId
@@ -1,4 +1,4 @@
import { mkdir, access, constants } from "fs/promises"
import { mkdir } from "fs/promises"
import * as path from "path"
import * as vscode from "vscode"
import os from "os"
@@ -31,9 +31,7 @@ export async function getShadowGitPath(globalStoragePath: string, taskId: string
/**
* 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. Checks to confirm that the workspace
* is accessible and that we will not encounter breaking permissions issues when
* creating checkpoints.
* like home, Desktop, Documents, or Downloads.
*
* Protected directories:
* - User's home directory
@@ -42,23 +40,13 @@ export async function getShadowGitPath(globalStoragePath: string, taskId: string
* - Downloads
*
* @returns Promise<string> The absolute path to the current working directory
* @throws Error if no workspace is detected, if in a protected directory, or if no read access
* @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.")
}
// Check if directory exists and we have read permissions
try {
await access(cwd, constants.R_OK)
} catch (error) {
throw new Error(
`Cannot access workspace directory. Please ensure VS Code has permission to access your workspace. Error: ${error instanceof Error ? error.message : String(error)}`,
)
}
const homedir = os.homedir()
const desktopPath = path.join(homedir, "Desktop")
const documentsPath = path.join(homedir, "Documents")
@@ -79,15 +79,6 @@ export class DiffViewProvider {
if (!this.relPath || !this.activeLineController || !this.fadedOverlayController) {
throw new Error("Required values not set")
}
// --- Fix to prevent duplicate BOM ---
// Strip potential BOM from incoming content. VS Code's `applyEdit` might implicitly handle the BOM
// when replacing from the start (0,0), and we want to avoid duplication.
// Final BOM is handled in `saveChanges`.
if (accumulatedContent.startsWith("\ufeff")) {
accumulatedContent = accumulatedContent.slice(1) // Remove the BOM character
}
this.newContent = accumulatedContent
const accumulatedLines = accumulatedContent.split("\n")
if (!isFinal) {
-102
View File
@@ -1,102 +0,0 @@
import * as net from "net"
import axios from "axios"
/**
* Check if a port is open on a given host
*/
export async function isPortOpen(host: string, port: number, timeout = 1000): Promise<boolean> {
return new Promise((resolve) => {
const socket = new net.Socket()
let status = false
// Set timeout
socket.setTimeout(timeout)
// Handle successful connection
socket.on("connect", () => {
status = true
socket.destroy()
})
// Handle any errors
socket.on("error", () => {
socket.destroy()
})
// Handle timeout
socket.on("timeout", () => {
socket.destroy()
})
// Handle close
socket.on("close", () => {
resolve(status)
})
// Attempt to connect
socket.connect(port, host)
})
}
/**
* Try to connect to Chrome at a specific IP address
*/
export async function tryConnect(ipAddress: string): Promise<{ endpoint: string; ip: string } | null> {
try {
const response = await axios.get(`http://${ipAddress}:9222/json/version`, { timeout: 1000 })
const data = response.data
return { endpoint: data.webSocketDebuggerUrl, ip: ipAddress }
} catch (error) {
return null
}
}
/**
* Discover Chrome instances (localhost only)
*/
export async function discoverChromeInstances(): Promise<string | null> {
// Only try localhost
const ipAddresses = ["localhost", "127.0.0.1"]
// Try connecting to each IP address
for (const ip of ipAddresses) {
const connection = await tryConnect(ip)
if (connection) {
return `http://${connection.ip}:9222`
}
}
return null
}
/**
* Test connection to a remote browser
*/
export async function testBrowserConnection(host: string): Promise<{ success: boolean; message: string; endpoint?: string }> {
try {
// Fetch the WebSocket endpoint from the Chrome DevTools Protocol
const versionUrl = `${host.replace(/\/$/, "")}/json/version`
const response = await axios.get(versionUrl, { timeout: 3000 })
const browserWSEndpoint = response.data.webSocketDebuggerUrl
if (!browserWSEndpoint) {
return {
success: false,
message: "Could not find webSocketDebuggerUrl in the response",
}
}
return {
success: true,
message: "Successfully connected to Chrome browser",
endpoint: browserWSEndpoint,
}
} catch (error) {
console.error(`Failed to connect to remote browser: ${error}`)
return {
success: false,
message: `Failed to connect: ${error instanceof Error ? error.message : String(error)}`,
}
}
}
+102 -404
View File
@@ -1,95 +1,36 @@
import * as vscode from "vscode"
import * as fs from "fs/promises"
import * as path from "path"
import { exec, spawn } from "child_process"
import { Browser, Page, ScreenshotOptions, TimeoutError, launch, connect } from "puppeteer-core"
import { Browser, Page, ScreenshotOptions, TimeoutError, launch } from "puppeteer-core"
// @ts-ignore
import PCR from "puppeteer-chromium-resolver"
import pWaitFor from "p-wait-for"
import { setTimeout as setTimeoutPromise } from "node:timers/promises"
import axios from "axios"
import { fileExistsAtPath } from "../../utils/fs"
import { BrowserActionResult } from "../../shared/ExtensionMessage"
import { BrowserSettings } from "../../shared/BrowserSettings"
import { discoverChromeInstances, testBrowserConnection, isPortOpen } from "./BrowserDiscovery"
import * as chromeLauncher from "chrome-launcher"
import { Controller } from "../../core/controller"
import { telemetryService } from "../../services/telemetry/TelemetryService"
// import * as chromeLauncher from "chrome-launcher"
interface PCRStats {
puppeteer: { launch: typeof launch }
executablePath: string
}
// Define browser connection info interface
export interface BrowserConnectionInfo {
isConnected: boolean
isRemote: boolean
host?: string
}
const DEBUG_PORT = 9222 // Chrome's default debugging port
// const DEBUG_PORT = 9222 // Chrome's default debugging port
export class BrowserSession {
private context: vscode.ExtensionContext
private browser?: Browser
private page?: Page
private currentMousePosition?: string
private cachedWebSocketEndpoint?: string
private lastConnectionAttempt: number = 0
browserSettings: BrowserSettings
private isConnectedToRemote: boolean = false
// Telemetry tracking properties
private sessionStartTime: number = 0
private browserActions: string[] = []
private taskId?: string
constructor(context: vscode.ExtensionContext, browserSettings: BrowserSettings) {
this.context = context
this.browserSettings = browserSettings
}
// Tests remote browser connection
async testConnection(host: string): Promise<{ success: boolean; message: string; endpoint?: string }> {
return testBrowserConnection(host)
}
/**
* Get current browser connection information
*/
getConnectionInfo(): BrowserConnectionInfo {
return {
isConnected: !!this.browser,
isRemote: this.isConnectedToRemote,
host: this.isConnectedToRemote ? this.browserSettings.remoteBrowserHost : undefined,
}
}
async getDetectedChromePath(): Promise<{ path: string; isBundled: boolean }> {
// First check VSCode config
const configPath = vscode.workspace.getConfiguration("cline").get<string>("chromeExecutablePath")
if (configPath && (await fileExistsAtPath(configPath))) {
return { path: configPath, isBundled: false }
}
// Then try to find system Chrome
try {
const systemPath = chromeLauncher.Launcher.getFirstInstallation()
// Add validation to ensure path is not in Trash - This can happen on Mac OS due to the way the chrome-launcher library works
if (systemPath && !systemPath.includes(".Trash") && (await fileExistsAtPath(systemPath))) {
return { path: systemPath, isBundled: false }
}
} catch (error) {
console.info("Could not find system Chrome:", error)
}
// Finally fall back to PCR's bundled version
const stats = await this.ensureChromiumExists()
return { path: stats.executablePath, isBundled: true }
}
async ensureChromiumExists(): Promise<PCRStats> {
private async ensureChromiumExists(): Promise<PCRStats> {
const globalStoragePath = this.context?.globalStorageUri?.fsPath
if (!globalStoragePath) {
throw new Error("Global storage uri is invalid")
@@ -101,342 +42,130 @@ export class BrowserSession {
await fs.mkdir(puppeteerDir, { recursive: true })
}
// if chromium doesn't exist, this will download it to path.join(puppeteerDir, ".chromium-browser-snapshots")
// if it does exist it will return the path to existing chromium
const stats = await PCR({ downloadPath: puppeteerDir })
const chromeExecutablePath = vscode.workspace.getConfiguration("cline").get<string>("chromeExecutablePath")
if (chromeExecutablePath && !(await fileExistsAtPath(chromeExecutablePath))) {
throw new Error(`Chrome executable not found at path: ${chromeExecutablePath}`)
}
const stats: PCRStats = chromeExecutablePath
? { puppeteer: require("puppeteer-core"), executablePath: chromeExecutablePath }
: // if chromium doesn't exist, this will download it to path.join(puppeteerDir, ".chromium-browser-snapshots")
// if it does exist it will return the path to existing chromium
await PCR({ downloadPath: puppeteerDir })
return stats
}
async relaunchChromeDebugMode(controller: Controller) {
const result = await vscode.window.showWarningMessage(
"This will close your existing Chrome tabs and relaunch Chrome in debug mode. Are you sure?",
{ modal: true },
"Yes",
)
// private async checkExistingChromeDebugger(): Promise<boolean> {
// try {
// // Try to connect to existing debugger
// const response = await fetch(`http://localhost:${DEBUG_PORT}/json/version`)
// return response.ok
// } catch {
// return false
// }
// }
if (result !== "Yes") {
controller?.postMessageToWebview({
type: "browserRelaunchResult",
success: false,
text: "Operation cancelled by user",
})
return
}
// async relaunchChromeDebugMode() {
// const result = await vscode.window.showWarningMessage(
// "This will close your existing Chrome tabs and relaunch Chrome in debug mode. Are you sure?",
// { modal: true },
// "Yes",
// )
try {
// Chrome-launcher's killAll only kills instances it launched
// We need to handle system Chrome processes separately
await this.killAllChromeBrowsers()
// if (result !== "Yes") {
// return
// }
// Wait a moment for Chrome to fully shut down
await new Promise((resolve) => setTimeout(resolve, 500))
// // // Kill any existing Chrome instances
// // await chromeLauncher.killAll()
// Instead of using any default flags, use a minimal set to ensure session persistence
// This closely mimics running "google-chrome-stable --remote-debugging-port=9222" from the CLI
const chromeFlags = [
"--remote-debugging-port=" + DEBUG_PORT,
"--disable-notifications",
// Do not add any flags that might interfere with profile data
]
// // // Launch Chrome with debug port
// // const launcher = new chromeLauncher.Launcher({
// // port: DEBUG_PORT,
// // chromeFlags: ["--remote-debugging-port=" + DEBUG_PORT, "--no-first-run", "--no-default-browser-check"],
// // })
const installation = chromeLauncher.Launcher.getFirstInstallation()
if (!installation) {
throw new Error("Could not find Chrome installation on this system")
}
console.info("chrome installation", installation)
// // await launcher.launch()
// const installation = chromeLauncher.Launcher.getFirstInstallation()
// if (!installation) {
// throw new Error("Could not find Chrome installation on this system")
// }
// console.log("chrome installation", installation)
// }
// Prepare the command arguments
const args = [`--remote-debugging-port=${DEBUG_PORT}`, "--disable-notifications", "chrome://newtab"]
// private async getSystemChromeExecutablePath(): Promise<string> {
// // Find installed Chrome
// const installation = chromeLauncher.Launcher.getFirstInstallation()
// if (!installation) {
// throw new Error("Could not find Chrome installation on this system")
// }
// console.log("chrome installation", installation)
// return installation
// }
// Spawn Chrome as a detached process
const chromeProcess = spawn(installation, args, {
detached: true, // This is key - makes the process independent of parent
stdio: "ignore", // Detach stdio to prevent hanging
shell: false, // Don't run in a shell
})
// Unref the process to allow Node to exit independently
chromeProcess.unref()
// Wait a moment to ensure Chrome has time to start
await new Promise((resolve) => setTimeout(resolve, 1000))
// Test if Chrome is actually running with debug port
const isRunning = await isPortOpen("localhost", DEBUG_PORT, 2000)
if (!isRunning) {
throw new Error("Chrome was launched but debug port is not responding")
}
controller?.postMessageToWebview({
type: "browserRelaunchResult",
success: true,
text: `Browser successfully launched with debug mode\nUsing: ${installation}`,
})
} catch (error) {
controller?.postMessageToWebview({
type: "browserRelaunchResult",
success: false,
text: `Failed to relaunch Chrome: ${error instanceof Error ? error.message : String(error)}`,
})
}
}
/**
* Set the task ID for telemetry tracking
* @param taskId The task ID to associate with browser actions
*/
setTaskId(taskId: string) {
this.taskId = taskId
}
// /**
// * Helper to detect users default Chrome data dir.
// * Adjust for OS if needed.
// */
// private getDefaultChromeUserDataDir(): string {
// const homedir = require("os").homedir()
// switch (process.platform) {
// case "win32":
// return path.join(homedir, "AppData", "Local", "Google", "Chrome", "User Data")
// case "darwin":
// return path.join(homedir, "Library", "Application Support", "Google", "Chrome")
// default:
// return path.join(homedir, ".config", "google-chrome")
// }
// }
async launchBrowser() {
console.log("launch browser called")
if (this.browser) {
// throw new Error("Browser already launched")
await this.closeBrowser() // this may happen when the model launches a browser again after having used it already before
}
// Reset tracking properties
this.sessionStartTime = Date.now()
this.browserActions = []
// Reset remote connection status
this.isConnectedToRemote = false
if (this.browserSettings.remoteBrowserEnabled) {
console.log(`launch browser called -- remote host mode (non-headless)`)
try {
await this.launchRemoteBrowser()
// Don't create a new page here, as we'll create it in launchRemoteBrowser
// Send telemetry for browser tool start
if (this.taskId) {
telemetryService.captureBrowserToolStart(this.taskId, this.browserSettings)
}
return
} catch (error) {
console.error("Failed to launch remote browser, falling back to local mode:", error)
// Capture error telemetry
if (this.taskId) {
telemetryService.captureBrowserError(
this.taskId,
"remote_browser_launch_error",
error instanceof Error ? error.message : String(error),
{
isRemote: true,
remoteBrowserHost: this.browserSettings.remoteBrowserHost,
},
)
}
await this.launchLocalBrowser()
}
} else {
console.log(`launch browser called -- local mode (headless)`)
await this.launchLocalBrowser()
}
this.page = await this.browser?.newPage()
// Send telemetry for browser tool start
if (this.taskId) {
telemetryService.captureBrowserToolStart(this.taskId, this.browserSettings)
}
}
async launchLocalBrowser() {
const { path } = await this.getDetectedChromePath()
this.browser = await launch({
const stats = await this.ensureChromiumExists()
this.browser = await stats.puppeteer.launch({
args: [
"--user-agent=Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36",
],
executablePath: path,
executablePath: stats.executablePath,
defaultViewport: this.browserSettings.viewport,
headless: "shell", // Always use headless mode for local connections
headless: this.browserSettings.headless,
})
this.isConnectedToRemote = false
}
async launchRemoteBrowser() {
let remoteBrowserHost = this.browserSettings.remoteBrowserHost
let browserWSEndpoint: string | undefined = this.cachedWebSocketEndpoint
let reconnectionAttempted = false
// if (this.browserSettings.chromeType === "system") {
// const userDataDir = this.getDefaultChromeUserDataDir()
// this.browser = await stats.puppeteer.launch({
// args: [`--user-data-dir=${userDataDir}`, "--profile-directory=Default"],
// executablePath: await this.getSystemChromeExecutablePath(),
// defaultViewport: this.browserSettings.viewport,
// headless: this.browserSettings.headless,
// })
// } else {
// this.browser = await stats.puppeteer.launch({
// args: [
// "--user-agent=Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36",
// ],
// executablePath: stats.executablePath,
// defaultViewport: this.browserSettings.viewport,
// headless: this.browserSettings.headless,
// })
// }
const getViewport = () => {
return this.browserSettings.viewport
}
// First try auto-discovery if no host is provided
if (!remoteBrowserHost) {
try {
console.info("No remote browser host provided, trying auto-discovery")
const discoveredHost = await discoverChromeInstances()
if (discoveredHost) {
console.info(`Auto-discovered Chrome at ${discoveredHost}`)
remoteBrowserHost = discoveredHost
}
} catch (error) {
console.log(`Auto-discovery failed: ${error}`)
}
}
// Try to connect with cached endpoint first if it exists and is recent (less than 1 hour old)
if (browserWSEndpoint && Date.now() - this.lastConnectionAttempt < 3600000) {
try {
console.info(`Attempting to connect using cached WebSocket endpoint: ${browserWSEndpoint}`)
this.browser = await connect({
browserWSEndpoint,
defaultViewport: getViewport(),
})
this.page = await this.browser?.newPage()
this.isConnectedToRemote = true
return
} catch (error) {
console.log(`Failed to connect using cached endpoint: ${error}`)
// Capture error telemetry
if (this.taskId) {
telemetryService.captureBrowserError(
this.taskId,
"cached_endpoint_connection_error",
error instanceof Error ? error.message : String(error),
{
isRemote: true,
endpoint: browserWSEndpoint,
},
)
}
// Clear the cached endpoint since it's no longer valid
this.cachedWebSocketEndpoint = undefined
// User wants to give up after one reconnection attempt
if (remoteBrowserHost) {
reconnectionAttempted = true
}
}
}
// Try to connect with host (either user-provided or auto-discovered)
if (remoteBrowserHost) {
try {
// Fetch the WebSocket endpoint from the Chrome DevTools Protocol
const versionUrl = `${remoteBrowserHost.replace(/\/$/, "")}/json/version`
console.info(`Fetching WebSocket endpoint from ${versionUrl}`)
const response = await axios.get(versionUrl)
browserWSEndpoint = response.data.webSocketDebuggerUrl
if (!browserWSEndpoint) {
throw new Error("Could not find webSocketDebuggerUrl in the response")
}
console.info(`Found WebSocket browser endpoint: ${browserWSEndpoint}`)
// Cache the successful endpoint
this.cachedWebSocketEndpoint = browserWSEndpoint
this.lastConnectionAttempt = Date.now()
this.browser = await connect({
browserWSEndpoint,
defaultViewport: getViewport(),
})
this.page = await this.browser?.newPage()
this.isConnectedToRemote = true
return
} catch (error) {
console.log(`Failed to connect to remote browser: ${error}`)
// Capture error telemetry
if (this.taskId) {
telemetryService.captureBrowserError(
this.taskId,
"remote_host_connection_error",
error instanceof Error ? error.message : String(error),
{
isRemote: true,
remoteBrowserHost,
},
)
}
}
}
// If we get here, all connection attempts failed
throw new Error(
"Failed to connect to remote browser. Make sure Chrome is running with remote debugging enabled (--remote-debugging-port=9222).",
)
}
/**
* Kill all Chrome instances, including those not launched by chrome-launcher
*/
private async killAllChromeBrowsers(): Promise<void> {
// First try chrome-launcher's killAll to handle instances it launched
try {
await chromeLauncher.killAll()
} catch (err: unknown) {
console.log("Error in chrome-launcher killAll:", err)
}
// Then kill other Chrome instances using platform-specific commands
try {
if (process.platform === "win32") {
// Windows: Use taskkill to forcefully terminate Chrome processes
await new Promise<void>((resolve, reject) => {
exec("taskkill /F /IM chrome.exe /T", () => resolve())
})
} else if (process.platform === "darwin") {
// macOS: Use pkill to terminate Chrome processes
await new Promise<void>((resolve) => {
exec('pkill -x "Google Chrome"', () => resolve())
})
} else {
// Linux: Use pkill for Chrome and chromium
await new Promise<void>((resolve) => {
exec('pkill -f "chrome|chromium"', () => resolve())
})
}
} catch (error) {
console.error("Error killing Chrome processes:", error)
}
// (latest version of puppeteer does not add headless to user agent)
this.page = await this.browser?.newPage()
}
async closeBrowser(): Promise<BrowserActionResult> {
if (this.browser || this.page) {
// Send telemetry for browser tool end if we have a task ID and session was started
if (this.taskId && this.sessionStartTime > 0) {
const sessionDuration = Date.now() - this.sessionStartTime
telemetryService.captureBrowserToolEnd(this.taskId, {
actionCount: this.browserActions.length,
duration: sessionDuration,
actions: this.browserActions,
})
}
if (this.isConnectedToRemote && this.browser) {
// Close the page/tab first if it exists
if (this.page) {
await this.page.close().catch(() => {})
console.info("closed remote browser tab...")
}
await this.browser.disconnect().catch(() => {})
console.info("disconnected from remote browser...")
// do not close the browser
} else if (this.isConnectedToRemote === false) {
await this.browser?.close().catch(() => {})
console.info("closed local browser...")
}
console.log("closing browser...")
await this.browser?.close().catch(() => {})
this.browser = undefined
this.page = undefined
this.currentMousePosition = undefined
this.isConnectedToRemote = false
// Reset tracking properties
this.sessionStartTime = 0
this.browserActions = []
}
return {}
}
@@ -472,18 +201,8 @@ export class BrowserSession {
try {
await action(this.page)
} catch (err) {
const errorMessage = err instanceof Error ? err.message : String(err)
if (!(err instanceof TimeoutError)) {
logs.push(`[Error] ${errorMessage}`)
// Capture error telemetry
if (this.taskId) {
telemetryService.captureBrowserError(this.taskId, "browser_action_error", errorMessage, {
isRemote: this.isConnectedToRemote,
action: this.browserActions[this.browserActions.length - 1],
})
}
logs.push(`[Error] ${err.toString()}`)
}
}
@@ -511,7 +230,7 @@ export class BrowserSession {
let screenshot = `data:image/webp;base64,${screenshotBase64}`
if (!screenshotBase64) {
console.info("webp screenshot failed, trying png")
console.log("webp screenshot failed, trying png")
screenshotBase64 = await this.page.screenshot({
...options,
type: "png",
@@ -520,13 +239,6 @@ export class BrowserSession {
}
if (!screenshotBase64) {
// Capture error telemetry
if (this.taskId) {
telemetryService.captureBrowserError(this.taskId, "screenshot_error", "Failed to take screenshot", {
isRemote: this.isConnectedToRemote,
action: this.browserActions[this.browserActions.length - 1],
})
}
throw new Error("Failed to take screenshot.")
}
@@ -543,8 +255,6 @@ export class BrowserSession {
}
async navigateToUrl(url: string): Promise<BrowserActionResult> {
this.browserActions.push(`navigate: url`)
return this.doAction(async (page) => {
// networkidle2 isn't good enough since page may take some time to load. we can assume locally running dev sites will reach networkidle0 in a reasonable amount of time
await page.goto(url, {
@@ -571,7 +281,7 @@ export class BrowserSession {
let currentHTMLSize = html.length
// let bodyHTMLSize = await page.evaluate(() => document.body.innerHTML.length)
console.info("last: ", lastHTMLSize, " <> curr: ", currentHTMLSize)
console.log("last: ", lastHTMLSize, " <> curr: ", currentHTMLSize)
if (lastHTMLSize !== 0 && currentHTMLSize === lastHTMLSize) {
countStableSizeIterations++
@@ -580,7 +290,7 @@ export class BrowserSession {
}
if (countStableSizeIterations >= minStableSizeIterations) {
console.info("Page rendered fully...")
console.log("Page rendered fully...")
break
}
@@ -590,8 +300,6 @@ export class BrowserSession {
}
async click(coordinate: string): Promise<BrowserActionResult> {
this.browserActions.push(`click: coordinate`)
const [x, y] = coordinate.split(",").map(Number)
return this.doAction(async (page) => {
// Set up network request monitoring
@@ -625,16 +333,12 @@ export class BrowserSession {
}
async type(text: string): Promise<BrowserActionResult> {
this.browserActions.push(`type:${text.length} chars`)
return this.doAction(async (page) => {
await page.keyboard.type(text)
})
}
async scrollDown(): Promise<BrowserActionResult> {
this.browserActions.push("scrollDown")
return this.doAction(async (page) => {
await page.evaluate(() => {
window.scrollBy({
@@ -647,8 +351,6 @@ export class BrowserSession {
}
async scrollUp(): Promise<BrowserActionResult> {
this.browserActions.push("scrollUp")
return this.doAction(async (page) => {
await page.evaluate(() => {
window.scrollBy({
@@ -659,8 +361,4 @@ export class BrowserSession {
await setTimeoutPromise(300)
})
}
async dispose() {
await this.closeBrowser()
}
}
+15 -19
View File
@@ -94,6 +94,10 @@ export class McpHub {
return this.connections.filter((conn) => !conn.server.disabled).map((conn) => conn.server)
}
getMode(): McpMode {
return vscode.workspace.getConfiguration("cline.mcp").get<McpMode>("mode", "full")
}
async getMcpServersPath(): Promise<string> {
const provider = this.controllerRef.deref()
if (!provider) {
@@ -623,12 +627,7 @@ export class McpHub {
// Update the tools list to reflect the change
const connection = this.connections.find((conn) => conn.server.name === serverName)
if (connection && connection.server.tools) {
// Update the autoApprove property of each tool in the in-memory server object
connection.server.tools = connection.server.tools.map((tool) => ({
...tool,
autoApprove: autoApprove.includes(tool.name),
}))
if (connection) {
await this.notifyWebviewOfServerChanges()
}
} catch (error) {
@@ -660,27 +659,24 @@ export class McpHub {
autoApprove: [],
}
const parsedConfig = ServerConfigSchema.parse(serverConfig)
// TS expects the server config to be a McpServerConfig, but we know it's valid
// The issue is that the type is not having the transportType field added to it
settings.mcpServers[serverName] = parsedConfig
// ToDo: Add input types reflecting the non-transformed version
settings.mcpServers[serverName] = serverConfig as unknown as McpServerConfig
const settingsPath = await this.getMcpSettingsFilePath()
// We don't write the zod-transformed version to the file.
// The above parse() call adds the transportType field to the server config
// It would be fine if this was written, but we don't want to clutter up the file with internal details
// ToDo: We could benefit from input / output types reflecting the non-transformed / transformed versions
await fs.writeFile(
settingsPath,
JSON.stringify({ mcpServers: { ...settings.mcpServers, [serverName]: serverConfig } }, null, 2),
)
await fs.writeFile(settingsPath, JSON.stringify(settings, null, 2))
await this.updateServerConnections(settings.mcpServers)
vscode.window.showInformationMessage(`Added ${serverName} MCP server`)
vscode.window.showInformationMessage(`Added and connected to ${serverName} MCP server`)
} catch (error) {
console.error("Failed to add remote MCP server:", error)
vscode.window.showErrorMessage(
`Failed to add remote MCP server: ${error instanceof Error ? error.message : String(error)}`,
)
throw error
}
}
+1 -1
View File
@@ -61,7 +61,7 @@ interface SearchResult {
const MAX_RESULTS = 300
export async function getBinPath(vscodeAppRoot: string): Promise<string | undefined> {
async function getBinPath(vscodeAppRoot: string): Promise<string | undefined> {
const checkPath = async (pkgFolder: string) => {
const fullPath = path.join(vscodeAppRoot, pkgFolder, binName)
return (await fileExistsAtPath(fullPath)) ? fullPath : undefined
-174
View File
@@ -1,174 +0,0 @@
import * as vscode from "vscode"
import * as path from "path"
import * as fs from "fs"
import * as childProcess from "child_process"
import * as readline from "readline"
import { getBinPath } from "../ripgrep"
import type { Fzf, FzfResultItem } from "fzf"
// Wrapper function for childProcess.spawn
export type SpawnFunction = typeof childProcess.spawn
export const getSpawnFunction = (): SpawnFunction => childProcess.spawn
export async function executeRipgrepForFiles(
rgPath: string,
workspacePath: string,
limit: number = 5000,
): Promise<{ path: string; type: "file" | "folder"; label?: string }[]> {
return new Promise((resolve, reject) => {
// Arguments for ripgrep to list files, follow symlinks, include hidden, and exclude common directories
const args = [
"--files",
"--follow",
"--hidden",
"-g",
"!**/{node_modules,.git,.github,out,dist,__pycache__,.venv,.env,venv,env,.cache,tmp,temp}/**",
workspacePath,
]
// Spawn the ripgrep process with the specified arguments
const rgProcess = getSpawnFunction()(rgPath, args)
const rl = readline.createInterface({ input: rgProcess.stdout })
// Array to store file results and Set to track unique directories
const fileResults: { path: string; type: "file" | "folder"; label?: string }[] = []
const dirSet = new Set<string>()
let count = 0
// Handle each line of output from ripgrep (each line is a file path)
rl.on("line", (line) => {
if (count >= limit) {
rl.close()
rgProcess.kill()
return
}
// Convert absolute path to a relative path from workspace root
const relativePath = path.relative(workspacePath, line)
// Add file result to array
fileResults.push({
path: relativePath,
type: "file",
label: path.basename(relativePath),
})
// Extract and add parent directories to the set
let dirPath = path.dirname(relativePath)
while (dirPath && dirPath !== "." && dirPath !== "/") {
dirSet.add(dirPath)
dirPath = path.dirname(dirPath)
}
count++
})
// Capture any error output from ripgrep
let errorOutput = ""
rgProcess.stderr.on("data", (data) => (errorOutput += data.toString()))
// When ripgrep finishes or is closed
rl.on("close", () => {
if (errorOutput && fileResults.length === 0) {
reject(new Error(`ripgrep process error: ${errorOutput.trim()}`))
return
}
// Transform directory paths from Set into structured results
const dirResults = Array.from(dirSet, (dirPath): { path: string; type: "folder"; label?: string } => ({
path: dirPath,
type: "folder",
label: path.basename(dirPath),
}))
// Resolve combined results of files and directories
resolve([...fileResults, ...dirResults])
})
// Handle process-level errors
rgProcess.on("error", (error) => reject(new Error(`ripgrep process error: ${error.message}`)))
})
}
export async function searchWorkspaceFiles(
query: string,
workspacePath: string,
limit: number = 20,
): Promise<{ path: string; type: "file" | "folder"; label?: string }[]> {
try {
const rgPath = await getBinPath(vscode.env.appRoot)
if (!rgPath) {
throw new Error("Could not find ripgrep binary")
}
// Get all files and directories
const allItems = await executeRipgrepForFiles(rgPath, workspacePath, 5000)
// If no query, just return the top items
if (!query.trim()) {
return allItems.slice(0, limit)
}
// Match Scoring - Prioritize the label (filename) by including it twice in the search string
// Use multiple tiebreakers in order of importance: Match score, then length of match (shorter=better)
// Get more (2x) results than needed for filtering, we pick the top half after sorting
const fzfModule = await import("fzf")
const fzf = new fzfModule.Fzf(allItems, {
selector: (item: { label?: string; path: string }) => `${item.label || ""} ${item.label || ""} ${item.path}`,
tiebreakers: [OrderbyMatchScore, fzfModule.byLengthAsc],
limit: limit * 2,
})
// The min threshold value will require some testing and tuning as the scores are exponential, and exagerated
const MIN_SCORE_THRESHOLD = 100
// Filter results by score and map to original items
// Use exponential scaling for normalization
// This gives a more dramatic difference between good and bad matches
const filteredResults = fzf
.find(query)
.filter(({ score }: { score: number }) => Math.exp(score / 20) >= MIN_SCORE_THRESHOLD)
.slice(0, limit)
// Verify if the path exists and is actually a directory
const verifiedResultsPromises = filteredResults.map(
async ({ item }: { item: { path: string; type: "file" | "folder"; label?: string } }) => {
const fullPath = path.join(workspacePath, item.path)
let type = item.type
try {
const stats = await fs.promises.lstat(fullPath)
type = stats.isDirectory() ? "folder" : "file"
} catch {
// Keep original type if path doesn't exist
}
return { ...item, type }
},
)
return await Promise.all(verifiedResultsPromises)
} catch (error) {
console.error("Error in searchWorkspaceFiles:", error)
return []
}
}
// Custom match scoring for results ordering
// Candidate score tiebreaker - fewer gaps between matched characters scores higher
export const OrderbyMatchScore = (a: FzfResultItem<any>, b: FzfResultItem<any>) => {
const countGaps = (positions: Iterable<number>) => {
let gaps = 0,
prev = -Infinity
for (const pos of positions) {
if (prev !== -Infinity && pos - prev > 1) {
gaps++
}
prev = pos
}
return gaps
}
return countGaps(a.positions) - countGaps(b.positions)
}
-117
View File
@@ -28,10 +28,6 @@ class PostHogClient {
TOKEN_USAGE: "task.tokens",
// Tracks switches between plan and act modes
MODE_SWITCH: "task.mode",
// Tracks when users select an option from AI-generated followup questions
OPTION_SELECTED: "task.option_selected",
// Tracks when users type a custom response instead of selecting an option from AI-generated followup questions
OPTIONS_IGNORED: "task.options_ignored",
// Tracks usage of the git-based checkpoint system (shadow_git_initialized, commit_created, branch_created, branch_deleted_active, branch_deleted_inactive, restored)
CHECKPOINT_USED: "task.checkpoint_used",
// Tracks when tools (like file operations, commands) are used
@@ -42,12 +38,6 @@ class PostHogClient {
RETRY_CLICKED: "task.retry_clicked",
// Tracks when a diff edit (replace_in_file) operation fails
DIFF_EDIT_FAILED: "task.diff_edit_failed",
// Tracks when the browser tool is started
BROWSER_TOOL_START: "task.browser_tool_start",
// Tracks when the browser tool is completed
BROWSER_TOOL_END: "task.browser_tool_end",
// Tracks when browser errors occur
BROWSER_ERROR: "task.browser_error",
},
// UI interaction events for tracking user engagement
UI: {
@@ -467,113 +457,6 @@ class PostHogClient {
})
}
/**
* Records when the browser tool is started
* @param taskId Unique identifier for the task
* @param browserSettings The browser settings being used
*/
public captureBrowserToolStart(taskId: string, browserSettings: any) {
this.capture({
event: PostHogClient.EVENTS.TASK.BROWSER_TOOL_START,
properties: {
taskId,
viewport: browserSettings.viewport,
isRemote: !!browserSettings.remoteBrowserEnabled,
remoteBrowserHost: browserSettings.remoteBrowserHost,
timestamp: new Date().toISOString(),
},
})
}
/**
* Records when the browser tool is completed
* @param taskId Unique identifier for the task
* @param stats Statistics about the browser session
*/
public captureBrowserToolEnd(
taskId: string,
stats: {
actionCount: number
duration: number
actions?: string[]
},
) {
this.capture({
event: PostHogClient.EVENTS.TASK.BROWSER_TOOL_END,
properties: {
taskId,
actionCount: stats.actionCount,
duration: stats.duration,
actions: stats.actions,
timestamp: new Date().toISOString(),
},
})
}
/**
* Records when browser errors occur during a task
* @param taskId Unique identifier for the task
* @param errorType Type of error that occurred (e.g., "launch_error", "connection_error", "navigation_error")
* @param errorMessage The error message
* @param context Additional context about where the error occurred
*/
public captureBrowserError(
taskId: string,
errorType: string,
errorMessage: string,
context?: {
action?: string
url?: string
isRemote?: boolean
[key: string]: any
},
) {
this.capture({
event: PostHogClient.EVENTS.TASK.BROWSER_ERROR,
properties: {
taskId,
errorType,
errorMessage,
context,
timestamp: new Date().toISOString(),
},
})
}
/**
* Records when a user selects an option from AI-generated followup questions
* @param taskId Unique identifier for the task
* @param qty The quantity of options that were presented
* @param mode The mode in which the option was selected ("plan" or "act")
*/
public captureOptionSelected(taskId: string, qty: number, mode: "plan" | "act") {
this.capture({
event: PostHogClient.EVENTS.TASK.OPTION_SELECTED,
properties: {
taskId,
qty,
mode,
},
})
}
/**
* Records when a user types a custom response instead of selecting one of the AI-generated followup questions
* @param taskId Unique identifier for the task
* @param qty The quantity of options that were presented
* @param mode The mode in which the custom response was provided ("plan" or "act")
*/
public captureOptionsIgnored(taskId: string, qty: number, mode: "plan" | "act") {
this.capture({
event: PostHogClient.EVENTS.TASK.OPTIONS_IGNORED,
properties: {
taskId,
qty,
mode,
},
})
}
public isTelemetryEnabled(): boolean {
return this.telemetryEnabled
}
+2 -4
View File
@@ -5,8 +5,7 @@ export interface AutoApprovalSettings {
actions: {
readFiles: boolean // Read files and directories
editFiles: boolean // Edit files
executeSafeCommands: boolean // Execute safe commands
executeAllCommands: boolean // Execute all commands
executeCommands: boolean // Execute safe commands
useBrowser: boolean // Use browser
useMcp: boolean // Use MCP servers
}
@@ -20,8 +19,7 @@ export const DEFAULT_AUTO_APPROVAL_SETTINGS: AutoApprovalSettings = {
actions: {
readFiles: false,
editFiles: false,
executeSafeCommands: false,
executeAllCommands: false,
executeCommands: false,
useBrowser: false,
useMcp: false,
},
+3 -4
View File
@@ -4,10 +4,10 @@ export interface BrowserSettings {
width: number
height: number
}
// Browser mode settings
headless: boolean
// Chrome installation to use
// chromeType: "chromium" | "system"
remoteBrowserHost?: string
remoteBrowserEnabled?: boolean
}
export const DEFAULT_BROWSER_SETTINGS: BrowserSettings = {
@@ -15,8 +15,7 @@ export const DEFAULT_BROWSER_SETTINGS: BrowserSettings = {
width: 900,
height: 600,
},
remoteBrowserEnabled: false,
remoteBrowserHost: "http://localhost:9222",
headless: true,
// chromeType: "chromium",
}
-34
View File
@@ -35,21 +35,12 @@ export interface ExtensionMessage {
| "openGraphData"
| "isImageUrlResult"
| "didUpdateSettings"
| "addRemoteServerResult"
| "userCreditsBalance"
| "userCreditsUsage"
| "userCreditsPayments"
| "totalTasksSize"
| "addToInput"
| "browserConnectionResult"
| "browserConnectionInfo"
| "detectedChromePath"
| "scrollToSettings"
| "browserRelaunchResult"
| "relativePathsResponse" // Handles single and multiple path responses
| "fileSearchResults"
text?: string
paths?: (string | null)[] // Used for relativePathsResponse
action?:
| "chatButtonClicked"
| "mcpButtonClicked"
@@ -89,23 +80,6 @@ export interface ExtensionMessage {
userCreditsUsage?: UsageTransaction[]
userCreditsPayments?: PaymentTransaction[]
totalTasksSize?: number | null
success?: boolean
endpoint?: string
isBundled?: boolean
isConnected?: boolean
isRemote?: boolean
host?: string
mentionsRequestId?: string
results?: Array<{
path: string
type: "file" | "folder"
label?: string
}>
addRemoteServerResult?: {
success: boolean
serverName: string
error?: string
}
}
export type Invoke = "sendMessage" | "primaryButtonClick" | "secondaryButtonClick"
@@ -118,7 +92,6 @@ export interface ExtensionState {
apiConfiguration?: ApiConfiguration
autoApprovalSettings: AutoApprovalSettings
browserSettings: BrowserSettings
remoteBrowserHost?: string
chatSettings: ChatSettings
checkpointTrackerErrorMessage?: string
clineMessages: ClineMessage[]
@@ -195,7 +168,6 @@ export type ClineSay =
| "deleted_api_reqs"
| "clineignore_error"
| "checkpoint_created"
| "load_mcp_documentation"
export interface ClineSayTool {
tool:
@@ -230,12 +202,6 @@ export type BrowserActionResult = {
currentMousePosition?: string
}
export interface BrowserConnectionInfo {
isConnected: boolean
isRemote: boolean
host?: string
}
export interface ClineAskUseMcpServer {
serverName: string
type: "use_mcp_tool" | "access_mcp_resource"
-14
View File
@@ -35,10 +35,6 @@ export interface WebviewMessage {
| "deleteMcpServer"
| "autoApprovalSettings"
| "browserSettings"
| "discoverBrowser"
| "testBrowserConnection"
| "browserConnectionResult"
| "browserRelaunchResult"
| "togglePlanActMode"
| "checkpointDiff"
| "checkpointRestore"
@@ -70,17 +66,9 @@ export interface WebviewMessage {
| "fetchUserCreditsData"
| "optionsResponse"
| "requestTotalTasksSize"
| "relaunchChromeDebugMode"
| "taskFeedback"
| "getBrowserConnectionInfo"
| "getDetectedChromePath"
| "detectedChromePath"
| "scrollToSettings"
| "getRelativePaths" // Handles single and multiple URI resolution
| "searchFiles"
// | "relaunchChromeDebugMode"
text?: string
uris?: string[] // Used for getRelativePaths
disabled?: boolean
askResponse?: ClineAskResponse
apiConfiguration?: ApiConfiguration
@@ -109,8 +97,6 @@ export interface WebviewMessage {
customInstructionsSetting?: string
// For task feedback
feedbackType?: TaskFeedbackType
mentionsRequestId?: string
query?: string
}
export type ClineAskResponse = "yesButtonClicked" | "noButtonClicked" | "messageResponse"
@@ -1,191 +0,0 @@
import { expect } from "chai"
import { mentionRegex, mentionRegexGlobal } from "../context-mentions"
interface TestResult {
actual: string | null
expected: string | null
}
function testMention(input: string, expected: string | null): TestResult {
const match = mentionRegex.exec(input)
return {
actual: match ? match[0] : null,
expected,
}
}
function assertMatch(result: TestResult) {
expect(result.actual).eq(result.expected)
return true
}
describe("Mention Regex", () => {
describe("Windows Path Support", () => {
it("matches simple Windows paths", () => {
const cases: Array<[string, string]> = [
["@/C:\\folder\\file.txt", "@/C:\\folder\\file.txt"],
["@/C:\\file.txt", "@/C:\\file.txt"],
]
cases.forEach(([input, expected]) => {
const result = testMention(input, expected)
assertMatch(result)
})
})
})
describe("Edge Cases", () => {
it("handles edge cases correctly", () => {
const cases: Array<[string, string]> = [
["@/C:\\Users\\name\\path\\to\\文件夹\\file.txt", "@/C:\\Users\\name\\path\\to\\文件夹\\file.txt"],
["@/path123/file-name_2.0.txt", "@/path123/file-name_2.0.txt"],
]
cases.forEach(([input, expected]) => {
const result = testMention(input, expected)
assertMatch(result)
})
})
})
describe("Existing Functionality", () => {
it("matches Unix paths", () => {
const cases: Array<[string, string]> = [
["@/usr/local/bin/file", "@/usr/local/bin/file"],
["@/path/to/file.txt", "@/path/to/file.txt"],
["@//etc/host", "@//etc/host"],
]
cases.forEach(([input, expected]) => {
const result = testMention(input, expected)
assertMatch(result)
})
})
it("matches URLs", () => {
const cases: Array<[string, string]> = [
["@http://example.com", "@http://example.com"],
["@https://example.com/path/to/file.html", "@https://example.com/path/to/file.html"],
["@ftp://server.example.com/file.zip", "@ftp://server.example.com/file.zip"],
]
cases.forEach(([input, expected]) => {
const result = testMention(input, expected)
assertMatch(result)
})
})
it("matches git hashes", () => {
const cases: Array<[string, string]> = [
["@abcdef1234567890abcdef1234567890abcdef12", "@abcdef1234567890abcdef1234567890abcdef12"],
]
cases.forEach(([input, expected]) => {
const result = testMention(input, expected)
assertMatch(result)
})
})
it("matches special keywords", () => {
const cases: Array<[string, string]> = [
["@problems", "@problems"],
["@git-changes", "@git-changes"],
["@terminal", "@terminal"],
]
cases.forEach(([input, expected]) => {
const result = testMention(input, expected)
assertMatch(result)
})
})
})
describe("Invalid Patterns", () => {
it("rejects invalid patterns", () => {
const cases: Array<[string, null]> = [
["C:\\folder\\file.txt", null],
["@", null],
["@ C:\\file.txt", null],
]
cases.forEach(([input, expected]) => {
const result = testMention(input, expected)
assertMatch(result)
})
})
it("matches only until invalid characters", () => {
const result = testMention("@/C:\\folder\\file.txt invalid suffix", "@/C:\\folder\\file.txt")
assertMatch(result)
})
})
describe("In Context", () => {
it("matches mentions within text", () => {
const cases: Array<[string, string]> = [
["Check the file at @/C:\\folder\\file.txt for details.", "@/C:\\folder\\file.txt"],
["Review @problems and @git-changes.", "@problems"],
["Multiple: @/file1.txt and @/C:\\file2.txt and @terminal", "@/file1.txt"],
]
cases.forEach(([input, expected]) => {
const result = testMention(input, expected)
assertMatch(result)
})
})
})
describe("Multiple Mentions", () => {
it("finds all mentions in a string using global regex", () => {
const text = "Check @/path/file1.txt and @/C:\\folder\\file2.txt and report any @problems to @git-changes"
const matches = text.match(mentionRegexGlobal)
expect(matches).deep.eq(["@/path/file1.txt", "@/C:\\folder\\file2.txt", "@problems", "@git-changes"])
})
})
describe("Special Characters in Paths", () => {
it("handles special characters in file paths", () => {
const cases: Array<[string, string]> = [
["@/path/with-dash/file_underscore.txt", "@/path/with-dash/file_underscore.txt"],
["@/C:\\folder+plus\\file(parens)[]brackets.txt", "@/C:\\folder+plus\\file(parens)[]brackets.txt"],
["@/path/with/file#hash%percent.txt", "@/path/with/file#hash%percent.txt"],
["@/path/with/file@symbol$dollar.txt", "@/path/with/file@symbol$dollar.txt"],
]
cases.forEach(([input, expected]) => {
const result = testMention(input, expected)
assertMatch(result)
})
})
})
describe("Mixed Path Types in Single String", () => {
it("correctly identifies the first path in a string with multiple path types", () => {
const text = "Check both @/unix/path and @/C:\\windows\\path for details."
const result = mentionRegex.exec(text) || []
expect(result[0]).eq("@/unix/path")
// Test starting from after the first match
const secondSearchStart = text.indexOf("@/C:")
const secondResult = mentionRegex.exec(text.substring(secondSearchStart)) || []
expect(secondResult[0]).eq("@/C:\\windows\\path")
})
})
describe("Non-Latin Character Support", () => {
it("handles international characters in paths", () => {
const cases: Array<[string, string]> = [
["@/path/to/你好/file.txt", "@/path/to/你好/file.txt"],
["@/C:\\用户\\документы\\файл.txt", "@/C:\\用户\\документы\\файл.txt"],
["@/путь/к/файлу.txt", "@/путь/к/файлу.txt"],
["@/C:\\folder\\file_äöü.txt", "@/C:\\folder\\file_äöü.txt"],
]
cases.forEach(([input, expected]) => {
const result = testMention(input, expected)
assertMatch(result)
})
})
})
})
+5 -54
View File
@@ -12,7 +12,6 @@ export type ApiProvider =
| "together"
| "deepseek"
| "qwen"
| "doubao"
| "mistral"
| "vscode-lm"
| "cline"
@@ -29,7 +28,6 @@ export interface ApiHandlerOptions {
liteLlmBaseUrl?: string
liteLlmModelId?: string
liteLlmApiKey?: string
liteLlmUsePromptCache?: boolean
anthropicBaseUrl?: string
openRouterApiKey?: string
openRouterModelId?: string
@@ -63,7 +61,6 @@ export interface ApiHandlerOptions {
togetherApiKey?: string
togetherModelId?: string
qwenApiKey?: string
doubaoApiKey?: string
mistralApiKey?: string
azureApiVersion?: string
vsCodeLmModelSelector?: any
@@ -379,14 +376,6 @@ export const vertexModels = {
inputPrice: 0,
outputPrice: 0,
},
"gemini-2.5-pro-preview-03-25": {
maxTokens: 65536,
contextWindow: 1_048_576,
supportsImages: true,
supportsPromptCache: false,
inputPrice: 1.25,
outputPrice: 10,
},
"gemini-2.0-flash-thinking-exp-01-21": {
maxTokens: 65_536,
contextWindow: 1_048_576,
@@ -469,14 +458,6 @@ export const geminiModels = {
inputPrice: 0,
outputPrice: 0,
},
"gemini-2.5-pro-preview-03-25": {
maxTokens: 65536,
contextWindow: 1_048_576,
supportsImages: true,
supportsPromptCache: false,
inputPrice: 1.25,
outputPrice: 10,
},
"gemini-2.0-flash-001": {
maxTokens: 8192,
contextWindow: 1_048_576,
@@ -667,8 +648,8 @@ 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). Input is the sum of cache reads and writes
supportsPromptCache: true,
inputPrice: 0.27,
outputPrice: 1.1,
cacheWritesPrice: 0.27,
cacheReadsPrice: 0.07,
@@ -677,8 +658,8 @@ 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)
supportsPromptCache: true,
inputPrice: 0.55,
outputPrice: 2.19,
cacheWritesPrice: 0.55,
cacheReadsPrice: 0.14,
@@ -1117,34 +1098,6 @@ export const mainlandQwenModels = {
},
} as const satisfies Record<string, ModelInfo>
// Doubao
// https://www.volcengine.com/docs/82379/1298459
// https://console.volcengine.com/ark/region:ark+cn-beijing/openManagement
export type DoubaoModelId = keyof typeof doubaoModels
export const doubaoDefaultModelId: DoubaoModelId = "doubao-1-5-pro-256k-250115"
export const doubaoModels = {
"doubao-1-5-pro-256k-250115": {
maxTokens: 12_288,
contextWindow: 256_000,
supportsImages: false,
supportsPromptCache: false,
inputPrice: 0.7,
outputPrice: 1.3,
cacheWritesPrice: 0,
cacheReadsPrice: 0,
},
"doubao-1-5-pro-32k-250115": {
maxTokens: 12_288,
contextWindow: 32_000,
supportsImages: false,
supportsPromptCache: false,
inputPrice: 0.11,
outputPrice: 0.3,
cacheWritesPrice: 0,
cacheReadsPrice: 0,
},
} as const satisfies Record<string, ModelInfo>
// Mistral
// https://docs.mistral.ai/getting-started/models/models_overview/
export type MistralModelId = keyof typeof mistralModels
@@ -1240,11 +1193,9 @@ export const liteLlmModelInfoSaneDefaults: ModelInfo = {
maxTokens: -1,
contextWindow: 128_000,
supportsImages: true,
supportsPromptCache: true,
supportsPromptCache: false,
inputPrice: 0,
outputPrice: 0,
cacheWritesPrice: 0,
cacheReadsPrice: 0,
}
// AskSage Models
@@ -1,202 +0,0 @@
import { describe, it } from "mocha"
import should from "should"
import sinon from "sinon"
import { Readable } from "stream"
import type { FzfResultItem } from "fzf"
import * as childProcess from "child_process"
import * as vscode from "vscode"
import * as fs from "fs"
import * as path from "path"
import * as fileSearch from "../../../services/search/file-search"
import * as ripgrep from "../../../services/ripgrep"
describe("File Search", function () {
let sandbox: sinon.SinonSandbox
let spawnStub: sinon.SinonStub
beforeEach(function () {
sandbox = sinon.createSandbox()
spawnStub = sandbox.stub()
// Create a wrapper function that matches the signature of childProcess.spawn
const spawnWrapper: typeof childProcess.spawn = function (command, options) {
return spawnStub(command, options)
}
sandbox.stub(fileSearch, "getSpawnFunction").returns(spawnWrapper)
// Use replaceGetter instead of stub().value() for non-configurable properties
sandbox.replaceGetter(vscode.env, "appRoot", () => "mock/app/root")
sandbox.stub(fs.promises, "lstat").resolves({ isDirectory: () => false } as fs.Stats)
sandbox.stub(ripgrep, "getBinPath").resolves("mock/ripgrep/path")
})
afterEach(function () {
sandbox.restore()
})
describe("executeRipgrepForFiles", function () {
it("should correctly process and return file and folder results", async function () {
const mockFiles = ["file1.txt", "folder1/file2.js", "folder1/subfolder/file3.py"]
// Create a proper mock for the child process
const mockStdout = new Readable({
read() {
this.push(mockFiles.join("\n"))
this.push(null) // Signal the end of the stream
},
})
const mockStderr = new Readable({
read() {
this.push(null) // Empty stream
},
})
spawnStub.returns({
stdout: mockStdout,
stderr: mockStderr,
on: sinon.stub().returns({}),
} as unknown as childProcess.ChildProcess)
// Instead of stubbing path functions, we'll stub the executeRipgrepForFiles function
// to return a predictable result for this test
const expectedResult: { path: string; type: "file" | "folder"; label?: string }[] = [
{ path: "file1.txt", type: "file", label: "file1.txt" },
{ path: "folder1/file2.js", type: "file", label: "file2.js" },
{ path: "folder1/subfolder/file3.py", type: "file", label: "file3.py" },
{ path: "folder1", type: "folder", label: "folder1" },
{ path: "folder1/subfolder", type: "folder", label: "subfolder" },
]
// Create a new stub for executeRipgrepForFiles
sandbox.stub(fileSearch, "executeRipgrepForFiles").resolves(expectedResult)
const result = await fileSearch.executeRipgrepForFiles("mock/path", "/workspace", 5000)
should(result).be.an.Array()
// Don't assert on the exact length as it may vary
const files = result.filter((item) => item.type === "file")
const folders = result.filter((item) => item.type === "folder")
// Verify we have at least the expected files and folders
should(files.length).be.greaterThanOrEqual(3)
should(folders.length).be.greaterThanOrEqual(2)
should(files[0]).have.properties({
path: "file1.txt",
type: "file",
label: "file1.txt",
})
should(folders).containDeep([
{ path: "folder1", type: "folder", label: "folder1" },
{ path: "folder1/subfolder", type: "folder", label: "subfolder" },
])
})
it("should handle errors from ripgrep", async function () {
const mockError = "Mock ripgrep error"
// Create proper mock streams for error case
const mockStdout = new Readable({
read() {
this.push(null) // Empty stream
},
})
const mockStderr = new Readable({
read() {
this.push(mockError)
this.push(null) // Signal the end of the stream
},
})
spawnStub.returns({
stdout: mockStdout,
stderr: mockStderr,
on: function (event: string, callback: Function) {
if (event === "error") {
callback(new Error(mockError))
}
return this
},
} as unknown as childProcess.ChildProcess)
await should(fileSearch.executeRipgrepForFiles("mock/path", "/workspace", 5000)).be.rejectedWith(
`ripgrep process error: ${mockError}`,
)
})
})
describe("searchWorkspaceFiles", function () {
it("should return top N results for empty query", async function () {
const mockItems: { path: string; type: "file" | "folder"; label?: string }[] = [
{ path: "file1.txt", type: "file", label: "file1.txt" },
{ path: "folder1", type: "folder", label: "folder1" },
{ path: "file2.js", type: "file", label: "file2.js" },
]
// Directly stub the searchWorkspaceFiles function for this test
// This avoids issues with the executeRipgrepForFiles function
const searchStub = sandbox.stub(fileSearch, "searchWorkspaceFiles")
searchStub.withArgs("", "/workspace", 2).resolves(mockItems.slice(0, 2))
const result = await fileSearch.searchWorkspaceFiles("", "/workspace", 2)
should(result).be.an.Array()
should(result).have.length(2)
should(result).deepEqual(mockItems.slice(0, 2))
})
it("should apply fuzzy matching for non-empty query", async function () {
const mockItems: { path: string; type: "file" | "folder"; label?: string }[] = [
{ path: "file1.txt", type: "file", label: "file1.txt" },
{ path: "folder1/important.js", type: "file", label: "important.js" },
{ path: "file2.js", type: "file", label: "file2.js" },
]
sandbox.stub(fileSearch, "executeRipgrepForFiles").resolves(mockItems)
const fzfStub = {
find: sinon.stub().returns([{ item: mockItems[1], score: 0 }]),
}
// Create a mock for the fzf module
const fzfModuleStub = {
Fzf: sinon.stub().returns(fzfStub),
byLengthAsc: sinon.stub(),
}
// Use a more reliable approach to mock dynamic imports
// This replaces the actual implementation of searchWorkspaceFiles to avoid the dynamic import
sandbox.stub(fileSearch, "searchWorkspaceFiles").callsFake(async (query, workspacePath, limit) => {
if (!query.trim()) {
return mockItems.slice(0, limit)
}
// Simulate the fuzzy search behavior
return [mockItems[1]]
})
const result = await fileSearch.searchWorkspaceFiles("imp", "/workspace", 2)
should(result).be.an.Array()
should(result).have.length(1)
should(result[0]).have.properties({
path: "folder1/important.js",
type: "file",
label: "important.js",
})
})
})
describe("OrderbyMatchScore", function () {
it("should prioritize results with fewer gaps between matched characters", function () {
const mockItemA: FzfResultItem<any> = { item: {}, positions: new Set([0, 1, 2, 5]), start: 0, end: 5, score: 0 }
const mockItemB: FzfResultItem<any> = { item: {}, positions: new Set([0, 2, 4, 6]), start: 0, end: 6, score: 0 }
const result = fileSearch.OrderbyMatchScore(mockItemA, mockItemB)
should(result).be.lessThan(0)
})
})
})
-11
View File
@@ -1,6 +1,5 @@
import * as path from "path"
import os from "os"
import * as vscode from "vscode"
/*
The Node.js 'path' module resolves and normalizes paths differently depending on the platform:
@@ -100,13 +99,3 @@ export function getReadablePath(cwd: string, relPath?: string): string {
}
}
}
export const getWorkspacePath = (defaultCwdPath = "") => {
const cwdPath = vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath).at(0) || defaultCwdPath
const currentFileUri = vscode.window.activeTextEditor?.document.uri
if (currentFileUri) {
const workspaceFolder = vscode.workspace.getWorkspaceFolder(currentFileUri)
return workspaceFolder?.uri.fsPath || cwdPath
}
return cwdPath
}
+1 -1
View File
@@ -14,5 +14,5 @@
"rootDir": "src"
},
"include": ["src/**/*.test.ts"],
"exclude": ["src/test/**/*.js", "src/**/__tests__/*"]
"exclude": ["src/test/**/*.js"]
}
-8
View File
@@ -1,8 +0,0 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"module": "commonjs"
},
"include": ["test/**/*.ts"],
"exclude": ["node_modules"]
}
+5 -7
View File
@@ -9,6 +9,7 @@
"version": "0.3.0",
"dependencies": {
"@floating-ui/react": "^0.27.4",
"@types/dompurify": "^3.0.5",
"@vscode/webview-ui-toolkit": "^1.4.0",
"debounce": "^2.1.1",
"dompurify": "^3.2.4",
@@ -35,7 +36,6 @@
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.2.0",
"@testing-library/user-event": "^14.6.1",
"@types/dompurify": "^3.0.5",
"@types/jest": "^29.5.14",
"@types/node": "^22.13.4",
"@types/react": "^18.3.18",
@@ -51,7 +51,7 @@
"tailwindcss": "^4.0.12",
"typescript": "^5.7.3",
"typescript-eslint": "^8.18.2",
"vite": "^6.2.5",
"vite": "^6.2.4",
"vitest": "^3.0.5"
}
},
@@ -3192,7 +3192,6 @@
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-3.0.5.tgz",
"integrity": "sha512-1Wg0g3BtQF7sSb27fJQAKck1HECM6zV1EB66j8JH9i3LCjYabJa0FSdiSgsD5K/RbrsR0SiraKacLB+T8ZVYAg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/trusted-types": "*"
@@ -3530,7 +3529,6 @@
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"devOptional": true,
"license": "MIT"
},
"node_modules/@types/unist": {
@@ -8878,9 +8876,9 @@
}
},
"node_modules/vite": {
"version": "6.2.5",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.2.5.tgz",
"integrity": "sha512-j023J/hCAa4pRIUH6J9HemwYfjB5llR2Ps0CWeikOtdR8+pAURAk0DoJC5/mm9kd+UgdnIy7d6HE4EAvlYhPhA==",
"version": "6.2.4",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.2.4.tgz",
"integrity": "sha512-veHMSew8CcRzhL5o8ONjy8gkfmFJAd5Ac16oxBUjlwgX3Gq2Wqr+qNC3TjPIpy7TPV/KporLga5GT9HqdrCizw==",
"dev": true,
"license": "MIT",
"dependencies": {
+2 -2
View File
@@ -14,6 +14,7 @@
},
"dependencies": {
"@floating-ui/react": "^0.27.4",
"@types/dompurify": "^3.0.5",
"@vscode/webview-ui-toolkit": "^1.4.0",
"debounce": "^2.1.1",
"dompurify": "^3.2.4",
@@ -40,7 +41,6 @@
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.2.0",
"@testing-library/user-event": "^14.6.1",
"@types/dompurify": "^3.0.5",
"@types/jest": "^29.5.14",
"@types/node": "^22.13.4",
"@types/react": "^18.3.18",
@@ -56,7 +56,7 @@
"tailwindcss": "^4.0.12",
"typescript": "^5.7.3",
"typescript-eslint": "^8.18.2",
"vite": "^6.2.5",
"vite": "^6.2.4",
"vitest": "^3.0.5"
}
}
+2 -2
View File
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useState } from "react"
import { useEvent } from "react-use"
import { ExtensionMessage } from "@shared/ExtensionMessage"
import { ExtensionMessage } from "../../src/shared/ExtensionMessage"
import ChatView from "./components/chat/ChatView"
import HistoryView from "./components/history/HistoryView"
import SettingsView from "./components/settings/SettingsView"
@@ -9,7 +9,7 @@ import AccountView from "./components/account/AccountView"
import { ExtensionStateContextProvider, useExtensionState } from "./context/ExtensionStateContext"
import { FirebaseAuthProvider } from "./context/FirebaseAuthContext"
import { vscode } from "./utils/vscode"
import McpView from "./components/mcp/configuration/McpConfigurationView"
import McpView from "./components/mcp/McpView"
const AppContent = () => {
const { didHydrateState, showWelcome, shouldShowAnnouncement, telemetrySetting, vscMachineId } = useExtensionState()
@@ -1,5 +1,5 @@
import { memo } from "react"
import { vscode } from "@/utils/vscode"
import { vscode } from "../../utils/vscode"
const AccountOptions = () => {
const handleAccountClick = () => {
@@ -1,13 +1,13 @@
import { VSCodeButton, VSCodeDivider, VSCodeLink } from "@vscode/webview-ui-toolkit/react"
import { memo, useEffect, useState } from "react"
import { useFirebaseAuth } from "@/context/FirebaseAuthContext"
import { vscode } from "@/utils/vscode"
import { useFirebaseAuth } from "../../context/FirebaseAuthContext"
import { vscode } from "../../utils/vscode"
import VSCodeButtonLink from "../common/VSCodeButtonLink"
import ClineLogoWhite from "../../assets/ClineLogoWhite"
import CountUp from "react-countup"
import CreditsHistoryTable from "./CreditsHistoryTable"
import { UsageTransaction, PaymentTransaction } from "@shared/ClineAccount"
import { useExtensionState } from "@/context/ExtensionStateContext"
import { UsageTransaction, PaymentTransaction } from "../../../../src/shared/ClineAccount"
import { useExtensionState } from "../../context/ExtensionStateContext"
type AccountViewProps = {
onDone: () => void
@@ -1,8 +1,8 @@
import { VSCodeDataGrid, VSCodeDataGridRow, VSCodeDataGridCell } from "@vscode/webview-ui-toolkit/react"
import { useState } from "react"
import { TabButton } from "../mcp/configuration/McpConfigurationView"
import { UsageTransaction, PaymentTransaction } from "@shared/ClineAccount"
import { formatDollars, formatTimestamp } from "@/utils/format"
import { TabButton } from "../mcp/McpView"
import { UsageTransaction, PaymentTransaction } from "../../../../src/shared/ClineAccount"
import { formatDollars, formatTimestamp } from "../../utils/format"
interface CreditsHistoryTableProps {
isLoading: boolean
@@ -1,219 +1,235 @@
import { VSCodeButton, VSCodeCheckbox, VSCodeDropdown, VSCodeOption } from "@vscode/webview-ui-toolkit/react"
import React, { useEffect, useRef, useState } from "react"
import React, { useRef, useState } from "react"
import { useClickAway } from "react-use"
import styled from "styled-components"
import { BROWSER_VIEWPORT_PRESETS } from "@shared/BrowserSettings"
import { useExtensionState } from "@/context/ExtensionStateContext"
import { vscode } from "@/utils/vscode"
import { BROWSER_VIEWPORT_PRESETS } from "../../../../src/shared/BrowserSettings"
import { useExtensionState } from "../../context/ExtensionStateContext"
import { vscode } from "../../utils/vscode"
import { CODE_BLOCK_BG_COLOR } from "../common/CodeBlock"
interface ConnectionInfo {
isConnected: boolean
isRemote: boolean
host?: string
interface BrowserSettingsMenuProps {
disabled?: boolean
maxWidth?: number
}
export const BrowserSettingsMenu = () => {
export const BrowserSettingsMenu: React.FC<BrowserSettingsMenuProps> = ({ disabled = false, maxWidth }) => {
const { browserSettings } = useExtensionState()
const [showMenu, setShowMenu] = useState(false)
const [hasMouseEntered, setHasMouseEntered] = useState(false)
const containerRef = useRef<HTMLDivElement>(null)
const [showInfoPopover, setShowInfoPopover] = useState(false)
const [connectionInfo, setConnectionInfo] = useState<ConnectionInfo>({
isConnected: false,
isRemote: !!browserSettings.remoteBrowserEnabled,
host: browserSettings.remoteBrowserHost,
const menuRef = useRef<HTMLDivElement>(null)
useClickAway(containerRef, () => {
if (showMenu) {
setShowMenu(false)
setHasMouseEntered(false)
}
})
const popoverRef = useRef<HTMLDivElement>(null)
// Get actual connection info from the browser session
useEffect(() => {
// Request connection info when component mounts
vscode.postMessage({
type: "getBrowserConnectionInfo",
})
const handleMouseEnter = () => {
setHasMouseEntered(true)
}
// Listen for connection info updates
const handleMessage = (event: MessageEvent) => {
const message = event.data
if (message.type === "browserConnectionInfo") {
setConnectionInfo({
isConnected: message.isConnected,
isRemote: message.isRemote,
host: message.host,
})
}
const handleMouseLeave = () => {
if (hasMouseEntered) {
setShowMenu(false)
setHasMouseEntered(false)
}
}
window.addEventListener("message", handleMessage)
return () => {
window.removeEventListener("message", handleMessage)
}
}, [browserSettings.remoteBrowserHost, browserSettings.remoteBrowserEnabled])
const handleControlsMouseLeave = (e: React.MouseEvent) => {
const menuElement = menuRef.current
// Close popover when clicking outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (menuElement && showMenu) {
const menuRect = menuElement.getBoundingClientRect()
// If mouse is moving towards the menu, don't close it
if (
popoverRef.current &&
!popoverRef.current.contains(event.target as Node) &&
!event.composedPath().some((el) => (el as HTMLElement).classList?.contains("browser-info-icon"))
e.clientY >= menuRect.top &&
e.clientY <= menuRect.bottom &&
e.clientX >= menuRect.left &&
e.clientX <= menuRect.right
) {
setShowInfoPopover(false)
return
}
}
if (showInfoPopover) {
document.addEventListener("mousedown", handleClickOutside)
}
return () => {
document.removeEventListener("mousedown", handleClickOutside)
}
}, [showInfoPopover])
setShowMenu(false)
setHasMouseEntered(false)
}
const openBrowserSettings = () => {
// First open the settings panel
const handleViewportChange = (event: Event) => {
const target = event.target as HTMLSelectElement
const selectedSize = BROWSER_VIEWPORT_PRESETS[target.value as keyof typeof BROWSER_VIEWPORT_PRESETS]
if (selectedSize) {
vscode.postMessage({
type: "browserSettings",
browserSettings: {
...browserSettings,
viewport: selectedSize,
},
})
}
}
const updateHeadless = (headless: boolean) => {
vscode.postMessage({
type: "openSettings",
type: "browserSettings",
browserSettings: {
...browserSettings,
headless,
},
})
// After a short delay, send a message to scroll to browser settings
setTimeout(() => {
vscode.postMessage({
type: "scrollToSettings",
text: "browser-settings-section",
})
}, 300) // Give the settings panel time to open
}
const toggleInfoPopover = () => {
setShowInfoPopover(!showInfoPopover)
// const updateChromeType = (chromeType: BrowserSettings["chromeType"]) => {
// vscode.postMessage({
// type: "browserSettings",
// browserSettings: {
// ...browserSettings,
// chromeType,
// },
// })
// }
// Request updated connection info when opening the popover
if (!showInfoPopover) {
vscode.postMessage({
type: "getBrowserConnectionInfo",
})
}
}
// Determine icon based on connection state
const getIconClass = () => {
if (connectionInfo.isRemote) {
return "codicon-remote"
} else {
return connectionInfo.isConnected ? "codicon-vm-running" : "codicon-info"
}
}
// Determine icon color based on connection state
const getIconColor = () => {
if (connectionInfo.isRemote) {
return connectionInfo.isConnected ? "var(--vscode-charts-blue)" : "var(--vscode-foreground)"
} else if (connectionInfo.isConnected) {
return "var(--vscode-charts-green)"
} else {
return "var(--vscode-foreground)"
}
}
// Check connection status every second to keep icon in sync
useEffect(() => {
// Request connection info immediately
vscode.postMessage({
type: "getBrowserConnectionInfo",
})
// Set up interval to refresh every second
const intervalId = setInterval(() => {
vscode.postMessage({
type: "getBrowserConnectionInfo",
})
}, 1000)
return () => clearInterval(intervalId)
}, [])
// const relaunchChromeDebugMode = () => {
// vscode.postMessage({
// type: "relaunchChromeDebugMode",
// })
// }
return (
<div ref={containerRef} style={{ position: "relative", marginTop: "-1px", display: "flex" }}>
<VSCodeButton
appearance="icon"
className="browser-info-icon"
onClick={toggleInfoPopover}
title="Browser connection info"
style={{ marginRight: "4px" }}>
<i
className={`codicon ${getIconClass()}`}
style={{
fontSize: "14.5px",
color: getIconColor(),
}}
/>
</VSCodeButton>
{showInfoPopover && (
<InfoPopover ref={popoverRef}>
<h4 style={{ margin: "0 0 8px 0" }}>Browser Connection</h4>
<InfoRow>
<InfoLabel>Status:</InfoLabel>
<InfoValue
style={{
color: connectionInfo.isConnected
? "var(--vscode-charts-green)"
: "var(--vscode-errorForeground)",
}}>
{connectionInfo.isConnected ? "Connected" : "Disconnected"}
</InfoValue>
</InfoRow>
{connectionInfo.isConnected && (
<InfoRow>
<InfoLabel>Type:</InfoLabel>
<InfoValue>{connectionInfo.isRemote ? "Remote" : "Local"}</InfoValue>
</InfoRow>
)}
{connectionInfo.isConnected && connectionInfo.isRemote && connectionInfo.host && (
<InfoRow>
<InfoLabel>Remote Host:</InfoLabel>
<InfoValue>{connectionInfo.host}</InfoValue>
</InfoRow>
)}
</InfoPopover>
)}
<VSCodeButton appearance="icon" onClick={openBrowserSettings}>
<div ref={containerRef} style={{ position: "relative", marginTop: "-1px" }} onMouseLeave={handleControlsMouseLeave}>
<VSCodeButton appearance="icon" onClick={() => setShowMenu(!showMenu)} disabled={disabled}>
<i className="codicon codicon-settings-gear" style={{ fontSize: "14.5px" }} />
</VSCodeButton>
{showMenu && (
<SettingsMenu ref={menuRef} maxWidth={maxWidth} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave}>
<SettingsGroup>
{/* <SettingsHeader>Headless Mode</SettingsHeader> */}
<VSCodeCheckbox
style={{ marginBottom: "8px", marginTop: -1 }}
checked={browserSettings.headless}
onChange={(e) => updateHeadless((e.target as HTMLInputElement).checked)}>
Run in headless mode
</VSCodeCheckbox>
<SettingsDescription>When enabled, Chrome will run in the background.</SettingsDescription>
</SettingsGroup>
{/* <SettingsGroup>
<SettingsHeader>Chrome Executable</SettingsHeader>
<VSCodeDropdown
style={{ width: "100%", marginBottom: "8px" }}
value={browserSettings.chromeType}
onChange={(e) =>
updateChromeType((e.target as HTMLSelectElement).value as BrowserSettings["chromeType"])
}>
<VSCodeOption value="chromium">Chromium (Auto-downloaded)</VSCodeOption>
<VSCodeOption value="system">System Chrome</VSCodeOption>
</VSCodeDropdown>
<SettingsDescription>
{browserSettings.chromeType === "system" ? (
<>
Cline will use your personal browser. You must{" "}
<VSCodeLink
href="#"
style={{ fontSize: "inherit" }}
onClick={(e: React.MouseEvent) => {
e.preventDefault()
relaunchChromeDebugMode()
}}>
relaunch Chrome in debug mode
</VSCodeLink>{" "}
to use this setting.
</>
) : (
"Cline will use a Chromium browser bundled with the extension."
)}
</SettingsDescription>
</SettingsGroup> */}
<SettingsGroup>
<SettingsHeader>Viewport Size</SettingsHeader>
<VSCodeDropdown
style={{ width: "100%" }}
value={
Object.entries(BROWSER_VIEWPORT_PRESETS).find(
([_, size]) =>
size.width === browserSettings.viewport.width &&
size.height === browserSettings.viewport.height,
)?.[0]
}
onChange={(event) => handleViewportChange(event as Event)}>
{Object.entries(BROWSER_VIEWPORT_PRESETS).map(([name]) => (
<VSCodeOption key={name} value={name}>
{name}
</VSCodeOption>
))}
</VSCodeDropdown>
</SettingsGroup>
</SettingsMenu>
)}
</div>
)
}
const InfoPopover = styled.div`
const SettingsMenu = styled.div<{ maxWidth?: number }>`
position: absolute;
top: 30px;
right: 0;
background-color: var(--vscode-editorWidget-background);
border: 1px solid var(--vscode-widget-border);
border-radius: 4px;
padding: 10px;
z-index: 100;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
width: 60dvw;
max-width: 250px;
top: calc(100% + 8px);
right: -2px;
background: ${CODE_BLOCK_BG_COLOR};
border: 1px solid var(--vscode-editorGroup-border);
padding: 8px;
border-radius: 3px;
z-index: 1000;
width: calc(100vw - 57px);
min-width: 0px;
max-width: ${(props) => (props.maxWidth ? `${props.maxWidth - 23}px` : "100vw")};
// Add invisible padding to create a safe hover zone
&::before {
content: "";
position: absolute;
top: -14px; // Same as margin-top in the parent's top property
left: 0;
right: -6px;
height: 14px;
}
&::after {
content: "";
position: absolute;
top: -6px;
right: 6px;
width: 10px;
height: 10px;
background: ${CODE_BLOCK_BG_COLOR};
border-left: 1px solid var(--vscode-editorGroup-border);
border-top: 1px solid var(--vscode-editorGroup-border);
transform: rotate(45deg);
z-index: 1; // Ensure arrow stays above the padding
}
`
const InfoRow = styled.div`
display: flex;
margin-bottom: 4px;
flex-wrap: wrap;
white-space: nowrap;
const SettingsGroup = styled.div`
&:not(:last-child) {
margin-bottom: 8px;
// padding-bottom: 8px;
border-bottom: 1px solid var(--vscode-editorGroup-border);
}
`
const InfoLabel = styled.div`
flex: 0 0 90px;
font-weight: 500;
const SettingsHeader = styled.div`
font-size: 11px;
font-weight: 600;
margin-bottom: 6px;
color: var(--vscode-foreground);
`
const InfoValue = styled.div`
flex: 1;
word-break: break-word;
const SettingsDescription = styled.div<{ isLast?: boolean }>`
font-size: 11px;
color: var(--vscode-descriptionForeground);
margin-bottom: ${(props) => (props.isLast ? "0" : "8px")};
`
export default BrowserSettingsMenu
+35 -38
View File
@@ -1,61 +1,51 @@
import { VSCodeButton, VSCodeLink } from "@vscode/webview-ui-toolkit/react"
import { CSSProperties, memo } from "react"
import { getAsVar, VSC_DESCRIPTION_FOREGROUND, VSC_INACTIVE_SELECTION_BACKGROUND } from "@/utils/vscStyles"
import { memo } from "react"
import { getAsVar, VSC_DESCRIPTION_FOREGROUND, VSC_INACTIVE_SELECTION_BACKGROUND } from "../../utils/vscStyles"
import { vscode } from "../../utils/vscode"
interface AnnouncementProps {
version: string
hideAnnouncement: () => void
}
const containerStyle: CSSProperties = {
backgroundColor: getAsVar(VSC_INACTIVE_SELECTION_BACKGROUND),
borderRadius: "3px",
padding: "12px 16px",
margin: "5px 15px 5px 15px",
position: "relative",
flexShrink: 0,
}
const closeIconStyle: CSSProperties = { position: "absolute", top: "8px", right: "8px" }
const h3TitleStyle: CSSProperties = { margin: "0 0 8px" }
const ulStyle: CSSProperties = { margin: "0 0 8px", paddingLeft: "12px" }
const accountIconStyle: CSSProperties = { fontSize: 11 }
const hrStyle: CSSProperties = {
height: "1px",
background: getAsVar(VSC_DESCRIPTION_FOREGROUND),
opacity: 0.1,
margin: "8px 0",
}
const linkContainerStyle: CSSProperties = { margin: "0" }
const linkStyle: CSSProperties = { display: "inline" }
/*
You must update the latestAnnouncementId in ClineProvider for new announcements to show to users. This new id will be compared with whats in state for the 'last announcement shown', and if it's different then the announcement will render. As soon as an announcement is shown, the id will be updated in state. This ensures that announcements are not shown more than once, even if the user doesn't close it themselves.
*/
const Announcement = ({ version, hideAnnouncement }: AnnouncementProps) => {
const minorVersion = version.split(".").slice(0, 2).join(".") // 2.0.0 -> 2.0
return (
<div style={containerStyle}>
<VSCodeButton appearance="icon" onClick={hideAnnouncement} style={closeIconStyle}>
<div
style={{
backgroundColor: getAsVar(VSC_INACTIVE_SELECTION_BACKGROUND),
borderRadius: "3px",
padding: "12px 16px",
margin: "5px 15px 5px 15px",
position: "relative",
flexShrink: 0,
}}>
<VSCodeButton appearance="icon" onClick={hideAnnouncement} style={{ position: "absolute", top: "8px", right: "8px" }}>
<span className="codicon codicon-close"></span>
</VSCodeButton>
<h3 style={h3TitleStyle}>
<h3 style={{ margin: "0 0 8px" }}>
🎉{" "}New in v{minorVersion}
</h3>
<ul style={ulStyle}>
<ul style={{ margin: "0 0 8px", paddingLeft: "12px" }}>
<li>
<b>Browser Tool Upgrades:</b> Use your local Chrome browser for session-based browsing, enabling debugging and
productivity workflows tied to your actual browser state!
<b>Add to Cline:</b> Right-click selected text in any file or terminal to quickly add context to your current
task! Plus, when you see a lightbulb icon, select 'Fix with Cline' to have Cline fix errors in your code.
</li>
<li>
<b>Auto-Approve Commands:</b> New option to automatically approve <b>ALL</b> commands (use at your own risk!)
<b>Billing Dashboard:</b> Track your remaining credits and transaction history right in the extension with a{" "}
<span className="codicon codicon-account" style={{ fontSize: 11 }}></span> Cline account!
</li>
<li>
<b>Easily Toggle MCP's:</b> New modal in the chat area to easily enable/disable MCP servers.
<b>Faster Inference:</b> Cline/OpenRouter users can sort underlying providers used by throughput, price, and
latency. Sorting by throughput will output faster generations (at a higher cost).
</li>
<li>
Drag and drop files/folders into chat by holding <code>Shift</code> while dragging it into the chat field.
<b>Enhanced MCP Support:</b> Dynamic image loading with GIF support, and a new delete button to clean up
failed servers.
</li>
<li>Reduces system prompt token size by 30% by dynamically loading MCP documentation only when needed.</li>
</ul>
{/*<ul style={{ margin: "0 0 8px", paddingLeft: "12px" }}>
<li>
@@ -104,17 +94,24 @@ const Announcement = ({ version, hideAnnouncement }: AnnouncementProps) => {
environments)
</li>
</ul>*/}
<div style={hrStyle} />
<p style={linkContainerStyle}>
<div
style={{
height: "1px",
background: getAsVar(VSC_DESCRIPTION_FOREGROUND),
opacity: 0.1,
margin: "8px 0",
}}
/>
<p style={{ margin: "0" }}>
Join us on{" "}
<VSCodeLink style={linkStyle} href="https://x.com/cline">
<VSCodeLink style={{ display: "inline" }} href="https://x.com/cline">
X,
</VSCodeLink>{" "}
<VSCodeLink style={linkStyle} href="https://discord.gg/cline">
<VSCodeLink style={{ display: "inline" }} href="https://discord.gg/cline">
discord,
</VSCodeLink>{" "}
or{" "}
<VSCodeLink style={linkStyle} href="https://www.reddit.com/r/cline/">
<VSCodeLink style={{ display: "inline" }} href="https://www.reddit.com/r/cline/">
r/cline
</VSCodeLink>
for more updates!
@@ -1,24 +1,15 @@
import { VSCodeCheckbox, VSCodeTextField } from "@vscode/webview-ui-toolkit/react"
import { useCallback, useState } from "react"
import styled from "styled-components"
import { useExtensionState } from "@/context/ExtensionStateContext"
import { AutoApprovalSettings } from "@shared/AutoApprovalSettings"
import { vscode } from "@/utils/vscode"
import { getAsVar, VSC_FOREGROUND, VSC_TITLEBAR_INACTIVE_FOREGROUND, VSC_DESCRIPTION_FOREGROUND } from "@/utils/vscStyles"
import { useExtensionState } from "../../context/ExtensionStateContext"
import { AutoApprovalSettings } from "../../../../src/shared/AutoApprovalSettings"
import { vscode } from "../../utils/vscode"
import { getAsVar, VSC_FOREGROUND, VSC_TITLEBAR_INACTIVE_FOREGROUND, VSC_DESCRIPTION_FOREGROUND } from "../../utils/vscStyles"
interface AutoApproveMenuProps {
style?: React.CSSProperties
}
const SubOptionAnimateIn = styled.div<{ show: boolean }>`
max-height: ${(props) => (props.show ? "100px" : "0")};
opacity: ${(props) => (props.show ? "1" : "0")};
overflow: hidden;
transition:
max-height 0.2s ease-in-out,
opacity 0.2s ease-in-out;
`
const ACTION_METADATA: {
id: keyof AutoApprovalSettings["actions"]
label: string
@@ -38,18 +29,12 @@ const ACTION_METADATA: {
description: "Allows modification of any files on your computer.",
},
{
id: "executeSafeCommands",
id: "executeCommands",
label: "Execute safe commands",
shortName: "Safe Commands",
shortName: "Commands",
description:
"Allows execution of safe terminal commands. If the model determines a command is potentially destructive, it will still require approval.",
},
{
id: "executeAllCommands",
label: "Execute all commands",
shortName: "All Commands",
description: "Allows execution of all terminal commands. Use at your own risk.",
},
{
id: "useBrowser",
label: "Use the browser",
@@ -68,26 +53,11 @@ const AutoApproveMenu = ({ style }: AutoApproveMenuProps) => {
const { autoApprovalSettings } = useExtensionState()
const [isExpanded, setIsExpanded] = useState(false)
const [isHoveringCollapsibleSection, setIsHoveringCollapsibleSection] = useState(false)
// Careful not to use partials to mutate since spread operator only does shallow copy
const enabledActions = ACTION_METADATA.filter((action) => autoApprovalSettings.actions[action.id])
const enabledActionsList = (() => {
// "All Commands" is the only label displayed if both are set
const safeCommandsEnabled = enabledActions.some((action) => action.id === "executeSafeCommands")
const allCommandsEnabled = enabledActions.some((action) => action.id === "executeAllCommands")
const otherActions = enabledActions
.filter((action) => action.id !== "executeSafeCommands" && action.id !== "executeAllCommands")
.map((action) => action.shortName)
if (allCommandsEnabled) {
return ["All Commands", ...otherActions].join(", ")
} else if (safeCommandsEnabled) {
return ["Safe Commands", ...otherActions].join(", ")
} else {
return otherActions.join(", ")
}
})()
const enabledActionsList = enabledActions.map((action) => action.shortName).join(", ")
const hasEnabledActions = enabledActions.length > 0
const updateEnabled = useCallback(
@@ -250,61 +220,26 @@ const AutoApproveMenu = ({ style }: AutoApproveMenuProps) => {
Auto-approve allows Cline to perform the following actions without asking for permission. Please use with
caution and only enable if you understand the risks.
</div>
{ACTION_METADATA.map((action) => {
if (action.id === "executeAllCommands") {
return (
// Option to make the "Approve All" option animate into the menu when "Approve Safe" is enabled
<SubOptionAnimateIn key={action.id} show={autoApprovalSettings.actions.executeSafeCommands}>
<div
style={{
margin: "6px 0",
marginLeft: "28px",
}}>
<VSCodeCheckbox
checked={autoApprovalSettings.actions[action.id]}
onChange={(e) => {
const checked = (e.target as HTMLInputElement).checked
updateAction(action.id, checked)
}}>
{action.label}
</VSCodeCheckbox>
<div
style={{
marginLeft: "28px",
color: getAsVar(VSC_DESCRIPTION_FOREGROUND),
fontSize: "12px",
}}>
{action.description}
</div>
</div>
</SubOptionAnimateIn>
)
}
return (
<div
key={action.id}
style={{
margin: "6px 0",
{ACTION_METADATA.map((action) => (
<div key={action.id} style={{ margin: "6px 0" }}>
<VSCodeCheckbox
checked={autoApprovalSettings.actions[action.id]}
onChange={(e) => {
const checked = (e.target as HTMLInputElement).checked
updateAction(action.id, checked)
}}>
<VSCodeCheckbox
checked={autoApprovalSettings.actions[action.id]}
onChange={(e) => {
const checked = (e.target as HTMLInputElement).checked
updateAction(action.id, checked)
}}>
{action.label}
</VSCodeCheckbox>
<div
style={{
marginLeft: "28px",
color: getAsVar(VSC_DESCRIPTION_FOREGROUND),
fontSize: "12px",
}}>
{action.description}
</div>
{action.label}
</VSCodeCheckbox>
<div
style={{
marginLeft: "28px",
color: getAsVar(VSC_DESCRIPTION_FOREGROUND),
fontSize: "12px",
}}>
{action.description}
</div>
)
})}
</div>
))}
<div
style={{
height: "0.5px",
@@ -1,16 +1,16 @@
import { VSCodeButton } from "@vscode/webview-ui-toolkit/react"
import deepEqual from "fast-deep-equal"
import React, { CSSProperties, memo, useEffect, useMemo, useRef, useState } from "react"
import React, { memo, useEffect, useMemo, useRef, useState } from "react"
import { useSize } from "react-use"
import styled from "styled-components"
import { BROWSER_VIEWPORT_PRESETS } from "@shared/BrowserSettings"
import { BrowserAction, BrowserActionResult, ClineMessage, ClineSayBrowserAction } from "@shared/ExtensionMessage"
import { useExtensionState } from "@/context/ExtensionStateContext"
import { vscode } from "@/utils/vscode"
import { BrowserSettingsMenu } from "@/components/browser/BrowserSettingsMenu"
import { CheckpointControls } from "@/components/common/CheckpointControls"
import CodeBlock, { CODE_BLOCK_BG_COLOR } from "@/components/common/CodeBlock"
import { ChatRowContent, ProgressIndicator } from "@/components/chat/ChatRow"
import { BROWSER_VIEWPORT_PRESETS } from "../../../../src/shared/BrowserSettings"
import { BrowserAction, BrowserActionResult, ClineMessage, ClineSayBrowserAction } from "../../../../src/shared/ExtensionMessage"
import { useExtensionState } from "../../context/ExtensionStateContext"
import { vscode } from "../../utils/vscode"
import { BrowserSettingsMenu } from "../browser/BrowserSettingsMenu"
import { CheckpointControls } from "../common/CheckpointControls"
import CodeBlock, { CODE_BLOCK_BG_COLOR } from "../common/CodeBlock"
import { ChatRowContent, ProgressIndicator } from "./ChatRow"
interface BrowserSessionRowProps {
messages: ClineMessage[]
@@ -21,93 +21,6 @@ interface BrowserSessionRowProps {
onHeightChange: (isTaller: boolean) => void
}
const browserSessionRowContainerInnerStyle: CSSProperties = {
display: "flex",
alignItems: "center",
gap: "10px",
marginBottom: "10px",
}
const browserIconStyle: CSSProperties = {
color: "var(--vscode-foreground)",
marginBottom: "-1.5px",
}
const approveTextStyle: CSSProperties = { fontWeight: "bold" }
const urlBarContainerStyle: CSSProperties = {
margin: "5px auto",
width: "calc(100% - 10px)",
display: "flex",
alignItems: "center",
gap: "4px",
}
const urlTextStyle: CSSProperties = {
textOverflow: "ellipsis",
overflow: "hidden",
whiteSpace: "nowrap",
width: "100%",
textAlign: "center",
}
const imgScreenshotStyle: CSSProperties = {
position: "absolute",
top: 0,
left: 0,
width: "100%",
height: "100%",
objectFit: "contain",
cursor: "pointer",
}
const noScreenshotContainerStyle: CSSProperties = {
position: "absolute",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
}
const noScreenshotIconStyle: CSSProperties = {
fontSize: "80px",
color: "var(--vscode-descriptionForeground)",
}
const consoleLogsContainerStyle: CSSProperties = { width: "100%" }
const consoleLogsTextStyle: CSSProperties = { fontSize: "0.8em" }
const paginationContainerStyle: CSSProperties = {
display: "flex",
justifyContent: "space-between",
alignItems: "center",
padding: "8px 0px",
marginTop: "15px",
borderTop: "1px solid var(--vscode-editorGroup-border)",
}
const paginationButtonGroupStyle: CSSProperties = { display: "flex", gap: "4px" }
const browserSessionStartedTextStyle: CSSProperties = { fontWeight: "bold" }
const codeBlockContainerStyle: CSSProperties = {
borderRadius: 3,
border: "1px solid var(--vscode-editorGroup-border)",
overflow: "hidden",
backgroundColor: CODE_BLOCK_BG_COLOR,
}
const browserActionBoxContainerStyle: CSSProperties = { padding: "10px 0 0 0" }
const browserActionBoxContainerInnerStyle: CSSProperties = {
borderRadius: 3,
backgroundColor: CODE_BLOCK_BG_COLOR,
overflow: "hidden",
border: "1px solid var(--vscode-editorGroup-border)",
}
const browseActionRowContainerStyle: CSSProperties = {
display: "flex",
alignItems: "center",
padding: "9px 10px",
}
const browseActionRowStyle: CSSProperties = {
whiteSpace: "normal",
wordBreak: "break-word",
}
const browseActionTextStyle: CSSProperties = { fontWeight: 500 }
const chatRowContentContainerStyle: CSSProperties = { padding: "10px 0 10px 0" }
const headerStyle: CSSProperties = {
display: "flex",
alignItems: "center",
gap: "10px",
marginBottom: "10px",
}
const BrowserSessionRow = memo((props: BrowserSessionRowProps) => {
const { messages, isLast, onHeightChange, lastModifiedMessage } = props
const { browserSettings } = useExtensionState()
@@ -331,12 +244,25 @@ const BrowserSessionRow = memo((props: BrowserSessionRowProps) => {
const maxWidth = browserSettings.viewport.width < BROWSER_VIEWPORT_PRESETS["Small Desktop (900x600)"].width ? 200 : undefined
const [browserSessionRow, { height }] = useSize(
// We don't declare a constant for the inline style here because `useSize` will try to modify the style object
// Which will cause `Uncaught TypeError: Cannot assign to read only property 'position' of object '#<Object>'`
<BrowserSessionRowContainer style={{ marginBottom: -10 }}>
<div style={browserSessionRowContainerInnerStyle}>
{isBrowsing ? <ProgressIndicator /> : <span className="codicon codicon-inspect" style={browserIconStyle}></span>}
<span style={approveTextStyle}>
<div
style={{
display: "flex",
alignItems: "center",
gap: "10px",
marginBottom: "10px",
}}>
{isBrowsing ? (
<ProgressIndicator />
) : (
<span
className={`codicon codicon-inspect`}
style={{
color: "var(--vscode-foreground)",
marginBottom: "-1.5px",
}}></span>
)}
<span style={{ fontWeight: "bold" }}>
<>{isAutoApproved ? "Cline is using the browser:" : "Cline wants to use the browser:"}</>
</span>
</div>
@@ -351,7 +277,14 @@ const BrowserSessionRow = memo((props: BrowserSessionRowProps) => {
margin: "0 auto 10px auto", // Center the container
}}>
{/* URL Bar */}
<div style={urlBarContainerStyle}>
<div
style={{
margin: "5px auto",
width: "calc(100% - 10px)",
display: "flex",
alignItems: "center",
gap: "4px",
}}>
<div
style={{
flex: 1,
@@ -363,9 +296,18 @@ const BrowserSessionRow = memo((props: BrowserSessionRowProps) => {
color: displayState.url ? "var(--vscode-input-foreground)" : "var(--vscode-descriptionForeground)",
fontSize: "12px",
}}>
<div style={urlTextStyle}>{displayState.url || "http"}</div>
<div
style={{
textOverflow: "ellipsis",
overflow: "hidden",
whiteSpace: "nowrap",
width: "100%",
textAlign: "center",
}}>
{displayState.url || "http"}
</div>
</div>
<BrowserSettingsMenu />
<BrowserSettingsMenu disabled={!shouldShowSettings} maxWidth={maxWidth} />
</div>
{/* Screenshot Area */}
@@ -380,7 +322,15 @@ const BrowserSessionRow = memo((props: BrowserSessionRowProps) => {
<img
src={displayState.screenshot}
alt="Browser screenshot"
style={imgScreenshotStyle}
style={{
position: "absolute",
top: 0,
left: 0,
width: "100%",
height: "100%",
objectFit: "contain",
cursor: "pointer",
}}
onClick={() =>
vscode.postMessage({
type: "openImage",
@@ -389,8 +339,20 @@ const BrowserSessionRow = memo((props: BrowserSessionRowProps) => {
}
/>
) : (
<div style={noScreenshotContainerStyle}>
<span className="codicon codicon-globe" style={noScreenshotIconStyle} />
<div
style={{
position: "absolute",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
}}>
<span
className="codicon codicon-globe"
style={{
fontSize: "80px",
color: "var(--vscode-descriptionForeground)",
}}
/>
</div>
)}
{displayState.mousePosition && (
@@ -405,7 +367,7 @@ const BrowserSessionRow = memo((props: BrowserSessionRowProps) => {
)}
</div>
<div style={consoleLogsContainerStyle}>
<div style={{ width: "100%" }}>
<div
onClick={() => {
setConsoleLogsExpanded(!consoleLogsExpanded)
@@ -420,7 +382,7 @@ const BrowserSessionRow = memo((props: BrowserSessionRowProps) => {
padding: `9px 8px ${consoleLogsExpanded ? 0 : 8}px 8px`,
}}>
<span className={`codicon codicon-chevron-${consoleLogsExpanded ? "down" : "right"}`}></span>
<span style={consoleLogsTextStyle}>Console Logs</span>
<span style={{ fontSize: "0.8em" }}>Console Logs</span>
</div>
{consoleLogsExpanded && (
<CodeBlock source={`${"```"}shell\n${displayState.consoleLogs || "(No new logs)"}\n${"```"}`} />
@@ -433,11 +395,19 @@ const BrowserSessionRow = memo((props: BrowserSessionRowProps) => {
{/* Pagination moved to bottom */}
{pages.length > 1 && (
<div style={paginationContainerStyle}>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
padding: "8px 0px",
marginTop: "15px",
borderTop: "1px solid var(--vscode-editorGroup-border)",
}}>
<div>
Step {currentPageIndex + 1} of {pages.length}
</div>
<div style={paginationButtonGroupStyle}>
<div style={{ display: "flex", gap: "4px" }}>
<VSCodeButton
disabled={currentPageIndex === 0 || isBrowsing}
onClick={() => setCurrentPageIndex((i) => i - 1)}>
@@ -483,13 +453,26 @@ const BrowserSessionRowContent = ({
isLast,
setMaxActionHeight,
}: BrowserSessionRowContentProps) => {
const headerStyle: React.CSSProperties = {
display: "flex",
alignItems: "center",
gap: "10px",
marginBottom: "10px",
}
if (message.ask === "browser_action_launch" || message.say === "browser_action_launch") {
return (
<>
<div style={headerStyle}>
<span style={browserSessionStartedTextStyle}>Browser Session Started</span>
<span style={{ fontWeight: "bold" }}>Browser Session Started</span>
</div>
<div style={codeBlockContainerStyle}>
<div
style={{
borderRadius: 3,
border: "1px solid var(--vscode-editorGroup-border)",
overflow: "hidden",
backgroundColor: CODE_BLOCK_BG_COLOR,
}}>
<CodeBlock source={`${"```"}shell\n${message.text}\n${"```"}`} forceWrap={true} />
</div>
</>
@@ -502,7 +485,7 @@ const BrowserSessionRowContent = ({
case "api_req_started":
case "text":
return (
<div style={chatRowContentContainerStyle}>
<div style={{ padding: "10px 0 10px 0" }}>
<ChatRowContent
message={message}
isExpanded={isExpanded(message.ts)}
@@ -560,11 +543,26 @@ const BrowserActionBox = ({ action, coordinate, text }: { action: BrowserAction;
}
}
return (
<div style={browserActionBoxContainerStyle}>
<div style={browserActionBoxContainerInnerStyle}>
<div style={browseActionRowContainerStyle}>
<span style={browseActionRowStyle}>
<span style={browseActionTextStyle}>Browse Action: </span>
<div style={{ padding: "10px 0 0 0" }}>
<div
style={{
borderRadius: 3,
backgroundColor: CODE_BLOCK_BG_COLOR,
overflow: "hidden",
border: "1px solid var(--vscode-editorGroup-border)",
}}>
<div
style={{
display: "flex",
alignItems: "center",
padding: "9px 10px",
}}>
<span
style={{
whiteSpace: "normal",
wordBreak: "break-word",
}}>
<span style={{ fontWeight: 500 }}>Browse Action: </span>
{getBrowserActionText(action, coordinate, text)}
</span>
</div>
@@ -573,7 +571,7 @@ const BrowserActionBox = ({ action, coordinate, text }: { action: BrowserAction;
)
}
const BrowserCursor: React.FC<{ style?: CSSProperties }> = ({ style }) => {
const BrowserCursor: React.FC<{ style?: React.CSSProperties }> = ({ style }) => {
// (can't use svgs in vsc extensions)
const cursorBase64 =
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABUAAAAYCAYAAAAVibZIAAAAAXNSR0IArs4c6QAAAERlWElmTU0AKgAAAAgAAYdpAAQAAAABAAAAGgAAAAAAA6ABAAMAAAABAAEAAKACAAQAAAABAAAAFaADAAQAAAABAAAAGAAAAADwi9a/AAADGElEQVQ4EZ2VbUiTURTH772be/PxZdsz3cZwC4RVaB8SAjMpxQwSWZbQG/TFkN7oW1Df+h6IRV9C+hCpKUSIZUXOfGM5tAKViijFFEyfZ7Ol29S1Pbdzl8Uw9+aBu91zzv3/nt17zt2DEZjBYOAkKrtFMXIghAWM8U2vMN/FctsxGRMpM7NbEEYNMM2CYUSInlJx3OpawO9i+XSNQYkmk2uFb9njzkcfVSr1p/GJiQKMULVaw2WuBv296UKRxWJR6wxGCmM1EAhSNppv33GBH9qI32cPTAtss9lUm6EM3N7R+RbigT+5/CeosFCZKpjEW+iorS1pb30wDUXzQfHqtD/9L3ieZ2ee1OJCmbL8QHnRs+4uj0wmW4QzrpCwvJ8zGg3JqAmhTLynuLiwv8/5KyND8Q3cEkUEDWu15oJE4KRQJt5hs1rcriGNRqP+DK4dyyWXXm/aFQ+cEpSJ8/LyDGPuEZNOmzsOroUSOqzXG/dtBU4ZysTZYKNut91sNo2Cq6cE9enz86s2g9OCMrFSqVC5hgb32u072W3jKMU90Hb1seC0oUwsB+t92bO/rKx0EFGkgFCnjjc1/gVvC8rE0L+4o63t4InjxwbAJQjTe3qD8QrLkXA4DC24fWtuajp06cLFYSBIFKGmXKPRRmAnME9sPt+yLwIWb9WN69fKoTneQz4Dh2mpPNkvfeV0jjecb9wNAkwIEVQq5VJOds4Kb+DXoAsiVquVwI1Dougpij6UyGYx+5cKroeDEFibm5lWRRMbH1+npmYrq6qhwlQHIbajZEf1fElcqGGFpGg9HMuKzpfBjhytCTMgkJ56RX09zy/ysENTBElmjIgJnmNChJqohDVQqpEfwkILE8v/o0GAnV9F1eEvofVQCbiTBEXOIPQh5PGgefDZeAcjrpGZjULBr/m3tZOnz7oEQWRAQZLjWlEU/XEJWySiILgRc5Cz1DkcAyuBFcnpfF0JiXWKpcolQXizhS5hKAqFpr0MVbgbuxJ6+5xX+P4wNpbqPPrugZfbmIbLmgQR3Aw8QSi66hUXulOFbF73GxqjE5BNXWNeAAAAAElFTkSuQmCC"
+16 -31
View File
@@ -12,25 +12,25 @@ import {
ClineSayTool,
COMPLETION_RESULT_CHANGES_FLAG,
ExtensionMessage,
} from "@shared/ExtensionMessage"
import { COMMAND_OUTPUT_STRING, COMMAND_REQ_APP_STRING } from "@shared/combineCommandSequences"
import { useExtensionState } from "@/context/ExtensionStateContext"
import { findMatchingResourceOrTemplate, getMcpServerDisplayName } from "@/utils/mcp"
import { vscode } from "@/utils/vscode"
import { CheckmarkControl } from "@/components/common/CheckmarkControl"
} from "../../../../src/shared/ExtensionMessage"
import { COMMAND_OUTPUT_STRING, COMMAND_REQ_APP_STRING } from "../../../../src/shared/combineCommandSequences"
import { useExtensionState } from "../../context/ExtensionStateContext"
import { findMatchingResourceOrTemplate, getMcpServerDisplayName } from "../../utils/mcp"
import { vscode } from "../../utils/vscode"
import { CheckmarkControl } from "../common/CheckmarkControl"
import { CheckpointControls, CheckpointOverlay } from "../common/CheckpointControls"
import CodeAccordian, { cleanPathPrefix } from "../common/CodeAccordian"
import CodeBlock, { CODE_BLOCK_BG_COLOR } from "@/components/common/CodeBlock"
import MarkdownBlock from "@/components/common/MarkdownBlock"
import Thumbnails from "@/components/common/Thumbnails"
import McpToolRow from "@/components/mcp/configuration/tabs/installed/server-row/McpToolRow"
import McpResponseDisplay from "@/components/mcp/chat-display/McpResponseDisplay"
import CreditLimitError from "@/components/chat/CreditLimitError"
import { OptionsButtons } from "@/components/chat/OptionsButtons"
import CodeBlock, { CODE_BLOCK_BG_COLOR } from "../common/CodeBlock"
import MarkdownBlock from "../common/MarkdownBlock"
import Thumbnails from "../common/Thumbnails"
import McpResourceRow from "../mcp/McpResourceRow"
import McpToolRow from "../mcp/McpToolRow"
import McpResponseDisplay from "../mcp/McpResponseDisplay"
import CreditLimitError from "./CreditLimitError"
import { OptionsButtons } from "./OptionsButtons"
import { highlightMentions } from "./TaskHeader"
import SuccessButton from "@/components/common/SuccessButton"
import TaskFeedbackButtons from "@/components/chat/TaskFeedbackButtons"
import McpResourceRow from "@/components/mcp/configuration/tabs/installed/server-row/McpResourceRow"
import SuccessButton from "../common/SuccessButton"
import TaskFeedbackButtons from "./TaskFeedbackButtons"
const ChatRowContainer = styled.div`
padding: 10px 6px 10px 15px;
@@ -989,21 +989,6 @@ export const ChatRowContent = ({ message, isExpanded, onToggleExpand, lastModifi
<CheckmarkControl messageTs={message.ts} isCheckpointCheckedOut={message.isCheckpointCheckedOut} />
</>
)
case "load_mcp_documentation":
return (
<div
style={{
display: "flex",
alignItems: "center",
color: "var(--vscode-foreground)",
opacity: 0.7,
fontSize: 12,
padding: "4px 0",
}}>
<i className="codicon codicon-book" style={{ marginRight: 6 }} />
Loading MCP documentation
</div>
)
case "completion_result":
const hasChanges = message.text?.endsWith(COMPLETION_RESULT_CHANGES_FLAG) ?? false
const text = hasChanges ? message.text?.slice(0, -COMPLETION_RESULT_CHANGES_FLAG.length) : message.text
+24 -146
View File
@@ -3,29 +3,26 @@ import React, { forwardRef, useCallback, useEffect, useLayoutEffect, useMemo, us
import DynamicTextArea from "react-textarea-autosize"
import { useClickAway, useEvent, useWindowSize } from "react-use"
import styled from "styled-components"
import { mentionRegex, mentionRegexGlobal } from "@shared/context-mentions"
import { ExtensionMessage } from "@shared/ExtensionMessage"
import { useExtensionState } from "@/context/ExtensionStateContext"
import { mentionRegex, mentionRegexGlobal } from "../../../../src/shared/context-mentions"
import { ExtensionMessage } from "../../../../src/shared/ExtensionMessage"
import { useExtensionState } from "../../context/ExtensionStateContext"
import {
ContextMenuOptionType,
getContextMenuOptions,
insertMention,
insertMentionDirectly,
removeMention,
shouldShowContextMenu,
SearchResult,
} from "@/utils/context-mentions"
import { useMetaKeyDetection, useShortcut } from "@/utils/hooks"
import { validateApiConfiguration, validateModelId } from "@/utils/validate"
import { vscode } from "@/utils/vscode"
import { CODE_BLOCK_BG_COLOR } from "@/components/common/CodeBlock"
import Thumbnails from "@/components/common/Thumbnails"
import Tooltip from "@/components/common/Tooltip"
import ApiOptions, { normalizeApiConfiguration } from "@/components/settings/ApiOptions"
import { MAX_IMAGES_PER_MESSAGE } from "@/components/chat/ChatView"
import ContextMenu from "@/components/chat/ContextMenu"
import { ChatSettings } from "@shared/ChatSettings"
import ServersToggleModal from "./ServersToggleModal"
} from "../../utils/context-mentions"
import { useMetaKeyDetection, useShortcut } from "../../utils/hooks"
import { validateApiConfiguration, validateModelId } from "../../utils/validate"
import { vscode } from "../../utils/vscode"
import { CODE_BLOCK_BG_COLOR } from "../common/CodeBlock"
import Thumbnails from "../common/Thumbnails"
import Tooltip from "../common/Tooltip"
import ApiOptions, { normalizeApiConfiguration } from "../settings/ApiOptions"
import { MAX_IMAGES_PER_MESSAGE } from "./ChatView"
import ContextMenu from "./ContextMenu"
import { ChatSettings } from "../../../../src/shared/ChatSettings"
interface ChatTextAreaProps {
inputValue: string
@@ -241,10 +238,7 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
const [arrowPosition, setArrowPosition] = useState(0)
const [menuPosition, setMenuPosition] = useState(0)
const [shownTooltipMode, setShownTooltipMode] = useState<ChatSettings["mode"] | null>(null)
const [pendingInsertions, setPendingInsertions] = useState<string[]>([])
const [fileSearchResults, setFileSearchResults] = useState<SearchResult[]>([])
const [searchLoading, setSearchLoading] = useState(false)
const [, metaKeyChar] = useMetaKeyDetection(platform)
// Add a ref to track previous menu state
@@ -274,23 +268,6 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
setGitCommits(commits)
break
}
case "relativePathsResponse": {
// New case for batch response
const validPaths = message.paths?.filter((path): path is string => !!path) || []
if (validPaths.length > 0) {
setPendingInsertions((prev) => [...prev, ...validPaths])
}
break
}
case "fileSearchResults": {
// Only update results if they match the current query or if there's no mentionsRequestId - better UX
if (!message.mentionsRequestId || message.mentionsRequestId === currentSearchQueryRef.current) {
setFileSearchResults(message.results || [])
setSearchLoading(false)
}
break
}
}
}, [])
@@ -395,7 +372,7 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
event.preventDefault()
setSelectedMenuIndex((prevIndex) => {
const direction = event.key === "ArrowUp" ? -1 : 1
const options = getContextMenuOptions(searchQuery, selectedType, queryItems, fileSearchResults)
const options = getContextMenuOptions(searchQuery, selectedType, queryItems)
const optionsLength = options.length
if (optionsLength === 0) return prevIndex
@@ -421,9 +398,7 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
}
if ((event.key === "Enter" || event.key === "Tab") && selectedMenuIndex !== -1) {
event.preventDefault()
const selectedOption = getContextMenuOptions(searchQuery, selectedType, queryItems, fileSearchResults)[
selectedMenuIndex
]
const selectedOption = getContextMenuOptions(searchQuery, selectedType, queryItems)[selectedMenuIndex]
if (
selectedOption &&
selectedOption.type !== ContextMenuOptionType.URL &&
@@ -490,44 +465,16 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
setInputValue,
justDeletedSpaceAfterMention,
queryItems,
fileSearchResults,
],
)
// Effect to set cursor position after state updates
useLayoutEffect(() => {
if (intendedCursorPosition !== null && textAreaRef.current) {
textAreaRef.current.setSelectionRange(intendedCursorPosition, intendedCursorPosition)
setIntendedCursorPosition(null) // Reset the state after applying
setIntendedCursorPosition(null) // Reset the state
}
}, [inputValue, intendedCursorPosition])
useEffect(() => {
if (pendingInsertions.length === 0 || !textAreaRef.current) {
return
}
const path = pendingInsertions[0]
const currentTextArea = textAreaRef.current
const currentValue = currentTextArea.value
const currentCursorPos =
intendedCursorPosition ??
(currentTextArea.selectionStart >= 0 ? currentTextArea.selectionStart : currentValue.length)
const { newValue, mentionIndex } = insertMentionDirectly(currentValue, currentCursorPos, path)
setInputValue(newValue)
const newCursorPosition = mentionIndex + path.length + 2
setIntendedCursorPosition(newCursorPosition)
setPendingInsertions((prev) => prev.slice(1))
}, [pendingInsertions, setInputValue])
const searchTimeoutRef = useRef<NodeJS.Timeout | null>(null)
const currentSearchQueryRef = useRef<string>("")
const handleInputChange = useCallback(
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
const newValue = e.target.value
@@ -541,36 +488,17 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
const lastAtIndex = newValue.lastIndexOf("@", newCursorPosition - 1)
const query = newValue.slice(lastAtIndex + 1, newCursorPosition)
setSearchQuery(query)
currentSearchQueryRef.current = query
if (query.length > 0 && !selectedType) {
if (query.length > 0) {
setSelectedMenuIndex(0)
// Clear any existing timeout
if (searchTimeoutRef.current) {
clearTimeout(searchTimeoutRef.current)
}
setSearchLoading(true)
// Set a timeout to debounce the search requests
searchTimeoutRef.current = setTimeout(() => {
vscode.postMessage({
type: "searchFiles",
query: query,
mentionsRequestId: query,
})
}, 200) // 200ms debounce
} else {
setSelectedMenuIndex(3) // Set to "File" option by default
}
} else {
setSearchQuery("")
setSelectedMenuIndex(-1)
setFileSearchResults([])
}
},
[setInputValue, setFileSearchResults, selectedType],
[setInputValue],
)
useEffect(() => {
@@ -883,63 +811,21 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
const onDrop = async (e: React.DragEvent) => {
e.preventDefault()
// --- 1. VSCode Explorer Drop Handling ---
let uris: string[] = []
const resourceUrlsData = e.dataTransfer.getData("resourceurls")
const vscodeUriListData = e.dataTransfer.getData("application/vnd.code.uri-list")
// 1a. Try 'resourceurls' first (used for multi-select)
if (resourceUrlsData) {
try {
uris = JSON.parse(resourceUrlsData)
uris = uris.map((uri) => decodeURIComponent(uri))
} catch (error) {
console.error("Failed to parse resourceurls JSON:", error)
uris = [] // Reset if parsing failed
}
}
// 1b. Fallback to 'application/vnd.code.uri-list' (newline separated)
if (uris.length === 0 && vscodeUriListData) {
uris = vscodeUriListData.split("\n").map((uri) => uri.trim())
}
// 1c. Filter for valid schemes (file or vscode-file) and non-empty strings
const validUris = uris.filter((uri) => uri && (uri.startsWith("vscode-file:") || uri.startsWith("file:")))
if (validUris.length > 0) {
setPendingInsertions([])
let initialCursorPos = inputValue.length
if (textAreaRef.current) {
initialCursorPos = textAreaRef.current.selectionStart
}
setIntendedCursorPosition(initialCursorPos)
vscode.postMessage({
type: "getRelativePaths",
uris: validUris,
})
return
}
const files = Array.from(e.dataTransfer.files)
const text = e.dataTransfer.getData("text")
if (text) {
handleTextDrop(text)
return
}
// --- 3. Image Drop Handling ---
// Only proceed if it wasn't a VSCode resource or plain text drop
const files = Array.from(e.dataTransfer.files)
const acceptedTypes = ["png", "jpeg", "webp"]
const imageFiles = files.filter((file) => {
const [type, subtype] = file.type.split("/")
return type === "image" && acceptedTypes.includes(subtype)
})
if (shouldDisableImages || imageFiles.length === 0) {
return
}
if (shouldDisableImages || imageFiles.length === 0) return
const imageDataArray = await readImageFiles(imageFiles)
const dataUrls = imageDataArray.filter((dataUrl): dataUrl is string => dataUrl !== null)
@@ -1014,8 +900,6 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
setSelectedIndex={setSelectedMenuIndex}
selectedType={selectedType}
queryItems={queryItems}
dynamicSearchResults={fileSearchResults}
isLoading={searchLoading}
/>
</div>
)}
@@ -1191,9 +1075,7 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
onClick={handleContextButtonClick}
style={{ padding: "0px 0px", height: "20px" }}>
<ButtonContainer>
<span className="flex items-center" style={{ fontSize: "13px", marginBottom: 1 }}>
@
</span>
<span style={{ fontSize: "13px", marginBottom: 1 }}>@</span>
{/* {showButtonText && <span style={{ fontSize: "10px" }}>Context</span>} */}
</ButtonContainer>
</VSCodeButton>
@@ -1210,14 +1092,10 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
}}
style={{ padding: "0px 0px", height: "20px" }}>
<ButtonContainer>
<span
className="codicon codicon-device-camera flex items-center"
style={{ fontSize: "14px", marginBottom: -3 }}
/>
<span className="codicon codicon-device-camera" style={{ fontSize: "14px", marginBottom: -3 }} />
{/* {showButtonText && <span style={{ fontSize: "10px" }}>Images</span>} */}
</ButtonContainer>
</VSCodeButton>
<ServersToggleModal />
<ModelContainer ref={modelSelectorRef}>
<ModelButtonWrapper ref={buttonRef}>
+16 -17
View File
@@ -11,22 +11,22 @@ import {
ClineSayBrowserAction,
ClineSayTool,
ExtensionMessage,
} from "@shared/ExtensionMessage"
import { findLast } from "@shared/array"
import { combineApiRequests } from "@shared/combineApiRequests"
import { combineCommandSequences } from "@shared/combineCommandSequences"
import { getApiMetrics } from "@shared/getApiMetrics"
import { useExtensionState } from "@/context/ExtensionStateContext"
import { vscode } from "@/utils/vscode"
import HistoryPreview from "@/components/history/HistoryPreview"
import { normalizeApiConfiguration } from "@/components/settings/ApiOptions"
import Announcement from "@/components/chat/Announcement"
import AutoApproveMenu from "@/components/chat/AutoApproveMenu"
import BrowserSessionRow from "@/components/chat/BrowserSessionRow"
import ChatRow from "@/components/chat/ChatRow"
import ChatTextArea from "@/components/chat/ChatTextArea"
import TaskHeader from "@/components/chat/TaskHeader"
import TelemetryBanner from "@/components/common/TelemetryBanner"
} from "../../../../src/shared/ExtensionMessage"
import { findLast } from "../../../../src/shared/array"
import { combineApiRequests } from "../../../../src/shared/combineApiRequests"
import { combineCommandSequences } from "../../../../src/shared/combineCommandSequences"
import { getApiMetrics } from "../../../../src/shared/getApiMetrics"
import { useExtensionState } from "../../context/ExtensionStateContext"
import { vscode } from "../../utils/vscode"
import HistoryPreview from "../history/HistoryPreview"
import { normalizeApiConfiguration } from "../settings/ApiOptions"
import Announcement from "./Announcement"
import AutoApproveMenu from "./AutoApproveMenu"
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
@@ -224,7 +224,6 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
case "mcp_server_response":
case "completion_result":
case "tool":
case "load_mcp_documentation":
break
}
break
+7 -55
View File
@@ -1,6 +1,6 @@
import React, { useEffect, useMemo, useRef, useState } from "react"
import { ContextMenuOptionType, ContextMenuQueryItem, getContextMenuOptions, SearchResult } from "@/utils/context-mentions"
import { cleanPathPrefix } from "@/components/common/CodeAccordian"
import React, { useEffect, useMemo, useRef } from "react"
import { ContextMenuOptionType, ContextMenuQueryItem, getContextMenuOptions } from "../../utils/context-mentions"
import { cleanPathPrefix } from "../common/CodeAccordian"
interface ContextMenuProps {
onSelect: (type: ContextMenuOptionType, value?: string) => void
@@ -10,8 +10,6 @@ interface ContextMenuProps {
setSelectedIndex: (index: number) => void
selectedType: ContextMenuOptionType | null
queryItems: ContextMenuQueryItem[]
dynamicSearchResults?: SearchResult[]
isLoading?: boolean
}
const ContextMenu: React.FC<ContextMenuProps> = ({
@@ -22,46 +20,13 @@ const ContextMenu: React.FC<ContextMenuProps> = ({
setSelectedIndex,
selectedType,
queryItems,
dynamicSearchResults = [],
isLoading = false,
}) => {
const menuRef = useRef<HTMLDivElement>(null)
// State to show delayed loading indicator
const [showDelayedLoading, setShowDelayedLoading] = useState(false)
const loadingTimeoutRef = useRef<NodeJS.Timeout | null>(null)
const filteredOptions = useMemo(() => {
const options = getContextMenuOptions(searchQuery, selectedType, queryItems, dynamicSearchResults)
return options
}, [searchQuery, selectedType, queryItems, dynamicSearchResults])
// Effect to handle delayed loading indicator (show "Searching..." after 500ms of searching)
useEffect(() => {
if (loadingTimeoutRef.current) {
clearTimeout(loadingTimeoutRef.current)
loadingTimeoutRef.current = null
}
if (isLoading && searchQuery) {
setShowDelayedLoading(false)
loadingTimeoutRef.current = setTimeout(() => {
if (isLoading) {
setShowDelayedLoading(true)
}
}, 500) // 500ms delay before showing "Searching..."
} else {
setShowDelayedLoading(false)
}
// Cleanup timeout on unmount or when dependencies change
return () => {
if (loadingTimeoutRef.current) {
clearTimeout(loadingTimeoutRef.current)
loadingTimeoutRef.current = null
}
}
}, [isLoading, searchQuery])
const filteredOptions = useMemo(
() => getContextMenuOptions(searchQuery, selectedType, queryItems),
[searchQuery, selectedType, queryItems],
)
useEffect(() => {
if (menuRef.current) {
@@ -184,19 +149,6 @@ const ContextMenu: React.FC<ContextMenuProps> = ({
overflowY: "auto",
}}>
{/* Can't use virtuoso since it requires fixed height and menu height is dynamic based on # of items */}
{showDelayedLoading && searchQuery && (
<div
style={{
padding: "8px 12px",
display: "flex",
alignItems: "center",
gap: "8px",
opacity: 0.7,
}}>
<i className="codicon codicon-loading codicon-modifier-spin" style={{ fontSize: "14px" }} />
<span>Searching...</span>
</div>
)}
{filteredOptions.map((option, index) => (
<div
key={`${option.type}-${option.value || index}`}
@@ -1,8 +1,8 @@
import React from "react"
import VSCodeButtonLink from "@/components/common/VSCodeButtonLink"
import VSCodeButtonLink from "../common/VSCodeButtonLink"
import { VSCodeButton } from "@vscode/webview-ui-toolkit/react"
import { vscode } from "@/utils/vscode"
import { Invoke } from "@shared/ExtensionMessage"
import { vscode } from "../../utils/vscode"
import { Invoke } from "../../../../src/shared/ExtensionMessage"
interface CreditLimitErrorProps {
currentBalance: number
@@ -1,6 +1,6 @@
import styled from "styled-components"
import { CODE_BLOCK_BG_COLOR } from "@/components/common/CodeBlock"
import { vscode } from "@/utils/vscode"
import { CODE_BLOCK_BG_COLOR } from "../common/CodeBlock"
import { vscode } from "../../utils/vscode"
const OptionButton = styled.button<{ isSelected?: boolean; isNotSelectable?: boolean }>`
padding: 8px 12px;
@@ -1,86 +0,0 @@
import React, { useRef, useState, useEffect } from "react"
import { useClickAway, useWindowSize } from "react-use"
import { useExtensionState } from "@/context/ExtensionStateContext"
import { CODE_BLOCK_BG_COLOR } from "@/components/common/CodeBlock"
import ServersToggleList from "@/components/mcp/configuration/tabs/installed/ServersToggleList"
import { vscode } from "@/utils/vscode"
import { VSCodeButton } from "@vscode/webview-ui-toolkit/react"
const ServersToggleModal: React.FC = () => {
const { mcpServers } = useExtensionState()
const [isVisible, setIsVisible] = useState(false)
const buttonRef = useRef<HTMLDivElement>(null)
const modalRef = useRef<HTMLDivElement>(null)
const { width: viewportWidth, height: viewportHeight } = useWindowSize()
const [arrowPosition, setArrowPosition] = useState(0)
const [menuPosition, setMenuPosition] = useState(0)
// Close modal when clicking outside
useClickAway(modalRef, () => {
setIsVisible(false)
})
// Calculate positions for modal and arrow
useEffect(() => {
if (isVisible && buttonRef.current) {
const buttonRect = buttonRef.current.getBoundingClientRect()
const buttonCenter = buttonRect.left + buttonRect.width / 2
const rightPosition = document.documentElement.clientWidth - buttonCenter - 5
setArrowPosition(rightPosition)
setMenuPosition(buttonRect.top + 1)
}
}, [isVisible, viewportWidth, viewportHeight])
useEffect(() => {
if (isVisible) {
vscode.postMessage({ type: "fetchLatestMcpServersFromHub" })
}
}, [isVisible])
return (
<div ref={modalRef}>
<div ref={buttonRef} className="inline-flex min-w-0 max-w-full">
<VSCodeButton
appearance="icon"
aria-label="MCP Servers"
onClick={() => setIsVisible(!isVisible)}
style={{ padding: "0px 0px", height: "20px" }}>
<div className="flex items-center gap-1 text-xs whitespace-nowrap min-w-0 w-full">
<span
className="codicon codicon-server flex items-center"
style={{ fontSize: "12.5px", marginBottom: 1 }}
/>
</div>
</VSCodeButton>
</div>
{isVisible && (
<div
className="fixed left-[15px] right-[15px] border border-[var(--vscode-editorGroup-border)] p-3 rounded z-[1000] overflow-y-auto"
style={{
bottom: `calc(100vh - ${menuPosition}px + 6px)`,
background: CODE_BLOCK_BG_COLOR,
maxHeight: "calc(100vh - 100px)",
overscrollBehavior: "contain",
}}>
<div
className="fixed w-[10px] h-[10px] z-[-1] rotate-45 border-r border-b border-[var(--vscode-editorGroup-border)]"
style={{
bottom: `calc(100vh - ${menuPosition}px)`,
right: arrowPosition,
background: CODE_BLOCK_BG_COLOR,
}}
/>
<div className="m-0 mb-2.5">MCP Servers</div>
<div style={{ marginBottom: "-10px" }}>
<ServersToggleList servers={mcpServers} isExpandable={false} hasTrashIcon={false} listGap="small" />
</div>
</div>
)}
</div>
)
}
export default ServersToggleModal
@@ -1,8 +1,9 @@
import React, { useState, useEffect } from "react"
import styled from "styled-components"
import { vscode } from "@/utils/vscode"
import { TaskFeedbackType } from "@shared/WebviewMessage"
import { vscode } from "../../utils/vscode"
import { TaskFeedbackType } from "../../../../src/shared/WebviewMessage"
import { VSCodeButton } from "@vscode/webview-ui-toolkit/react"
import VSCodeButtonLink from "../common/VSCodeButtonLink"
interface TaskFeedbackButtonsProps {
messageTs: number
@@ -1,14 +1,14 @@
import { VSCodeButton } from "@vscode/webview-ui-toolkit/react"
import React, { memo, useEffect, useMemo, useRef, useState } from "react"
import { useWindowSize } from "react-use"
import { mentionRegexGlobal } from "@shared/context-mentions"
import { ClineMessage } from "@shared/ExtensionMessage"
import { useExtensionState } from "@/context/ExtensionStateContext"
import { formatLargeNumber } from "@/utils/format"
import { formatSize } from "@/utils/format"
import { vscode } from "@/utils/vscode"
import Thumbnails from "@/components/common/Thumbnails"
import { normalizeApiConfiguration } from "@/components/settings/ApiOptions"
import { mentionRegexGlobal } from "../../../../src/shared/context-mentions"
import { ClineMessage } from "../../../../src/shared/ExtensionMessage"
import { useExtensionState } from "../../context/ExtensionStateContext"
import { formatLargeNumber } from "../../utils/format"
import { formatSize } from "../../utils/size"
import { vscode } from "../../utils/vscode"
import Thumbnails from "../common/Thumbnails"
import { normalizeApiConfiguration } from "../settings/ApiOptions"
interface TaskHeaderProps {
task: ClineMessage
@@ -1,4 +1,3 @@
import React from "react"
import { render, screen, fireEvent } from "@testing-library/react"
import { describe, it, expect, vi } from "vitest"
import Announcement from "../Announcement"
@@ -23,9 +22,18 @@ describe("Announcement", () => {
expect(hideAnnouncement).toHaveBeenCalled()
})
it("renders the enhanced MCP support announcement", () => {
it("renders the mcp server improvements announcement", () => {
render(<Announcement version="2.0.0" hideAnnouncement={hideAnnouncement} />)
// Updated text based on actual component output
expect(screen.getByText(/Enhanced MCP Support:/)).toBeInTheDocument()
expect(screen.getByText(/MCP server improvements:/)).toBeInTheDocument()
})
it("renders the 'See new changes' button feature", () => {
render(<Announcement version="2.0.0" hideAnnouncement={hideAnnouncement} />)
expect(screen.getByText(/See it in action here./)).toBeInTheDocument()
})
it("renders the demo link", () => {
render(<Announcement version="2.0.0" hideAnnouncement={hideAnnouncement} />)
expect(screen.getByText(/See a demo here./)).toBeInTheDocument()
})
})
@@ -1,9 +1,10 @@
import { useCallback, useRef, useState, useEffect } from "react"
import { useEvent } from "react-use"
import { useClickAway, useEvent } from "react-use"
import styled from "styled-components"
import { ExtensionMessage } from "@shared/ExtensionMessage"
import { vscode } from "@/utils/vscode"
import { CODE_BLOCK_BG_COLOR } from "@/components/common/CodeBlock"
import { ExtensionMessage } from "../../../../src/shared/ExtensionMessage"
import { vscode } from "../../utils/vscode"
import { CODE_BLOCK_BG_COLOR } from "./CodeBlock"
import { ClineCheckpointRestore } from "../../../../src/shared/WebviewMessage"
import { VSCodeButton } from "@vscode/webview-ui-toolkit/react"
import { createPortal } from "react-dom"
import { useFloating, offset, flip, shift } from "@floating-ui/react"
@@ -2,10 +2,10 @@ import { VSCodeButton } from "@vscode/webview-ui-toolkit/react"
import { useCallback, useRef, useState } from "react"
import { useClickAway, useEvent } from "react-use"
import styled from "styled-components"
import { ExtensionMessage } from "@shared/ExtensionMessage"
import { vscode } from "@/utils/vscode"
import { CODE_BLOCK_BG_COLOR } from "@/components/common/CodeBlock"
import { ClineCheckpointRestore } from "@shared/WebviewMessage"
import { ExtensionMessage } from "../../../../src/shared/ExtensionMessage"
import { vscode } from "../../utils/vscode"
import { CODE_BLOCK_BG_COLOR } from "./CodeBlock"
import { ClineCheckpointRestore } from "../../../../src/shared/WebviewMessage"
interface CheckpointOverlayProps {
messageTs?: number
@@ -1,6 +1,6 @@
import { memo, useMemo } from "react"
import { getLanguageFromPath } from "@/utils/getLanguageFromPath"
import CodeBlock, { CODE_BLOCK_BG_COLOR } from "@/components/common/CodeBlock"
import { getLanguageFromPath } from "../../utils/getLanguageFromPath"
import CodeBlock, { CODE_BLOCK_BG_COLOR } from "./CodeBlock"
interface CodeAccordianProps {
code?: string
@@ -3,7 +3,7 @@ import { useRemark } from "react-remark"
import rehypeHighlight, { Options } from "rehype-highlight"
import styled from "styled-components"
import { visit } from "unist-util-visit"
import { useExtensionState } from "@/context/ExtensionStateContext"
import { useExtensionState } from "../../context/ExtensionStateContext"
export const CODE_BLOCK_BG_COLOR = "var(--vscode-editor-background, --vscode-sideBar-background, rgb(30 30 30))"
@@ -3,9 +3,9 @@ import { useRemark } from "react-remark"
import rehypeHighlight, { Options } from "rehype-highlight"
import styled from "styled-components"
import { visit } from "unist-util-visit"
import { useExtensionState } from "@/context/ExtensionStateContext"
import { CODE_BLOCK_BG_COLOR } from "@/components/common/CodeBlock"
import MermaidBlock from "@/components/common/MermaidBlock"
import { useExtensionState } from "../../context/ExtensionStateContext"
import { CODE_BLOCK_BG_COLOR } from "./CodeBlock"
import MermaidBlock from "./MermaidBlock"
interface MarkdownBlockProps {
markdown?: string
@@ -1,8 +1,8 @@
import { useEffect, useRef, useState } from "react"
import mermaid from "mermaid"
import { useDebounceEffect } from "@/utils/useDebounceEffect"
import { useDebounceEffect } from "../../utils/useDebounceEffect"
import styled from "styled-components"
import { vscode } from "@/utils/vscode"
import { vscode } from "../../utils/vscode"
const MERMAID_THEME = {
background: "#1e1e1e", // VS Code dark theme background
@@ -1,8 +1,8 @@
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 "@shared/TelemetrySetting"
import { vscode } from "../../utils/vscode"
import { TelemetrySetting } from "../../../../src/shared/TelemetrySetting"
const BannerContainer = styled.div`
background-color: var(--vscode-banner-background);
@@ -1,6 +1,6 @@
import React, { useState, useRef, useLayoutEffect, memo } from "react"
import { useWindowSize } from "react-use"
import { vscode } from "@/utils/vscode"
import { vscode } from "../../utils/vscode"
interface ThumbnailsProps {
images: string[]
+1 -1
View File
@@ -6,7 +6,7 @@ import {
VSC_SIDEBAR_BACKGROUND,
VSC_INPUT_PLACEHOLDER_FOREGROUND,
VSC_INPUT_BORDER,
} from "@/utils/vscStyles"
} from "../../utils/vscStyles"
interface TooltipProps {
visible: boolean
@@ -1,8 +1,8 @@
import { VSCodeButton } from "@vscode/webview-ui-toolkit/react"
import { useExtensionState } from "@/context/ExtensionStateContext"
import { vscode } from "@/utils/vscode"
import { useExtensionState } from "../../context/ExtensionStateContext"
import { vscode } from "../../utils/vscode"
import { memo } from "react"
import { formatLargeNumber } from "@/utils/format"
import { formatLargeNumber } from "../../utils/format"
type HistoryPreviewProps = {
showHistoryView: () => void
@@ -1,14 +1,14 @@
import { VSCodeButton, VSCodeTextField, VSCodeRadioGroup, VSCodeRadio } from "@vscode/webview-ui-toolkit/react"
import { useExtensionState } from "@/context/ExtensionStateContext"
import { vscode } from "@/utils/vscode"
import { useExtensionState } from "../../context/ExtensionStateContext"
import { vscode } from "../../utils/vscode"
import { Virtuoso } from "react-virtuoso"
import { memo, useMemo, useState, useEffect, useCallback } from "react"
import Fuse, { FuseResult } from "fuse.js"
import { formatLargeNumber } from "@/utils/format"
import { formatSize } from "@/utils/format"
import { ExtensionMessage } from "@shared/ExtensionMessage"
import { formatLargeNumber } from "../../utils/format"
import { formatSize } from "../../utils/size"
import { ExtensionMessage } from "../../../../src/shared/ExtensionMessage"
import { useEvent } from "react-use"
import DangerButton from "@/components/common/DangerButton"
import DangerButton from "../common/DangerButton"
type HistoryViewProps = {
onDone: () => void
@@ -1,8 +1,8 @@
import React from "react"
import { vscode } from "@/utils/vscode"
import React, { useEffect, useRef } from "react"
import { vscode } from "../../utils/vscode"
import DOMPurify from "dompurify"
import { getSafeHostname, formatUrlForOpening, checkIfImageUrl } from "./utils/mcpRichUtil"
import ChatErrorBoundary from "@/components/chat/ChatErrorBoundary"
import { getSafeHostname, formatUrlForOpening, checkIfImageUrl } from "./McpRichUtil"
import ChatErrorBoundary from "../chat/ChatErrorBoundary"
interface ImagePreviewProps {
url: string
@@ -1,8 +1,8 @@
import React from "react"
import { vscode } from "@/utils/vscode"
import React, { useEffect, useState } from "react"
import { vscode } from "../../utils/vscode"
import DOMPurify from "dompurify"
import { getSafeHostname, normalizeRelativeUrl } from "./utils/mcpRichUtil"
import ChatErrorBoundary from "@/components/chat/ChatErrorBoundary"
import { getSafeHostname, normalizeRelativeUrl } from "./McpRichUtil"
import ChatErrorBoundary from "../chat/ChatErrorBoundary"
interface OpenGraphData {
title?: string
@@ -1,4 +1,4 @@
import { McpResource, McpResourceTemplate } from "@shared/mcp"
import { McpResource, McpResourceTemplate } from "../../../../src/shared/mcp"
type McpResourceRowProps = {
item: McpResource | McpResourceTemplate
@@ -1,10 +1,20 @@
import React, { useEffect, useState, useCallback } from "react"
import LinkPreview from "./LinkPreview"
import ImagePreview from "./ImagePreview"
import { vscode } from "../../utils/vscode"
import DOMPurify from "dompurify"
import styled from "styled-components"
import { CODE_BLOCK_BG_COLOR } from "@/components/common/CodeBlock"
import ChatErrorBoundary from "@/components/chat/ChatErrorBoundary"
import { isUrl, isLocalhostUrl, formatUrlForOpening, checkIfImageUrl } from "./utils/mcpRichUtil"
import { CODE_BLOCK_BG_COLOR } from "../common/CodeBlock"
import ChatErrorBoundary from "../chat/ChatErrorBoundary"
import {
safeCreateUrl,
isUrl,
getSafeHostname,
isLocalhostUrl,
normalizeRelativeUrl,
formatUrlForOpening,
checkIfImageUrl,
} from "./McpRichUtil"
// Maximum number of URLs to process in total, per response
export const MAX_URLS = 50
@@ -1,4 +1,4 @@
import { vscode } from "@/utils/vscode"
import { vscode } from "../../utils/vscode"
// Safely create a URL object with error handling and ensure HTTPS
export const safeCreateUrl = (url: string): URL | null => {
@@ -1,7 +1,7 @@
import { VSCodeCheckbox } from "@vscode/webview-ui-toolkit/react"
import { McpTool } from "@shared/mcp"
import { vscode } from "@/utils/vscode"
import { useExtensionState } from "@/context/ExtensionStateContext"
import { McpTool } from "../../../../src/shared/mcp"
import { vscode } from "../../utils/vscode"
import { useExtensionState } from "../../context/ExtensionStateContext"
type McpToolRowProps = {
tool: McpTool
@@ -14,10 +14,7 @@ const McpToolRow = ({ tool, serverName }: McpToolRowProps) => {
// Accept the event object
const handleAutoApproveChange = (event: any) => {
// Only proceed if the event was triggered by a direct user interaction
if (!serverName) {
return
}
if (!serverName || !event.isTrusted) return
vscode.postMessage({
type: "toggleToolAutoApprove",
@@ -1,31 +1,210 @@
import { McpServer } from "@shared/mcp"
import { DEFAULT_MCP_TIMEOUT_SECONDS } from "@shared/mcp"
import { useState } from "react"
import { vscode } from "@/utils/vscode"
import {
VSCodeButton,
VSCodeCheckbox,
VSCodeDropdown,
VSCodeOption,
VSCodeLink,
VSCodePanels,
VSCodePanelTab,
VSCodePanelView,
VSCodeDropdown,
VSCodeOption,
VSCodeCheckbox,
} from "@vscode/webview-ui-toolkit/react"
import { getMcpServerDisplayName } from "@/utils/mcp"
import DangerButton from "@/components/common/DangerButton"
import McpToolRow from "./McpToolRow"
import { useEffect, useState } from "react"
import styled from "styled-components"
import { DEFAULT_MCP_TIMEOUT_SECONDS, McpServer } from "../../../../src/shared/mcp"
import { useExtensionState } from "../../context/ExtensionStateContext"
import { getMcpServerDisplayName } from "../../utils/mcp"
import { vscode } from "../../utils/vscode"
import McpMarketplaceView from "./marketplace/McpMarketplaceView"
import McpResourceRow from "./McpResourceRow"
import { useExtensionState } from "@/context/ExtensionStateContext"
import McpToolRow from "./McpToolRow"
import DangerButton from "../common/DangerButton"
const ServerRow = ({
server,
isExpandable = true,
hasTrashIcon = true,
type McpViewProps = {
onDone: () => void
}
const McpView = ({ onDone }: McpViewProps) => {
const { mcpServers: servers, mcpMarketplaceEnabled } = useExtensionState()
const [activeTab, setActiveTab] = useState(mcpMarketplaceEnabled ? "marketplace" : "installed")
const handleTabChange = (tab: string) => {
setActiveTab(tab)
}
useEffect(() => {
if (!mcpMarketplaceEnabled && activeTab === "marketplace") {
// If marketplace is disabled and we're on marketplace tab, switch to installed
setActiveTab("installed")
}
}, [mcpMarketplaceEnabled, activeTab])
useEffect(() => {
if (mcpMarketplaceEnabled) {
vscode.postMessage({ type: "silentlyRefreshMcpMarketplace" })
vscode.postMessage({ type: "fetchLatestMcpServersFromHub" })
}
}, [mcpMarketplaceEnabled])
return (
<div
style={{
position: "fixed",
top: 0,
left: 0,
right: 0,
bottom: 0,
display: "flex",
flexDirection: "column",
}}>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
padding: "10px 17px 5px 20px",
}}>
<h3 style={{ color: "var(--vscode-foreground)", margin: 0 }}>MCP Servers</h3>
<VSCodeButton onClick={onDone}>Done</VSCodeButton>
</div>
<div style={{ flex: 1, overflow: "auto" }}>
{/* Tabs container */}
<div
style={{
display: "flex",
gap: "1px",
padding: "0 20px 0 20px",
borderBottom: "1px solid var(--vscode-panel-border)",
}}>
{mcpMarketplaceEnabled && (
<TabButton isActive={activeTab === "marketplace"} onClick={() => handleTabChange("marketplace")}>
Marketplace
</TabButton>
)}
<TabButton isActive={activeTab === "installed"} onClick={() => handleTabChange("installed")}>
Installed
</TabButton>
</div>
{/* Content container */}
<div style={{ width: "100%" }}>
{mcpMarketplaceEnabled && activeTab === "marketplace" && <McpMarketplaceView />}
{activeTab === "installed" && (
<div style={{ padding: "16px 20px" }}>
<div
style={{
color: "var(--vscode-foreground)",
fontSize: "13px",
marginBottom: "16px",
marginTop: "5px",
}}>
The{" "}
<VSCodeLink href="https://github.com/modelcontextprotocol" style={{ display: "inline" }}>
Model Context Protocol
</VSCodeLink>{" "}
enables communication with locally running MCP servers that provide additional tools and resources
to extend Cline's capabilities. You can use{" "}
<VSCodeLink href="https://github.com/modelcontextprotocol/servers" style={{ display: "inline" }}>
community-made servers
</VSCodeLink>{" "}
or ask Cline to create new tools specific to your workflow (e.g., "add a tool that gets the latest
npm docs").{" "}
<VSCodeLink href="https://x.com/sdrzn/status/1867271665086074969" style={{ display: "inline" }}>
See a demo here.
</VSCodeLink>
</div>
{servers.length > 0 ? (
<div
style={{
display: "flex",
flexDirection: "column",
gap: "10px",
}}>
{servers.map((server) => (
<ServerRow key={server.name} server={server} />
))}
</div>
) : (
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: "12px",
marginTop: 20,
marginBottom: 20,
color: "var(--vscode-descriptionForeground)",
}}>
No MCP servers installed
</div>
)}
{/* Settings Section */}
<div style={{ marginBottom: "20px", marginTop: 10 }}>
<VSCodeButton
appearance="secondary"
style={{ width: "100%", marginBottom: "5px" }}
onClick={() => {
vscode.postMessage({ type: "openMcpSettings" })
}}>
<span className="codicon codicon-server" style={{ marginRight: "6px" }}></span>
Configure MCP Servers
</VSCodeButton>
<div style={{ textAlign: "center" }}>
<VSCodeLink
onClick={() => {
vscode.postMessage({
type: "openExtensionSettings",
text: "cline.mcp",
})
}}
style={{ fontSize: "12px" }}>
Advanced MCP Settings
</VSCodeLink>
</div>
</div>
</div>
)}
</div>
</div>
</div>
)
}
const StyledTabButton = styled.button<{ isActive: boolean }>`
background: none;
border: none;
border-bottom: 2px solid ${(props) => (props.isActive ? "var(--vscode-foreground)" : "transparent")};
color: ${(props) => (props.isActive ? "var(--vscode-foreground)" : "var(--vscode-descriptionForeground)")};
padding: 8px 16px;
cursor: pointer;
font-size: 13px;
margin-bottom: -1px;
font-family: inherit;
&:hover {
color: var(--vscode-foreground);
}
`
export const TabButton = ({
children,
isActive,
onClick,
}: {
server: McpServer
isExpandable?: boolean
hasTrashIcon?: boolean
}) => {
children: React.ReactNode
isActive: boolean
onClick: () => void
}) => (
<StyledTabButton isActive={isActive} onClick={onClick}>
{children}
</StyledTabButton>
)
// Server Row Component
const ServerRow = ({ server }: { server: McpServer }) => {
const { mcpMarketplaceCatalog, autoApprovalSettings } = useExtensionState()
const [isExpanded, setIsExpanded] = useState(false)
@@ -43,7 +222,7 @@ const ServerRow = ({
}
const handleRowClick = () => {
if (!server.error && isExpandable) {
if (!server.error) {
setIsExpanded(!isExpanded)
}
}
@@ -112,13 +291,12 @@ const ServerRow = ({
alignItems: "center",
padding: "8px",
background: "var(--vscode-textCodeBlock-background)",
cursor: server.error ? "default" : isExpandable ? "pointer" : "default",
cursor: server.error ? "default" : "pointer",
borderRadius: isExpanded || server.error ? "4px 4px 0 0" : "4px",
opacity: server.disabled ? 0.6 : 1,
}}
onClick={handleRowClick}>
{!server.error && isExpandable && (
{!server.error && (
<span className={`codicon codicon-chevron-${isExpanded ? "down" : "right"}`} style={{ marginRight: "8px" }} />
)}
<span
@@ -146,18 +324,16 @@ const ServerRow = ({
disabled={server.status === "connecting"}>
<span className="codicon codicon-sync"></span>
</VSCodeButton>
{hasTrashIcon && (
<VSCodeButton
appearance="icon"
title="Delete Server"
onClick={(e) => {
e.stopPropagation()
handleDelete()
}}
disabled={isDeleting}>
<span className="codicon codicon-trash"></span>
</VSCodeButton>
)}
<VSCodeButton
appearance="icon"
title="Delete Server"
onClick={(e) => {
e.stopPropagation()
handleDelete()
}}
disabled={isDeleting}>
<span className="codicon codicon-trash"></span>
</VSCodeButton>
</div>
)}
{/* Toggle Switch */}
@@ -368,4 +544,4 @@ const ServerRow = ({
)
}
export default ServerRow
export default McpView
@@ -1,121 +0,0 @@
import { VSCodeButton } from "@vscode/webview-ui-toolkit/react"
import { useEffect, useState } from "react"
import styled from "styled-components"
import { useExtensionState } from "@/context/ExtensionStateContext"
import { vscode } from "@/utils/vscode"
import AddRemoteServerForm from "./tabs/add-server/AddRemoteServerForm"
import McpMarketplaceView from "./tabs/marketplace/McpMarketplaceView"
import InstalledServersView from "./tabs/installed/InstalledServersView"
type McpViewProps = {
onDone: () => void
}
const McpConfigurationView = ({ onDone }: McpViewProps) => {
const { mcpMarketplaceEnabled } = useExtensionState()
const [activeTab, setActiveTab] = useState(mcpMarketplaceEnabled ? "marketplace" : "installed")
const handleTabChange = (tab: string) => {
setActiveTab(tab)
}
useEffect(() => {
if (!mcpMarketplaceEnabled && activeTab === "marketplace") {
// If marketplace is disabled and we're on marketplace tab, switch to installed
setActiveTab("installed")
}
}, [mcpMarketplaceEnabled, activeTab])
useEffect(() => {
if (mcpMarketplaceEnabled) {
vscode.postMessage({ type: "silentlyRefreshMcpMarketplace" })
vscode.postMessage({ type: "fetchLatestMcpServersFromHub" })
}
}, [mcpMarketplaceEnabled])
return (
<div
style={{
position: "fixed",
top: 0,
left: 0,
right: 0,
bottom: 0,
display: "flex",
flexDirection: "column",
}}>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
padding: "10px 17px 5px 20px",
}}>
<h3 style={{ color: "var(--vscode-foreground)", margin: 0 }}>MCP Servers</h3>
<VSCodeButton onClick={onDone}>Done</VSCodeButton>
</div>
<div style={{ flex: 1, overflow: "auto" }}>
{/* Tabs container */}
<div
style={{
display: "flex",
gap: "1px",
padding: "0 20px 0 20px",
borderBottom: "1px solid var(--vscode-panel-border)",
}}>
{mcpMarketplaceEnabled && (
<TabButton isActive={activeTab === "marketplace"} onClick={() => handleTabChange("marketplace")}>
Marketplace
</TabButton>
)}
<TabButton isActive={activeTab === "addRemote"} onClick={() => handleTabChange("addRemote")}>
Remote Servers
</TabButton>
<TabButton isActive={activeTab === "installed"} onClick={() => handleTabChange("installed")}>
Installed
</TabButton>
</div>
{/* Content container */}
<div style={{ width: "100%" }}>
{mcpMarketplaceEnabled && activeTab === "marketplace" && <McpMarketplaceView />}
{activeTab === "addRemote" && <AddRemoteServerForm onServerAdded={() => handleTabChange("installed")} />}
{activeTab === "installed" && <InstalledServersView />}
</div>
</div>
</div>
)
}
const StyledTabButton = styled.button<{ isActive: boolean }>`
background: none;
border: none;
border-bottom: 2px solid ${(props) => (props.isActive ? "var(--vscode-foreground)" : "transparent")};
color: ${(props) => (props.isActive ? "var(--vscode-foreground)" : "var(--vscode-descriptionForeground)")};
padding: 8px 16px;
cursor: pointer;
font-size: 13px;
margin-bottom: -1px;
font-family: inherit;
&:hover {
color: var(--vscode-foreground);
}
`
export const TabButton = ({
children,
isActive,
onClick,
}: {
children: React.ReactNode
isActive: boolean
onClick: () => void
}) => (
<StyledTabButton isActive={isActive} onClick={onClick}>
{children}
</StyledTabButton>
)
export default McpConfigurationView
@@ -1,40 +0,0 @@
import { VSCodeButton, VSCodeLink } from "@vscode/webview-ui-toolkit/react"
import { vscode } from "@/utils/vscode"
import styled from "styled-components"
import { LINKS } from "@/constants"
type AddLocalServerFormProps = {
onServerAdded: () => void
}
const AddLocalServerForm = ({ onServerAdded }: AddLocalServerFormProps) => {
return (
<FormContainer>
<div className="text-[var(--vscode-foreground)]">
Add a local MCP server by configuring it in <code>cline_mcp_settings.json</code>. You'll need to specify the
server name, command, arguments, and any required environment variables in the JSON configuration. Learn more
<VSCodeLink href={LINKS.DOCUMENTATION.LOCAL_MCP_SERVER_DOCS} style={{ display: "inline" }}>
here.
</VSCodeLink>
</div>
<VSCodeButton
appearance="primary"
style={{ width: "100%", marginBottom: "5px", marginTop: 8 }}
onClick={() => {
vscode.postMessage({ type: "openMcpSettings" })
}}>
Open cline_mcp_settings.json
</VSCodeButton>
</FormContainer>
)
}
const FormContainer = styled.div`
padding: 16px 20px;
display: flex;
flex-direction: column;
gap: 8px;
`
export default AddLocalServerForm
@@ -1,145 +0,0 @@
import { useCallback, useRef, useState } from "react"
import { vscode } from "@/utils/vscode"
import { VSCodeButton, VSCodeLink, VSCodeTextField } from "@vscode/webview-ui-toolkit/react"
import { useEvent } from "react-use"
import { LINKS } from "@/constants"
const AddRemoteServerForm = ({ onServerAdded }: { onServerAdded: () => void }) => {
const [serverName, setServerName] = useState("")
const [serverUrl, setServerUrl] = useState("")
const [isSubmitting, setIsSubmitting] = useState(false)
const [error, setError] = useState("")
const [showConnectingMessage, setShowConnectingMessage] = useState(false)
// Store submitted values to check if the server was added
const submittedValues = useRef<{ name: string } | null>(null)
const handleMessage = useCallback(
(event: MessageEvent) => {
const message = event.data
if (
message.type === "addRemoteServerResult" &&
isSubmitting &&
submittedValues.current &&
message.addRemoteServerResult?.serverName === submittedValues.current.name
) {
if (message.addRemoteServerResult.success) {
// Handle success
setIsSubmitting(false)
setServerName("")
setServerUrl("")
submittedValues.current = null
onServerAdded()
setShowConnectingMessage(false)
} else {
// Handle error
setIsSubmitting(false)
setError(message.addRemoteServerResult.error || "Failed to add server")
setShowConnectingMessage(false)
}
}
},
[isSubmitting, onServerAdded],
)
useEvent("message", handleMessage)
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
if (!serverName.trim()) {
setError("Server name is required")
return
}
if (!serverUrl.trim()) {
setError("Server URL is required")
return
}
try {
new URL(serverUrl)
} catch (err) {
setError("Invalid URL format")
return
}
setError("")
submittedValues.current = { name: serverName.trim() }
setIsSubmitting(true)
setShowConnectingMessage(true)
vscode.postMessage({
type: "addRemoteServer",
serverName: serverName.trim(),
serverUrl: serverUrl.trim(),
})
}
return (
<div className="p-4 px-5">
<div className="text-[var(--vscode-foreground)] mb-2">
Add a remote MCP server by providing a name and its URL endpoint. Learn more{" "}
<VSCodeLink href={LINKS.DOCUMENTATION.REMOTE_MCP_SERVER_DOCS} style={{ display: "inline" }}>
here.
</VSCodeLink>
</div>
<form onSubmit={handleSubmit}>
<div className="mb-2">
<VSCodeTextField
value={serverName}
onChange={(e) => {
setServerName((e.target as HTMLInputElement).value)
setError("")
}}
disabled={isSubmitting}
className="w-full"
placeholder="mcp-server">
Server Name
</VSCodeTextField>
</div>
<div className="mb-2">
<VSCodeTextField
value={serverUrl}
onChange={(e) => {
setServerUrl((e.target as HTMLInputElement).value)
setError("")
}}
disabled={isSubmitting}
placeholder="https://example.com/mcp-server"
className="w-full mr-4">
Server URL
</VSCodeTextField>
</div>
{error && <div className="mb-3 text-[var(--vscode-errorForeground)]">{error}</div>}
<div className="flex items-center mt-3 w-full">
<VSCodeButton type="submit" disabled={isSubmitting} className="w-full">
{isSubmitting ? "Adding..." : "Add Server"}
</VSCodeButton>
{showConnectingMessage && (
<div className="ml-3 text-[var(--vscode-notificationsInfoIcon-foreground)] text-sm">
Connecting to server... This may take a few seconds.
</div>
)}
</div>
<VSCodeButton
appearance="secondary"
style={{ width: "100%", marginBottom: "5px", marginTop: 15 }}
onClick={() => {
vscode.postMessage({ type: "openMcpSettings" })
}}>
Edit Configuration
</VSCodeButton>
</form>
</div>
)
}
export default AddRemoteServerForm
@@ -1,63 +0,0 @@
import { VSCodeButton, VSCodeLink } from "@vscode/webview-ui-toolkit/react"
import { vscode } from "@/utils/vscode"
import { useExtensionState } from "@/context/ExtensionStateContext"
import ServersToggleList from "./ServersToggleList"
const InstalledServersView = () => {
const { mcpServers: servers } = useExtensionState()
return (
<div style={{ padding: "16px 20px" }}>
<div
style={{
color: "var(--vscode-foreground)",
fontSize: "13px",
marginBottom: "16px",
marginTop: "5px",
}}>
The{" "}
<VSCodeLink href="https://github.com/modelcontextprotocol" style={{ display: "inline" }}>
Model Context Protocol
</VSCodeLink>{" "}
enables communication with locally running MCP servers that provide additional tools and resources to extend
Cline's capabilities. You can use{" "}
<VSCodeLink href="https://github.com/modelcontextprotocol/servers" style={{ display: "inline" }}>
community-made servers
</VSCodeLink>{" "}
or ask Cline to create new tools specific to your workflow (e.g., "add a tool that gets the latest npm docs").{" "}
<VSCodeLink href="https://x.com/sdrzn/status/1867271665086074969" style={{ display: "inline" }}>
See a demo here.
</VSCodeLink>
</div>
<ServersToggleList servers={servers} isExpandable={true} hasTrashIcon={false} />
{/* Settings Section */}
<div style={{ marginBottom: "20px", marginTop: 10 }}>
<VSCodeButton
appearance="secondary"
style={{ width: "100%", marginBottom: "5px" }}
onClick={() => {
vscode.postMessage({ type: "openMcpSettings" })
}}>
<span className="codicon codicon-server" style={{ marginRight: "6px" }}></span>
Configure MCP Servers
</VSCodeButton>
<div style={{ textAlign: "center" }}>
<VSCodeLink
onClick={() => {
vscode.postMessage({
type: "openExtensionSettings",
text: "cline.mcp",
})
}}
style={{ fontSize: "12px" }}>
Advanced MCP Settings
</VSCodeLink>
</div>
</div>
</div>
)
}
export default InstalledServersView

Some files were not shown because too many files have changed in this diff Show More