Compare commits

..
Author SHA1 Message Date
celestial-vault e8f78b1731 changeset 2025-03-25 14:39:18 -07:00
celestial-vault 2d9245b4a6 filter out free models 2025-03-25 13:55:15 -07:00
100 changed files with 4038 additions and 7298 deletions
+7
View File
@@ -0,0 +1,7 @@
---
"claude-dev": minor
---
Add Bedrock prompt caching support (optional).
This feature protected under checkbox because it is not yet rolled out to everyone, and if you will try to send cache headers, and its not enabled for you, you will get error.
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
feat(bedrock): adding Amazon Nova
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
Improve file handling for NextJS folder naming conventions and increase file listing limits. Fix glob pattern interpretation issues with parentheses in folder names
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": minor
---
Add Enable extended thinking for LiteLLM provider
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
Handle input too large Anthropic
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
Fix "See more" not showing up for tasks after task un-fold
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
Fix gpt-4.5-preview's supportsPromptCache value to true
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
Filter Cline Provider models
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
Can test on WebIDE
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": minor
---
Added a script to create test tasks in dev mode
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
updated move context management out of cline
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": minor
---
Added a tab for configuring local servers and documentation links
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": minor
---
Added support for SambaNova QwQ-32B model
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": minor
---
Add OpenAI "dynamic" model chatgpt-4o-latest
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
DangerButton.tsx to Tailwind
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
feat(bedrock): adding two regions
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
Added permissions checks, error handling, and git options to deal with cases where the entire workspace or specific files within it cannot be accessed. These issues were preventing checkpoints from working correctly, or causing checkpoints to hang.
+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
@@ -1,19 +0,0 @@
"""
Coverage utility package for GitHub Actions workflows.
This package handles extracting coverage percentages, comparing them, and generating PR comments.
"""
# Import external dependencies
import requests
# Import main function for CLI usage
from .__main__ import main
# Import functions from extraction module
from .extraction import extract_coverage, compare_coverage, run_coverage, set_verbose
# Import functions from github_api module
from .github_api import generate_comment, post_comment, set_github_output
# Import functions from workflow module
from .workflow import process_coverage_workflow
-154
View File
@@ -1,154 +0,0 @@
"""
Main module.
This module provides the CLI interface for the coverage utility script.
"""
import sys
import argparse
from .extraction import extract_coverage, compare_coverage, run_coverage, set_verbose
from .github_api import generate_comment, post_comment, set_github_output
from .workflow import process_coverage_workflow
from .util import log
def setup_verbose_mode(args):
"""
Set up verbose mode based on command line arguments.
Args:
args: Parsed command line arguments
"""
if getattr(args, 'verbose', False):
set_verbose(True)
log("Verbose mode enabled")
def main():
# Create parent parser with common arguments
parent_parser = argparse.ArgumentParser(add_help=False)
parent_parser.add_argument('-v', '--verbose', action='store_true', help='Enable verbose output')
# Create main parser that inherits common arguments
parser = argparse.ArgumentParser(description='Coverage utility script for GitHub Actions workflows', parents=[parent_parser])
subparsers = parser.add_subparsers(dest='command', help='Command to run')
# extract-coverage command - used directly in workflow
extract_parser = subparsers.add_parser('extract-coverage', help='Extract coverage percentage from a file', parents=[parent_parser])
extract_parser.add_argument('file_path', help='Path to the coverage report file')
extract_parser.add_argument('--type', choices=['extension', 'webview'], default='extension',
help='Type of coverage report')
extract_parser.add_argument('--github-output', action='store_true', help='Output in GitHub Actions format')
# compare-coverage command - used by process-workflow
compare_parser = subparsers.add_parser('compare-coverage', help='Compare coverage percentages', parents=[parent_parser])
compare_parser.add_argument('base_cov', help='Base branch coverage percentage')
compare_parser.add_argument('pr_cov', help='PR branch coverage percentage')
compare_parser.add_argument('--output-prefix', default='', help='Prefix for GitHub Actions output variables')
compare_parser.add_argument('--github-output', action='store_true', help='Output in GitHub Actions format')
# generate-comment command - used by process-workflow
comment_parser = subparsers.add_parser('generate-comment', help='Generate PR comment with coverage comparison', parents=[parent_parser])
comment_parser.add_argument('base_ext_cov', help='Base branch extension coverage')
comment_parser.add_argument('pr_ext_cov', help='PR branch extension coverage')
comment_parser.add_argument('ext_decreased', help='Whether extension coverage decreased (true/false)')
comment_parser.add_argument('ext_diff', help='Extension coverage difference')
comment_parser.add_argument('base_web_cov', help='Base branch webview coverage')
comment_parser.add_argument('pr_web_cov', help='PR branch webview coverage')
comment_parser.add_argument('web_decreased', help='Whether webview coverage decreased (true/false)')
comment_parser.add_argument('web_diff', help='Webview coverage difference')
# post-comment command - used by process-workflow
post_parser = subparsers.add_parser('post-comment', help='Post a comment to a GitHub PR', parents=[parent_parser])
post_parser.add_argument('comment_path', help='Path to the file containing the comment text')
post_parser.add_argument('pr_number', help='PR number')
post_parser.add_argument('repo', help='Repository in the format "owner/repo"')
post_parser.add_argument('--token', help='GitHub token')
# run-coverage command - used by process-workflow
run_parser = subparsers.add_parser('run-coverage', help='Run a coverage command and extract the coverage percentage', parents=[parent_parser])
run_parser.add_argument('coverage_cmd', help='Command to run')
run_parser.add_argument('output_file', help='File to save the output to')
run_parser.add_argument('--type', choices=['extension', 'webview'], default='extension',
help='Type of coverage report')
run_parser.add_argument('--github-output', action='store_true', help='Output in GitHub Actions format')
# process-workflow command - used directly in workflow
workflow_parser = subparsers.add_parser('process-workflow', help='Process the entire coverage workflow', parents=[parent_parser])
workflow_parser.add_argument('--base-branch', required=True, help='Base branch name')
workflow_parser.add_argument('--pr-number', help='PR number')
workflow_parser.add_argument('--repo', help='Repository in the format "owner/repo"')
workflow_parser.add_argument('--token', help='GitHub token')
# set-github-output command - used by process-workflow
output_parser = subparsers.add_parser('set-github-output', help='Set GitHub Actions output variable', parents=[parent_parser])
output_parser.add_argument('name', help='Output variable name')
output_parser.add_argument('value', help='Output variable value')
args = parser.parse_args()
# Set up verbose mode
setup_verbose_mode(args)
if args.command == 'extract-coverage':
log(f"Extracting coverage from file: {args.file_path} (type: {args.type})")
coverage_pct = extract_coverage(args.file_path, args.type)
if args.github_output:
set_github_output(f"{args.type}_coverage", coverage_pct)
else:
log(f"Coverage: {coverage_pct}%")
elif args.command == 'compare-coverage':
log(f"Comparing coverage: base={args.base_cov}%, PR={args.pr_cov}%")
decreased, diff = compare_coverage(args.base_cov, args.pr_cov)
if args.github_output:
prefix = args.output_prefix
set_github_output(f"{prefix}decreased", str(decreased).lower())
set_github_output(f"{prefix}diff", diff)
log(f"Coverage difference: {diff}%")
log(f"Coverage decreased: {decreased}")
else:
log(f"decreased={str(decreased).lower()}")
log(f"diff={diff}")
elif args.command == 'generate-comment':
log("Generating coverage comparison comment")
comment = generate_comment(
args.base_ext_cov, args.pr_ext_cov, args.ext_decreased, args.ext_diff,
args.base_web_cov, args.pr_web_cov, args.web_decreased, args.web_diff
)
# Output the comment to stdout
log(comment)
elif args.command == 'post-comment':
log(f"Posting comment from {args.comment_path} to PR #{args.pr_number} in {args.repo}")
post_comment(args.comment_path, args.pr_number, args.repo, args.token)
elif args.command == 'run-coverage':
log(f"Running coverage command: {args.coverage_cmd}")
log(f"Output file: {args.output_file}")
log(f"Coverage type: {args.type}")
coverage_pct = run_coverage(args.coverage_cmd, args.output_file, args.type)
if args.github_output:
set_github_output(f"{args.type}_coverage", coverage_pct)
else:
log(f"Coverage: {coverage_pct}%")
elif args.command == 'process-workflow':
log("Processing coverage workflow")
log(f"Base branch: {args.base_branch}")
if args.pr_number:
log(f"PR number: {args.pr_number}")
if args.repo:
log(f"Repository: {args.repo}")
process_coverage_workflow(args)
elif args.command == 'set-github-output':
log(f"Setting GitHub output: {args.name}={args.value}")
set_github_output(args.name, args.value)
else:
log("No command specified")
parser.print_help()
sys.exit(1)
if __name__ == "__main__":
main()
@@ -1,265 +0,0 @@
"""
Coverage extraction module.
This module handles extracting coverage percentages from coverage report files.
"""
import os
import re
import sys
import shlex
import subprocess
import traceback
from .util import log, file_exists, get_file_size, list_directory, is_safe_command, run_command
# Global verbose flag
verbose = False
def set_verbose(value):
"""Set the global verbose flag."""
global verbose
verbose = value
def print_debug_output(content, coverage_type):
"""
Print debug information about the coverage output.
Args:
content: The content of the coverage file
coverage_type: Type of coverage report (extension or webview)
"""
if not verbose:
return
# Extract and print only the coverage summary section
if coverage_type == "extension":
# Look for the coverage summary section
summary_match = re.search(r'=============================== Coverage summary ===============================\n(.*?)\n=+', content, re.DOTALL)
if summary_match:
sys.stdout.write("\n##[group]EXTENSION COVERAGE SUMMARY\n")
sys.stdout.write("=============================== Coverage summary ===============================\n")
sys.stdout.write(summary_match.group(1) + "\n")
sys.stdout.write("================================================================================\n")
sys.stdout.write("##[endgroup]\n")
sys.stdout.flush()
else:
sys.stdout.write("\n##[warning]No coverage summary found in extension coverage file\n")
sys.stdout.flush()
else: # webview
# Look for the coverage table - specifically the "All files" row
table_match = re.search(r'% Coverage report from v8.*?-+\|.*?\n.*?\n(All files.*?)(?:\n[^\n]*\|)', content, re.DOTALL)
if table_match:
sys.stdout.write("\n##[group]WEBVIEW COVERAGE SUMMARY\n")
sys.stdout.write("% Coverage report from v8\n")
sys.stdout.write("-------------------|---------|----------|---------|---------|-------------------\n")
sys.stdout.write("File | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s \n")
sys.stdout.write("-------------------|---------|----------|---------|---------|-------------------\n")
sys.stdout.write(table_match.group(1) + "\n")
sys.stdout.write("-------------------|---------|----------|---------|---------|-------------------\n")
sys.stdout.write("##[endgroup]\n")
sys.stdout.flush()
else:
sys.stdout.write("\n##[warning]No coverage table found in webview coverage file\n")
sys.stdout.flush()
def extract_coverage(file_path, coverage_type="extension"):
"""
Extract coverage percentage from a coverage report file.
Args:
file_path: Path to the coverage report file
coverage_type: Type of coverage report (extension or webview)
Returns:
Coverage percentage as a float
"""
# Always print file path for debugging
log(f"Checking coverage file: {file_path}")
# Check if file exists and get its size
if not file_exists(file_path):
sys.stdout.write(f"\n##[error]File {file_path} does not exist\n")
sys.stdout.flush()
log(f"Error: File {file_path} does not exist")
# Check if the directory exists
dir_path = os.path.dirname(file_path)
if not os.path.exists(dir_path):
sys.stdout.write(f"\n##[error]Directory {dir_path} does not exist\n")
sys.stdout.flush()
log(f"Error: Directory {dir_path} does not exist")
else:
# List directory contents for debugging
log(f"Directory {dir_path} exists, listing contents:")
try:
dir_contents = list_directory(dir_path)
for name, size in dir_contents:
log(f" {name} - {size}")
sys.stdout.write(f" {name} - {size}\n")
sys.stdout.flush()
except Exception as e:
log(f"Error listing directory: {e}")
return 0.0
file_size = get_file_size(file_path)
log(f"File size: {file_size} bytes")
sys.stdout.write(f"\n##[info]Coverage file {file_path} exists, size: {file_size} bytes\n")
sys.stdout.flush()
if file_size == 0:
sys.stdout.write(f"\n##[warning]File {file_path} is empty\n")
sys.stdout.flush()
log(f"Warning: File {file_path} is empty")
return 0.0
# List directory contents for debugging
dir_path = os.path.dirname(file_path)
log(f"Directory contents of {dir_path}:")
try:
dir_contents = list_directory(dir_path)
for name, size in dir_contents:
log(f" {name} - {size}")
except Exception as e:
log(f"Error listing directory: {e}")
with open(file_path, 'r') as f:
content = f.read()
# Print debug information if verbose
print_debug_output(content, coverage_type)
# Extract coverage percentage based on coverage type
if coverage_type == "extension":
# Extract the percentage from the "Lines" row in the coverage summary
# Pattern: Lines : xx.xx% ( xxxxxxx/xxxxxxx )
lines_match = re.search(r'Lines\s*:\s*(\d+\.\d+)%', content)
if lines_match:
coverage_pct = float(lines_match.group(1))
if verbose:
sys.stdout.write(f"Pattern matched (Lines percentage): {coverage_pct}\n")
sys.stdout.flush()
return coverage_pct
else:
# No coverage data found, log full content for debugging
log("No coverage data found. Full file content:")
log("=== Full file content ===")
log(content)
log("=== End file content ===")
else: # webview
# Extract the percentage from the "% Lines" column in the "All files" row
# Pattern: All files | xx.xx | xx.xx | xx.xx | xx.xx |
all_files_match = re.search(r'All files\s+\|\s+\d+\.\d+\s+\|\s+\d+\.\d+\s+\|\s+\d+\.\d+\s+\|\s+(\d+\.\d+)', content)
if all_files_match:
coverage_pct = float(all_files_match.group(1))
if verbose:
sys.stdout.write(f"Pattern matched (All files % Lines): {coverage_pct}\n")
sys.stdout.flush()
return coverage_pct
else:
# No coverage data found, log full content for debugging
log("No coverage data found. Full file content:")
log("=== Full file content ===")
log(content)
log("=== End file content ===")
# If no match found, return 0.0
return 0.0
def compare_coverage(base_cov, pr_cov):
"""
Compare coverage percentages between base and PR branches.
Args:
base_cov: Base branch coverage percentage
pr_cov: PR branch coverage percentage
Returns:
Tuple of (decreased, diff)
"""
try:
base_cov = float(base_cov)
pr_cov = float(pr_cov)
except ValueError:
sys.stdout.write(f"Error: Invalid coverage values - base: {base_cov}, PR: {pr_cov}\n")
sys.stdout.flush()
return False, 0
diff = pr_cov - base_cov
decreased = diff < 0
return decreased, abs(diff)
def run_coverage(command, output_file, coverage_type="extension"):
"""
Run a coverage command and extract the coverage percentage.
Args:
command: Command to run
output_file: File to save the output to
coverage_type: Type of coverage report (extension or webview)
Returns:
Coverage percentage as a float
Raises:
SystemExit: If the output file is not created or is empty
"""
try:
# Run the command and capture output
if not is_safe_command(command):
error_msg = f"ERROR: Unsafe command detected: {command}"
log(error_msg)
sys.stdout.write(f"\n##[error]{error_msg}\n")
sys.stdout.flush()
sys.exit(1)
# Run command using safe execution from util
returncode, stdout, stderr = run_command(command)
# Log command result
log(f"Command exit code: {returncode}")
log(f"Command stdout length: {len(stdout)} bytes")
log(f"Command stderr length: {len(stderr)} bytes")
# Save output to file
log(f"Saving command output to {output_file}")
with open(output_file, 'w') as f:
f.write(stdout)
if stderr:
f.write("\n\n=== STDERR ===\n")
f.write(stderr)
# Verify file was created and has content
if not file_exists(output_file):
error_msg = f"ERROR: Output file {output_file} was not created"
log(error_msg)
sys.stdout.write(f"\n##[error]{error_msg}\n")
sys.stdout.flush()
sys.exit(1) # Exit with error code to fail the workflow
file_size = get_file_size(output_file)
if file_size == 0:
error_msg = f"ERROR: Output file {output_file} is empty"
log(error_msg)
sys.stdout.write(f"\n##[error]{error_msg}\n")
sys.stdout.flush()
sys.exit(1) # Exit with error code to fail the workflow
log(f"Output file size: {file_size} bytes")
# Extract coverage percentage
coverage_pct = extract_coverage(output_file, coverage_type)
log(f"{coverage_type.capitalize()} coverage: {coverage_pct}%")
return coverage_pct
except Exception as e:
error_msg = f"Error running coverage command: {e}"
log(error_msg)
sys.stdout.write(f"\n##[error]{error_msg}\n")
sys.stdout.flush()
# Print stack trace for debugging
log(traceback.format_exc())
sys.exit(1) # Exit with error code to fail the workflow
@@ -1,177 +0,0 @@
"""
GitHub API module.
This module handles interactions with the GitHub API for posting comments to PRs.
"""
import os
import requests
from .util import log, file_exists
def generate_comment(base_ext_cov, pr_ext_cov, ext_decreased, ext_diff,
base_web_cov, pr_web_cov, web_decreased, web_diff):
"""
Generate a PR comment with coverage comparison.
Args:
base_ext_cov: Base branch extension coverage
pr_ext_cov: PR branch extension coverage
ext_decreased: Whether extension coverage decreased
ext_diff: Extension coverage difference
base_web_cov: Base branch webview coverage
pr_web_cov: PR branch webview coverage
web_decreased: Whether webview coverage decreased
web_diff: Webview coverage difference
Returns:
Comment text
"""
from datetime import datetime
# Convert string inputs to appropriate types
try:
base_ext_cov = float(base_ext_cov)
pr_ext_cov = float(pr_ext_cov)
# Handle ext_decreased as either string or boolean
if isinstance(ext_decreased, str):
ext_decreased = ext_decreased.lower() == 'true'
else:
ext_decreased = bool(ext_decreased)
ext_diff = float(ext_diff)
base_web_cov = float(base_web_cov)
pr_web_cov = float(pr_web_cov)
# Handle web_decreased as either string or boolean
if isinstance(web_decreased, str):
web_decreased = web_decreased.lower() == 'true'
else:
web_decreased = bool(web_decreased)
web_diff = float(web_diff)
except ValueError as e:
log(f"Error converting input values: {e}")
return ""
# Add a unique identifier to find this comment later
comment = '<!-- COVERAGE_REPORT -->\n'
comment += '## Coverage Report\n\n'
# Extension coverage
comment += '### Extension Coverage\n\n'
comment += f'Base branch: {base_ext_cov:.0f}%\n\n'
comment += f'PR branch: {pr_ext_cov:.0f}%\n\n'
if ext_decreased:
comment += f'⚠️ **Warning: Coverage decreased by {ext_diff:.2f}%**\n\n'
comment += 'Consider adding tests to cover your changes.\n\n'
else:
comment += '✅ Coverage increased or remained the same\n\n'
# Webview coverage
comment += '### Webview Coverage\n\n'
comment += f'Base branch: {base_web_cov:.0f}%\n\n'
comment += f'PR branch: {pr_web_cov:.0f}%\n\n'
if web_decreased:
comment += f'⚠️ **Warning: Coverage decreased by {web_diff:.2f}%**\n\n'
comment += 'Consider adding tests to cover your changes.\n\n'
else:
comment += '✅ Coverage increased or remained the same\n\n'
# Overall assessment
comment += '### Overall Assessment\n\n'
if ext_decreased or web_decreased:
comment += '⚠️ **Test coverage has decreased in this PR**\n\n'
comment += 'Please consider adding tests to maintain or improve coverage.\n\n'
else:
comment += '✅ **Test coverage has been maintained or improved**\n\n'
# Add timestamp
comment += f'\n\n<sub>Last updated: {datetime.now().isoformat()}</sub>'
return comment
def post_comment(comment_path, pr_number, repo, token=None):
"""
Post a comment to a GitHub PR.
Args:
comment_path: Path to the file containing the comment text
pr_number: PR number
repo: Repository in the format "owner/repo"
token: GitHub token
"""
if not file_exists(comment_path):
log(f"Error: Comment file {comment_path} does not exist")
return
with open(comment_path, 'r') as f:
comment_body = f.read()
if not token:
token = os.environ.get('GITHUB_TOKEN')
if not token:
log("Error: GitHub token not provided")
return
# Find existing comment
headers = {
'Authorization': f'token {token}',
'Accept': 'application/vnd.github.v3+json'
}
# Get all comments
comments_url = f'https://api.github.com/repos/{repo}/issues/{pr_number}/comments'
log(f"Getting comments from: {comments_url}")
response = requests.get(comments_url, headers=headers)
if response.status_code != 200:
log(f"Error getting comments: {response.status_code} - {response.text}")
return
comments = response.json()
log(f"Found {len(comments)} existing comments")
# Find comment with our identifier
comment_id = None
for comment in comments:
if '<!-- COVERAGE_REPORT -->' in comment['body']:
comment_id = comment['id']
log(f"Found existing coverage report comment with ID: {comment_id}")
break
if comment_id:
# Update existing comment
update_url = f'https://api.github.com/repos/{repo}/issues/comments/{comment_id}'
log(f"Updating existing comment at: {update_url}")
response = requests.patch(update_url, headers=headers, json={'body': comment_body})
if response.status_code == 200:
log(f"Successfully updated existing comment: {comment_id}")
else:
log(f"Error updating comment: {response.status_code} - {response.text}")
else:
# Create new comment
log(f"Creating new comment at: {comments_url}")
response = requests.post(comments_url, headers=headers, json={'body': comment_body})
if response.status_code == 201:
log("Successfully created new comment")
else:
log(f"Error creating comment: {response.status_code} - {response.text}")
def set_github_output(name, value):
"""
Set GitHub Actions output variable.
Args:
name: Output variable name
value: Output variable value
"""
# Write to the GitHub output file if available
if 'GITHUB_OUTPUT' in os.environ:
with open(os.environ['GITHUB_OUTPUT'], 'a') as f:
f.write(f"{name}={value}\n")
else:
# Fallback to the deprecated method for backward compatibility
log(f"::set-output name={name}::{value}")
# Also print for human readability
log(f"{name}: {value}")
-245
View File
@@ -1,245 +0,0 @@
"""
Utility module.
This module provides utility functions used across the coverage check scripts.
"""
import os
import sys
import re
import shlex
import subprocess
import traceback
from typing import List, Tuple, Dict, Any, Optional, Union
# List of allowed commands and their arguments
ALLOWED_COMMANDS = {
'xvfb-run': ['-a'],
'npm': ['run', 'test:coverage', 'ci', 'install', '--no-save', '@vitest/coverage-v8', 'check-types', 'lint', 'format', 'compile'],
'cd': ['webview-ui'],
'python': ['-m', 'coverage_check'],
'git': ['fetch', 'checkout', 'origin'],
}
def is_safe_command(command: Union[str, List[str]]) -> bool:
"""
Check if a command is safe to execute.
Args:
command: Command to check (string or list)
Returns:
True if command is safe, False otherwise
"""
# Convert string command to list
if isinstance(command, str):
try:
cmd_parts = shlex.split(command)
except ValueError:
return False
else:
cmd_parts = command
if not cmd_parts:
return False
# Get base command
base_cmd = os.path.basename(cmd_parts[0])
# Check if command is in allowed list
if base_cmd not in ALLOWED_COMMANDS:
return False
# For each argument, check for suspicious patterns
for arg in cmd_parts[1:]:
# Check for shell metacharacters
if re.search(r'[;&|`$]', arg):
return False
# Check for path traversal
if '..' in arg and not (base_cmd == 'npm' and arg.startswith('@')):
return False
return True
def log(message: str) -> None:
"""
Write a message to stdout and flush.
Args:
message: The message to write
"""
sys.stdout.write(f"{message}\n")
sys.stdout.flush()
def file_exists(file_path: str) -> bool:
"""
Check if a file exists.
Args:
file_path: Path to the file
Returns:
True if the file exists, False otherwise
"""
return os.path.exists(file_path) and os.path.isfile(file_path)
def get_file_size(file_path: str) -> int:
"""
Get the size of a file in bytes.
Args:
file_path: Path to the file
Returns:
Size of the file in bytes, or 0 if the file doesn't exist
"""
if file_exists(file_path):
return os.path.getsize(file_path)
return 0
def list_directory(dir_path: str) -> List[Tuple[str, Union[int, str]]]:
"""
List the contents of a directory.
Args:
dir_path: Path to the directory
Returns:
List of (name, size) tuples for each file/directory in the directory
"""
if not os.path.exists(dir_path) or not os.path.isdir(dir_path):
return []
contents = []
for item in os.listdir(dir_path):
item_path = os.path.join(dir_path, item)
if os.path.isfile(item_path):
contents.append((item, os.path.getsize(item_path)))
else:
contents.append((item, "DIR"))
return contents
def read_file_content(file_path: str, default: str = "") -> str:
"""
Read file content with error handling.
Args:
file_path: Path to the file
default: Default value to return if file cannot be read
Returns:
File content or default value
"""
if not file_exists(file_path):
log(f"File does not exist: {file_path}")
return default
try:
with open(file_path, 'r') as f:
return f.read()
except Exception as e:
log(f"Error reading file {file_path}: {e}")
return default
def write_file_content(file_path: str, content: str) -> bool:
"""
Write content to file with error handling.
Args:
file_path: Path to the file
content: Content to write
Returns:
True if successful, False otherwise
"""
try:
# Create directory if it doesn't exist
os.makedirs(os.path.dirname(file_path), exist_ok=True)
with open(file_path, 'w') as f:
f.write(content)
return True
except Exception as e:
log(f"Error writing to file {file_path}: {e}")
return False
def run_command(command: Union[str, List[str]], capture_output: bool = True) -> Tuple[int, str, str]:
"""
Run a command and return the result.
Args:
command: Command to run (string or list)
capture_output: Whether to capture stdout/stderr
Returns:
Tuple of (returncode, stdout, stderr)
"""
if not is_safe_command(command):
error_msg = f"Unsafe command detected: {command}"
log(error_msg)
return 1, "", error_msg
log(f"Running command: {command}")
try:
# Convert string command to list
if isinstance(command, str):
cmd_list = shlex.split(command)
else:
cmd_list = command
result = subprocess.run(
cmd_list,
shell=False, # Never use shell=True for security
capture_output=capture_output,
text=True
)
log(f"Command exit code: {result.returncode}")
return result.returncode, result.stdout, result.stderr
except Exception as e:
log(f"Error running command: {e}")
log(traceback.format_exc())
return 1, "", str(e)
def find_pattern(content: str, pattern: str, group: int = 0,
default: Optional[str] = None) -> Optional[str]:
"""
Find a pattern in content and return the specified group.
Args:
content: Text content to search
pattern: Regex pattern to search for
group: Group number to return (default: 0 for entire match)
default: Default value to return if pattern not found
Returns:
Matched text or default value
"""
match = re.search(pattern, content, re.DOTALL)
if match:
return match.group(group)
return default
def get_env_var(name: str, default: Optional[str] = None) -> Optional[str]:
"""
Get environment variable with default value.
Args:
name: Environment variable name
default: Default value if not set
Returns:
Environment variable value or default
"""
return os.environ.get(name, default)
def format_exception(e: Exception) -> str:
"""
Format an exception with traceback for logging.
Args:
e: Exception to format
Returns:
Formatted exception string
"""
return f"{type(e).__name__}: {str(e)}\n{traceback.format_exc()}"
-432
View File
@@ -1,432 +0,0 @@
"""
Workflow module.
This module handles the main workflow logic for running coverage tests and processing results.
"""
import os
import re
import sys
import subprocess
import traceback
from .extraction import run_coverage, compare_coverage, extract_coverage
from .github_api import generate_comment, post_comment, set_github_output
from .util import log, file_exists, get_file_size, list_directory, run_command
def is_valid_branch_name(branch_name: str) -> bool:
"""
Validate a git branch name.
Args:
branch_name: Branch name to validate
Returns:
True if valid, False otherwise
"""
# Check for common branch name patterns
if not re.match(r'^[a-zA-Z0-9_\-./]+$', branch_name):
return False
# Check for path traversal
if '..' in branch_name:
return False
# Check for shell metacharacters
if re.search(r'[;&|`$]', branch_name):
return False
return True
def checkout_branch(branch_name: str) -> None:
"""
Checkout a branch for testing.
Args:
branch_name: Branch name to checkout
Raises:
RuntimeError: If branch checkout fails
ValueError: If branch name is invalid
"""
if not is_valid_branch_name(branch_name):
raise ValueError(f"Invalid branch name: {branch_name}")
log(f"=== Checking out branch: {branch_name} ===")
# Fetch the branch
returncode, stdout, stderr = run_command(['git', 'fetch', 'origin', branch_name])
if returncode != 0:
log(f"ERROR: Failed to fetch branch {branch_name}")
log(f"Error details: {stderr}")
raise RuntimeError(f"Git fetch failed: {stderr}")
# Checkout the branch
returncode, stdout, stderr = run_command(['git', 'checkout', branch_name])
if returncode != 0:
log(f"ERROR: Failed to checkout branch {branch_name}")
log(f"Error details: {stderr}")
raise RuntimeError(f"Git checkout failed: {stderr}")
log(f"Successfully checked out branch: {branch_name}")
def extract_extension_coverage_from_file(file_path):
"""Extract extension coverage from file when run_coverage returns 0."""
if not file_exists(file_path):
log(f"File {file_path} does not exist, cannot extract extension coverage")
return 0.0
file_size = get_file_size(file_path)
if file_size == 0:
log(f"File {file_path} is empty, cannot extract extension coverage")
return 0.0
log(f"Extension coverage is 0.0, trying to read from file directly: {file_path} (size: {file_size} bytes)")
with open(file_path, 'r') as f:
content = f.read()
# Extract the percentage from the "Lines" row in the coverage summary
# Pattern: Lines : xx.xx% ( xxxxxxx/xxxxxxx )
lines_match = re.search(r'Lines\s*:\s*(\d+\.\d+)%', content)
if lines_match:
coverage = float(lines_match.group(1))
log(f"Found extension coverage in file: {coverage}%")
return coverage
return 0.0
def extract_webview_coverage_from_file(file_path):
"""Extract webview coverage from file when run_coverage returns 0."""
if not file_exists(file_path):
log(f"File {file_path} does not exist, cannot extract webview coverage")
return 0.0
file_size = get_file_size(file_path)
if file_size == 0:
log(f"File {file_path} is empty, cannot extract webview coverage")
return 0.0
log(f"Webview coverage is 0.0, trying to read from file directly: {file_path} (size: {file_size} bytes)")
with open(file_path, 'r') as f:
content = f.read()
# Extract the percentage from the "% Lines" column in the "All files" row
# Pattern: All files | xx.xx | xx.xx | xx.xx | xx.xx |
all_files_match = re.search(r'All files\s+\|\s+\d+\.\d+\s+\|\s+\d+\.\d+\s+\|\s+\d+\.\d+\s+\|\s+(\d+\.\d+)', content)
if all_files_match:
coverage = float(all_files_match.group(1))
log(f"Found webview coverage in file: {coverage}%")
return coverage
return 0.0
def run_extension_coverage(branch_name=None):
"""Run extension coverage tests and extract results."""
prefix = 'base_' if branch_name else ''
file_path = f"{prefix}extension_coverage.txt"
# Run coverage tests
ext_cov = run_coverage(
["xvfb-run", "-a", "npm", "run", "test:coverage"],
file_path,
"extension"
)
# If coverage is 0.0, try to extract from file directly
if ext_cov == 0.0:
ext_cov = extract_extension_coverage_from_file(file_path)
return ext_cov
def run_webview_coverage(branch_name=None):
"""Run webview coverage tests and extract results."""
prefix = 'base_' if branch_name else ''
file_path = f"{prefix}webview_coverage.txt"
# Save current directory
original_dir = os.getcwd()
try:
# Change to webview-ui directory
os.chdir('webview-ui')
# Install coverage dependency
returncode, stdout, stderr = run_command(["npm", "install", "--no-save", "@vitest/coverage-v8"])
if returncode != 0:
log(f"Failed to install coverage dependency: {stderr}")
return 0.0
# Run coverage tests from webview-ui directory
web_cov = run_coverage(
["npm", "run", "test:coverage"],
os.path.join('..', file_path),
"webview"
)
finally:
# Always change back to original directory
os.chdir(original_dir)
# If coverage is 0.0, try to extract from file directly
if web_cov == 0.0:
web_cov = extract_webview_coverage_from_file(file_path)
return web_cov
def run_branch_coverage(branch_name=None):
"""
Run coverage tests for a branch.
Args:
branch_name: Name of the branch to checkout before running tests (optional)
Returns:
Tuple of (extension_coverage, webview_coverage)
"""
# Checkout branch if specified
if branch_name:
checkout_branch(branch_name)
# Run coverage tests
log(f"=== Running coverage tests{' for ' + branch_name if branch_name else ''} ===")
# Run extension and webview coverage
ext_cov = run_extension_coverage(branch_name)
web_cov = run_webview_coverage(branch_name)
return ext_cov, web_cov
def find_potential_coverage_files():
"""Find potential coverage files in the current directory and webview-ui."""
log("Searching for potential coverage files...")
# Find files in current directory
current_dir_files = list_directory('.')
for name, size in current_dir_files:
if 'coverage' in name.lower() and size != "DIR":
log(f"Found potential coverage file: {name} (size: {size} bytes)")
# Find files in webview-ui directory
if os.path.exists('webview-ui') and os.path.isdir('webview-ui'):
webview_files = list_directory('webview-ui')
for name, size in webview_files:
if 'coverage' in name.lower() and size != "DIR":
log(f"Found potential webview coverage file: webview-ui/{name} (size: {size} bytes)")
else:
log("webview-ui directory not found")
def generate_warnings(base_ext_cov, pr_ext_cov, ext_decreased, ext_diff,
base_web_cov, pr_web_cov, web_decreased, web_diff):
"""Generate warnings for coverage decreases."""
if not (ext_decreased or web_decreased):
return []
warnings = [
"Test coverage has decreased in this PR",
f"Extension coverage: {base_ext_cov}% -> {pr_ext_cov}% (Diff: {ext_diff}%)",
f"Webview coverage: {base_web_cov}% -> {pr_web_cov}% (Diff: {web_diff}%)"
]
# Additional warning for significant decrease (more than 1%)
if ext_decreased and ext_diff > 1.0:
warnings.append(f"Extension coverage decreased by more than 1% ({ext_diff}%). Consider adding tests to cover your changes.")
if web_decreased and web_diff > 1.0:
warnings.append(f"Webview coverage decreased by more than 1% ({web_diff}%). Consider adding tests to cover your changes.")
return warnings
def output_warnings(warnings):
"""Output warnings to GitHub step summary and console."""
if not warnings:
return
# Get the GitHub step summary file path from environment variable
github_step_summary = os.environ.get('GITHUB_STEP_SUMMARY')
# Write to GitHub step summary if available
if github_step_summary:
with open(github_step_summary, 'a') as f:
f.write("## Coverage Warnings\n\n")
for warning in warnings:
f.write(f"⚠️ {warning}\n\n")
# Also output to console with ::warning:: syntax for backward compatibility
for warning in warnings:
log(f"::warning::{warning}")
def output_github_results(pr_ext_cov, pr_web_cov, base_ext_cov, base_web_cov,
ext_decreased, ext_diff, web_decreased, web_diff):
"""Output results for GitHub Actions."""
set_github_output("pr_extension_coverage", pr_ext_cov)
set_github_output("pr_webview_coverage", pr_web_cov)
set_github_output("base_extension_coverage", base_ext_cov)
set_github_output("base_webview_coverage", base_web_cov)
set_github_output("extension_decreased", str(ext_decreased).lower())
set_github_output("extension_diff", ext_diff)
set_github_output("webview_decreased", str(web_decreased).lower())
set_github_output("webview_diff", web_diff)
def extract_pr_coverage_from_artifacts():
"""
Extract PR branch coverage from artifact files.
Returns:
Tuple of (extension_coverage, webview_coverage)
Raises:
SystemExit: If the coverage files don't exist
"""
log("=== Extracting PR branch coverage from artifacts ===")
# Check if the coverage files exist
ext_file_path = "extension_coverage.txt"
web_file_path = "webview-ui/webview_coverage.txt"
# Extract extension coverage
log(f"Extracting extension coverage from {ext_file_path}")
if not file_exists(ext_file_path):
error_msg = f"ERROR: PR extension coverage file {ext_file_path} not found"
log(error_msg)
# List directory contents for debugging
log("Current directory contents:")
try:
dir_contents = list_directory('.')
for name, size in dir_contents:
log(f" {name} - {size}\n")
except Exception as e:
log(f"Error listing directory: {e}")
sys.exit(1) # Exit with error code to fail the workflow
ext_cov = extract_extension_coverage_from_file(ext_file_path)
log(f"PR extension coverage from artifact: {ext_cov}%")
# Extract webview coverage
log(f"Extracting webview coverage from {web_file_path}")
if not file_exists(web_file_path):
error_msg = f"ERROR: PR webview coverage file {web_file_path} not found"
log(error_msg)
# Check if the webview-ui directory exists
if not os.path.exists('webview-ui'):
log("ERROR: webview-ui directory not found")
else:
# List webview-ui directory contents for debugging
log("webview-ui directory contents:")
try:
dir_contents = list_directory('webview-ui')
for name, size in dir_contents:
log(f" {name} - {size}")
except Exception as e:
log(f"Error listing directory: {e}")
sys.exit(1) # Exit with error code to fail the workflow
web_cov = extract_webview_coverage_from_file(web_file_path)
log(f"PR webview coverage from artifact: {web_cov}%")
return ext_cov, web_cov
def process_coverage_workflow(args):
"""
Process the entire coverage workflow.
Args:
args: Command line arguments
"""
# Initialize all variables at the start
pr_ext_cov = 0.0
pr_web_cov = 0.0
base_ext_cov = 0.0
base_web_cov = 0.0
ext_decreased = False
ext_diff = 0.0
web_decreased = False
web_diff = 0.0
try:
# Validate branch name
if not is_valid_branch_name(args.base_branch):
raise ValueError(f"Invalid base branch name: {args.base_branch}")
# Check if we're running in GitHub Actions
is_github_actions = 'GITHUB_ACTIONS' in os.environ
if is_github_actions:
log("Running in GitHub Actions environment")
# Extract PR branch coverage from artifacts (from test job)
pr_ext_cov, pr_web_cov = extract_pr_coverage_from_artifacts()
# Verify PR coverage values
if pr_ext_cov == 0.0:
log("WARNING: PR extension coverage is 0.0, this may indicate an issue with the coverage report")
find_potential_coverage_files()
if pr_web_cov == 0.0:
log("WARNING: PR webview coverage is 0.0, this may indicate an issue with the coverage report")
find_potential_coverage_files()
# Run base branch coverage
log(f"=== Running base branch coverage for {args.base_branch} ===")
base_ext_cov, base_web_cov = run_branch_coverage(args.base_branch)
# Verify base coverage values
if base_ext_cov == 0.0:
log("WARNING: Base extension coverage is 0.0, this may indicate an issue with the coverage report")
if base_web_cov == 0.0:
log("WARNING: Base webview coverage is 0.0, this may indicate an issue with the coverage report")
# Compare coverage
log("=== Comparing extension coverage ===")
ext_decreased, ext_diff = compare_coverage(base_ext_cov, pr_ext_cov)
log("=== Comparing webview coverage ===")
web_decreased, web_diff = compare_coverage(base_web_cov, pr_web_cov)
# Print summary of coverage values
log("\n=== Coverage Summary ===")
log(f"PR extension coverage: {pr_ext_cov}%")
log(f"Base extension coverage: {base_ext_cov}%")
log(f"Extension coverage change: {'+' if not ext_decreased else '-'}{ext_diff}%")
log(f"PR webview coverage: {pr_web_cov}%")
log(f"Base webview coverage: {base_web_cov}%")
log(f"Webview coverage change: {'+' if not web_decreased else '-'}{web_diff}%")
# Generate and output warnings
warnings = generate_warnings(
base_ext_cov, pr_ext_cov, ext_decreased, ext_diff,
base_web_cov, pr_web_cov, web_decreased, web_diff
)
output_warnings(warnings)
# Generate comment
log("=== Generating comment ===")
comment = generate_comment(
base_ext_cov, pr_ext_cov, str(ext_decreased).lower(), ext_diff,
base_web_cov, pr_web_cov, str(web_decreased).lower(), web_diff
)
# Save comment to file
with open("coverage_comment.md", "w") as f:
f.write(comment)
# Post comment if PR number is provided
if args.pr_number:
log(f"=== Posting comment to PR #{args.pr_number} ===")
post_comment("coverage_comment.md", args.pr_number, args.repo, args.token)
# Output results for GitHub Actions
output_github_results(
pr_ext_cov, pr_web_cov, base_ext_cov, base_web_cov,
ext_decreased, ext_diff, web_decreased, web_diff
)
except Exception as e:
log(f"ERROR in process_coverage_workflow: {e}")
traceback.print_exc()
# Try to output results even if there was an error
try:
output_github_results(
pr_ext_cov, pr_web_cov, base_ext_cov, base_web_cov,
ext_decreased, ext_diff, web_decreased, web_diff
)
except Exception as e2:
log(f"ERROR outputting GitHub results: {e2}")
@@ -22,6 +22,7 @@ Environment Variables:
#!/usr/bin/env python3
import os
import sys
CHANGELOG_PATH = os.environ.get("CHANGELOG_PATH", "CHANGELOG.md")
VERSION = os.environ['VERSION']
@@ -31,49 +32,72 @@ NEW_CONTENT = os.environ.get("NEW_CONTENT", "")
def overwrite_changelog_section(changelog_text: str, new_content: str):
# Find the section for the specified version
version_pattern = f"## {VERSION}\n"
unformmatted_prev_version_pattern = f"## {PREV_VERSION}\n"
bracketed_version_pattern = f"## [{VERSION}]\n"
prev_version_pattern = f"## [{PREV_VERSION}]\n"
print(f"latest version: {VERSION}")
print(f"prev_version: {PREV_VERSION}")
notes_start_index = changelog_text.find(version_pattern) + len(version_pattern)
notes_end_index = changelog_text.find(prev_version_pattern, notes_start_index) if PREV_VERSION and (prev_version_pattern in changelog_text or unformmatted_prev_version_pattern in changelog_text) else len(changelog_text)
# Try both unbracketed and bracketed version patterns
version_index = changelog_text.find(version_pattern)
if version_index == -1:
version_index = changelog_text.find(bracketed_version_pattern)
if version_index == -1:
# If version not found, add it at the top (after the first line)
first_newline = changelog_text.find('\n')
if first_newline == -1:
# If no newline found, just prepend
return f"## [{VERSION}]\n\n{changelog_text}"
return f"{changelog_text[:first_newline + 1]}## [{VERSION}]\n\n{changelog_text[first_newline + 1:]}"
else:
# Using bracketed version
version_pattern = bracketed_version_pattern
notes_start_index = version_index + len(version_pattern)
notes_end_index = changelog_text.find(prev_version_pattern, notes_start_index) if PREV_VERSION and prev_version_pattern in changelog_text else len(changelog_text)
if new_content:
return changelog_text[:notes_start_index] + f"{new_content}\n" + changelog_text[notes_end_index:]
else:
changeset_lines = changelog_text[notes_start_index:notes_end_index].split("\n")
filtered_lines = []
for line in changeset_lines:
# If the previous line is a changeset format
if len(filtered_lines) > 1 and filtered_lines[-1].startswith("### "):
# Remove the last two lines from the filted_lines
filtered_lines.pop()
filtered_lines.pop()
else:
filtered_lines.append(line.strip())
# Prepend a new line to the first line of filtered_lines
if filtered_lines:
filtered_lines[0] = "\n" + filtered_lines[0]
# Print filted_lines wiht a "\n" at the end of each line
for line in filtered_lines:
print(line.strip())
parsed_lines = "\n".join(line for line in filtered_lines)
# Ensure we have at least 2 lines before removing them
if len(changeset_lines) < 2:
print("Warning: Changeset content has fewer than 2 lines")
parsed_lines = "\n".join(changeset_lines)
else:
# Remove the first two lines from the regular changeset format, ex: \n### Patch Changes
parsed_lines = "\n".join(changeset_lines[2:])
updated_changelog = changelog_text[:notes_start_index] + parsed_lines + changelog_text[notes_end_index:]
# Ensure version number is bracketed
updated_changelog = updated_changelog.replace(f"## {VERSION}", f"## [{VERSION}]")
return updated_changelog
with open(CHANGELOG_PATH, 'r') as f:
changelog_content = f.read()
try:
print(f"Reading changelog from: {CHANGELOG_PATH}")
with open(CHANGELOG_PATH, 'r') as f:
changelog_content = f.read()
new_changelog = overwrite_changelog_section(changelog_content, NEW_CONTENT)
# print("----------------------------------------------------------------------------------")
# print(new_changelog)
# print("----------------------------------------------------------------------------------")
# Write back to CHANGELOG.md
with open(CHANGELOG_PATH, 'w') as f:
f.write(new_changelog)
print(f"Changelog content length: {len(changelog_content)} characters")
print("First 200 characters of changelog:")
print(changelog_content[:200])
print("----------------------------------------------------------------------------------")
print(f"{CHANGELOG_PATH} updated successfully!")
new_changelog = overwrite_changelog_section(changelog_content, NEW_CONTENT)
print("New changelog content:")
print("----------------------------------------------------------------------------------")
print(new_changelog)
print("----------------------------------------------------------------------------------")
print(f"Writing updated changelog back to: {CHANGELOG_PATH}")
with open(CHANGELOG_PATH, 'w') as f:
f.write(new_changelog)
print(f"{CHANGELOG_PATH} updated successfully!")
except FileNotFoundError:
print(f"Error: Changelog file not found at {CHANGELOG_PATH}")
sys.exit(1)
except Exception as e:
print(f"Error updating changelog: {str(e)}")
print(f"Current working directory: {os.getcwd()}")
sys.exit(1)
@@ -1,282 +0,0 @@
#!/usr/bin/env python3
"""
Tests for coverage_check script.
"""
import os
import sys
import unittest
import subprocess
import tempfile
from unittest.mock import patch, MagicMock, call, mock_open
# Add parent directory to path so we can import coverage modules
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
from coverage_check import extract_coverage, compare_coverage, set_verbose, generate_comment, post_comment, set_github_output
from coverage_check.util import log, file_exists, get_file_size, list_directory
class TestCoverage(unittest.TestCase):
# Class variables to store coverage files
temp_dir = None
extension_coverage_file = None
webview_coverage_file = None
@classmethod
def setUpClass(cls):
"""Set up test environment once for all tests."""
# Create temporary directory for test files
cls.temp_dir = tempfile.TemporaryDirectory()
cls.extension_coverage_file = os.path.join(cls.temp_dir.name, 'extension_coverage.txt')
cls.webview_coverage_file = os.path.join(cls.temp_dir.name, 'webview_coverage.txt')
# Run actual tests to generate coverage reports
cls.generate_coverage_reports()
# Verify files exist and are not empty
assert os.path.exists(cls.extension_coverage_file), \
f"Extension coverage file {cls.extension_coverage_file} does not exist"
assert os.path.getsize(cls.extension_coverage_file) > 0, \
f"Extension coverage file {cls.extension_coverage_file} is empty"
assert os.path.exists(cls.webview_coverage_file), \
f"Webview coverage file {cls.webview_coverage_file} does not exist"
assert os.path.getsize(cls.webview_coverage_file) > 0, \
f"Webview coverage file {cls.webview_coverage_file} is empty"
@classmethod
def tearDownClass(cls):
"""Clean up test environment after all tests."""
if cls.temp_dir:
cls.temp_dir.cleanup()
@classmethod
def generate_coverage_reports(cls):
"""Generate real coverage reports by running tests."""
log("Generating coverage reports (this may take a while)...")
# Run extension tests with coverage
try:
# Get absolute paths
root_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), '../../..'))
webview_dir = os.path.join(root_dir, 'webview-ui')
# Use xvfb-run on Linux
if sys.platform.startswith('linux'):
cmd = f"cd {root_dir} && xvfb-run -a npm run test:coverage > {cls.extension_coverage_file} 2>&1"
else:
cmd = f"cd {root_dir} && npm run test:coverage > {cls.extension_coverage_file} 2>&1"
log("Running extension tests...")
log(f"Command: {cmd}")
result = subprocess.run(cmd, shell=True, check=False, capture_output=True, text=True)
log(f"Extension tests exit code: {result.returncode}")
# Run webview tests with coverage
log("Running webview tests...")
cmd = f"cd {webview_dir} && npm run test:coverage > {cls.webview_coverage_file} 2>&1"
log(f"Command: {cmd}")
result = subprocess.run(cmd, shell=True, check=False, capture_output=True, text=True)
log(f"Webview tests exit code: {result.returncode}")
# Verify files were created
if file_exists(cls.extension_coverage_file):
ext_size = get_file_size(cls.extension_coverage_file)
log(f"Extension coverage file created: {cls.extension_coverage_file} (size: {ext_size} bytes)")
else:
log(f"WARNING: Extension coverage file was not created: {cls.extension_coverage_file}")
if file_exists(cls.webview_coverage_file):
web_size = get_file_size(cls.webview_coverage_file)
log(f"Webview coverage file created: {cls.webview_coverage_file} (size: {web_size} bytes)")
else:
log(f"WARNING: Webview coverage file was not created: {cls.webview_coverage_file}")
log("Coverage reports generation completed.")
except Exception as e:
log(f"Error generating coverage reports: {e}")
import traceback
log(traceback.format_exc())
# Create empty files if tests fail
log("Creating fallback coverage files...")
with open(cls.extension_coverage_file, 'w') as f:
f.write("No coverage data available")
with open(cls.webview_coverage_file, 'w') as f:
f.write("No coverage data available")
def test_extract_coverage(self):
"""Test extract_coverage function with both extension and webview coverage."""
# Check if verbose mode is enabled
if '-v' in sys.argv or '--verbose' in sys.argv:
set_verbose(True)
# Verify files exist before testing
self.assertTrue(file_exists(self.extension_coverage_file),
f"Extension coverage file does not exist: {self.extension_coverage_file}")
self.assertTrue(file_exists(self.webview_coverage_file),
f"Webview coverage file does not exist: {self.webview_coverage_file}")
# Log file sizes
ext_size = get_file_size(self.extension_coverage_file)
web_size = get_file_size(self.webview_coverage_file)
log(f"Extension coverage file size: {ext_size} bytes")
log(f"Webview coverage file size: {web_size} bytes")
# Test extension coverage
log("Testing extension coverage extraction...")
ext_coverage_pct = extract_coverage(self.extension_coverage_file, 'extension')
# Check that coverage percentage is a float
self.assertIsInstance(ext_coverage_pct, float)
# Check that coverage percentage is between 0 and 100
self.assertGreaterEqual(ext_coverage_pct, 0)
self.assertLessEqual(ext_coverage_pct, 100)
# Log coverage percentage for debugging
log(f"Extension coverage: {ext_coverage_pct}%")
# Test webview coverage
log("Testing webview coverage extraction...")
web_coverage_pct = extract_coverage(self.webview_coverage_file, 'webview')
# Convert to float if it's an integer
if isinstance(web_coverage_pct, int):
web_coverage_pct = float(web_coverage_pct)
# Check that coverage percentage is a float
self.assertIsInstance(web_coverage_pct, float)
# Check that coverage percentage is between 0 and 100
self.assertGreaterEqual(web_coverage_pct, 0)
self.assertLessEqual(web_coverage_pct, 100)
# Log coverage percentage for debugging
log(f"Webview coverage: {web_coverage_pct}%")
def test_compare_coverage(self):
"""Test compare_coverage function."""
# Test with coverage increase
decreased, diff = compare_coverage(80, 90)
self.assertFalse(decreased)
self.assertEqual(diff, 10)
# Test with coverage decrease
decreased, diff = compare_coverage(90, 80)
self.assertTrue(decreased)
self.assertEqual(diff, 10)
# Test with no change
decreased, diff = compare_coverage(80, 80)
self.assertFalse(decreased)
self.assertEqual(diff, 0)
def test_generate_comment(self):
"""Test generate_comment function."""
comment = generate_comment(
80, 90, 'false', 10,
70, 75, 'false', 5
)
# Check that comment contains expected sections
self.assertIn('Coverage Report', comment)
self.assertIn('Extension Coverage', comment)
self.assertIn('Webview Coverage', comment)
self.assertIn('Overall Assessment', comment)
# Check that comment contains coverage percentages
self.assertIn('Base branch: 80%', comment)
self.assertIn('PR branch: 90%', comment)
self.assertIn('Base branch: 70%', comment)
self.assertIn('PR branch: 75%', comment)
# Check that comment contains correct assessment
self.assertIn('Coverage increased or remained the same', comment)
self.assertIn('Test coverage has been maintained or improved', comment)
@patch('coverage_check.requests.get')
@patch('coverage_check.requests.post')
@patch('coverage_check.requests.patch')
def test_post_comment_new(self, mock_patch, mock_post, mock_get):
"""Test post_comment function when creating a new comment."""
# Create a temporary comment file
comment_file = os.path.join(self.temp_dir.name, 'comment.md')
with open(comment_file, 'w') as f:
f.write('<!-- COVERAGE_REPORT -->\nTest comment')
# Mock the API responses
mock_get.return_value = MagicMock(status_code=200, json=lambda: [])
mock_post.return_value = MagicMock(status_code=201)
# Test post_comment function
post_comment(comment_file, '123', 'owner/repo', 'token')
# Check that the correct API calls were made
mock_get.assert_called_once()
mock_post.assert_called_once()
mock_patch.assert_not_called()
@patch('coverage_check.requests.get')
@patch('coverage_check.requests.post')
@patch('coverage_check.requests.patch')
def test_post_comment_update(self, mock_patch, mock_post, mock_get):
"""Test post_comment function when updating an existing comment."""
# Create a temporary comment file
comment_file = os.path.join(self.temp_dir.name, 'comment.md')
with open(comment_file, 'w') as f:
f.write('<!-- COVERAGE_REPORT -->\nTest comment')
# Mock the API responses
mock_get.return_value = MagicMock(
status_code=200,
json=lambda: [{'id': 456, 'body': '<!-- COVERAGE_REPORT -->\nOld comment'}]
)
mock_patch.return_value = MagicMock(status_code=200)
# Test post_comment function
post_comment(comment_file, '123', 'owner/repo', 'token')
# Check that the correct API calls were made
mock_get.assert_called_once()
mock_patch.assert_called_once()
mock_post.assert_not_called()
def test_set_github_output(self):
"""Test set_github_output function."""
# Capture stdout
with patch('sys.stdout', new=MagicMock()) as mock_stdout:
# Mock environment without GITHUB_OUTPUT
with patch.dict('os.environ', {}, clear=True):
set_github_output('test_name', 'test_value')
# Check that the correct output was printed to stdout
mock_stdout.assert_has_calls([
# GitHub Actions output format (deprecated method)
call.write('::set-output name=test_name::test_value\n'),
call.flush(),
# Human readable format
call.write('test_name: test_value\n'),
call.flush()
], any_order=False)
# Reset mock for next test
mock_stdout.reset_mock()
# Test with GITHUB_OUTPUT environment variable
with patch.dict('os.environ', {'GITHUB_OUTPUT': '/tmp/github_output'}), \
patch('builtins.open', mock_open()) as mock_file:
set_github_output('test_name', 'test_value')
# Check that file was written to
mock_file.assert_called_once_with('/tmp/github_output', 'a')
mock_file().write.assert_called_once_with('test_name=test_value\n')
# Check that human readable output was printed
mock_stdout.assert_has_calls([
call.write('test_name: test_value\n'),
call.flush()
], any_order=False)
if __name__ == '__main__':
unittest.main()
-91
View File
@@ -1,91 +0,0 @@
name: Changeset Converter
run-name: Changeset Conversion
on:
pull_request:
types: [closed]
env:
REPO_PATH: ${{ github.repository }}
GIT_REF: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || 'main' }}
NODE_VERSION: 20.18.1
jobs:
# Job 1: Create version bump PR when changesets are merged to main
changeset-pr-version-bump:
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: Git Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
ref: ${{ env.GIT_REF }}
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: "npm"
- name: Install Dependencies
run: npm install changeset
# Check if there are any new changesets to process
- name: Check for changesets
id: check-changesets
run: |
NEW_CHANGESETS=$(find .changeset -name "*.md" ! -name "README.md" | wc -l | tr -d ' ')
echo "Changesets diff with previous version: $NEW_CHANGESETS"
echo "new_changesets=$NEW_CHANGESETS" >> $GITHUB_OUTPUT
# Create version bump PR using changesets/action if there are new changesets
- name: Create Changeset Pull Request
if: steps.check-changesets.outputs.new_changesets != '0'
uses: changesets/action@v1
with:
commit: "changeset version bump"
title: "Changeset version bump"
version: npm run version-packages # This performs the changeset version bump
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Get current and previous versions to edit changelog entry
- name: Get version
id: get_version
run: |
VERSION=$(git show HEAD:package.json | jq -r '.version')
echo "version=$VERSION" >> $GITHUB_OUTPUT
PREV_VERSION=$(git show origin/main:package.json | jq -r '.version')
echo "prev_version=$PREV_VERSION" >> $GITHUB_OUTPUT
echo "version=$VERSION"
echo "prev_version=$PREV_VERSION"
# Update CHANGELOG.md with proper format
- name: Update Changelog Format
env:
VERSION: ${{ steps.get_version.outputs.version }}
PREV_VERSION: ${{ steps.get_version.outputs.prev_version }}
run: python .github/scripts/overwrite_changeset_changelog.py
# Commit and push changelog updates
- name: Push Changelog updates to Pull Request
run: |
git config user.name "github-actions"
git config user.email github-actions@github.com
echo "Running git add and commit..."
git add CHANGELOG.md
git commit -m "Updating CHANGELOG.md format"
git status
echo "--------------------------------------------------------------------------------"
echo "Pushing to remote..."
echo "--------------------------------------------------------------------------------"
CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD)
git push origin $CURRENT_BRANCH
+117
View File
@@ -0,0 +1,117 @@
name: Check Changeset
run-name: Check for Changeset in PR
permissions:
contents: read
pull-requests: write
on:
pull_request:
branches:
- main
types: [opened, synchronize, reopened, ready_for_review]
jobs:
check-changeset:
# Skip draft PRs and dependabot PRs
if: github.event.pull_request.draft == false && github.actor != 'dependabot[bot]'
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4
with:
fetch-depth: 0
ref: ${{ github.event.pull_request.head.sha }}
- name: Check for changeset
id: check-changeset
run: |
# Debug info
echo "Current directory: $(pwd)"
echo "PR Base Ref: ${{ github.event.pull_request.base.ref }}"
echo "PR Head Ref: ${{ github.event.pull_request.head.ref }}"
echo "PR Head SHA: ${{ github.event.pull_request.head.sha }}"
echo "Git status:"
git status
# Get list of changed files
git fetch origin ${{ github.event.pull_request.base.ref }}
CHANGED_FILES=$(git diff --name-only origin/${{ github.event.pull_request.base.ref }} HEAD)
echo "Changed files:"
echo "$CHANGED_FILES"
# Check if any of the changed files are in docs/ or .github/
echo "Checking if changes are docs-only..."
DOCS_ONLY=true
while IFS= read -r file; do
if [[ ! "$file" =~ ^(docs/|.github/) ]]; then
echo "Found non-docs change: $file"
DOCS_ONLY=false
break
fi
done <<< "$CHANGED_FILES"
# If changes are docs-only, skip changeset check
if [ "$DOCS_ONLY" = true ]; then
echo "All changes are in docs/ or .github/, skipping changeset check"
exit 0
else
echo "Changes include non-docs files, checking for changeset..."
fi
# Check if any changeset files are in the changed files
echo "Checking for changeset files in changed files..."
CHANGESET_IN_PR=false
while IFS= read -r file; do
if [[ "$file" =~ ^\.changeset/.*\.md$ && "$file" != ".changeset/README.md" && "$file" != ".changeset/config.json" ]]; then
echo "Found changeset file in PR: $file"
CHANGESET_IN_PR=true
break
fi
done <<< "$CHANGED_FILES"
if [ "$CHANGESET_IN_PR" = false ]; then
echo "No changeset files found in changed files. Changed files in .changeset/:"
echo "$CHANGED_FILES" | grep "^\.changeset/" || true
echo "::error::No changeset file found in PR changes. Please run 'npm run changeset' to create one."
exit 1
fi
- name: Comment on PR
if: failure()
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7
with:
script: |
const message = `This PR requires a changeset since it includes user-facing changes. Please:
1. Run \`npm run changeset\` locally
2. Choose the appropriate version bump:
- \`major\` for breaking changes (1.0.0 → 2.0.0)
- \`minor\` for new features (1.0.0 → 1.1.0)
- \`patch\` for bug fixes (1.0.0 → 1.0.1)
3. Write a clear description of your changes
4. Commit the generated changeset file
Note: Documentation-only changes do not require a changeset.`;
// Get existing comments
const comments = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number
});
// Check if we already commented
const botComment = comments.data.find(comment =>
comment.user.login === 'github-actions[bot]' &&
comment.body.includes('This PR requires a changeset')
);
if (!botComment) {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body: message
});
}
+2 -123
View File
@@ -25,17 +25,6 @@ jobs:
with:
node-version: 20.15.1
# Setup Python for coverage script
- name: Setup Python
uses: actions/setup-python@v4
with:
python-version: "3.10"
- name: Install Python dependencies
run: |
python -m pip install --upgrade pip
pip install requests
# Cache root dependencies - only reuse if package-lock.json exactly matches
- name: Cache root dependencies
uses: actions/cache@v4
@@ -69,115 +58,5 @@ jobs:
- name: Prettier / Format Check
run: npm run format
# Build the extension before running tests
- name: Build Extension
run: npm run compile
# Run extension tests with coverage
- name: Extension Tests with Coverage
id: extension_coverage
continue-on-error: true
run: |
xvfb-run -a npm run test:coverage > extension_coverage.txt 2>&1 || true
PYTHONPATH=.github/scripts python -m coverage_check extract-coverage extension_coverage.txt --type=extension --github-output --verbose
# Run webview tests with coverage
- name: Webview Tests with Coverage
id: webview_coverage
continue-on-error: true
run: |
cd webview-ui
# Ensure coverage dependency is installed
npm install --no-save @vitest/coverage-v8
npm run test:coverage > webview_coverage.txt 2>&1 || true
cd ..
PYTHONPATH=.github/scripts python -m coverage_check extract-coverage webview-ui/webview_coverage.txt --type=webview --github-output --verbose
# Save coverage reports as artifacts (workflow-scoped)
- name: Save Coverage Reports
uses: actions/upload-artifact@v4
with:
name: pr-coverage-reports
path: |
extension_coverage.txt
webview-ui/webview_coverage.txt
retention-period: workflow # Artifacts are automatically deleted when the workflow completes
coverage:
needs: test
runs-on: ubuntu-latest
# Only run on PRs to main branch
if: github.event_name == 'pull_request' && github.base_ref == 'main'
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0 # Fetch all history for accurate comparison
# Setup Python for coverage script
- name: Setup Python
uses: actions/setup-python@v4
with:
python-version: "3.10"
- name: Install Python dependencies
run: |
python -m pip install --upgrade pip
pip install requests
- name: Setup Node.js environment
uses: actions/setup-node@v4
with:
node-version: 20.15.1
# Cache root dependencies - only reuse if package-lock.json exactly matches
- name: Cache root dependencies
uses: actions/cache@v4
id: root-cache
with:
path: node_modules
key: ${{ runner.os }}-npm-${{ hashFiles('package-lock.json') }}
# Cache webview-ui dependencies - only reuse if package-lock.json exactly matches
- name: Cache webview-ui dependencies
uses: actions/cache@v4
id: webview-cache
with:
path: webview-ui/node_modules
key: ${{ runner.os }}-npm-webview-${{ hashFiles('webview-ui/package-lock.json') }}
- name: Install root dependencies
if: steps.root-cache.outputs.cache-hit != 'true'
run: npm ci
- name: Install webview-ui dependencies
if: steps.webview-cache.outputs.cache-hit != 'true'
run: cd webview-ui && npm ci
# Build the extension before running tests
- name: Build Extension
run: npm run compile
# Download coverage artifacts from test job
- name: Download Coverage Reports
uses: actions/download-artifact@v4
with:
name: pr-coverage-reports
path: . # Download to root directory to match expected paths
# Process coverage workflow
- name: Process coverage workflow
id: coverage
run: |
# Extract PR number from GITHUB_REF
PR_NUMBER=$(echo "$GITHUB_REF" | sed -e 's/refs\/pull\///' -e 's/\/merge//')
# Run the coverage workflow from root directory
PYTHONPATH=.github/scripts python -m coverage_check process-workflow \
--base-branch ${{ github.base_ref }} \
--pr-number $PR_NUMBER \
--repo $GITHUB_REPOSITORY \
--token ${{ secrets.GITHUB_TOKEN }} \
--verbose
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Extension Tests
run: xvfb-run -a npm run test
+1 -8
View File
@@ -9,11 +9,4 @@ tmp
pnpm-lock.yaml
.clineignore
.venv
.actrc
# Ignore coverage directories and files
coverage
# But don't ignore the coverage scripts in .github/scripts/
!.github/scripts/coverage/
.clineignore
+2 -39
View File
@@ -1,42 +1,5 @@
# Changelog
## [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
- Add gemini-2.5-pro-exp-03-25 to Vertex AI (thanks @arri-cc!)
- Add access to history, mcp, and new task buttons in popout view
- Add task feedback telemetry (thumbs up/down on task completion)
- Add toggle disabled for remote servers
- Move the MCP Restart and Delete buttons and add an auto-approve all toggle
- Update Requestly UX for model selection (thanks @arafatkatze!)
- Add escape for html content for gemini when running commands
- Improve search and replace edit failure behaviors
## [3.8.4]
- Add Sambanova Deepseek-V3-0324
- Add cost calculation support for LiteLLM provider
- Fix bug where Cline would use plan_mode_response bug without response parameter
## [3.8.3]
- Add support for SambaNova QwQ-32B model
- Add OpenAI "dynamic" model chatgpt-4o-latest
- Add Amazon Nova models to AWS Bedrock
- Improve file handling for NextJS folder naming (fixes issues with parentheses in folder names)
- Add Gemini 2.5 Pro to Google AI Studio available models
- Handle "input too large" errors for Anthropic
- Fix "See more" not showing up for tasks after task un-fold
- Fix gpt-4.5-preview's supportsPromptCache value to true
## [3.8.2]
- Fix bug where switching to plan/act would result in VS Code LM/OpenRouter model being reset
@@ -44,12 +7,12 @@
## [3.8.0]
- Add 'Add to Cline' as an option when you right-click in a file or the terminal, making it easier to add context to your current task
- Add 'Fix with Cline' code action - when you see a lightbulb icon in your editor, you can now select 'Fix with Cline' to send the code and associated errors for Cline to fix. (Cursor users can also use the 'Quick Fix (CMD + .)' menu to see this option)
- Add 'Fix with Cline' code action - when you see a lightbulb icon in your editor, you can now select 'Fix with Cline' to send the code and associated errors for Cline to fix. (Cursor users can also use the 'Quick Fix (CMD + .)' menu to see this option)
- Add Account view to display billing and usage history for Cline account users. You can now keep track of credits used and transaction history right in the extension!
- Add 'Sort underling provider routing' setting to Cline/OpenRouter allowing you to sort provider used by throughput, price, latency, or the default (combination of price and uptime)
- Improve rich MCP display with dynamic image loading and support for GIFs
- Add 'Documentation' menu item to easily access Cline's docs
- Add OpenRouter's new usage_details feature for more reliable cost reporting
- Add OpenRouter's new usage_details feature for more reliable cost reporting
- Display total space Cline takes on disk next to 'Delete all Tasks' button in History view
- Fix 'Context Window Exceeded' error for OpenRouter/Cline Accounts (additional support coming soon)
- Fix bug where OpenRouter model ID would be set to invalid value
-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
+1 -1
View File
@@ -28,7 +28,7 @@ There are multiple places online to find MCP servers:
2. **Example Interaction with Cline:**
```
User: "Cline, I want to add the MCP server for Brave browser control. Here's the GitHub link: https://github.com/modelcontextprotocol/servers/tree/main/src/brave-search Can you add it?"
User: "Cline, I want to add the MCP server for Brave browser control. Here's the GitHub link: https://github.com/modelcontextprotocol/servers/tree/main/src/brave Can you add it?"
Cline: "OK. Cloning the repository to the MCP directory. It needs to be built because it has a 'package.json' file. Should I run 'npm run build'?"
+2 -3
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
-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.
-8
View File
@@ -72,14 +72,6 @@ const extensionConfig = {
copyWasmFiles,
/* add to the end of plugins array */
esbuildProblemMatcherPlugin,
{
name: "alias-plugin",
setup(build) {
build.onResolve({ filter: /^pkce-challenge$/ }, (args) => {
return { path: require.resolve("pkce-challenge/dist/index.browser.js") }
})
},
},
],
entryPoints: ["src/extension.ts"],
format: "cjs",
+44 -67
View File
@@ -1,12 +1,12 @@
{
"name": "claude-dev",
"version": "3.8.4",
"version": "3.8.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "claude-dev",
"version": "3.8.4",
"version": "3.8.0",
"license": "Apache-2.0",
"dependencies": {
"@anthropic-ai/bedrock-sdk": "^0.12.4",
@@ -23,6 +23,10 @@
"@opentelemetry/sdk-node": "^0.39.1",
"@opentelemetry/sdk-trace-node": "^1.30.1",
"@opentelemetry/semantic-conventions": "^1.30.0",
"@types/clone-deep": "^4.0.4",
"@types/get-folder-size": "^3.0.4",
"@types/pdf-parse": "^1.1.4",
"@types/turndown": "^5.0.5",
"@vscode/codicons": "^0.0.36",
"axios": "^1.8.2",
"cheerio": "^1.0.0",
@@ -60,15 +64,11 @@
"devDependencies": {
"@changesets/cli": "^2.27.12",
"@types/chai": "^5.0.1",
"@types/clone-deep": "^4.0.4",
"@types/diff": "^5.2.1",
"@types/get-folder-size": "^3.0.4",
"@types/mocha": "^10.0.7",
"@types/node": "20.x",
"@types/pdf-parse": "^1.1.4",
"@types/should": "^11.2.0",
"@types/sinon": "^17.0.4",
"@types/turndown": "^5.0.5",
"@types/vscode": "^1.84.0",
"@typescript-eslint/eslint-plugin": "^7.14.1",
"@typescript-eslint/parser": "^7.11.0",
@@ -8581,9 +8581,7 @@
"node_modules/@types/clone-deep": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/@types/clone-deep/-/clone-deep-4.0.4.tgz",
"integrity": "sha512-vXh6JuuaAha6sqEbJueYdh5zNBPPgG1OYumuz2UvLvriN6ABHDSW8ludREGWJb1MLIzbwZn4q4zUbUCerJTJfA==",
"dev": true,
"license": "MIT"
"integrity": "sha512-vXh6JuuaAha6sqEbJueYdh5zNBPPgG1OYumuz2UvLvriN6ABHDSW8ludREGWJb1MLIzbwZn4q4zUbUCerJTJfA=="
},
"node_modules/@types/deep-eql": {
"version": "4.0.2",
@@ -8602,7 +8600,6 @@
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/get-folder-size/-/get-folder-size-3.0.4.tgz",
"integrity": "sha512-tSf/k7Undx6jKRwpChR9tl+0ZPf0BVwkjBRtJ5qSnz6iWm2ZRYMAS2MktC2u7YaTAFHmxpL/LBxI85M7ioJCSg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
@@ -8645,7 +8642,6 @@
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@types/pdf-parse/-/pdf-parse-1.1.4.tgz",
"integrity": "sha512-+gbBHbNCVGGYw1S9lAIIvrHW47UYOhMIFUsJcMkMrzy1Jf0vulBN3XQIjPgnoOXveMuHnF3b57fXROnY/Or7eg==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/should": {
@@ -8676,7 +8672,6 @@
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/@types/turndown/-/turndown-5.0.5.tgz",
"integrity": "sha512-TL2IgGgc7B5j78rIccBtlYAnkuv8nUQqhQc+DSYV5j9Be9XOcm/SKOVRuA47xAVI3680Tk9B1d8flK2GWT2+4w==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/uuid": {
@@ -9345,75 +9340,50 @@
"license": "MIT"
},
"node_modules/bare-events": {
"version": "2.5.4",
"resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.5.4.tgz",
"integrity": "sha512-+gFfDkR8pj4/TrWCGUGWmJIkBwuxPS5F+a5yWjOHQt2hHvNZd5YLzadjmDUtFmMM4y429bnKLa8bYBMHcYdnQA==",
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.4.2.tgz",
"integrity": "sha512-qMKFd2qG/36aA4GwvKq8MxnPgCQAmBWmSyLWsJcbn8v03wvIPQ/hG1Ms8bPzndZxMDoHpxez5VOS+gC9Yi24/Q==",
"license": "Apache-2.0",
"optional": true
},
"node_modules/bare-fs": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.0.2.tgz",
"integrity": "sha512-S5mmkMesiduMqnz51Bfh0Et9EX0aTCJxhsI4bvzFFLs8Z1AV8RDHadfY5CyLwdoLHgXbNBEN1gQcbEtGwuvixw==",
"version": "2.3.5",
"resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-2.3.5.tgz",
"integrity": "sha512-SlE9eTxifPDJrT6YgemQ1WGFleevzwY+XAP1Xqgl56HtcrisC2CHCZ2tq6dBpcH2TnNxwUEUGhweo+lrQtYuiw==",
"license": "Apache-2.0",
"optional": true,
"dependencies": {
"bare-events": "^2.5.4",
"bare-path": "^3.0.0",
"bare-stream": "^2.6.4"
},
"engines": {
"bare": ">=1.16.0"
},
"peerDependencies": {
"bare-buffer": "*"
},
"peerDependenciesMeta": {
"bare-buffer": {
"optional": true
}
"bare-events": "^2.0.0",
"bare-path": "^2.0.0",
"bare-stream": "^2.0.0"
}
},
"node_modules/bare-os": {
"version": "3.6.1",
"resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.6.1.tgz",
"integrity": "sha512-uaIjxokhFidJP+bmmvKSgiMzj2sV5GPHaZVAIktcxcpCyBFFWO+YlikVAdhmUo2vYFvFhOXIAlldqV29L8126g==",
"version": "2.4.4",
"resolved": "https://registry.npmjs.org/bare-os/-/bare-os-2.4.4.tgz",
"integrity": "sha512-z3UiI2yi1mK0sXeRdc4O1Kk8aOa/e+FNWZcTiPB/dfTWyLypuE99LibgRaQki914Jq//yAWylcAt+mknKdixRQ==",
"license": "Apache-2.0",
"optional": true,
"engines": {
"bare": ">=1.14.0"
}
"optional": true
},
"node_modules/bare-path": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.0.0.tgz",
"integrity": "sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw==",
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/bare-path/-/bare-path-2.1.3.tgz",
"integrity": "sha512-lh/eITfU8hrj9Ru5quUp0Io1kJWIk1bTjzo7JH1P5dWmQ2EL4hFUlfI8FonAhSlgIfhn63p84CDY/x+PisgcXA==",
"license": "Apache-2.0",
"optional": true,
"dependencies": {
"bare-os": "^3.0.1"
"bare-os": "^2.1.0"
}
},
"node_modules/bare-stream": {
"version": "2.6.5",
"resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.6.5.tgz",
"integrity": "sha512-jSmxKJNJmHySi6hC42zlZnq00rga4jjxcgNZjY9N5WlOe/iOoGRtdwGsHzQv2RlH2KOYMwGUXhf2zXd32BA9RA==",
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.3.0.tgz",
"integrity": "sha512-pVRWciewGUeCyKEuRxwv06M079r+fRjAQjBEK2P6OYGrO43O+Z0LrPZZEjlc4mB6C2RpZ9AxJ1s7NLEtOHO6eA==",
"license": "Apache-2.0",
"optional": true,
"dependencies": {
"streamx": "^2.21.0"
},
"peerDependencies": {
"bare-buffer": "*",
"bare-events": "*"
},
"peerDependenciesMeta": {
"bare-buffer": {
"optional": true
},
"bare-events": {
"optional": true
}
"b4a": "^1.6.6",
"streamx": "^2.20.0"
}
},
"node_modules/base64-js": {
@@ -15201,6 +15171,12 @@
],
"license": "MIT"
},
"node_modules/queue-tick": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/queue-tick/-/queue-tick-1.0.1.tgz",
"integrity": "sha512-kJt5qhMxoszgU/62PLP1CJytzd2NKetjSRnyuj31fDd3Rlcz3fzlFdFLD1SItunPwyqEOkca6GbV612BWfaBag==",
"license": "MIT"
},
"node_modules/randombytes": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz",
@@ -16137,12 +16113,13 @@
}
},
"node_modules/streamx": {
"version": "2.22.0",
"resolved": "https://registry.npmjs.org/streamx/-/streamx-2.22.0.tgz",
"integrity": "sha512-sLh1evHOzBy/iWRiR6d1zRcLao4gGZr3C1kzNz4fopCOKJb6xD9ub8Mpi9Mr1R6id5o43S+d93fI48UC5uM9aw==",
"version": "2.20.1",
"resolved": "https://registry.npmjs.org/streamx/-/streamx-2.20.1.tgz",
"integrity": "sha512-uTa0mU6WUC65iUvzKH4X9hEdvSW7rbPxPtwfWiLMSj3qTdQbAiUboZTxauKfpFuGIGa1C2BYijZ7wgdUXICJhA==",
"license": "MIT",
"dependencies": {
"fast-fifo": "^1.3.2",
"queue-tick": "^1.0.1",
"text-decoder": "^1.1.0"
},
"optionalDependencies": {
@@ -16405,17 +16382,17 @@
}
},
"node_modules/tar-fs": {
"version": "3.0.8",
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.0.8.tgz",
"integrity": "sha512-ZoROL70jptorGAlgAYiLoBLItEKw/fUxg9BSYK/dF/GAGYFJOJJJMvjPAKDJraCXFwadD456FCuvLWgfhMsPwg==",
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.0.6.tgz",
"integrity": "sha512-iokBDQQkUyeXhgPYaZxmczGPhnhXZ0CmrqI+MOb/WFGS9DW5wnfrLgtjUJBvz50vQ3qfRwJ62QVoCFu8mPVu5w==",
"license": "MIT",
"dependencies": {
"pump": "^3.0.0",
"tar-stream": "^3.1.5"
},
"optionalDependencies": {
"bare-fs": "^4.0.1",
"bare-path": "^3.0.0"
"bare-fs": "^2.1.1",
"bare-path": "^2.1.0"
}
},
"node_modules/tar-stream": {
+14 -37
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.8.6",
"version": "3.8.2",
"icon": "assets/icons/icon.png",
"engines": {
"vscode": "^1.84.0"
@@ -102,6 +102,11 @@
"category": "Cline",
"when": "cline.isDevMode"
},
{
"command": "cline.openDocumentation",
"title": "Documentation",
"icon": "$(book)"
},
{
"command": "cline.addToChat",
"title": "Add to Cline",
@@ -141,46 +146,19 @@
"when": "view == claude-dev.SidebarProvider"
},
{
"command": "cline.accountButtonClicked",
"command": "cline.openDocumentation",
"group": "navigation@5",
"when": "view == claude-dev.SidebarProvider"
},
{
"command": "cline.settingsButtonClicked",
"command": "cline.accountButtonClicked",
"group": "navigation@6",
"when": "view == claude-dev.SidebarProvider"
}
],
"editor/title": [
{
"command": "cline.plusButtonClicked",
"group": "navigation@1",
"when": "activeWebviewPanelId == claude-dev.TabPanelProvider"
},
{
"command": "cline.mcpButtonClicked",
"group": "navigation@2",
"when": "activeWebviewPanelId == claude-dev.TabPanelProvider"
},
{
"command": "cline.historyButtonClicked",
"group": "navigation@3",
"when": "activeWebviewPanelId == claude-dev.TabPanelProvider"
},
{
"command": "cline.popoutButtonClicked",
"group": "navigation@4",
"when": "activeWebviewPanelId == claude-dev.TabPanelProvider"
},
{
"command": "cline.accountButtonClicked",
"group": "navigation@5",
"when": "activeWebviewPanelId == claude-dev.TabPanelProvider"
},
{
"command": "cline.settingsButtonClicked",
"group": "navigation@6",
"when": "activeWebviewPanelId == claude-dev.TabPanelProvider"
"group": "navigation@7",
"when": "view == claude-dev.SidebarProvider"
}
],
"editor/context": [
@@ -303,7 +281,6 @@
"format:fix": "prettier . --write",
"test": "vscode-test",
"test:ci": "node scripts/test-ci.js",
"test:coverage": "vscode-test --coverage",
"install:all": "npm install && cd webview-ui && npm install",
"dev:webview": "cd webview-ui && npm run dev",
"build:webview": "cd webview-ui && npm run build",
@@ -317,15 +294,11 @@
"devDependencies": {
"@changesets/cli": "^2.27.12",
"@types/chai": "^5.0.1",
"@types/clone-deep": "^4.0.4",
"@types/diff": "^5.2.1",
"@types/get-folder-size": "^3.0.4",
"@types/mocha": "^10.0.7",
"@types/node": "20.x",
"@types/pdf-parse": "^1.1.4",
"@types/should": "^11.2.0",
"@types/sinon": "^17.0.4",
"@types/turndown": "^5.0.5",
"@types/vscode": "^1.84.0",
"@typescript-eslint/eslint-plugin": "^7.14.1",
"@typescript-eslint/parser": "^7.11.0",
@@ -356,6 +329,10 @@
"@opentelemetry/sdk-node": "^0.39.1",
"@opentelemetry/sdk-trace-node": "^1.30.1",
"@opentelemetry/semantic-conventions": "^1.30.0",
"@types/clone-deep": "^4.0.4",
"@types/get-folder-size": "^3.0.4",
"@types/pdf-parse": "^1.1.4",
"@types/turndown": "^5.0.5",
"@vscode/codicons": "^0.0.36",
"axios": "^1.8.2",
"cheerio": "^1.0.0",
+1 -1
View File
@@ -23,7 +23,7 @@ export class AnthropicHandler implements ApiHandler {
let stream: AnthropicStream<Anthropic.RawMessageStreamEvent>
const modelId = model.id
const budget_tokens = this.options.thinkingBudgetTokens || 0
let budget_tokens = this.options.thinkingBudgetTokens || 0
const reasoningOn = modelId.includes("3-7") && budget_tokens !== 0 ? true : false
switch (modelId) {
+3 -3
View File
@@ -25,7 +25,7 @@ export class AwsBedrockHandler implements ApiHandler {
@withRetry()
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
// cross region inference requires prefixing the model id with the region
const modelId = await this.getModelId()
let modelId = await this.getModelId()
const model = this.getModel()
// Check if this is an Amazon Nova model
@@ -40,7 +40,7 @@ export class AwsBedrockHandler implements ApiHandler {
return
}
const budget_tokens = this.options.thinkingBudgetTokens || 0
let budget_tokens = this.options.thinkingBudgetTokens || 0
const reasoningOn = modelId.includes("3-7") && budget_tokens !== 0 ? true : false
// Get model info and message indices for caching
@@ -250,7 +250,7 @@ export class AwsBedrockHandler implements ApiHandler {
*/
async getModelId(): Promise<string> {
if (this.options.awsUseCrossRegionInference) {
const regionPrefix = this.getRegion().slice(0, 3)
let regionPrefix = this.getRegion().slice(0, 3)
switch (regionPrefix) {
case "us-":
return `us.${this.getModel().id}`
-5
View File
@@ -17,11 +17,6 @@ export class ClineHandler implements ApiHandler {
this.client = new OpenAI({
baseURL: "https://api.cline.bot/v1",
apiKey: this.options.clineApiKey || "",
defaultHeaders: {
"HTTP-Referer": "https://cline.bot", // Optional, for including your app on cline.bot rankings.
"X-Title": "Cline", // Optional. Shows in rankings on cline.bot.
"X-Task-ID": this.options.taskId || "", // Include the task ID in the request headers
},
})
}
+2 -65
View File
@@ -17,40 +17,6 @@ export class LiteLlmHandler implements ApiHandler {
})
}
async calculateCost(prompt_tokens: number, completion_tokens: number): Promise<number | undefined> {
// Reference: https://github.com/BerriAI/litellm/blob/122ee634f434014267af104814022af1d9a0882f/litellm/proxy/spend_tracking/spend_management_endpoints.py#L1473
const modelId = this.options.liteLlmModelId || liteLlmDefaultModelId
try {
const response = await fetch(`${this.client.baseURL}/spend/calculate`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${this.options.liteLlmApiKey}`,
},
body: JSON.stringify({
completion_response: {
model: modelId,
usage: {
prompt_tokens,
completion_tokens,
},
},
}),
})
if (response.ok) {
const data: { cost: number } = await response.json()
return data.cost
} else {
console.error("Error calculating spend:", response.statusText)
return undefined
}
} catch (error) {
console.error("Error calculating spend:", error)
return undefined
}
}
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
const formattedMessages = convertToOpenAiMessages(messages)
const systemMessage: OpenAI.Chat.ChatCompletionSystemMessageParam = {
@@ -59,16 +25,10 @@ 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
}
const stream = await this.client.chat.completions.create({
@@ -77,16 +37,10 @@ export class LiteLlmHandler implements ApiHandler {
temperature,
stream: true,
stream_options: { include_usage: true },
...(thinkingConfig && { thinking: thinkingConfig }), // Add thinking configuration when applicable
})
const inputCost = (await this.calculateCost(1e6, 0)) || 0
const outputCost = (await this.calculateCost(0, 1e6)) || 0
for await (const chunk of stream) {
const delta = chunk.choices[0]?.delta
// Handle normal text content
if (delta?.content) {
yield {
type: "text",
@@ -94,28 +48,11 @@ 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
yield {
type: "usage",
inputTokens: chunk.usage.prompt_tokens || 0,
outputTokens: chunk.usage.completion_tokens || 0,
totalCost,
}
}
}
+1 -10
View File
@@ -34,7 +34,6 @@ export class OpenAiHandler implements ApiHandler {
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
const modelId = this.options.openAiModelId ?? ""
const isDeepseekReasoner = modelId.includes("deepseek-reasoner")
const isR1FormatRequired = this.options.openAiModelInfo?.isR1FormatRequired ?? false
const isO3Mini = modelId.includes("o3-mini")
let openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [
@@ -43,15 +42,8 @@ export class OpenAiHandler implements ApiHandler {
]
let temperature: number | undefined = this.options.openAiModelInfo?.temperature ?? openAiModelInfoSaneDefaults.temperature
let reasoningEffort: ChatCompletionReasoningEffort | undefined = undefined
let maxTokens: number | undefined
if (this.options.openAiModelInfo?.maxTokens && this.options.openAiModelInfo.maxTokens > 0) {
maxTokens = Number(this.options.openAiModelInfo.maxTokens)
} else {
maxTokens = undefined
}
if (isDeepseekReasoner || isR1FormatRequired) {
if (isDeepseekReasoner) {
openAiMessages = convertToR1Format([{ role: "user", content: systemPrompt }, ...messages])
}
@@ -65,7 +57,6 @@ export class OpenAiHandler implements ApiHandler {
model: modelId,
messages: openAiMessages,
temperature,
max_tokens: maxTokens,
reasoning_effort: reasoningEffort,
stream: true,
stream_options: { include_usage: true },
+1 -1
View File
@@ -26,7 +26,7 @@ export class RequestyHandler implements ApiHandler {
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
const modelId = this.options.requestyModelId ?? ""
const openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [
let openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [
{ role: "system", content: systemPrompt },
...convertToOpenAiMessages(messages),
]
+1 -1
View File
@@ -307,7 +307,7 @@ function parseToolCall(toolName: string, content: string): ToolCall | null {
// Parse nested XML elements
const paramRegex = /<(\w+)>([\s\S]*?)<\/\1>/gs
let match: RegExpExecArray | null
let match
while ((match = paramRegex.exec(innerContent)) !== null) {
const [, paramName, paramValue] = match
+1 -1
View File
@@ -28,7 +28,7 @@ export function convertToOllamaMessages(anthropicMessages: Anthropic.Messages.Me
)
// Process tool result messages FIRST since they must follow the tool use messages
const toolResultImages: string[] = []
let toolResultImages: string[] = []
toolMessages.forEach((toolMessage) => {
// The Anthropic SDK allows tool results to be a string or an array of text and image blocks, enabling rich and structured content. In contrast, the Ollama SDK only supports tool results as a single string, so we map the Anthropic tool result parts into one concatenated string to maintain compatibility.
let content: string
+2 -2
View File
@@ -38,7 +38,7 @@ export function convertToOpenAiMessages(
)
// Process tool result messages FIRST since they must follow the tool use messages
const toolResultImages: Anthropic.Messages.ImageBlockParam[] = []
let toolResultImages: Anthropic.Messages.ImageBlockParam[] = []
toolMessages.forEach((toolMessage) => {
// The Anthropic SDK allows tool results to be a string or an array of text and image blocks, enabling rich and structured content. In contrast, the OpenAI SDK only supports tool results as a single string, so we map the Anthropic tool result parts into one concatenated string to maintain compatibility.
let content: string
@@ -127,7 +127,7 @@ export function convertToOpenAiMessages(
}
// Process tool use messages
const tool_calls: OpenAI.Chat.ChatCompletionMessageToolCall[] = toolMessages.map((toolMessage) => ({
let tool_calls: OpenAI.Chat.ChatCompletionMessageToolCall[] = toolMessages.map((toolMessage) => ({
id: toolMessage.id,
type: "function",
function: {
File diff suppressed because it is too large Load Diff
-11
View File
@@ -1,11 +0,0 @@
# Core Architecture
Extension entry point (extension.ts) -> webview -> controller -> task
```tree
core/
├── webview/ # Manages webview lifecycle
├── controller/ # Handles webview messages and task management
├── task/ # Executes API requests and tool operations
└── ... # Additional components to help with context, parsing user/assistant messages, etc.
```
+1 -1
View File
@@ -20,7 +20,7 @@ export const toolUseNames = [
"use_mcp_tool",
"access_mcp_resource",
"ask_followup_question",
"plan_mode_respond",
"plan_mode_response",
"attempt_completion",
] as const
@@ -1,7 +1,7 @@
import { AssistantMessageContent, TextContent, ToolUse, ToolParamName, toolParamNames, toolUseNames, ToolUseName } from "."
export function parseAssistantMessage(assistantMessage: string) {
const contentBlocks: AssistantMessageContent[] = []
let contentBlocks: AssistantMessageContent[] = []
let currentTextContent: TextContent | undefined = undefined
let currentTextContentStartIndex = 0
let currentToolUse: ToolUse | undefined = undefined
File diff suppressed because it is too large Load Diff
-82
View File
@@ -117,88 +117,6 @@ Otherwise, if you have not completed the task and do not need additional informa
const prettyPatchLines = lines.slice(4)
return prettyPatchLines.join("\n")
},
taskResumption: (
mode: "plan" | "act",
agoText: string,
cwd: string,
wasRecent: boolean | 0 | undefined,
responseText?: string,
) => {
return `[TASK RESUMPTION] ${
mode === "plan"
? `This task was interrupted ${agoText}. The conversation may have been incomplete. Be aware that the project state may have changed since then. The current working directory is now '${cwd.toPosix()}'.\n\nNote: If you previously attempted a tool use that the user did not provide a result for, you should assume the tool use was not successful. However you are in PLAN MODE, so rather than continuing the task, you must respond to the user's message.`
: `This task was interrupted ${agoText}. It may or may not be complete, so please reassess the task context. Be aware that the project state may have changed since then. The current working directory is now '${cwd.toPosix()}'. If the task has not been completed, retry the last step before interruption and proceed with completing the task.\n\nNote: If you previously attempted a tool use that the user did not provide a result for, you should assume the tool use was not successful and assess whether you should retry. If the last tool was a browser_action, the browser has been closed and you must launch a new browser if needed.`
}${
wasRecent
? "\n\nIMPORTANT: If the last tool use was a replace_in_file or write_to_file that was interrupted, the file was reverted back to its original state before the interrupted edit, and you do NOT need to re-read the file as you already have its up-to-date contents."
: ""
}${
responseText
? `\n\n${mode === "plan" ? "New message to respond to with plan_mode_respond tool (be sure to provide your response in the <response> parameter)" : "New instructions for task continuation"}:\n<user_message>\n${responseText}\n</user_message>`
: mode === "plan"
? "(The user did not provide a new message. Consider asking them how they'd like you to proceed, or to switch to Act mode to continue with the task.)"
: ""
}`
},
planModeInstructions: () => {
return `In this mode you should focus on information gathering, asking questions, and architecting a solution. Once you have a plan, use the plan_mode_respond tool to engage in a conversational back and forth with the user. Do not use the plan_mode_respond tool until you've gathered all the information you need e.g. with read_file or ask_followup_question.
(Remember: If it seems the user wants you to use tools only available in Act Mode, you should ask the user to "toggle to Act mode" (use those words) - they will have to manually do this themselves with the Plan/Act toggle button below. You do not have the ability to switch to Act Mode yourself, and must wait for the user to do it themselves once they are satisfied with the plan. You also cannot present an option to toggle to Act mode, as this will be something you need to direct the user to do manually themselves.)`
},
fileEditWithUserChanges: (
relPath: string,
userEdits: string,
autoFormattingEdits: string | undefined,
finalContent: string | undefined,
newProblemsMessage: string | undefined,
) =>
`The user made the following updates to your content:\n\n${userEdits}\n\n` +
(autoFormattingEdits
? `The user's editor also applied the following auto-formatting to your content:\n\n${autoFormattingEdits}\n\n(Note: Pay close attention to changes such as single quotes being converted to double quotes, semicolons being removed or added, long lines being broken into multiple lines, adjusting indentation style, adding/removing trailing commas, etc. This will help you ensure future SEARCH/REPLACE operations to this file are accurate.)\n\n`
: "") +
`The updated content, which includes both your original modifications and the additional edits, has been successfully saved to ${relPath.toPosix()}. Here is the full, updated content of the file that was saved:\n\n` +
`<final_file_content path="${relPath.toPosix()}">\n${finalContent}\n</final_file_content>\n\n` +
`Please note:\n` +
`1. You do not need to re-write the file with these changes, as they have already been applied.\n` +
`2. Proceed with the task using this updated file content as the new baseline.\n` +
`3. If the user's edits have addressed part of the task or changed the requirements, adjust your approach accordingly.` +
`4. IMPORTANT: For any future changes to this file, use the final_file_content shown above as your reference. This content reflects the current state of the file, including both user edits and any auto-formatting (e.g., if you used single quotes but the formatter converted them to double quotes). Always base your SEARCH/REPLACE operations on this final version to ensure accuracy.\n` +
`${newProblemsMessage}`,
fileEditWithoutUserChanges: (
relPath: string,
autoFormattingEdits: string | undefined,
finalContent: string | undefined,
newProblemsMessage: string | undefined,
) =>
`The content was successfully saved to ${relPath.toPosix()}.\n\n` +
(autoFormattingEdits
? `Along with your edits, the user's editor applied the following auto-formatting to your content:\n\n${autoFormattingEdits}\n\n(Note: Pay close attention to changes such as single quotes being converted to double quotes, semicolons being removed or added, long lines being broken into multiple lines, adjusting indentation style, adding/removing trailing commas, etc. This will help you ensure future SEARCH/REPLACE operations to this file are accurate.)\n\n`
: "") +
`Here is the full, updated content of the file that was saved:\n\n` +
`<final_file_content path="${relPath.toPosix()}">\n${finalContent}\n</final_file_content>\n\n` +
`IMPORTANT: For any future changes to this file, use the final_file_content shown above as your reference. This content reflects the current state of the file, including any auto-formatting (e.g., if you used single quotes but the formatter converted them to double quotes). Always base your SEARCH/REPLACE operations on this final version to ensure accuracy.\n\n` +
`${newProblemsMessage}`,
diffError: (relPath: string, originalContent: string | undefined) =>
`This is likely because the SEARCH block content doesn't match exactly with what's in the file, or if you used multiple SEARCH/REPLACE blocks they may not have been in the order they appear in the file.\n\n` +
`The file was reverted to its original state:\n\n` +
`<file_content path="${relPath.toPosix()}">\n${originalContent}\n</file_content>\n\n` +
`Now that you have the latest state of the file, try the operation again with fewer/more precise SEARCH blocks.\n(If you run into this error 3 times in a row, you may use the write_to_file tool as a fallback. Keep in mind, the write_to_file fallback is far from ideal, as this means you'll be re-writing the entire contents of the file just to make a few edits, which takes time and money. So let's bias towards using replace_in_file as effectively as possible)`,
toolAlreadyUsed: (toolName: string) =>
`Tool [${toolName}] was not executed because a tool has already been used in this message. Only one tool may be used per message. You must assess the first tool's result before proceeding to use the next tool.`,
clineIgnoreInstructions: (content: string) =>
`# .clineignore\n\n(The following is provided by a root-level .clineignore file where the user has specified files and directories that should not be accessed. When using list_files, you'll notice a ${LOCK_TEXT_SYMBOL} next to files that are blocked. Attempting to access the file's contents e.g. through read_file will result in an error.)\n\n${content}\n.clineignore`,
clineRulesDirectoryInstructions: (cwd: string, content: string) =>
`# .clinerules/\n\nThe following is provided by a root-level .clinerules/ directory where the user has specified instructions for this working directory (${cwd.toPosix()})\n\n${content}`,
clineRulesFileInstructions: (cwd: string, content: string) =>
`# .clinerules\n\nThe following is provided by a root-level .clinerules file where the user has specified instructions for this working directory (${cwd.toPosix()})\n\n${content}`,
}
// to avoid circular dependency
+8 -8
View File
@@ -216,7 +216,7 @@ Usage:
Description: Ask the user a question to gather additional information needed to complete the task. This tool should be used when you encounter ambiguities, need clarification, or require more details to proceed effectively. It allows for interactive problem-solving by enabling direct communication with the user. Use this tool judiciously to maintain a balance between gathering necessary information and avoiding excessive back-and-forth.
Parameters:
- question: (required) The question to ask the user. This should be a clear, specific question that addresses the information you need.
- options: (optional) An array of 2-5 options for the user to choose from. Each option should be a string describing a possible answer. You may not always need to provide options, but it may be helpful in many cases where it can save the user from having to type out a response manually. IMPORTANT: NEVER include an option to toggle to Act mode, as this would be something you need to direct the user to do manually themselves if needed.
- options: (optional) An array of 2-5 options for the user to choose from. Each option should be a string describing a possible answer. You may not always need to provide options, but it may be helpful in many cases where it can save the user from having to type out a response manually.
Usage:
<ask_followup_question>
<question>Your question here</question>
@@ -239,18 +239,18 @@ Your final result description here
<command>Command to demonstrate result (optional)</command>
</attempt_completion>
## plan_mode_respond
## plan_mode_response
Description: Respond to the user's inquiry in an effort to plan a solution to the user's task. This tool should be used when you need to provide a response to a question or statement from the user about how you plan to accomplish the task. This tool is only available in PLAN MODE. The environment_details will specify the current mode, if it is not PLAN MODE then you should not use this tool. Depending on the user's message, you may ask questions to get clarification about the user's request, architect a solution to the task, and to brainstorm ideas with the user. For example, if the user's task is to create a website, you may start by asking some clarifying questions, then present a detailed plan for how you will accomplish the task given the context, and perhaps engage in a back and forth to finalize the details before the user switches you to ACT MODE to implement the solution.
Parameters:
- response: (required) The response to provide to the user. Do not try to use tools in this parameter, this is simply a chat response. (You MUST use the response parameter, do not simply place the response text directly within <plan_mode_respond> tags.)
- response: (required) The response to provide to the user. Do not try to use tools in this parameter, this is simply a chat response.
- options: (optional) An array of 2-5 options for the user to choose from. Each option should be a string describing a possible choice or path forward in the planning process. This can help guide the discussion and make it easier for the user to provide input on key decisions. You may not always need to provide options, but it may be helpful in many cases where it can save the user from having to type out a response manually. Do NOT present an option to toggle to Act mode, as this will be something you need to direct the user to do manually themselves.
Usage:
<plan_mode_respond>
<plan_mode_response>
<response>Your response here</response>
<options>
Array of options here (optional), e.g. ["Option 1", "Option 2", "Option 3"]
</options>
</plan_mode_respond>
</plan_mode_response>
# Tool Use Examples
@@ -881,11 +881,11 @@ ACT MODE V.S. PLAN MODE
In each user message, the environment_details will specify the current mode. There are two modes:
- ACT MODE: In this mode, you have access to all tools EXCEPT the plan_mode_respond tool.
- ACT MODE: In this mode, you have access to all tools EXCEPT the plan_mode_response tool.
- In ACT MODE, you use tools to accomplish the user's task. Once you've completed the user's task, you use the attempt_completion tool to present the result of the task to the user.
- PLAN MODE: In this special mode, you have access to the plan_mode_respond tool.
- PLAN MODE: In this special mode, you have access to the plan_mode_response tool.
- In PLAN MODE, the goal is to gather information and get context to create a detailed plan for accomplishing the task, which the user will review and approve before they switch you to ACT MODE to implement the solution.
- In PLAN MODE, when you need to converse with the user or present a plan, you should use the plan_mode_respond tool to deliver your response directly, rather than using <thinking> tags to analyze when to respond. Do not talk about using plan_mode_respond - just use it directly to share your thoughts and provide helpful answers.
- In PLAN MODE, when you need to converse with the user or present a plan, you should use the plan_mode_response tool to deliver your response directly, rather than using <thinking> tags to analyze when to respond. Do not talk about using plan_mode_response - just use it directly to share your thoughts and provide helpful answers.
## What is PLAN MODE?
-73
View File
@@ -1,73 +0,0 @@
import * as path from "path"
import * as vscode from "vscode"
import fs from "fs/promises"
import { Anthropic } from "@anthropic-ai/sdk"
import { fileExistsAtPath } from "../../utils/fs"
import { ClineMessage } from "../../shared/ExtensionMessage"
export const GlobalFileNames = {
apiConversationHistory: "api_conversation_history.json",
uiMessages: "ui_messages.json",
openRouterModels: "openrouter_models.json",
mcpSettings: "cline_mcp_settings.json",
clineRules: ".clinerules",
}
export async function ensureTaskDirectoryExists(context: vscode.ExtensionContext, taskId: string): Promise<string> {
const globalStoragePath = context.globalStorageUri.fsPath
const taskDir = path.join(globalStoragePath, "tasks", taskId)
await fs.mkdir(taskDir, { recursive: true })
return taskDir
}
export async function getSavedApiConversationHistory(
context: vscode.ExtensionContext,
taskId: string,
): Promise<Anthropic.MessageParam[]> {
const filePath = path.join(await ensureTaskDirectoryExists(context, taskId), GlobalFileNames.apiConversationHistory)
const fileExists = await fileExistsAtPath(filePath)
if (fileExists) {
return JSON.parse(await fs.readFile(filePath, "utf8"))
}
return []
}
export async function saveApiConversationHistory(
context: vscode.ExtensionContext,
taskId: string,
apiConversationHistory: Anthropic.MessageParam[],
) {
try {
const filePath = path.join(await ensureTaskDirectoryExists(context, taskId), GlobalFileNames.apiConversationHistory)
await fs.writeFile(filePath, JSON.stringify(apiConversationHistory))
} catch (error) {
// in the off chance this fails, we don't want to stop the task
console.error("Failed to save API conversation history:", error)
}
}
export async function getSavedClineMessages(context: vscode.ExtensionContext, taskId: string): Promise<ClineMessage[]> {
const filePath = path.join(await ensureTaskDirectoryExists(context, taskId), GlobalFileNames.uiMessages)
if (await fileExistsAtPath(filePath)) {
return JSON.parse(await fs.readFile(filePath, "utf8"))
} else {
// check old location
const oldPath = path.join(await ensureTaskDirectoryExists(context, taskId), "claude_messages.json")
if (await fileExistsAtPath(oldPath)) {
const data = JSON.parse(await fs.readFile(oldPath, "utf8"))
await fs.unlink(oldPath) // remove old file
return data
}
}
return []
}
export async function saveClineMessages(context: vscode.ExtensionContext, taskId: string, uiMessages: ClineMessage[]) {
try {
const taskDir = await ensureTaskDirectoryExists(context, taskId)
const filePath = path.join(taskDir, GlobalFileNames.uiMessages)
await fs.writeFile(filePath, JSON.stringify(uiMessages))
} catch (error) {
console.error("Failed to save ui messages:", error)
}
}
-67
View File
@@ -1,67 +0,0 @@
export type SecretKey =
| "apiKey"
| "clineApiKey"
| "openRouterApiKey"
| "awsAccessKey"
| "awsSecretKey"
| "awsSessionToken"
| "openAiApiKey"
| "geminiApiKey"
| "openAiNativeApiKey"
| "deepSeekApiKey"
| "requestyApiKey"
| "togetherApiKey"
| "qwenApiKey"
| "mistralApiKey"
| "liteLlmApiKey"
| "authNonce"
| "asksageApiKey"
| "xaiApiKey"
| "sambanovaApiKey"
export type GlobalStateKey =
| "apiProvider"
| "apiModelId"
| "awsRegion"
| "awsUseCrossRegionInference"
| "awsBedrockUsePromptCache"
| "awsBedrockEndpoint"
| "awsProfile"
| "awsUseProfile"
| "vertexProjectId"
| "vertexRegion"
| "lastShownAnnouncementId"
| "customInstructions"
| "taskHistory"
| "openAiBaseUrl"
| "openAiModelId"
| "openAiModelInfo"
| "ollamaModelId"
| "ollamaBaseUrl"
| "ollamaApiOptionsCtxNum"
| "lmStudioModelId"
| "lmStudioBaseUrl"
| "anthropicBaseUrl"
| "azureApiVersion"
| "openRouterModelId"
| "openRouterModelInfo"
| "openRouterProviderSorting"
| "autoApprovalSettings"
| "browserSettings"
| "chatSettings"
| "vsCodeLmModelSelector"
| "userInfo"
| "previousModeApiProvider"
| "previousModeModelId"
| "previousModeThinkingBudgetTokens"
| "previousModeVsCodeLmModelSelector"
| "previousModeModelInfo"
| "liteLlmBaseUrl"
| "liteLlmModelId"
| "qwenApiLine"
| "requestyModelId"
| "togetherModelId"
| "mcpMarketplaceCatalog"
| "telemetrySetting"
| "asksageApiUrl"
| "thinkingBudgetTokens"
| "planActSeparateModelsSetting"
-421
View File
@@ -1,421 +0,0 @@
import * as vscode from "vscode"
import { DEFAULT_CHAT_SETTINGS } from "../../shared/ChatSettings"
import { DEFAULT_BROWSER_SETTINGS } from "../../shared/BrowserSettings"
import { DEFAULT_AUTO_APPROVAL_SETTINGS } from "../../shared/AutoApprovalSettings"
import { GlobalStateKey, SecretKey } from "./state-keys"
import { ApiConfiguration, ApiProvider, ModelInfo } from "../../shared/api"
import { HistoryItem } from "../../shared/HistoryItem"
import { AutoApprovalSettings } from "../../shared/AutoApprovalSettings"
import { BrowserSettings } from "../../shared/BrowserSettings"
import { ChatSettings } from "../../shared/ChatSettings"
import { TelemetrySetting } from "../../shared/TelemetrySetting"
import { UserInfo } from "../../shared/UserInfo"
/*
Storage
https://dev.to/kompotkot/how-to-use-secretstorage-in-your-vscode-extensions-2hco
https://www.eliostruyf.com/devhack-code-extension-storage-options/
*/
// global
export async function updateGlobalState(context: vscode.ExtensionContext, key: GlobalStateKey, value: any) {
await context.globalState.update(key, value)
}
export async function getGlobalState(context: vscode.ExtensionContext, key: GlobalStateKey) {
return await context.globalState.get(key)
}
// secrets
export async function storeSecret(context: vscode.ExtensionContext, key: SecretKey, value?: string) {
if (value) {
await context.secrets.store(key, value)
} else {
await context.secrets.delete(key)
}
}
export async function getSecret(context: vscode.ExtensionContext, key: SecretKey) {
return await context.secrets.get(key)
}
// workspace
export async function updateWorkspaceState(context: vscode.ExtensionContext, key: string, value: any) {
await context.workspaceState.update(key, value)
}
export async function getWorkspaceState(context: vscode.ExtensionContext, key: string) {
return await context.workspaceState.get(key)
}
export async function getAllExtensionState(context: vscode.ExtensionContext) {
const [
storedApiProvider,
apiModelId,
apiKey,
openRouterApiKey,
clineApiKey,
awsAccessKey,
awsSecretKey,
awsSessionToken,
awsRegion,
awsUseCrossRegionInference,
awsBedrockUsePromptCache,
awsBedrockEndpoint,
awsProfile,
awsUseProfile,
vertexProjectId,
vertexRegion,
openAiBaseUrl,
openAiApiKey,
openAiModelId,
openAiModelInfo,
ollamaModelId,
ollamaBaseUrl,
ollamaApiOptionsCtxNum,
lmStudioModelId,
lmStudioBaseUrl,
anthropicBaseUrl,
geminiApiKey,
openAiNativeApiKey,
deepSeekApiKey,
requestyApiKey,
requestyModelId,
togetherApiKey,
togetherModelId,
qwenApiKey,
mistralApiKey,
azureApiVersion,
openRouterModelId,
openRouterModelInfo,
openRouterProviderSorting,
lastShownAnnouncementId,
customInstructions,
taskHistory,
autoApprovalSettings,
browserSettings,
chatSettings,
vsCodeLmModelSelector,
liteLlmBaseUrl,
liteLlmModelId,
userInfo,
previousModeApiProvider,
previousModeModelId,
previousModeModelInfo,
previousModeVsCodeLmModelSelector,
previousModeThinkingBudgetTokens,
qwenApiLine,
liteLlmApiKey,
telemetrySetting,
asksageApiKey,
asksageApiUrl,
xaiApiKey,
thinkingBudgetTokens,
sambanovaApiKey,
planActSeparateModelsSettingRaw,
] = await Promise.all([
getGlobalState(context, "apiProvider") as Promise<ApiProvider | undefined>,
getGlobalState(context, "apiModelId") as Promise<string | undefined>,
getSecret(context, "apiKey") as Promise<string | undefined>,
getSecret(context, "openRouterApiKey") as Promise<string | undefined>,
getSecret(context, "clineApiKey") as Promise<string | undefined>,
getSecret(context, "awsAccessKey") as Promise<string | undefined>,
getSecret(context, "awsSecretKey") as Promise<string | undefined>,
getSecret(context, "awsSessionToken") as Promise<string | undefined>,
getGlobalState(context, "awsRegion") as Promise<string | undefined>,
getGlobalState(context, "awsUseCrossRegionInference") as Promise<boolean | undefined>,
getGlobalState(context, "awsBedrockUsePromptCache") as Promise<boolean | undefined>,
getGlobalState(context, "awsBedrockEndpoint") as Promise<string | undefined>,
getGlobalState(context, "awsProfile") as Promise<string | undefined>,
getGlobalState(context, "awsUseProfile") as Promise<boolean | undefined>,
getGlobalState(context, "vertexProjectId") as Promise<string | undefined>,
getGlobalState(context, "vertexRegion") as Promise<string | undefined>,
getGlobalState(context, "openAiBaseUrl") as Promise<string | undefined>,
getSecret(context, "openAiApiKey") as Promise<string | undefined>,
getGlobalState(context, "openAiModelId") as Promise<string | undefined>,
getGlobalState(context, "openAiModelInfo") as Promise<ModelInfo | undefined>,
getGlobalState(context, "ollamaModelId") as Promise<string | undefined>,
getGlobalState(context, "ollamaBaseUrl") as Promise<string | undefined>,
getGlobalState(context, "ollamaApiOptionsCtxNum") as Promise<string | undefined>,
getGlobalState(context, "lmStudioModelId") as Promise<string | undefined>,
getGlobalState(context, "lmStudioBaseUrl") as Promise<string | undefined>,
getGlobalState(context, "anthropicBaseUrl") as Promise<string | undefined>,
getSecret(context, "geminiApiKey") as Promise<string | undefined>,
getSecret(context, "openAiNativeApiKey") as Promise<string | undefined>,
getSecret(context, "deepSeekApiKey") as Promise<string | undefined>,
getSecret(context, "requestyApiKey") as Promise<string | undefined>,
getGlobalState(context, "requestyModelId") as Promise<string | undefined>,
getSecret(context, "togetherApiKey") as Promise<string | undefined>,
getGlobalState(context, "togetherModelId") as Promise<string | undefined>,
getSecret(context, "qwenApiKey") 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>,
getGlobalState(context, "openRouterModelInfo") as Promise<ModelInfo | undefined>,
getGlobalState(context, "openRouterProviderSorting") as Promise<string | undefined>,
getGlobalState(context, "lastShownAnnouncementId") as Promise<string | undefined>,
getGlobalState(context, "customInstructions") as Promise<string | undefined>,
getGlobalState(context, "taskHistory") as Promise<HistoryItem[] | undefined>,
getGlobalState(context, "autoApprovalSettings") as Promise<AutoApprovalSettings | undefined>,
getGlobalState(context, "browserSettings") as Promise<BrowserSettings | undefined>,
getGlobalState(context, "chatSettings") as Promise<ChatSettings | undefined>,
getGlobalState(context, "vsCodeLmModelSelector") as Promise<vscode.LanguageModelChatSelector | undefined>,
getGlobalState(context, "liteLlmBaseUrl") as Promise<string | undefined>,
getGlobalState(context, "liteLlmModelId") as Promise<string | undefined>,
getGlobalState(context, "userInfo") as Promise<UserInfo | undefined>,
getGlobalState(context, "previousModeApiProvider") as Promise<ApiProvider | undefined>,
getGlobalState(context, "previousModeModelId") as Promise<string | undefined>,
getGlobalState(context, "previousModeModelInfo") as Promise<ModelInfo | undefined>,
getGlobalState(context, "previousModeVsCodeLmModelSelector") as Promise<vscode.LanguageModelChatSelector | undefined>,
getGlobalState(context, "previousModeThinkingBudgetTokens") as Promise<number | undefined>,
getGlobalState(context, "qwenApiLine") as Promise<string | undefined>,
getSecret(context, "liteLlmApiKey") as Promise<string | undefined>,
getGlobalState(context, "telemetrySetting") as Promise<TelemetrySetting | undefined>,
getSecret(context, "asksageApiKey") as Promise<string | undefined>,
getGlobalState(context, "asksageApiUrl") as Promise<string | undefined>,
getSecret(context, "xaiApiKey") as Promise<string | undefined>,
getGlobalState(context, "thinkingBudgetTokens") as Promise<number | undefined>,
getSecret(context, "sambanovaApiKey") as Promise<string | undefined>,
getGlobalState(context, "planActSeparateModelsSetting") as Promise<boolean | undefined>,
])
let apiProvider: ApiProvider
if (storedApiProvider) {
apiProvider = storedApiProvider
} else {
// Either new user or legacy user that doesn't have the apiProvider stored in state
// (If they're using OpenRouter or Bedrock, then apiProvider state will exist)
if (apiKey) {
apiProvider = "anthropic"
} else {
// New users should default to openrouter, since they've opted to use an API key instead of signing in
apiProvider = "openrouter"
}
}
const o3MiniReasoningEffort = vscode.workspace.getConfiguration("cline.modelSettings.o3Mini").get("reasoningEffort", "medium")
const mcpMarketplaceEnabled = vscode.workspace.getConfiguration("cline").get<boolean>("mcpMarketplace.enabled", true)
// Plan/Act separate models setting is a boolean indicating whether the user wants to use different models for plan and act. Existing users expect this to be enabled, while we want new users to opt in to this being disabled by default.
// On win11 state sometimes initializes as empty string instead of undefined
let planActSeparateModelsSetting: boolean | undefined = undefined
if (planActSeparateModelsSettingRaw === true || planActSeparateModelsSettingRaw === false) {
planActSeparateModelsSetting = planActSeparateModelsSettingRaw
} else {
// default to true for existing users
if (storedApiProvider) {
planActSeparateModelsSetting = true
} else {
// default to false for new users
planActSeparateModelsSetting = false
}
// this is a special case where it's a new state, but we want it to default to different values for existing and new users.
// persist so next time state is retrieved it's set to the correct value.
await updateGlobalState(context, "planActSeparateModelsSetting", planActSeparateModelsSetting)
}
return {
apiConfiguration: {
apiProvider,
apiModelId,
apiKey,
openRouterApiKey,
clineApiKey,
awsAccessKey,
awsSecretKey,
awsSessionToken,
awsRegion,
awsUseCrossRegionInference,
awsBedrockUsePromptCache,
awsBedrockEndpoint,
awsProfile,
awsUseProfile,
vertexProjectId,
vertexRegion,
openAiBaseUrl,
openAiApiKey,
openAiModelId,
openAiModelInfo,
ollamaModelId,
ollamaBaseUrl,
ollamaApiOptionsCtxNum,
lmStudioModelId,
lmStudioBaseUrl,
anthropicBaseUrl,
geminiApiKey,
openAiNativeApiKey,
deepSeekApiKey,
requestyApiKey,
requestyModelId,
togetherApiKey,
togetherModelId,
qwenApiKey,
qwenApiLine,
mistralApiKey,
azureApiVersion,
openRouterModelId,
openRouterModelInfo,
openRouterProviderSorting,
vsCodeLmModelSelector,
o3MiniReasoningEffort,
thinkingBudgetTokens,
liteLlmBaseUrl,
liteLlmModelId,
liteLlmApiKey,
asksageApiKey,
asksageApiUrl,
xaiApiKey,
sambanovaApiKey,
},
lastShownAnnouncementId,
customInstructions,
taskHistory,
autoApprovalSettings: autoApprovalSettings || DEFAULT_AUTO_APPROVAL_SETTINGS, // default value can be 0 or empty string
browserSettings: browserSettings || DEFAULT_BROWSER_SETTINGS,
chatSettings: chatSettings || DEFAULT_CHAT_SETTINGS,
userInfo,
previousModeApiProvider,
previousModeModelId,
previousModeModelInfo,
previousModeVsCodeLmModelSelector,
previousModeThinkingBudgetTokens,
mcpMarketplaceEnabled,
telemetrySetting: telemetrySetting || "unset",
planActSeparateModelsSetting,
}
}
export async function updateApiConfiguration(context: vscode.ExtensionContext, apiConfiguration: ApiConfiguration) {
const {
apiProvider,
apiModelId,
apiKey,
openRouterApiKey,
awsAccessKey,
awsSecretKey,
awsSessionToken,
awsRegion,
awsUseCrossRegionInference,
awsBedrockUsePromptCache,
awsBedrockEndpoint,
awsProfile,
awsUseProfile,
vertexProjectId,
vertexRegion,
openAiBaseUrl,
openAiApiKey,
openAiModelId,
openAiModelInfo,
ollamaModelId,
ollamaBaseUrl,
ollamaApiOptionsCtxNum,
lmStudioModelId,
lmStudioBaseUrl,
anthropicBaseUrl,
geminiApiKey,
openAiNativeApiKey,
deepSeekApiKey,
requestyApiKey,
requestyModelId,
togetherApiKey,
togetherModelId,
qwenApiKey,
mistralApiKey,
azureApiVersion,
openRouterModelId,
openRouterModelInfo,
openRouterProviderSorting,
vsCodeLmModelSelector,
liteLlmBaseUrl,
liteLlmModelId,
liteLlmApiKey,
qwenApiLine,
asksageApiKey,
asksageApiUrl,
xaiApiKey,
thinkingBudgetTokens,
clineApiKey,
sambanovaApiKey,
} = apiConfiguration
await updateGlobalState(context, "apiProvider", apiProvider)
await updateGlobalState(context, "apiModelId", apiModelId)
await storeSecret(context, "apiKey", apiKey)
await storeSecret(context, "openRouterApiKey", openRouterApiKey)
await storeSecret(context, "awsAccessKey", awsAccessKey)
await storeSecret(context, "awsSecretKey", awsSecretKey)
await storeSecret(context, "awsSessionToken", awsSessionToken)
await updateGlobalState(context, "awsRegion", awsRegion)
await updateGlobalState(context, "awsUseCrossRegionInference", awsUseCrossRegionInference)
await updateGlobalState(context, "awsBedrockUsePromptCache", awsBedrockUsePromptCache)
await updateGlobalState(context, "awsBedrockEndpoint", awsBedrockEndpoint)
await updateGlobalState(context, "awsProfile", awsProfile)
await updateGlobalState(context, "awsUseProfile", awsUseProfile)
await updateGlobalState(context, "vertexProjectId", vertexProjectId)
await updateGlobalState(context, "vertexRegion", vertexRegion)
await updateGlobalState(context, "openAiBaseUrl", openAiBaseUrl)
await storeSecret(context, "openAiApiKey", openAiApiKey)
await updateGlobalState(context, "openAiModelId", openAiModelId)
await updateGlobalState(context, "openAiModelInfo", openAiModelInfo)
await updateGlobalState(context, "ollamaModelId", ollamaModelId)
await updateGlobalState(context, "ollamaBaseUrl", ollamaBaseUrl)
await updateGlobalState(context, "ollamaApiOptionsCtxNum", ollamaApiOptionsCtxNum)
await updateGlobalState(context, "lmStudioModelId", lmStudioModelId)
await updateGlobalState(context, "lmStudioBaseUrl", lmStudioBaseUrl)
await updateGlobalState(context, "anthropicBaseUrl", anthropicBaseUrl)
await storeSecret(context, "geminiApiKey", geminiApiKey)
await storeSecret(context, "openAiNativeApiKey", openAiNativeApiKey)
await storeSecret(context, "deepSeekApiKey", deepSeekApiKey)
await storeSecret(context, "requestyApiKey", requestyApiKey)
await storeSecret(context, "togetherApiKey", togetherApiKey)
await storeSecret(context, "qwenApiKey", qwenApiKey)
await storeSecret(context, "mistralApiKey", mistralApiKey)
await storeSecret(context, "liteLlmApiKey", liteLlmApiKey)
await storeSecret(context, "xaiApiKey", xaiApiKey)
await updateGlobalState(context, "azureApiVersion", azureApiVersion)
await updateGlobalState(context, "openRouterModelId", openRouterModelId)
await updateGlobalState(context, "openRouterModelInfo", openRouterModelInfo)
await updateGlobalState(context, "openRouterProviderSorting", openRouterProviderSorting)
await updateGlobalState(context, "vsCodeLmModelSelector", vsCodeLmModelSelector)
await updateGlobalState(context, "liteLlmBaseUrl", liteLlmBaseUrl)
await updateGlobalState(context, "liteLlmModelId", liteLlmModelId)
await updateGlobalState(context, "qwenApiLine", qwenApiLine)
await updateGlobalState(context, "requestyModelId", requestyModelId)
await updateGlobalState(context, "togetherModelId", togetherModelId)
await storeSecret(context, "asksageApiKey", asksageApiKey)
await updateGlobalState(context, "asksageApiUrl", asksageApiUrl)
await updateGlobalState(context, "thinkingBudgetTokens", thinkingBudgetTokens)
await storeSecret(context, "clineApiKey", clineApiKey)
await storeSecret(context, "sambanovaApiKey", sambanovaApiKey)
}
export async function resetExtensionState(context: vscode.ExtensionContext) {
for (const key of context.globalState.keys()) {
await context.globalState.update(key, undefined)
}
const secretKeys: SecretKey[] = [
"apiKey",
"openRouterApiKey",
"awsAccessKey",
"awsSecretKey",
"awsSessionToken",
"openAiApiKey",
"geminiApiKey",
"openAiNativeApiKey",
"deepSeekApiKey",
"requestyApiKey",
"togetherApiKey",
"qwenApiKey",
"mistralApiKey",
"clineApiKey",
"liteLlmApiKey",
"asksageApiKey",
"xaiApiKey",
"sambanovaApiKey",
]
for (const key of secretKeys) {
await storeSecret(context, key, undefined)
}
}
File diff suppressed because it is too large Load Diff
-334
View File
@@ -1,334 +0,0 @@
import axios from "axios"
import * as vscode from "vscode"
import { getNonce } from "./getNonce"
import { getUri } from "./getUri"
import { getTheme } from "../../integrations/theme/getTheme"
import { Controller } from "../controller"
import { findLast } from "../../shared/array"
/*
https://github.com/microsoft/vscode-webview-ui-toolkit-samples/blob/main/default/weather-webview/src/providers/WeatherViewProvider.ts
https://github.com/KumarVariable/vscode-extension-sidebar-html/blob/master/src/customSidebarViewProvider.ts
*/
export class WebviewProvider implements vscode.WebviewViewProvider {
public static readonly sideBarId = "claude-dev.SidebarProvider" // used in package.json as the view's id. This value cannot be changed due to how vscode caches views based on their id, and updating the id would break existing instances of the extension.
public static readonly tabPanelId = "claude-dev.TabPanelProvider"
private static activeInstances: Set<WebviewProvider> = new Set()
public view?: vscode.WebviewView | vscode.WebviewPanel
private disposables: vscode.Disposable[] = []
controller: Controller
constructor(
readonly context: vscode.ExtensionContext,
private readonly outputChannel: vscode.OutputChannel,
) {
WebviewProvider.activeInstances.add(this)
this.controller = new Controller(context, outputChannel, this)
}
async dispose() {
if (this.view && "dispose" in this.view) {
this.view.dispose()
}
while (this.disposables.length) {
const x = this.disposables.pop()
if (x) {
x.dispose()
}
}
await this.controller.dispose()
WebviewProvider.activeInstances.delete(this)
}
public static getVisibleInstance(): WebviewProvider | undefined {
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
webviewView.webview.options = {
// Allow scripts in the webview
enableScripts: true,
localResourceRoots: [this.context.extensionUri],
}
webviewView.webview.html =
this.context.extensionMode === vscode.ExtensionMode.Development
? await this.getHMRHtmlContent(webviewView.webview)
: this.getHtmlContent(webviewView.webview)
// Sets up an event listener to listen for messages passed from the webview view context
// and executes code based on the message that is received
this.setWebviewMessageListener(webviewView.webview)
// Logs show up in bottom panel > Debug Console
//console.log("registering listener")
// Listen for when the panel becomes visible
// https://github.com/microsoft/vscode-discussions/discussions/840
if ("onDidChangeViewState" in webviewView) {
// WebviewView and WebviewPanel have all the same properties except for this visibility listener
// panel
webviewView.onDidChangeViewState(
() => {
if (this.view?.visible) {
this.controller.postMessageToWebview({
type: "action",
action: "didBecomeVisible",
})
}
},
null,
this.disposables,
)
} else if ("onDidChangeVisibility" in webviewView) {
// sidebar
webviewView.onDidChangeVisibility(
() => {
if (this.view?.visible) {
this.controller.postMessageToWebview({
type: "action",
action: "didBecomeVisible",
})
}
},
null,
this.disposables,
)
}
// Listen for when the view is disposed
// This happens when the user closes the view or when the view is closed programmatically
webviewView.onDidDispose(
async () => {
await this.dispose()
},
null,
this.disposables,
)
// // if the extension is starting a new session, clear previous task state
// this.clearTask()
{
// Listen for configuration changes
vscode.workspace.onDidChangeConfiguration(
async (e) => {
if (e && e.affectsConfiguration("workbench.colorTheme")) {
// Sends latest theme name to webview
await this.controller.postMessageToWebview({
type: "theme",
text: JSON.stringify(await getTheme()),
})
}
if (e && e.affectsConfiguration("cline.mcpMarketplace.enabled")) {
// Update state when marketplace tab setting changes
await this.controller.postStateToWebview()
}
},
null,
this.disposables,
)
// if the extension is starting a new session, clear previous task state
this.controller.clearTask()
this.outputChannel.appendLine("Webview view resolved")
}
}
/**
* Defines and returns the HTML that should be rendered within the webview panel.
*
* @remarks This is also the place where references to the React webview build files
* are created and inserted into the webview HTML.
*
* @param webview A reference to the extension webview
* @param extensionUri The URI of the directory containing the extension
* @returns A template string literal containing the HTML that should be
* rendered within the webview panel
*/
private getHtmlContent(webview: vscode.Webview): string {
// Get the local path to main script run in the webview,
// then convert it to a uri we can use in the webview.
// The CSS file from the React build output
const stylesUri = getUri(webview, this.context.extensionUri, ["webview-ui", "build", "assets", "index.css"])
// The JS file from the React build output
const scriptUri = getUri(webview, this.context.extensionUri, ["webview-ui", "build", "assets", "index.js"])
// The codicon font from the React build output
// https://github.com/microsoft/vscode-extension-samples/blob/main/webview-codicons-sample/src/extension.ts
// we installed this package in the extension so that we can access it how its intended from the extension (the font file is likely bundled in vscode), and we just import the css fileinto our react app we don't have access to it
// don't forget to add font-src ${webview.cspSource};
const codiconsUri = getUri(webview, this.context.extensionUri, [
"node_modules",
"@vscode",
"codicons",
"dist",
"codicon.css",
])
// const scriptUri = webview.asWebviewUri(vscode.Uri.joinPath(this._extensionUri, "assets", "main.js"))
// const styleResetUri = webview.asWebviewUri(vscode.Uri.joinPath(this._extensionUri, "assets", "reset.css"))
// const styleVSCodeUri = webview.asWebviewUri(vscode.Uri.joinPath(this._extensionUri, "assets", "vscode.css"))
// // Same for stylesheet
// const stylesheetUri = webview.asWebviewUri(vscode.Uri.joinPath(this._extensionUri, "assets", "main.css"))
// Use a nonce to only allow a specific script to be run.
/*
content security policy of your webview to only allow scripts that have a specific nonce
create a content security policy meta tag so that only loading scripts with a nonce is allowed
As your extension grows you will likely want to add custom styles, fonts, and/or images to your webview. If you do, you will need to update the content security policy meta tag to explicity allow for these resources. E.g.
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src ${webview.cspSource}; font-src ${webview.cspSource}; img-src ${webview.cspSource} https:; script-src 'nonce-${nonce}';">
- 'unsafe-inline' is required for styles due to vscode-webview-toolkit's dynamic style injection
- since we pass base64 images to the webview, we need to specify img-src ${webview.cspSource} data:;
in meta tag we add nonce attribute: A cryptographic nonce (only used once) to allow scripts. The server must generate a unique nonce value each time it transmits a policy. It is critical to provide a nonce that cannot be guessed as bypassing a resource's policy is otherwise trivial.
*/
const nonce = getNonce()
// Tip: Install the es6-string-html VS Code extension to enable code highlighting below
return /*html*/ `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,shrink-to-fit=no">
<meta name="theme-color" content="#000000">
<link rel="stylesheet" type="text/css" href="${stylesUri}">
<link href="${codiconsUri}" rel="stylesheet" />
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; connect-src https://*.posthog.com https://*.firebaseauth.com https://*.firebaseio.com https://*.googleapis.com https://*.firebase.com; font-src ${webview.cspSource}; style-src ${webview.cspSource} 'unsafe-inline'; img-src ${webview.cspSource} https: data:; script-src 'nonce-${nonce}' 'unsafe-eval';">
<title>Cline</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<script type="module" nonce="${nonce}" src="${scriptUri}"></script>
</body>
</html>
`
}
/**
* Connects to the local Vite dev server to allow HMR, with fallback to the bundled assets
*
* @param webview A reference to the extension webview
* @returns A template string literal containing the HTML that should be
* rendered within the webview panel
*/
private async getHMRHtmlContent(webview: vscode.Webview): Promise<string> {
const localPort = 25463
const localServerUrl = `localhost:${localPort}`
// Check if local dev server is running.
try {
await axios.get(`http://${localServerUrl}`)
} catch (error) {
vscode.window.showErrorMessage(
"Cline: Local webview dev server is not running, HMR will not work. Please run 'npm run dev:webview' before launching the extension to enable HMR. Using bundled assets.",
)
return this.getHtmlContent(webview)
}
const nonce = getNonce()
const stylesUri = getUri(webview, this.context.extensionUri, ["webview-ui", "build", "assets", "index.css"])
const codiconsUri = getUri(webview, this.context.extensionUri, [
"node_modules",
"@vscode",
"codicons",
"dist",
"codicon.css",
])
const scriptEntrypoint = "src/main.tsx"
const scriptUri = `http://${localServerUrl}/${scriptEntrypoint}`
const reactRefresh = /*html*/ `
<script nonce="${nonce}" type="module">
import RefreshRuntime from "http://${localServerUrl}/@react-refresh"
RefreshRuntime.injectIntoGlobalHook(window)
window.$RefreshReg$ = () => {}
window.$RefreshSig$ = () => (type) => type
window.__vite_plugin_react_preamble_installed__ = true
</script>
`
const csp = [
"default-src 'none'",
`font-src ${webview.cspSource}`,
`style-src ${webview.cspSource} 'unsafe-inline' https://* http://${localServerUrl} http://0.0.0.0:${localPort}`,
`img-src ${webview.cspSource} https: data:`,
`script-src 'unsafe-eval' https://* http://${localServerUrl} http://0.0.0.0:${localPort} 'nonce-${nonce}'`,
`connect-src https://* ws://${localServerUrl} ws://0.0.0.0:${localPort} http://${localServerUrl} http://0.0.0.0:${localPort}`,
]
return /*html*/ `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,shrink-to-fit=no">
<meta http-equiv="Content-Security-Policy" content="${csp.join("; ")}">
<link rel="stylesheet" type="text/css" href="${stylesUri}">
<link href="${codiconsUri}" rel="stylesheet" />
<title>Cline</title>
</head>
<body>
<div id="root"></div>
${reactRefresh}
<script type="module" src="${scriptUri}"></script>
</body>
</html>
`
}
/**
* Sets up an event listener to listen for messages passed from the webview context and
* executes code based on the message that is received.
*
* IMPORTANT: When passing methods as callbacks in JavaScript/TypeScript, the method's
* 'this' context can be lost. This happens because the method is passed as a
* standalone function reference, detached from its original object.
*
* The Problem:
* Doing: webview.onDidReceiveMessage(this.controller.handleWebviewMessage)
* Would cause 'this' inside handleWebviewMessage to be undefined or wrong,
* leading to "TypeError: this.setUserInfo is not a function"
*
* The Solution:
* We wrap the method call in an arrow function, which:
* 1. Preserves the lexical scope's 'this' binding
* 2. Ensures handleWebviewMessage is called as a method on the controller instance
* 3. Maintains access to all controller methods and properties
*
* Alternative solutions could use .bind() or making handleWebviewMessage an arrow
* function property, but this approach is clean and explicit.
*
* @param webview The webview instance to attach the message listener to
*/
private setWebviewMessageListener(webview: vscode.Webview) {
webview.onDidReceiveMessage(
(message) => {
this.controller.handleWebviewMessage(message)
},
null,
this.disposables,
)
}
}
+4 -4
View File
@@ -1,7 +1,7 @@
import * as vscode from "vscode"
import * as fs from "fs/promises"
import * as path from "path"
import { Controller } from "../../core/controller"
import { ClineProvider } from "../../core/webview/ClineProvider"
import { HistoryItem } from "../../shared/HistoryItem"
import { ClineMessage } from "../../shared/ExtensionMessage"
@@ -9,7 +9,7 @@ import { ClineMessage } from "../../shared/ExtensionMessage"
* Registers development-only commands for task manipulation.
* These are only activated in development mode.
*/
export function registerTaskCommands(context: vscode.ExtensionContext, controller: Controller): vscode.Disposable[] {
export function registerTaskCommands(context: vscode.ExtensionContext, provider: ClineProvider): vscode.Disposable[] {
return [
vscode.commands.registerCommand("cline.dev.createTestTasks", async () => {
const count = await vscode.window.showInputBox({
@@ -88,13 +88,13 @@ export function registerTaskCommands(context: vscode.ExtensionContext, controlle
}
// Update task history in global state
await controller.updateTaskHistory(historyItem)
await provider.updateTaskHistory(historyItem)
progress.report({ increment: 100 / tasksCount })
}
// Update the UI to show the new tasks
await controller.postStateToWebview()
await provider.postStateToWebview()
vscode.window.showInformationMessage(`Created ${tasksCount} test tasks`)
},
+11 -12
View File
@@ -1,28 +1,27 @@
import * as vscode from "vscode"
import { Controller } from "../core/controller"
import { ClineProvider } from "../core/webview/ClineProvider"
import { ClineAPI } from "./cline"
import { getGlobalState } from "../core/storage/state"
export function createClineAPI(outputChannel: vscode.OutputChannel, sidebarController: Controller): ClineAPI {
export function createClineAPI(outputChannel: vscode.OutputChannel, sidebarProvider: ClineProvider): ClineAPI {
const api: ClineAPI = {
setCustomInstructions: async (value: string) => {
await sidebarController.updateCustomInstructions(value)
await sidebarProvider.updateCustomInstructions(value)
outputChannel.appendLine("Custom instructions set")
},
getCustomInstructions: async () => {
return (await getGlobalState(sidebarController.context, "customInstructions")) as string | undefined
return (await sidebarProvider.getGlobalState("customInstructions")) as string | undefined
},
startNewTask: async (task?: string, images?: string[]) => {
outputChannel.appendLine("Starting new task")
await sidebarController.clearTask()
await sidebarController.postStateToWebview()
await sidebarController.postMessageToWebview({
await sidebarProvider.clearTask()
await sidebarProvider.postStateToWebview()
await sidebarProvider.postMessageToWebview({
type: "action",
action: "chatButtonClicked",
})
await sidebarController.postMessageToWebview({
await sidebarProvider.postMessageToWebview({
type: "invoke",
invoke: "sendMessage",
text: task,
@@ -37,7 +36,7 @@ export function createClineAPI(outputChannel: vscode.OutputChannel, sidebarContr
outputChannel.appendLine(
`Sending message: ${message ? `"${message}"` : "undefined"} with ${images?.length || 0} image(s)`,
)
await sidebarController.postMessageToWebview({
await sidebarProvider.postMessageToWebview({
type: "invoke",
invoke: "sendMessage",
text: message,
@@ -47,7 +46,7 @@ export function createClineAPI(outputChannel: vscode.OutputChannel, sidebarContr
pressPrimaryButton: async () => {
outputChannel.appendLine("Pressing primary button")
await sidebarController.postMessageToWebview({
await sidebarProvider.postMessageToWebview({
type: "invoke",
invoke: "primaryButtonClick",
})
@@ -55,7 +54,7 @@ export function createClineAPI(outputChannel: vscode.OutputChannel, sidebarContr
pressSecondaryButton: async () => {
outputChannel.appendLine("Pressing secondary button")
await sidebarController.postMessageToWebview({
await sidebarProvider.postMessageToWebview({
type: "invoke",
invoke: "secondaryButtonClick",
})
+49 -88
View File
@@ -2,13 +2,13 @@
// Import the module and reference it with the alias vscode in your code below
import { setTimeout as setTimeoutPromise } from "node:timers/promises"
import * as vscode from "vscode"
import { ClineProvider } from "./core/webview/ClineProvider"
import { Logger } from "./services/logging/Logger"
import { createClineAPI } from "./exports"
import "./utils/path" // necessary to have access to String.prototype.toPosix
import { DIFF_VIEW_URI_SCHEME } from "./integrations/editor/DiffViewProvider"
import assert from "node:assert"
import { telemetryService } from "./services/telemetry/TelemetryService"
import { WebviewProvider } from "./core/webview"
/*
Built using https://github.com/microsoft/vscode-webview-ui-toolkit
@@ -30,48 +30,34 @@ export function activate(context: vscode.ExtensionContext) {
Logger.initialize(outputChannel)
Logger.log("Cline extension activated")
const sidebarWebview = new WebviewProvider(context, outputChannel)
const sidebarProvider = new ClineProvider(context, outputChannel)
vscode.commands.executeCommand("setContext", "cline.isDevMode", IS_DEV && IS_DEV === "true")
context.subscriptions.push(
vscode.window.registerWebviewViewProvider(WebviewProvider.sideBarId, sidebarWebview, {
vscode.window.registerWebviewViewProvider(ClineProvider.sideBarId, sidebarProvider, {
webviewOptions: { retainContextWhenHidden: true },
}),
)
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")
await sidebarProvider.clearTask()
await sidebarProvider.postStateToWebview()
await sidebarProvider.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", () => {
sidebarProvider.postMessageToWebview({
type: "action",
action: "mcpButtonClicked",
})
}),
)
@@ -79,7 +65,7 @@ export function activate(context: vscode.ExtensionContext) {
Logger.log("Opening Cline in new tab")
// (this example uses webviewProvider activation event which is necessary to deserialize cached webview, but since we use retainContextWhenHidden, we don't need to use that event)
// https://github.com/microsoft/vscode-extension-samples/blob/main/webview-sample/src/extension.ts
const tabWebview = new WebviewProvider(context, outputChannel)
const tabProvider = new ClineProvider(context, outputChannel)
//const column = vscode.window.activeTextEditor ? vscode.window.activeTextEditor.viewColumn : undefined
const lastCol = Math.max(...vscode.window.visibleTextEditors.map((editor) => editor.viewColumn || 0))
@@ -90,7 +76,7 @@ export function activate(context: vscode.ExtensionContext) {
}
const targetCol = hasVisibleEditors ? Math.max(lastCol + 1, 1) : vscode.ViewColumn.Two
const panel = vscode.window.createWebviewPanel(WebviewProvider.tabPanelId, "Cline", targetCol, {
const panel = vscode.window.createWebviewPanel(ClineProvider.tabPanelId, "Cline", targetCol, {
enableScripts: true,
retainContextWhenHidden: true,
localResourceRoots: [context.extensionUri],
@@ -101,7 +87,7 @@ export function activate(context: vscode.ExtensionContext) {
light: vscode.Uri.joinPath(context.extensionUri, "assets", "icons", "robot_panel_light.png"),
dark: vscode.Uri.joinPath(context.extensionUri, "assets", "icons", "robot_panel_dark.png"),
}
tabWebview.resolveWebviewView(panel)
tabProvider.resolveWebviewView(panel)
// Lock the editor group so clicking on files doesn't open them over the panel
await setTimeoutPromise(100)
@@ -112,62 +98,39 @@ 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)
sidebarProvider.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", () => {
sidebarProvider.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", () => {
sidebarProvider.postMessageToWebview({
type: "action",
action: "accountButtonClicked",
})
}),
)
context.subscriptions.push(
vscode.commands.registerCommand("cline.openDocumentation", () => {
vscode.env.openExternal(vscode.Uri.parse("https://docs.cline.bot/"))
}),
)
/*
We use the text document content provider API to show the left side for diff view by creating a virtual document for the original content. This makes it readonly so users know to edit the right side if they want to keep their changes.
@@ -192,15 +155,15 @@ export function activate(context: vscode.ExtensionContext) {
const path = uri.path
const query = new URLSearchParams(uri.query.replace(/\+/g, "%2B"))
const visibleWebview = WebviewProvider.getVisibleInstance()
if (!visibleWebview) {
const visibleProvider = ClineProvider.getVisibleInstance()
if (!visibleProvider) {
return
}
switch (path) {
case "/openrouter": {
const code = query.get("code")
if (code) {
await visibleWebview?.controller.handleOpenRouterCallback(code)
await visibleProvider.handleOpenRouterCallback(code)
}
break
}
@@ -216,13 +179,13 @@ export function activate(context: vscode.ExtensionContext) {
})
// Validate state parameter
if (!(await visibleWebview?.controller.validateAuthState(state))) {
if (!(await visibleProvider.validateAuthState(state))) {
vscode.window.showErrorMessage("Invalid auth state")
return
}
if (token && apiKey) {
await visibleWebview?.controller.handleAuthCallback(token, apiKey)
await visibleProvider.handleAuthCallback(token, apiKey)
}
break
}
@@ -237,7 +200,7 @@ export function activate(context: vscode.ExtensionContext) {
// Use dynamic import to avoid loading the module in production
import("./dev/commands/tasks")
.then((module) => {
const devTaskCommands = module.registerTaskCommands(context, sidebarWebview.controller)
const devTaskCommands = module.registerTaskCommands(context, sidebarProvider)
context.subscriptions.push(...devTaskCommands)
Logger.log("Cline dev task commands registered")
})
@@ -266,8 +229,8 @@ export function activate(context: vscode.ExtensionContext) {
const filePath = editor.document.uri.fsPath
const languageId = editor.document.languageId
const visibleWebview = WebviewProvider.getVisibleInstance()
await visibleWebview?.controller.addSelectedCodeToChat(
// Send to sidebar provider
await sidebarProvider.addSelectedCodeToChat(
selectedText,
filePath,
languageId,
@@ -316,8 +279,7 @@ export function activate(context: vscode.ExtensionContext) {
*/
// Send to sidebar provider
const visibleWebview = WebviewProvider.getVisibleInstance()
await visibleWebview?.controller.addSelectedTerminalOutputToChat(terminalContents, terminal.name)
await sidebarProvider.addSelectedTerminalOutputToChat(terminalContents, terminal.name)
} catch (error) {
// Ensure clipboard is restored even if an error occurs
await vscode.env.clipboard.writeText(tempCopyBuffer)
@@ -388,12 +350,11 @@ export function activate(context: vscode.ExtensionContext) {
const languageId = editor.document.languageId
// Send to sidebar provider with diagnostics
const visibleWebview = WebviewProvider.getVisibleInstance()
await visibleWebview?.controller.fixWithCline(selectedText, filePath, languageId, diagnostics)
await sidebarProvider.fixWithCline(selectedText, filePath, languageId, diagnostics)
}),
)
return createClineAPI(outputChannel, sidebarWebview.controller)
return createClineAPI(outputChannel, sidebarProvider)
}
// This method is called when your extension is deactivated
+8
View File
@@ -0,0 +1,8 @@
// NOTE: These are here temporarily until we find a better home for them
export const GlobalFileNames = {
apiConversationHistory: "api_conversation_history.json",
uiMessages: "ui_messages.json",
openRouterModels: "openrouter_models.json",
mcpSettings: "cline_mcp_settings.json",
clineRules: ".clinerules",
}
@@ -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)
}
@@ -3,7 +3,7 @@ import os from "os"
import * as path from "path"
import simpleGit, { SimpleGit } from "simple-git"
import * as vscode from "vscode"
import { Controller as ClineProvider } from "../../core/controller"
import { ClineProvider } from "../../core/webview/ClineProvider"
import { fileExistsAtPath } from "../../utils/fs"
import { globby } from "globby"
@@ -46,14 +46,6 @@ class CheckpointTracker {
private lastRetrievedShadowGitConfigWorkTree?: string
private gitOperations: GitOperations
/**
* Helper method to clean commit hashes that might have a "HEAD " prefix.
* Used for backward compatibility with old tasks that stored hashes with the prefix.
*/
private cleanCommitHash(hash: string): string {
return hash.startsWith("HEAD ") ? hash.slice(5) : hash
}
/**
* Creates a new CheckpointTracker instance to manage checkpoints for a specific task.
* The constructor is private - use the static create() method to instantiate.
@@ -165,10 +157,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
@@ -177,8 +166,8 @@ class CheckpointTracker {
"--allow-empty": null,
"--no-verify": null,
})
const commitHash = (result.commit || "").replace(/^HEAD\s+/, "")
console.warn(`Checkpoint commit created: `, commitHash)
const commitHash = result.commit || ""
console.warn(`Checkpoint commit created.`)
const durationMs = Math.round(performance.now() - startTime)
telemetryService.captureCheckpointUsage(this.taskId, "commit_created", durationMs)
@@ -253,7 +242,7 @@ class CheckpointTracker {
const gitPath = await getShadowGitPath(this.globalStoragePath, this.taskId, this.cwdHash)
const git = simpleGit(path.dirname(gitPath))
console.debug(`Using shadow git at: ${gitPath}`)
await git.reset(["--hard", this.cleanCommitHash(commitHash)]) // Hard reset to target commit
await git.reset(["--hard", commitHash]) // Hard reset to target commit
console.debug(`Successfully reset to checkpoint: ${commitHash}`)
const durationMs = Math.round(performance.now() - startTime)
@@ -294,8 +283,7 @@ class CheckpointTracker {
// Stage all changes so that untracked files appear in diff summary
await this.gitOperations.addCheckpointFiles(git)
const cleanRhs = rhsHash ? this.cleanCommitHash(rhsHash) : undefined
const diffRange = cleanRhs ? `${this.cleanCommitHash(lhsHash)}..${cleanRhs}` : this.cleanCommitHash(lhsHash)
const diffRange = rhsHash ? `${lhsHash}..${rhsHash}` : lhsHash
console.info(`Diff range: ${diffRange}`)
const diffSummary = await git.diffSummary([diffRange])
@@ -306,7 +294,7 @@ class CheckpointTracker {
let beforeContent = ""
try {
beforeContent = await git.show([`${this.cleanCommitHash(lhsHash)}:${filePath}`])
beforeContent = await git.show([`${lhsHash}:${filePath}`])
} catch (_) {
// file didn't exist in older commit => remains empty
}
@@ -314,7 +302,7 @@ class CheckpointTracker {
let afterContent = ""
if (rhsHash) {
try {
afterContent = await git.show([`${this.cleanCommitHash(rhsHash)}:${filePath}`])
afterContent = await git.show([`${rhsHash}:${filePath}`])
} catch (_) {
// file didn't exist in newer commit => remains empty
}
@@ -359,8 +347,7 @@ class CheckpointTracker {
// Stage all changes so that untracked files appear in diff summary
await this.gitOperations.addCheckpointFiles(git)
const cleanRhs = rhsHash ? this.cleanCommitHash(rhsHash) : undefined
const diffRange = cleanRhs ? `${this.cleanCommitHash(lhsHash)}..${cleanRhs}` : this.cleanCommitHash(lhsHash)
const diffRange = rhsHash ? `${lhsHash}..${rhsHash}` : lhsHash
const diffSummary = await git.diffSummary([diffRange])
const durationMs = Math.round(performance.now() - startTime)
@@ -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")
+1 -1
View File
@@ -71,7 +71,7 @@ export class TerminalProcess extends EventEmitter<TerminalProcessEvents> {
// remove ansi
data = stripAnsi(data)
// Split data by newlines
const lines = data ? data.split("\n") : []
let lines = data ? data.split("\n") : []
// Remove non-human readable characters from the first line
if (lines.length > 0) {
lines[0] = lines[0].replace(/[^\x20-\x7E]/g, "")
@@ -1,18 +1,18 @@
import * as vscode from "vscode"
import * as path from "path"
import { listFiles } from "../../services/glob/list-files"
import { Controller } from "../../core/controller"
import { ClineProvider } from "../../core/webview/ClineProvider"
const cwd = vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath).at(0)
// Note: this is not a drop-in replacement for listFiles at the start of tasks, since that will be done for Desktops when there is no workspace selected
class WorkspaceTracker {
private controllerRef: WeakRef<Controller>
private providerRef: WeakRef<ClineProvider>
private disposables: vscode.Disposable[] = []
private filePaths: Set<string> = new Set()
constructor(controller: Controller) {
this.controllerRef = new WeakRef(controller)
constructor(provider: ClineProvider) {
this.providerRef = new WeakRef(provider)
this.registerListeners()
}
@@ -85,7 +85,7 @@ class WorkspaceTracker {
if (!cwd) {
return
}
this.controllerRef.deref()?.postMessageToWebview({
this.providerRef.deref()?.postMessageToWebview({
type: "workspaceUpdated",
filePaths: Array.from(this.filePaths).map((file) => {
const relativePath = path.relative(cwd, file).toPosix()
+8 -8
View File
@@ -1,20 +1,20 @@
import axios, { AxiosRequestConfig, AxiosResponse } from "axios"
import { Controller } from "../../core/controller"
import { ClineProvider } from "../../core/webview/ClineProvider"
import type { BalanceResponse, PaymentTransaction, UsageTransaction } from "../../shared/ClineAccount"
export class ClineAccountService {
private readonly baseUrl = "https://api.cline.bot/v1"
private controllerRef: WeakRef<Controller>
private providerRef: WeakRef<ClineProvider>
constructor(controller: Controller) {
this.controllerRef = new WeakRef(controller)
constructor(provider: ClineProvider) {
this.providerRef = new WeakRef(provider)
}
/**
* Get the user's Cline Account key from the apiConfiguration
*/
private async getClineApiKey(): Promise<string | undefined> {
const provider = this.controllerRef.deref()
const provider = this.providerRef.deref()
if (!provider) {
return undefined
}
@@ -64,7 +64,7 @@ export class ClineAccountService {
const data = await this.authenticatedRequest<BalanceResponse>("/user/credits/balance")
// Post to webview
await this.controllerRef.deref()?.postMessageToWebview({
await this.providerRef.deref()?.postMessageToWebview({
type: "userCreditsBalance",
userCreditsBalance: data,
})
@@ -84,7 +84,7 @@ export class ClineAccountService {
const data = await this.authenticatedRequest<UsageTransaction[]>("/user/credits/usage")
// Post to webview
await this.controllerRef.deref()?.postMessageToWebview({
await this.providerRef.deref()?.postMessageToWebview({
type: "userCreditsUsage",
userCreditsUsage: data,
})
@@ -104,7 +104,7 @@ export class ClineAccountService {
const data = await this.authenticatedRequest<PaymentTransaction[]>("/user/credits/payments")
// Post to webview
await this.controllerRef.deref()?.postMessageToWebview({
await this.providerRef.deref()?.postMessageToWebview({
type: "userCreditsPayments",
userCreditsPayments: data,
})
+1 -1
View File
@@ -212,7 +212,7 @@ export class BrowserSession {
interval: 100,
}).catch(() => {})
const options: ScreenshotOptions = {
let options: ScreenshotOptions = {
encoding: "base64",
// clip: {
+2 -2
View File
@@ -68,8 +68,8 @@ Breadth-first traversal of directory structure level by level up to a limit:
- Timeout mechanism prevents infinite loops
*/
async function globbyLevelByLevel(limit: number, options?: Options) {
const results: Set<string> = new Set()
const queue: string[] = ["*"]
let results: Set<string> = new Set()
let queue: string[] = ["*"]
const globbingProcess = async () => {
while (queue.length > 0 && results.size < limit) {
+165 -223
View File
@@ -1,5 +1,5 @@
import { Client } from "@modelcontextprotocol/sdk/client/index.js"
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"
import { StdioClientTransport, StdioServerParameters } from "@modelcontextprotocol/sdk/client/stdio.js"
import {
CallToolResultSchema,
ListResourcesResultSchema,
@@ -14,7 +14,7 @@ import * as fs from "fs/promises"
import * as path from "path"
import * as vscode from "vscode"
import { z } from "zod"
import { Controller } from "../../core/controller"
import { ClineProvider } from "../../core/webview/ClineProvider"
import {
DEFAULT_MCP_TIMEOUT_SECONDS,
McpMode,
@@ -29,62 +29,38 @@ import {
import { fileExistsAtPath } from "../../utils/fs"
import { arePathsEqual } from "../../utils/path"
import { secondsToMs } from "../../utils/time"
import { GlobalFileNames } from "../../core/storage/disk"
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"
// Default timeout for internal MCP data requests in milliseconds; is not the same as the user facing timeout stored as DEFAULT_MCP_TIMEOUT_SECONDS
const DEFAULT_REQUEST_TIMEOUT_MS = 5000
import { GlobalFileNames } from "../../global-constants"
export type McpConnection = {
server: McpServer
client: Client
transport: StdioClientTransport | SSEClientTransport
transport: StdioClientTransport
}
export type McpTransportType = "stdio" | "sse"
export type McpServerConfig = z.infer<typeof ServerConfigSchema>
const AutoApproveSchema = z.array(z.string()).default([])
const BaseConfigSchema = z.object({
const StdioConfigSchema = z.object({
command: z.string(),
args: z.array(z.string()).optional(),
env: z.record(z.string()).optional(),
autoApprove: AutoApproveSchema.optional(),
disabled: z.boolean().optional(),
timeout: z.number().min(MIN_MCP_TIMEOUT_SECONDS).optional().default(DEFAULT_MCP_TIMEOUT_SECONDS),
})
const SseConfigSchema = BaseConfigSchema.extend({
url: z.string().url(),
}).transform((config) => ({
...config,
transportType: "sse" as const,
}))
const StdioConfigSchema = BaseConfigSchema.extend({
command: z.string(),
args: z.array(z.string()).optional(),
env: z.record(z.string()).optional(),
}).transform((config) => ({
...config,
transportType: "stdio" as const,
}))
const ServerConfigSchema = z.union([StdioConfigSchema, SseConfigSchema])
const McpSettingsSchema = z.object({
mcpServers: z.record(ServerConfigSchema),
mcpServers: z.record(StdioConfigSchema),
})
export class McpHub {
private controllerRef: WeakRef<Controller>
private providerRef: WeakRef<ClineProvider>
private disposables: vscode.Disposable[] = []
private settingsWatcher?: vscode.FileSystemWatcher
private fileWatchers: Map<string, FSWatcher> = new Map()
connections: McpConnection[] = []
isConnecting: boolean = false
constructor(controller: Controller) {
this.controllerRef = new WeakRef(controller)
constructor(provider: ClineProvider) {
this.providerRef = new WeakRef(provider)
this.watchMcpSettingsFile()
this.initializeMcpServers()
}
@@ -99,7 +75,7 @@ export class McpHub {
}
async getMcpServersPath(): Promise<string> {
const provider = this.controllerRef.deref()
const provider = this.providerRef.deref()
if (!provider) {
throw new Error("Provider not available")
}
@@ -108,7 +84,7 @@ export class McpHub {
}
async getMcpSettingsFilePath(): Promise<string> {
const provider = this.controllerRef.deref()
const provider = this.providerRef.deref()
if (!provider) {
throw new Error("Provider not available")
}
@@ -127,51 +103,32 @@ export class McpHub {
return mcpSettingsFilePath
}
private async readAndValidateMcpSettingsFile(): Promise<z.infer<typeof McpSettingsSchema> | undefined> {
try {
const settingsPath = await this.getMcpSettingsFilePath()
const content = await fs.readFile(settingsPath, "utf-8")
let config: any
// Parse JSON file content
try {
config = JSON.parse(content)
} catch (error) {
vscode.window.showErrorMessage(
"Invalid MCP settings format. Please ensure your settings follow the correct JSON format.",
)
return undefined
}
// Validate against schema
const result = McpSettingsSchema.safeParse(config)
if (!result.success) {
vscode.window.showErrorMessage("Invalid MCP settings schema.")
return undefined
}
return result.data
} catch (error) {
console.error("Failed to read MCP settings:", error)
return undefined
}
}
private async watchMcpSettingsFile(): Promise<void> {
const settingsPath = await this.getMcpSettingsFilePath()
this.disposables.push(
vscode.workspace.onDidSaveTextDocument(async (document) => {
if (arePathsEqual(document.uri.fsPath, settingsPath)) {
const settings = await this.readAndValidateMcpSettingsFile()
if (settings) {
try {
vscode.window.showInformationMessage("Updating MCP servers...")
await this.updateServerConnections(settings.mcpServers)
vscode.window.showInformationMessage("MCP servers updated")
} catch (error) {
console.error("Failed to process MCP settings change:", error)
}
const content = await fs.readFile(settingsPath, "utf-8")
const errorMessage =
"Invalid MCP settings format. Please ensure your settings follow the correct JSON format."
let config: any
try {
config = JSON.parse(content)
} catch (error) {
vscode.window.showErrorMessage(errorMessage)
return
}
const result = McpSettingsSchema.safeParse(config)
if (!result.success) {
vscode.window.showErrorMessage(errorMessage)
return
}
try {
vscode.window.showInformationMessage("Updating MCP servers...")
await this.updateServerConnections(result.data.mcpServers || {})
vscode.window.showInformationMessage("MCP servers updated")
} catch (error) {
console.error("Failed to process MCP settings change:", error)
}
}
}),
@@ -179,16 +136,17 @@ export class McpHub {
}
private async initializeMcpServers(): Promise<void> {
const settings = await this.readAndValidateMcpSettingsFile()
if (settings) {
await this.updateServerConnections(settings.mcpServers)
try {
const settingsPath = await this.getMcpSettingsFilePath()
const content = await fs.readFile(settingsPath, "utf-8")
const config = JSON.parse(content)
await this.updateServerConnections(config.mcpServers || {})
} catch (error) {
console.error("Failed to initialize MCP servers:", error)
}
}
private async connectToServer(
name: string,
config: z.infer<typeof StdioConfigSchema> | z.infer<typeof SseConfigSchema>,
): Promise<void> {
private async connectToServer(name: string, config: StdioServerParameters): Promise<void> {
// Remove existing connection if it exists (should never happen, the connection should be deleted beforehand)
this.connections = this.connections.filter((conn) => conn.server.name !== name)
@@ -197,29 +155,23 @@ export class McpHub {
const client = new Client(
{
name: "Cline",
version: this.controllerRef.deref()?.context.extension?.packageJSON?.version ?? "1.0.0",
version: this.providerRef.deref()?.context.extension?.packageJSON?.version ?? "1.0.0",
},
{
capabilities: {},
},
)
let transport: StdioClientTransport | SSEClientTransport
if (config.transportType === "sse") {
transport = new SSEClientTransport(new URL(config.url), {})
} else {
transport = new StdioClientTransport({
command: config.command,
args: config.args,
env: {
...config.env,
...(process.env.PATH ? { PATH: process.env.PATH } : {}),
// ...(process.env.NODE_PATH ? { NODE_PATH: process.env.NODE_PATH } : {}),
},
stderr: "pipe", // necessary for stderr to be available
})
}
const transport = new StdioClientTransport({
command: config.command,
args: config.args,
env: {
...config.env,
...(process.env.PATH ? { PATH: process.env.PATH } : {}),
// ...(process.env.NODE_PATH ? { NODE_PATH: process.env.NODE_PATH } : {}),
},
stderr: "pipe", // necessary for stderr to be available
})
transport.onerror = async (error) => {
console.error(`Transport error for "${name}":`, error)
@@ -239,54 +191,62 @@ export class McpHub {
await this.notifyWebviewOfServerChanges()
}
// If the config is invalid, show an error
if (!StdioConfigSchema.safeParse(config).success) {
console.error(`Invalid config for "${name}": missing or invalid parameters`)
const connection: McpConnection = {
server: {
name,
config: JSON.stringify(config),
status: "disconnected",
error: "Invalid config: missing or invalid parameters",
},
client,
transport,
}
this.connections.push(connection)
return
}
// valid schema
const parsedConfig = StdioConfigSchema.parse(config)
const connection: McpConnection = {
server: {
name,
config: JSON.stringify(config),
status: "connecting",
disabled: config.disabled,
disabled: parsedConfig.disabled,
},
client,
transport,
}
this.connections.push(connection)
if (config.transportType === "stdio") {
// transport.stderr is only available after the process has been started. However we can't start it separately from the .connect() call because it also starts the transport. And we can't place this after the connect call since we need to capture the stderr stream before the connection is established, in order to capture errors during the connection process.
// As a workaround, we start the transport ourselves, and then monkey-patch the start method to no-op so that .connect() doesn't try to start it again.
await transport.start()
const stderrStream = (transport as StdioClientTransport).stderr
if (stderrStream) {
stderrStream.on("data", async (data: Buffer) => {
const output = data.toString()
// Check if output contains INFO level log
const isInfoLog = /^\s*INFO\b/.test(output)
if (isInfoLog) {
// Log normal informational messages
console.info(`Server "${name}" info:`, output)
} else {
// Treat as error log
console.error(`Server "${name}" stderr:`, output)
const connection = this.connections.find((conn) => conn.server.name === name)
if (connection) {
this.appendErrorMessage(connection, output)
// Only notify webview if server is already disconnected
if (connection.server.status === "disconnected") {
await this.notifyWebviewOfServerChanges()
}
}
// transport.stderr is only available after the process has been started. However we can't start it separately from the .connect() call because it also starts the transport. And we can't place this after the connect call since we need to capture the stderr stream before the connection is established, in order to capture errors during the connection process.
// As a workaround, we start the transport ourselves, and then monkey-patch the start method to no-op so that .connect() doesn't try to start it again.
await transport.start()
const stderrStream = transport.stderr
if (stderrStream) {
stderrStream.on("data", async (data: Buffer) => {
const errorOutput = data.toString()
console.error(`Server "${name}" stderr:`, errorOutput)
const connection = this.connections.find((conn) => conn.server.name === name)
if (connection) {
// NOTE: we do not set server status to "disconnected" because stderr logs do not necessarily mean the server crashed or disconnected, it could just be informational. In fact when the server first starts up, it immediately logs "<name> server running on stdio" to stderr.
this.appendErrorMessage(connection, errorOutput)
// Only need to update webview right away if it's already disconnected
if (connection.server.status === "disconnected") {
await this.notifyWebviewOfServerChanges()
}
})
} else {
console.error(`No stderr stream for ${name}`)
}
transport.start = async () => {} // No-op now, .connect() won't fail
}
})
} else {
console.error(`No stderr stream for ${name}`)
}
transport.start = async () => {} // No-op now, .connect() won't fail
// Connect
await client.connect(transport)
connection.server.status = "connected"
connection.server.error = ""
@@ -312,15 +272,9 @@ export class McpHub {
private async fetchToolsList(serverName: string): Promise<McpTool[]> {
try {
const connection = this.connections.find((conn) => conn.server.name === serverName)
if (!connection) {
throw new Error(`No connection found for server: ${serverName}`)
}
const response = await connection.client.request({ method: "tools/list" }, ListToolsResultSchema, {
timeout: DEFAULT_REQUEST_TIMEOUT_MS,
})
const response = await this.connections
.find((conn) => conn.server.name === serverName)
?.client.request({ method: "tools/list" }, ListToolsResultSchema)
// Get autoApprove settings
const settingsPath = await this.getMcpSettingsFilePath()
@@ -346,7 +300,7 @@ export class McpHub {
try {
const response = await this.connections
.find((conn) => conn.server.name === serverName)
?.client.request({ method: "resources/list" }, ListResourcesResultSchema, { timeout: DEFAULT_REQUEST_TIMEOUT_MS })
?.client.request({ method: "resources/list" }, ListResourcesResultSchema)
return response?.resources || []
} catch (error) {
// console.error(`Failed to fetch resources for ${serverName}:`, error)
@@ -358,10 +312,7 @@ export class McpHub {
try {
const response = await this.connections
.find((conn) => conn.server.name === serverName)
?.client.request({ method: "resources/templates/list" }, ListResourceTemplatesResultSchema, {
timeout: DEFAULT_REQUEST_TIMEOUT_MS,
})
?.client.request({ method: "resources/templates/list" }, ListResourceTemplatesResultSchema)
return response?.resourceTemplates || []
} catch (error) {
// console.error(`Failed to fetch resource templates for ${serverName}:`, error)
@@ -382,7 +333,7 @@ export class McpHub {
}
}
async updateServerConnections(newServers: Record<string, McpServerConfig>): Promise<void> {
async updateServerConnections(newServers: Record<string, any>): Promise<void> {
this.isConnecting = true
this.removeAllFileWatchers()
const currentNames = new Set(this.connections.map((conn) => conn.server.name))
@@ -403,9 +354,7 @@ export class McpHub {
if (!currentConnection) {
// New server
try {
if (config.transportType === "stdio") {
this.setupFileWatcher(name, config)
}
this.setupFileWatcher(name, config)
await this.connectToServer(name, config)
} catch (error) {
console.error(`Failed to connect to new MCP server ${name}:`, error)
@@ -413,9 +362,7 @@ export class McpHub {
} else if (!deepEqual(JSON.parse(currentConnection.server.config), config)) {
// Existing server with changed config
try {
if (config.transportType === "stdio") {
this.setupFileWatcher(name, config)
}
this.setupFileWatcher(name, config)
await this.deleteConnection(name)
await this.connectToServer(name, config)
console.log(`Reconnected MCP server with updated config: ${name}`)
@@ -455,7 +402,7 @@ export class McpHub {
async restartConnection(serverName: string): Promise<void> {
this.isConnecting = true
const provider = this.controllerRef.deref()
const provider = this.providerRef.deref()
if (!provider) {
return
}
@@ -490,7 +437,7 @@ export class McpHub {
const content = await fs.readFile(settingsPath, "utf-8")
const config = JSON.parse(content)
const serverOrder = Object.keys(config.mcpServers || {})
await this.controllerRef.deref()?.postMessageToWebview({
await this.providerRef.deref()?.postMessageToWebview({
type: "mcpServers",
mcpServers: [...this.connections]
.sort((a, b) => {
@@ -511,21 +458,64 @@ export class McpHub {
// Public methods for server management
public async toggleServerDisabled(serverName: string, disabled: boolean): Promise<void> {
let settingsPath: string
try {
const config = await this.readAndValidateMcpSettingsFile()
if (!config) {
throw new Error("Failed to read or validate MCP settings")
settingsPath = await this.getMcpSettingsFilePath()
// Ensure the settings file exists and is accessible
try {
await fs.access(settingsPath)
} catch (error) {
console.error("Settings file not accessible:", error)
throw new Error("Settings file not accessible")
}
const content = await fs.readFile(settingsPath, "utf-8")
const config = JSON.parse(content)
// Validate the config structure
if (!config || typeof config !== "object") {
throw new Error("Invalid config structure")
}
if (!config.mcpServers || typeof config.mcpServers !== "object") {
config.mcpServers = {}
}
if (config.mcpServers[serverName]) {
config.mcpServers[serverName].disabled = disabled
// Create a new server config object to ensure clean structure
const serverConfig = {
...config.mcpServers[serverName],
disabled,
}
const settingsPath = await this.getMcpSettingsFilePath()
await fs.writeFile(settingsPath, JSON.stringify(config, null, 2))
// Ensure required fields exist
if (!serverConfig.autoApprove) {
serverConfig.autoApprove = []
}
config.mcpServers[serverName] = serverConfig
// Write the entire config back
const updatedConfig = {
mcpServers: config.mcpServers,
}
await fs.writeFile(settingsPath, JSON.stringify(updatedConfig, null, 2))
const connection = this.connections.find((conn) => conn.server.name === serverName)
if (connection) {
connection.server.disabled = disabled
try {
connection.server.disabled = disabled
// Only refresh capabilities if connected
if (connection.server.status === "connected") {
connection.server.tools = await this.fetchToolsList(serverName)
connection.server.resources = await this.fetchResourcesList(serverName)
connection.server.resourceTemplates = await this.fetchResourceTemplatesList(serverName)
}
} catch (error) {
console.error(`Failed to refresh capabilities for ${serverName}:`, error)
}
}
await this.notifyWebviewOfServerChanges()
@@ -578,7 +568,7 @@ export class McpHub {
try {
const config = JSON.parse(connection.server.config)
const parsedConfig = ServerConfigSchema.parse(config)
const parsedConfig = StdioConfigSchema.parse(config)
timeout = secondsToMs(parsedConfig.timeout)
} catch (error) {
console.error(`Failed to parse timeout configuration for server ${serverName}: ${error}`)
@@ -599,7 +589,7 @@ export class McpHub {
)
}
async toggleToolAutoApprove(serverName: string, toolNames: string[], shouldAllow: boolean): Promise<void> {
async toggleToolAutoApprove(serverName: string, toolName: string, shouldAllow: boolean): Promise<void> {
try {
const settingsPath = await this.getMcpSettingsFilePath()
const content = await fs.readFile(settingsPath, "utf-8")
@@ -611,16 +601,14 @@ export class McpHub {
}
const autoApprove = config.mcpServers[serverName].autoApprove
for (const toolName of toolNames) {
const toolIndex = autoApprove.indexOf(toolName)
const toolIndex = autoApprove.indexOf(toolName)
if (shouldAllow && toolIndex === -1) {
// Add tool to autoApprove list
autoApprove.push(toolName)
} else if (!shouldAllow && toolIndex !== -1) {
// Remove tool from autoApprove list
autoApprove.splice(toolIndex, 1)
}
if (shouldAllow && toolIndex === -1) {
// Add tool to autoApprove list
autoApprove.push(toolName)
} else if (!shouldAllow && toolIndex !== -1) {
// Remove tool from autoApprove list
autoApprove.splice(toolIndex, 1)
}
await fs.writeFile(settingsPath, JSON.stringify(config, null, 2))
@@ -628,6 +616,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 = await this.fetchToolsList(serverName)
await this.notifyWebviewOfServerChanges()
}
} catch (error) {
@@ -637,53 +626,6 @@ export class McpHub {
}
}
public async addRemoteServer(serverName: string, serverUrl: string) {
try {
const settings = await this.readAndValidateMcpSettingsFile()
if (!settings) {
throw new Error("Failed to read MCP settings")
}
if (settings.mcpServers[serverName]) {
throw new Error(`An MCP server with the name "${serverName}" already exists`)
}
const urlValidation = z.string().url().safeParse(serverUrl)
if (!urlValidation.success) {
throw new Error(`Invalid server URL: ${serverUrl}. Please provide a valid URL.`)
}
const serverConfig = {
url: serverUrl,
disabled: false,
autoApprove: [],
}
const parsedConfig = ServerConfigSchema.parse(serverConfig)
settings.mcpServers[serverName] = parsedConfig
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 this.updateServerConnections(settings.mcpServers)
vscode.window.showInformationMessage(`Added ${serverName} MCP server`)
} catch (error) {
console.error("Failed to add remote MCP server:", error)
throw error
}
}
public async deleteServer(serverName: string) {
try {
const settingsPath = await this.getMcpSettingsFilePath()
@@ -714,7 +656,7 @@ export class McpHub {
public async updateServerTimeout(serverName: string, timeout: number): Promise<void> {
try {
// Validate timeout against schema
const setConfigResult = BaseConfigSchema.shape.timeout.safeParse(timeout)
const setConfigResult = StdioConfigSchema.shape.timeout.safeParse(timeout)
if (!setConfigResult.success) {
throw new Error(`Invalid timeout value: ${timeout}. Must be at minimum ${MIN_MCP_TIMEOUT_SECONDS} seconds.`)
}
@@ -0,0 +1,312 @@
import { context, SpanKind, trace } from "@opentelemetry/api"
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http"
import { Resource } from "@opentelemetry/resources"
import { SimpleSpanProcessor } from "@opentelemetry/sdk-trace-base"
import { NodeTracerProvider } from "@opentelemetry/sdk-trace-node"
import { ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION } from "@opentelemetry/semantic-conventions"
import { Anthropic } from "@anthropic-ai/sdk"
import * as vscode from "vscode"
import { ClineProvider } from "../../core/webview/ClineProvider"
export type TelemetryChatMessage = {
role: "user" | "assistant" | "system"
ts: number
content: Anthropic.Messages.MessageParam["content"]
}
interface ConversationMetadata {
apiProvider?: string
model?: string
tokensIn: number
tokensOut: number
}
const { IS_DEV } = process.env
/**
Cline Telemetry (currently only available in DEV builds)
Advanced Setting to opt-in to LLM observability, allowing you to share message data, code, and more extensive telemetry to help improve prompts used in Cline, train our models, and understand failure states more accurately.
"cline.conversationTelemetry": {
"type": "boolean",
"default": false,
"markdownDescription": "Share message data, code, and more extensive telemetry. This data may be used to improve prompts used in Cline, train models, and understand failure states more accurately. [Learn more](https://docs.cline.bot/more-info/llm-observability)"
}
*/
export class ConversationTelemetryService {
private providerRef: WeakRef<ClineProvider>
private distinctId: string = vscode.env.machineId
private apiEndpoint: string = "https://api.cline.bot/v1/traces"
private tracerProvider: NodeTracerProvider | undefined
private tracer: any
private messageIndices: Map<string, number> = new Map()
constructor(provider: ClineProvider) {
this.providerRef = new WeakRef(provider)
}
private async getClineApiKey(): Promise<string | undefined> {
const provider = this.providerRef.deref()
if (!provider) {
return undefined
}
const { apiConfiguration } = await provider.getStateToPostToWebview()
return apiConfiguration?.clineApiKey
}
public isOptedInToConversationTelemetry(): boolean {
// First check global telemetry level - telemetry should only be enabled when level is "all"
const telemetryLevel = vscode.workspace.getConfiguration("telemetry").get<string>("telemetryLevel", "all")
const isGlobalTelemetryEnabled = telemetryLevel === "all"
// User has to manually opt in to conversation telemetry in Advanced Settings
const isConversationTelemetryEnabled =
vscode.workspace.getConfiguration("cline").get<boolean>("conversationTelemetry") ?? false
// Currently only enabled in dev environment
const isDevEnvironment = !!IS_DEV
return isDevEnvironment && isGlobalTelemetryEnabled && isConversationTelemetryEnabled
}
private async initializeTracer() {
try {
// Create a resource that identifies our service
const resource = new Resource({
[ATTR_SERVICE_NAME]: "cline-extension",
[ATTR_SERVICE_VERSION]: "1.0.0",
})
const clineApiKey = await this.getClineApiKey()
console.log("[ConversationTelemetry] Initializing OpenTelemetry tracer...")
// Configure the OTLP exporter
const headers: Record<string, string> = {
"Content-Type": "application/json",
}
// Add API key to headers if available
if (clineApiKey) {
headers["Authorization"] = `Bearer ${clineApiKey}`
}
const exporter = new OTLPTraceExporter({
url: this.apiEndpoint,
headers,
})
// Create the span processor
const spanProcessor = new SimpleSpanProcessor(exporter as any)
// Create the trace provider with the span processor in the config
this.tracerProvider = new NodeTracerProvider({
resource,
spanProcessors: [spanProcessor as any],
})
// Register the provider
this.tracerProvider.register()
// Get a tracer
this.tracer = trace.getTracer("cline-conversation-tracer")
console.log("[ConversationTelemetry] OpenTelemetry tracer initialized successfully")
} catch (error) {
console.error("[ConversationTelemetry] Failed to initialize OpenTelemetry tracer:", error)
}
}
/**
* Captures a message in the conversation as an OpenTelemetry span
* ONLY HAPPENS IF USER IS OPTED INTO CONVERSATION TELEMETRY IN ADVANCED SETTINGS
*/
public async captureMessage(taskId: string, message: TelemetryChatMessage, metadata: ConversationMetadata) {
// Do NOT capture message if user has not explicitly opted in
if (!this.isOptedInToConversationTelemetry()) {
return
}
if (!this.tracer) {
await this.initializeTracer()
}
try {
// Convert taskId to a valid trace ID (must be 32 hex chars)
const traceId = this.generateTraceIdFromTimestamp(taskId)
// Convert message timestamp to a valid span ID (must be 16 hex chars)
if (!message.ts && message.ts !== 0) {
throw new Error("Message timestamp is required")
}
const timestamp = message.ts
const spanId = this.generateSpanIdFromTimestamp(timestamp)
// Create a span context with our IDs
const spanContext = trace.setSpanContext(context.active(), {
traceId,
spanId,
isRemote: false,
traceFlags: 1, // Sampled
})
// Start a new span with the context
const span = this.tracer.startSpan(
`message.${message.role}`,
{
kind: SpanKind.CLIENT,
startTime: this.millisecondsToHrTime(timestamp), // Convert to nanoseconds
},
spanContext,
)
// Get the message index for this task
const messageIndex = this.getNextMessageIndex(taskId)
// Add attributes to the span
span.setAttribute("task.id", taskId)
span.setAttribute("user.id", this.distinctId)
span.setAttribute("message.role", message.role)
span.setAttribute("message.timestamp", timestamp)
span.setAttribute("message.index", messageIndex)
const c = message.content
// Add Braintrust-compatible attributes
span.setAttribute("gen_ai.request.model", metadata.model)
if (message.role === "user") {
span.setAttribute("gen_ai.prompt", this.extractContent(message))
} else if (message.role === "assistant") {
span.setAttribute("gen_ai.completion", this.extractContent(message))
span.setAttribute("gen_ai.usage.prompt_tokens", metadata.tokensIn)
span.setAttribute("gen_ai.usage.completion_tokens", metadata.tokensOut)
} else if (message.role === "system") {
span.setAttribute("gen_ai.system_prompt", this.extractContent(message))
}
// Add custom metadata in Braintrust format
span.setAttribute("braintrust.metadata.api_provider", metadata.apiProvider)
span.setAttribute("braintrust.metadata.ts", message.ts)
// End the span immediately since messages are discrete events
span.end(this.millisecondsToHrTime(timestamp)) // Convert to nanoseconds
console.log(`[ConversationTelemetry] Captured ${message.role} message for task ${taskId}`, { span })
} catch (error) {
console.error("[ConversationTelemetry] Error capturing message:", error)
}
}
/**
* Convert a decimal timestamp to a valid trace ID (32 hex chars)
*/
private generateTraceIdFromTimestamp(timestamp: string): string {
// Pad with zeros and convert to hex
const hex = BigInt(timestamp).toString(16).padStart(32, "0")
return hex.substring(0, 32) // Ensure it's exactly 32 chars
}
/**
* Converts milliseconds to high-resolution time format expected by OpenTelemetry
* Returns [seconds, nanoseconds]
*/
private millisecondsToHrTime(milliseconds: number): [number, number] {
return [
Math.floor(milliseconds / 1000), // seconds
(milliseconds % 1000) * 1000000, // nanoseconds (remainder in ms * 10^6)
]
}
/**
* Convert a decimal timestamp to a valid span ID (16 hex chars)
*/
private generateSpanIdFromTimestamp(timestamp: number): string {
// Pad with zeros and convert to hex
const hex = BigInt(timestamp).toString(16).padStart(16, "0")
return hex.substring(0, 16) // Ensure it's exactly 16 chars
}
/**
* Helper to extract content from different message formats
*/
private extractContent(message: TelemetryChatMessage): string {
if (typeof message.content === "string") {
return message.content
}
return message.content
.map((block) => (block.type === "text" ? block.text : null))
.filter(Boolean)
.join("\n")
}
/**
* Track message indices per task
*/
private getNextMessageIndex(taskId: string): number {
const currentIndex = this.messageIndices.get(taskId) || 0
this.messageIndices.set(taskId, currentIndex + 1)
return currentIndex
}
/**
* Sends conversation data to cleanup endpoint to remove deleted messages from telemetry
* ONLY HAPPENS IF USER IS OPTED INTO CONVERSATION TELEMETRY IN ADVANCED SETTINGS
*/
public async cleanupTask(taskId: string, conversationData: any): Promise<void> {
// Do NOT send data if user has not explicitly opted in
if (!this.isOptedInToConversationTelemetry()) {
return
}
const clineApiKey = await this.getClineApiKey()
if (!clineApiKey) {
return
}
try {
// Configure the headers with API key
const headers: Record<string, string> = {
"Content-Type": "application/json",
}
// Add API key to headers
headers["Authorization"] = `Bearer ${clineApiKey}`
// Send the data to the cleanup endpoint
const cleanupEndpoint = `${this.apiEndpoint.replace("/traces", "/traces/cleanup")}`
// Use fetch API to send the data
const response = await fetch(cleanupEndpoint, {
method: "POST",
headers,
body: JSON.stringify({
taskId: taskId,
conversationData,
userId: this.distinctId,
}),
})
if (!response.ok) {
throw new Error(`Failed to send cleanup data: ${response.status} ${response.statusText}`)
}
console.log(`[ConversationTelemetry] Cleanup data sent for task ${taskId}`)
} catch (error) {
console.error("[ConversationTelemetry] Error sending cleanup data:", error)
}
}
/**
* Shutdown the tracer provider
*/
public async shutdown(): Promise<void> {
if (this.tracerProvider) {
await this.tracerProvider.shutdown()
}
}
}
@@ -2,8 +2,6 @@ import { PostHog } from "posthog-node"
import * as vscode from "vscode"
import { version as extensionVersion } from "../../../package.json"
import type { TaskFeedbackType } from "../../shared/WebviewMessage"
/**
* PostHogClient handles telemetry event tracking for the Cline extension
* Uses PostHog analytics to track user interactions and system events
@@ -20,8 +18,6 @@ class PostHogClient {
RESTARTED: "task.restarted",
// Tracks when a task is finished, with acceptance or rejection status
COMPLETED: "task.completed",
// Tracks user feedback on completed tasks
FEEDBACK: "task.feedback",
// Tracks when a message is sent in a conversation
CONVERSATION_TURN: "task.conversation_turn",
// Tracks token consumption for cost and usage analysis
@@ -36,8 +32,6 @@ class PostHogClient {
HISTORICAL_LOADED: "task.historical_loaded",
// Tracks when the retry button is clicked for failed operations
RETRY_CLICKED: "task.retry_clicked",
// Tracks when a diff edit (replace_in_file) operation fails
DIFF_EDIT_FAILED: "task.diff_edit_failed",
},
// UI interaction events for tracking user engagement
UI: {
@@ -240,22 +234,6 @@ class PostHogClient {
})
}
/**
* Records user feedback on completed tasks
* @param taskId Unique identifier for the task
* @param feedbackType The type of feedback ("thumbs_up" or "thumbs_down")
*/
public captureTaskFeedback(taskId: string, feedbackType: TaskFeedbackType) {
console.info("TelemetryService: Capturing task feedback", { taskId, feedbackType })
this.capture({
event: PostHogClient.EVENTS.TASK.FEEDBACK,
properties: {
taskId,
feedbackType,
},
})
}
// Tool events
/**
* Records when a tool is used during task execution
@@ -399,21 +377,6 @@ class PostHogClient {
})
}
/**
* Records when a diff edit (replace_in_file) operation fails
* @param taskId Unique identifier for the task
* @param errorType Type of error that occurred (e.g., "search_not_found", "invalid_format")
*/
public captureDiffEditFailure(taskId: string, errorType?: string) {
this.capture({
event: PostHogClient.EVENTS.TASK.DIFF_EDIT_FAILED,
properties: {
taskId,
errorType,
},
})
}
/**
* Records when a different model is selected for use
* @param model Name of the selected model
+1 -7
View File
@@ -35,7 +35,6 @@ export interface ExtensionMessage {
| "openGraphData"
| "isImageUrlResult"
| "didUpdateSettings"
| "addRemoteServerResult"
| "userCreditsBalance"
| "userCreditsUsage"
| "userCreditsPayments"
@@ -81,11 +80,6 @@ export interface ExtensionMessage {
userCreditsUsage?: UsageTransaction[]
userCreditsPayments?: PaymentTransaction[]
totalTasksSize?: number | null
addRemoteServerResult?: {
success: boolean
serverName: string
error?: string
}
}
export type Invoke = "sendMessage" | "primaryButtonClick" | "secondaryButtonClick"
@@ -136,7 +130,7 @@ export interface ClineMessage {
export type ClineAsk =
| "followup"
| "plan_mode_respond"
| "plan_mode_response"
| "command"
| "command_output"
| "completion_result"
+1 -8
View File
@@ -8,7 +8,6 @@ import { TelemetrySetting } from "./TelemetrySetting"
export interface WebviewMessage {
type:
| "addRemoteServer"
| "apiConfiguration"
| "webviewDidLaunch"
| "newTask"
@@ -66,7 +65,6 @@ export interface WebviewMessage {
| "fetchUserCreditsData"
| "optionsResponse"
| "requestTotalTasksSize"
| "taskFeedback"
// | "relaunchChromeDebugMode"
text?: string
disabled?: boolean
@@ -83,8 +81,7 @@ export interface WebviewMessage {
timeout?: number
// For toggleToolAutoApprove
serverName?: string
serverUrl?: string
toolNames?: string[]
toolName?: string
autoApprove?: boolean
// For auth
@@ -95,12 +92,8 @@ export interface WebviewMessage {
planActSeparateModelsSetting?: boolean
telemetrySetting?: TelemetrySetting
customInstructionsSetting?: string
// For task feedback
feedbackType?: TaskFeedbackType
}
export type ClineAskResponse = "yesButtonClicked" | "noButtonClicked" | "messageResponse"
export type ClineCheckpointRestore = "task" | "workspace" | "taskAndWorkspace"
export type TaskFeedbackType = "thumbs_up" | "thumbs_down"
+3 -22
View File
@@ -24,7 +24,6 @@ export interface ApiHandlerOptions {
apiModelId?: string
apiKey?: string // anthropic
clineApiKey?: string
taskId?: string // Used to identify the task in API requests
liteLlmBaseUrl?: string
liteLlmModelId?: string
liteLlmApiKey?: string
@@ -94,7 +93,6 @@ export interface ModelInfo {
export interface OpenAiCompatibleModelInfo extends ModelInfo {
temperature?: number
isR1FormatRequired?: boolean
}
// Anthropic
@@ -368,9 +366,9 @@ export const vertexModels = {
inputPrice: 0,
outputPrice: 0,
},
"gemini-2.5-pro-exp-03-25": {
maxTokens: 65536,
contextWindow: 1_048_576,
"gemini-2.0-pro-exp-02-05": {
maxTokens: 8192,
contextWindow: 2_097_152,
supportsImages: true,
supportsPromptCache: false,
inputPrice: 0,
@@ -439,7 +437,6 @@ export const openAiModelInfoSaneDefaults: OpenAiCompatibleModelInfo = {
contextWindow: 128_000,
supportsImages: true,
supportsPromptCache: false,
isR1FormatRequired: false,
inputPrice: 0,
outputPrice: 0,
temperature: 0,
@@ -450,14 +447,6 @@ export const openAiModelInfoSaneDefaults: OpenAiCompatibleModelInfo = {
export type GeminiModelId = keyof typeof geminiModels
export const geminiDefaultModelId: GeminiModelId = "gemini-2.0-flash-001"
export const geminiModels = {
"gemini-2.5-pro-exp-03-25": {
maxTokens: 65536,
contextWindow: 1_048_576,
supportsImages: true,
supportsPromptCache: false,
inputPrice: 0,
outputPrice: 0,
},
"gemini-2.0-flash-001": {
maxTokens: 8192,
contextWindow: 1_048_576,
@@ -1418,12 +1407,4 @@ export const sambanovaModels = {
inputPrice: 0.5,
outputPrice: 1.0,
},
"DeepSeek-V3-0324": {
maxTokens: 4096,
contextWindow: 8192,
supportsImages: false,
supportsPromptCache: false,
inputPrice: 1.0,
outputPrice: 1.5,
},
} as const satisfies Record<string, ModelInfo>
+3 -3
View File
@@ -23,13 +23,13 @@ export function combineApiRequests(messages: ClineMessage[]): ClineMessage[] {
for (let i = 0; i < messages.length; i++) {
if (messages[i].type === "say" && messages[i].say === "api_req_started") {
const startedRequest = JSON.parse(messages[i].text || "{}")
let startedRequest = JSON.parse(messages[i].text || "{}")
let j = i + 1
while (j < messages.length) {
if (messages[j].type === "say" && messages[j].say === "api_req_finished") {
const finishedRequest = JSON.parse(messages[j].text || "{}")
const combinedRequest = {
let finishedRequest = JSON.parse(messages[j].text || "{}")
let combinedRequest = {
...startedRequest,
...finishedRequest,
}
-57
View File
@@ -1,57 +0,0 @@
import { describe, it } from "mocha"
import "should"
import { fixModelHtmlEscaping, removeInvalidChars } from "./string"
describe("fixModelHtmlEscaping", () => {
it("should convert &gt; to >", () => {
fixModelHtmlEscaping("foo &gt; bar").should.equal("foo > bar")
})
it("should convert &lt; to <", () => {
fixModelHtmlEscaping("foo &lt; bar").should.equal("foo < bar")
})
it('should convert &quot; to "', () => {
fixModelHtmlEscaping("foo &quot;bar&quot;").should.equal('foo "bar"')
})
it("should convert &amp; to &", () => {
fixModelHtmlEscaping("foo &amp; bar").should.equal("foo & bar")
})
it("should convert &apos; to '", () => {
fixModelHtmlEscaping("foo &apos;bar&apos;").should.equal("foo 'bar'")
})
it("should handle multiple entities in the same string", () => {
fixModelHtmlEscaping("&lt;div&gt;Hello &quot;World&quot; &amp; &apos;Universe&apos;&lt;/div&gt;").should.equal(
"<div>Hello \"World\" & 'Universe'</div>",
)
})
it("should return unchanged string when no HTML entities are present", () => {
fixModelHtmlEscaping("normal string").should.equal("normal string")
})
})
describe("removeInvalidChars", () => {
it("should remove replacement characters", () => {
removeInvalidChars("hello\uFFFDworld").should.equal("helloworld")
})
it("should remove characters", () => {
removeInvalidChars("helloworld").should.equal("helloworld")
})
it("should remove multiple replacement characters", () => {
removeInvalidChars("h\uFFFDe\uFFFDl\uFFFDl\uFFFDo").should.equal("hello")
})
it("should remove multiple characters", () => {
removeInvalidChars("hello").should.equal("hello")
})
it("should return unchanged string when no replacement characters are present", () => {
removeInvalidChars("normal string").should.equal("normal string")
})
})
+103 -612
View File
File diff suppressed because it is too large Load Diff
+3 -5
View File
@@ -9,11 +9,11 @@
"preview": "vite preview",
"lint": "eslint .",
"test": "vitest run",
"test:watch": "vitest dev",
"test:coverage": "vitest run --coverage"
"test:watch": "vitest dev"
},
"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,14 +40,12 @@
"@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",
"@types/react-dom": "^18.3.5",
"@types/vscode-webview": "^1.57.5",
"@vitejs/plugin-react-swc": "^3.5.0",
"@vitest/coverage-v8": "^3.0.9",
"eslint": "^9.17.0",
"eslint-plugin-react-hooks": "^5.0.0",
"eslint-plugin-react-refresh": "^0.4.16",
@@ -56,7 +54,7 @@
"tailwindcss": "^4.0.12",
"typescript": "^5.7.3",
"typescript-eslint": "^8.18.2",
"vite": "^6.2.4",
"vite": "^6.2.1",
"vitest": "^3.0.5"
}
}
@@ -7,7 +7,6 @@ import ClineLogoWhite from "../../assets/ClineLogoWhite"
import CountUp from "react-countup"
import CreditsHistoryTable from "./CreditsHistoryTable"
import { UsageTransaction, PaymentTransaction } from "../../../../src/shared/ClineAccount"
import { useExtensionState } from "../../context/ExtensionStateContext"
type AccountViewProps = {
onDone: () => void
@@ -30,11 +29,7 @@ const AccountView = ({ onDone }: AccountViewProps) => {
}
export const ClineAccountView = () => {
const { user: firebaseUser, handleSignOut } = useFirebaseAuth()
const { userInfo, apiConfiguration } = useExtensionState()
let user = apiConfiguration?.clineApiKey ? firebaseUser || userInfo : undefined
const { user, handleSignOut } = useFirebaseAuth()
const [balance, setBalance] = useState(0)
const [isLoading, setIsLoading] = useState(true)
const [usageData, setUsageData] = useState<UsageTransaction[]>([])
@@ -152,7 +147,7 @@ export const ClineAccountView = () => {
</div>
</div>
) : (
<div className="flex flex-col items-center pr-3">
<div className="flex flex-col items-center pr-3 max-w-[400px]">
<ClineLogoWhite className="size-16 mb-4" />
<p style={{}}>
+2 -25
View File
@@ -30,7 +30,6 @@ import CreditLimitError from "./CreditLimitError"
import { OptionsButtons } from "./OptionsButtons"
import { highlightMentions } from "./TaskHeader"
import SuccessButton from "../common/SuccessButton"
import TaskFeedbackButtons from "./TaskFeedbackButtons"
const ChatRowContainer = styled.div`
padding: 10px 6px 10px 15px;
@@ -1001,17 +1000,6 @@ export const ChatRowContent = ({ message, isExpanded, onToggleExpand, lastModifi
}}>
{icon}
{title}
<TaskFeedbackButtons
messageTs={message.ts}
isFromHistory={
!isLast ||
lastModifiedMessage?.ask === "resume_completed_task" ||
lastModifiedMessage?.ask === "resume_task"
}
style={{
marginLeft: "auto",
}}
/>
</div>
<div
style={{
@@ -1154,17 +1142,6 @@ export const ChatRowContent = ({ message, isExpanded, onToggleExpand, lastModifi
}}>
{icon}
{title}
<TaskFeedbackButtons
messageTs={message.ts}
isFromHistory={
!isLast ||
lastModifiedMessage?.ask === "resume_completed_task" ||
lastModifiedMessage?.ask === "resume_task"
}
style={{
marginLeft: "auto",
}}
/>
</div>
<div
style={{
@@ -1233,7 +1210,7 @@ export const ChatRowContent = ({ message, isExpanded, onToggleExpand, lastModifi
</div>
</>
)
case "plan_mode_respond": {
case "plan_mode_response": {
let response: string | undefined
let options: string[] | undefined
let selected: string | undefined
@@ -1252,7 +1229,7 @@ export const ChatRowContent = ({ message, isExpanded, onToggleExpand, lastModifi
<OptionsButtons
options={options}
selected={selected}
isActive={isLast && lastModifiedMessage?.ask === "plan_mode_respond"}
isActive={isLast && lastModifiedMessage?.ask === "plan_mode_response"}
/>
</div>
)
+3 -3
View File
@@ -119,9 +119,9 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
// setPrimaryButtonText(undefined)
// setSecondaryButtonText(undefined)
break
case "plan_mode_respond":
case "plan_mode_response":
setTextAreaDisabled(isPartial)
setClineAsk("plan_mode_respond")
setClineAsk("plan_mode_response")
setEnableButtons(false)
// setPrimaryButtonText(undefined)
// setSecondaryButtonText(undefined)
@@ -285,7 +285,7 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
} else if (clineAsk) {
switch (clineAsk) {
case "followup":
case "plan_mode_respond":
case "plan_mode_response":
case "tool":
case "browser_action_launch":
case "command": // user can provide feedback to a tool or command use
@@ -1,126 +0,0 @@
import React, { useState, useEffect } from "react"
import styled from "styled-components"
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
isFromHistory?: boolean
style?: React.CSSProperties
}
const IconWrapper = styled.span`
color: var(--vscode-descriptionForeground);
`
const ButtonWrapper = styled.div`
transform: scale(0.85);
`
const TaskFeedbackButtons: React.FC<TaskFeedbackButtonsProps> = ({ messageTs, isFromHistory = false, style }) => {
const [feedback, setFeedback] = useState<TaskFeedbackType | null>(null)
const [shouldShow, setShouldShow] = useState<boolean>(true)
// Check localStorage on mount to see if feedback was already given for this message
useEffect(() => {
try {
const feedbackHistory = localStorage.getItem("taskFeedbackHistory") || "{}"
const history = JSON.parse(feedbackHistory)
// Check if this specific message timestamp has received feedback
if (history[messageTs]) {
setShouldShow(false)
}
} catch (e) {
console.error("Error checking feedback history:", e)
}
}, [messageTs])
// Don't show buttons if this is from history or feedback was already given
if (isFromHistory || !shouldShow) {
return null
}
const handleFeedback = (type: TaskFeedbackType) => {
if (feedback !== null) return // Already provided feedback
setFeedback(type)
// Send feedback to extension
vscode.postMessage({
type: "taskFeedback",
feedbackType: type,
})
// Store in localStorage that feedback was provided for this message
try {
const feedbackHistory = localStorage.getItem("taskFeedbackHistory") || "{}"
const history = JSON.parse(feedbackHistory)
history[messageTs] = true
localStorage.setItem("taskFeedbackHistory", JSON.stringify(history))
} catch (e) {
console.error("Error updating feedback history:", e)
}
}
return (
<Container style={style}>
<ButtonsContainer>
<ButtonWrapper>
<VSCodeButton
appearance="icon"
onClick={() => handleFeedback("thumbs_up")}
disabled={feedback !== null}
title="This was helpful"
aria-label="This was helpful">
<IconWrapper>
<span
className={`codicon ${feedback === "thumbs_up" ? "codicon-thumbsup-filled" : "codicon-thumbsup"}`}
/>
</IconWrapper>
</VSCodeButton>
</ButtonWrapper>
<ButtonWrapper>
<VSCodeButton
appearance="icon"
onClick={() => handleFeedback("thumbs_down")}
disabled={feedback !== null && feedback !== "thumbs_down"}
title="This wasn't helpful"
aria-label="This wasn't helpful">
<IconWrapper>
<span
className={`codicon ${feedback === "thumbs_down" ? "codicon-thumbsdown-filled" : "codicon-thumbsdown"}`}
/>
</IconWrapper>
</VSCodeButton>
</ButtonWrapper>
{/* <VSCodeButtonLink
href="https://github.com/cline/cline/issues/new?template=bug_report.yml"
appearance="icon"
title="Report a bug"
aria-label="Report a bug">
<span className="codicon codicon-bug" />
</VSCodeButtonLink> */}
</ButtonsContainer>
</Container>
)
}
const Container = styled.div`
display: flex;
align-items: center;
justify-content: flex-end;
`
const ButtonsContainer = styled.div`
display: flex;
gap: 0px;
opacity: 0.5;
&:hover {
opacity: 1;
}
`
export default TaskFeedbackButtons
@@ -85,7 +85,7 @@ const HistoryView = ({ onDone }: HistoryViewProps) => {
}, [presentableTasks])
const taskHistorySearchResults = useMemo(() => {
const results = searchQuery ? highlight(fuse.search(searchQuery)) : presentableTasks
let results = searchQuery ? highlight(fuse.search(searchQuery)) : presentableTasks
results.sort((a, b) => {
switch (sortOption) {
+3 -5
View File
@@ -11,15 +11,13 @@ type McpToolRowProps = {
const McpToolRow = ({ tool, serverName }: McpToolRowProps) => {
const { autoApprovalSettings } = useExtensionState()
// Accept the event object
const handleAutoApproveChange = (event: any) => {
// Only proceed if the event was triggered by a direct user interaction
if (!serverName || !event.isTrusted) return
const handleAutoApproveChange = () => {
if (!serverName) return
vscode.postMessage({
type: "toggleToolAutoApprove",
serverName,
toolNames: [tool.name],
toolName: tool.name,
autoApprove: !tool.autoApprove,
})
}
+4 -83
View File
@@ -6,7 +6,6 @@ import {
VSCodePanelView,
VSCodeDropdown,
VSCodeOption,
VSCodeCheckbox,
} from "@vscode/webview-ui-toolkit/react"
import { useEffect, useState } from "react"
import styled from "styled-components"
@@ -18,8 +17,6 @@ import McpMarketplaceView from "./marketplace/McpMarketplaceView"
import McpResourceRow from "./McpResourceRow"
import McpToolRow from "./McpToolRow"
import DangerButton from "../common/DangerButton"
import AddRemoteServerForm from "./tabs/AddRemoteServerForm"
import AddLocalServerForm from "./tabs/AddLocalServerForm"
type McpViewProps = {
onDone: () => void
@@ -28,7 +25,6 @@ type McpViewProps = {
const McpView = ({ onDone }: McpViewProps) => {
const { mcpServers: servers, mcpMarketplaceEnabled } = useExtensionState()
const [activeTab, setActiveTab] = useState(mcpMarketplaceEnabled ? "marketplace" : "installed")
const [activeSubTab, setActiveSubTab] = useState("remote")
const handleTabChange = (tab: string) => {
setActiveTab(tab)
@@ -84,9 +80,6 @@ const McpView = ({ onDone }: McpViewProps) => {
Marketplace
</TabButton>
)}
<TabButton isActive={activeTab === "addRemote"} onClick={() => handleTabChange("addRemote")}>
Add Server
</TabButton>
<TabButton isActive={activeTab === "installed"} onClick={() => handleTabChange("installed")}>
Installed
</TabButton>
@@ -95,33 +88,6 @@ const McpView = ({ onDone }: McpViewProps) => {
{/* Content container */}
<div style={{ width: "100%" }}>
{mcpMarketplaceEnabled && activeTab === "marketplace" && <McpMarketplaceView />}
{activeTab === "addRemote" && (
<div style={{ padding: "5px 0 0 0" }}>
<div
style={{
display: "flex",
gap: "1px",
padding: "0 20px 0 20px",
borderBottom: "1px solid var(--vscode-panel-border)",
}}>
<TabButton isActive={activeSubTab === "remote"} onClick={() => setActiveSubTab("remote")}>
Remote (SSE)
</TabButton>
<TabButton isActive={activeSubTab === "local"} onClick={() => setActiveSubTab("local")}>
Local (Command)
</TabButton>
</div>
<div>
{activeSubTab === "remote" && (
<AddRemoteServerForm onServerAdded={() => handleTabChange("installed")} />
)}
{activeSubTab === "local" && (
<AddLocalServerForm onServerAdded={() => handleTabChange("installed")} />
)}
</div>
</div>
)}
{activeTab === "installed" && (
<div style={{ padding: "16px 20px" }}>
<div
@@ -181,7 +147,8 @@ const McpView = ({ onDone }: McpViewProps) => {
onClick={() => {
vscode.postMessage({ type: "openMcpSettings" })
}}>
Edit Configuration
<span className="codicon codicon-server" style={{ marginRight: "6px" }}></span>
Configure MCP Servers
</VSCodeButton>
<div style={{ textAlign: "center" }}>
@@ -237,7 +204,7 @@ export const TabButton = ({
// Server Row Component
const ServerRow = ({ server }: { server: McpServer }) => {
const { mcpMarketplaceCatalog, autoApprovalSettings } = useExtensionState()
const { mcpMarketplaceCatalog } = useExtensionState()
const [isExpanded, setIsExpanded] = useState(false)
const [isDeleting, setIsDeleting] = useState(false)
@@ -304,17 +271,6 @@ const ServerRow = ({ server }: { server: McpServer }) => {
})
}
const handleAutoApproveChange = () => {
if (!server.name) return
vscode.postMessage({
type: "toggleToolAutoApprove",
serverName: server.name,
toolNames: server.tools?.map((tool) => tool.name) || [],
autoApprove: !server.tools?.every((tool) => tool.autoApprove),
})
}
return (
<div style={{ marginBottom: "10px" }}>
<div
@@ -343,33 +299,7 @@ const ServerRow = ({ server }: { server: McpServer }) => {
}}>
{getMcpServerDisplayName(server.name, mcpMarketplaceCatalog)}
</span>
{/* Collapsed view controls */}
{!server.error && (
<div style={{ display: "flex", alignItems: "center", gap: "4px", marginLeft: "8px" }}>
<VSCodeButton
appearance="icon"
title="Restart Server"
onClick={(e) => {
e.stopPropagation()
handleRestart()
}}
disabled={server.status === "connecting"}>
<span className="codicon codicon-sync"></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 */}
<div style={{ display: "flex", alignItems: "center", marginLeft: "8px" }} onClick={(e) => e.stopPropagation()}>
<div style={{ display: "flex", alignItems: "center", marginRight: "8px" }} onClick={(e) => e.stopPropagation()}>
<div
role="switch"
aria-checked={!server.disabled}
@@ -492,15 +422,6 @@ const ServerRow = ({ server }: { server: McpServer }) => {
{server.tools.map((tool) => (
<McpToolRow key={tool.name} tool={tool} serverName={server.name} />
))}
{server.name && autoApprovalSettings.enabled && autoApprovalSettings.actions.useMcp && (
<VSCodeCheckbox
style={{ marginBottom: -10 }}
checked={server.tools.every((tool) => tool.autoApprove)}
onChange={handleAutoApproveChange}
data-tool="all-tools">
Auto-approve all tools
</VSCodeCheckbox>
)}
</div>
) : (
<div
@@ -1,41 +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
@@ -733,7 +733,7 @@ const ApiOptions = ({ showModelOptions, apiErrorMessage, modelIdErrorMessage, is
This key is stored locally and only used to make API requests from this extension.
{!apiConfiguration?.geminiApiKey && (
<VSCodeLink
href="https://aistudio.google.com/apikey"
href="https://ai.google.dev/"
style={{
display: "inline",
fontSize: "inherit",
@@ -820,7 +820,7 @@ const ApiOptions = ({ showModelOptions, apiErrorMessage, modelIdErrorMessage, is
checked={!!apiConfiguration?.openAiModelInfo?.supportsImages}
onChange={(e: any) => {
const isChecked = e.target.checked === true
const modelInfo = apiConfiguration?.openAiModelInfo
let modelInfo = apiConfiguration?.openAiModelInfo
? apiConfiguration.openAiModelInfo
: { ...openAiModelInfoSaneDefaults }
modelInfo.supportsImages = isChecked
@@ -846,22 +846,6 @@ const ApiOptions = ({ showModelOptions, apiErrorMessage, modelIdErrorMessage, is
}}>
Supports Computer Use
</VSCodeCheckbox>
<VSCodeCheckbox
checked={!!apiConfiguration?.openAiModelInfo?.isR1FormatRequired}
onChange={(e: any) => {
const isChecked = e.target.checked === true
let modelInfo = apiConfiguration?.openAiModelInfo
? apiConfiguration.openAiModelInfo
: { ...openAiModelInfoSaneDefaults }
modelInfo = { ...modelInfo, isR1FormatRequired: isChecked }
setApiConfiguration({
...apiConfiguration,
openAiModelInfo: modelInfo,
})
}}>
Enable R1 messages format
</VSCodeCheckbox>
<div style={{ display: "flex", gap: 10, marginTop: "5px" }}>
<VSCodeTextField
value={
@@ -871,7 +855,7 @@ const ApiOptions = ({ showModelOptions, apiErrorMessage, modelIdErrorMessage, is
}
style={{ flex: 1 }}
onInput={(input: any) => {
const modelInfo = apiConfiguration?.openAiModelInfo
let modelInfo = apiConfiguration?.openAiModelInfo
? apiConfiguration.openAiModelInfo
: { ...openAiModelInfoSaneDefaults }
modelInfo.contextWindow = Number(input.target.value)
@@ -890,7 +874,7 @@ const ApiOptions = ({ showModelOptions, apiErrorMessage, modelIdErrorMessage, is
}
style={{ flex: 1 }}
onInput={(input: any) => {
const modelInfo = apiConfiguration?.openAiModelInfo
let modelInfo = apiConfiguration?.openAiModelInfo
? apiConfiguration.openAiModelInfo
: { ...openAiModelInfoSaneDefaults }
modelInfo.maxTokens = input.target.value
@@ -911,7 +895,7 @@ const ApiOptions = ({ showModelOptions, apiErrorMessage, modelIdErrorMessage, is
}
style={{ flex: 1 }}
onInput={(input: any) => {
const modelInfo = apiConfiguration?.openAiModelInfo
let modelInfo = apiConfiguration?.openAiModelInfo
? apiConfiguration.openAiModelInfo
: { ...openAiModelInfoSaneDefaults }
modelInfo.inputPrice = input.target.value
@@ -930,7 +914,7 @@ const ApiOptions = ({ showModelOptions, apiErrorMessage, modelIdErrorMessage, is
}
style={{ flex: 1 }}
onInput={(input: any) => {
const modelInfo = apiConfiguration?.openAiModelInfo
let modelInfo = apiConfiguration?.openAiModelInfo
? apiConfiguration.openAiModelInfo
: { ...openAiModelInfoSaneDefaults }
modelInfo.outputPrice = input.target.value
@@ -950,7 +934,7 @@ const ApiOptions = ({ showModelOptions, apiErrorMessage, modelIdErrorMessage, is
: openAiModelInfoSaneDefaults.temperature?.toString()
}
onInput={(input: any) => {
const modelInfo = apiConfiguration?.openAiModelInfo
let modelInfo = apiConfiguration?.openAiModelInfo
? apiConfiguration.openAiModelInfo
: { ...openAiModelInfoSaneDefaults }
@@ -1205,24 +1189,6 @@ const ApiOptions = ({ showModelOptions, apiErrorMessage, modelIdErrorMessage, is
placeholder={"e.g. gpt-4"}>
<span style={{ fontWeight: 500 }}>Model ID</span>
</VSCodeTextField>
<>
<ThinkingBudgetSlider apiConfiguration={apiConfiguration} setApiConfiguration={setApiConfiguration} />
<p
style={{
fontSize: "12px",
marginTop: "5px",
color: "var(--vscode-descriptionForeground)",
}}>
Extended thinking is available for models as Sonnet-3-7, o3-mini, Deepseek R1, etc. More info on{" "}
<VSCodeLink
href="https://docs.litellm.ai/docs/reasoning_content"
style={{ display: "inline", fontSize: "inherit" }}>
thinking mode configuration
</VSCodeLink>
</p>
</>
<p
style={{
fontSize: "12px",
@@ -1720,12 +1686,6 @@ export function normalizeApiConfiguration(apiConfiguration?: ApiConfiguration):
selectedModelId: apiConfiguration?.lmStudioModelId || "",
selectedModelInfo: openAiModelInfoSaneDefaults,
}
case "requesty":
return {
selectedProvider: provider,
selectedModelId: apiConfiguration?.requestyModelId || "",
selectedModelInfo: openAiModelInfoSaneDefaults,
}
case "vscode-lm":
return {
selectedProvider: provider,
@@ -1,13 +1,9 @@
import { VSCodeButton } from "@vscode/webview-ui-toolkit/react"
import { useFirebaseAuth } from "../../context/FirebaseAuthContext"
import { vscode } from "../../utils/vscode"
import { useExtensionState } from "../../context/ExtensionStateContext"
export const ClineAccountInfoCard = () => {
const { user: firebaseUser, handleSignOut } = useFirebaseAuth()
const { userInfo, apiConfiguration } = useExtensionState()
let user = apiConfiguration?.clineApiKey ? firebaseUser || userInfo : undefined
const { user, handleSignOut } = useFirebaseAuth()
const handleLogin = () => {
vscode.postMessage({ type: "accountLoginClicked" })
@@ -66,7 +66,7 @@ const OpenAiModelPicker: React.FC = () => {
}, [searchableItems])
const modelSearchResults = useMemo(() => {
const results: { id: string; html: string }[] = searchTerm
let results: { id: string; html: string }[] = searchTerm
? highlight(fuse.search(searchTerm), "model-item-highlight")
: searchableItems
// results.sort((a, b) => a.id.localeCompare(b.id)) NOTE: sorting like this causes ids in objects to be reordered and mismatched
@@ -87,7 +87,7 @@ const OpenRouterModelPicker: React.FC<OpenRouterModelPickerProps> = ({ isPopup }
}, [searchableItems])
const modelSearchResults = useMemo(() => {
const results: { id: string; html: string }[] = searchTerm
let results: { id: string; html: string }[] = searchTerm
? highlight(fuse.search(searchTerm), "model-item-highlight")
: searchableItems
// results.sort((a, b) => a.id.localeCompare(b.id)) NOTE: sorting like this causes ids in objects to be reordered and mismatched
@@ -123,12 +123,11 @@ const ThinkingBudgetSlider = ({ apiConfiguration, setApiConfiguration }: Thinkin
{isEnabled && (
<>
<LabelContainer>
<Label htmlFor="thinking-budget-slider">
<Label>
<strong>Budget:</strong> {localValue.toLocaleString()} tokens
</Label>
</LabelContainer>
<RangeInput
id="thinking-budget-slider"
type="range"
min={MIN_VALID_TOKENS}
max={maxSliderValue}
@@ -140,16 +139,9 @@ const ThinkingBudgetSlider = ({ apiConfiguration, setApiConfiguration }: Thinkin
$value={localValue}
$min={MIN_VALID_TOKENS}
$max={maxSliderValue}
aria-label={`Thinking budget: ${localValue.toLocaleString()} tokens`}
aria-valuemin={MIN_VALID_TOKENS}
aria-valuemax={maxSliderValue}
aria-valuenow={localValue}
aria-describedby="thinking-budget-description"
/>
<Description id="thinking-budget-description">
Higher budgets may allow you to achieve more comprehensive and nuanced reasoning
</Description>
<Description>Higher budgets may allow you to achieve more comprehensive and nuanced reasoning</Description>
</>
)}
</Container>
-6
View File
@@ -1,6 +0,0 @@
export const LINKS = {
DOCUMENTATION: {
REMOTE_MCP_SERVER_DOCS: "https://docs.cline.bot/mcp-servers/connecting-to-a-remote-server",
LOCAL_MCP_SERVER_DOCS: "https://docs.cline.bot/mcp-servers/configuring-mcp-servers#editing-mcp-settings-files",
},
}
@@ -37,13 +37,6 @@ export const FirebaseAuthProvider: React.FC<{ children: React.ReactNode }> = ({
setUser(user)
setIsInitialized(true)
console.log("onAuthStateChanged user", user)
if (!user) {
// when opening the extension in a new webview (ie if you logged in to sidebar webview but then open a popout tab webview) this effect will trigger without the original webview's session, resulting in us clearing out the user info object.
// we rely on this object to determine if the user is logged in, so we only want to clear it when the user logs out, rather than whenever a webview without a session is opened.
return
}
// Sync auth state with extension
vscode.postMessage({
type: "authStateChanged",
-4
View File
@@ -10,10 +10,6 @@ export default defineConfig({
environment: "jsdom",
globals: true,
setupFiles: ["./src/setupTests.ts"],
coverage: {
provider: "v8",
reportOnFailure: true,
},
},
build: {
outDir: "build",