mirror of
https://github.com/cline/cline.git
synced 2026-09-02 15:52:29 +08:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bbd0b4d674 |
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": minor
|
||||
---
|
||||
|
||||
Test Minor
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
allow enabling prompt caching for LiteLLM + Claude
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Test Patch
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": minor
|
||||
---
|
||||
|
||||
Drag and drop of file/folders into cline chat
|
||||
+41
-287
@@ -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"
|
||||
@@ -359,7 +252,7 @@ class Task {
|
||||
} catch (error) {
|
||||
// 4. Error handling with retry
|
||||
if (isOpenRouter && !this.didAutomaticallyRetryFailedApiRequest) {
|
||||
await setTimeoutPromise(1000)
|
||||
await delay(1000)
|
||||
this.didAutomaticallyRetryFailedApiRequest = true
|
||||
yield* this.attemptApiRequest(previousApiReqIndex)
|
||||
return
|
||||
@@ -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
-1
@@ -1 +1 @@
|
||||
* @saoudrizwan @ocasta181 @NightTrek @pashpashpash @dcbartlett @saito-sv
|
||||
* @saoudrizwan @ocasta181 @NightTrek @pashpashpash @dcbartlett
|
||||
|
||||
@@ -31,14 +31,6 @@ body:
|
||||
label: Relevant API REQUEST output
|
||||
description: Please copy and paste any relevant output. This will be automatically formatted into code, so no need for backticks.
|
||||
render: shell
|
||||
- type: input
|
||||
id: provider-model
|
||||
attributes:
|
||||
label: Provider/Model
|
||||
description: What provider and model were you using when the issue occurred?
|
||||
placeholder: "e.g., cline:anthropic/claude-3.7-sonnet, gemini:gemini-2.5-pro-exp-03-25"
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: operating-system
|
||||
attributes:
|
||||
|
||||
@@ -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
|
||||
@@ -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}")
|
||||
@@ -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()}"
|
||||
@@ -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,58 +22,119 @@ Environment Variables:
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
CHANGELOG_PATH = os.environ.get("CHANGELOG_PATH", "CHANGELOG.md")
|
||||
VERSION = os.environ['VERSION']
|
||||
PREV_VERSION = os.environ.get("PREV_VERSION", "")
|
||||
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"
|
||||
prev_version_pattern = f"## [{PREV_VERSION}]\n"
|
||||
print(f"latest version: {VERSION}")
|
||||
print(f"prev_version: {PREV_VERSION}")
|
||||
# Find the section for the specified version
|
||||
version_index = -1
|
||||
version_pattern = f"## {VERSION}\n"
|
||||
bracketed_version_pattern = f"## [{VERSION}]\n"
|
||||
header_end_index = 0
|
||||
print(f"latest version: {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)
|
||||
|
||||
def fetch_changelog_header(changelog_text: str):
|
||||
global version_pattern, version_index, bracketed_version_pattern, header_end_index
|
||||
header = ""
|
||||
print(f"Starting fetch_changelog_header")
|
||||
# Try both unbracketed and bracketed version patterns
|
||||
version_index = changelog_text.find(version_pattern)
|
||||
if version_index == -1:
|
||||
print("Version not found, trying bracketed version pattern")
|
||||
version_index = changelog_text.find(bracketed_version_pattern)
|
||||
if version_index == -1:
|
||||
print("Bracketed version not found, adding new version header")
|
||||
# If version not found, add it at the top (after the first line)
|
||||
first_newline = changelog_text.find('\n')
|
||||
print(f"First newline index: {first_newline}")
|
||||
if first_newline == -1:
|
||||
print("No newline found, prepending new version header")
|
||||
# If no newline found, just prepend
|
||||
header = f"## [{VERSION}]\n\n"
|
||||
header = f"{changelog_text[:first_newline + 1]}\n## [{VERSION}]\n\n"
|
||||
else:
|
||||
# Using bracketed version
|
||||
version_pattern = bracketed_version_pattern
|
||||
header = changelog_text[:version_index]
|
||||
else:
|
||||
header = changelog_text[:version_index]
|
||||
|
||||
header_end_index = len(header)
|
||||
return header
|
||||
|
||||
|
||||
def generate_changelog_section(changelog_text: str, new_content: str):
|
||||
|
||||
global version_pattern, version_index, header_end_index
|
||||
print(f"Starting generate_changelog_section")
|
||||
print(f"Version index: {version_index}")
|
||||
print(f"Version pattern: {version_pattern} {len(version_pattern)}")
|
||||
print(f"Header end index: {header_end_index}")
|
||||
|
||||
prev_version_pattern = "## ["
|
||||
prev_version_index = changelog_text[header_end_index:].find(prev_version_pattern)
|
||||
print(f"Previous version index: {prev_version_index}")
|
||||
|
||||
if new_content:
|
||||
return changelog_text[:notes_start_index] + f"{new_content}\n" + changelog_text[notes_end_index:]
|
||||
print("Detected new content, overwriting existing changeset")
|
||||
return f"{new_content}\n" + changelog_text[prev_version_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)
|
||||
updated_changelog = changelog_text[:notes_start_index] + parsed_lines + changelog_text[notes_end_index:]
|
||||
print("No new content provided, reformatting existing changeset")
|
||||
changeset_lines = changelog_text[header_end_index:prev_version_index].split("\n")
|
||||
print(f"Changeset lines: {changeset_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:])
|
||||
|
||||
# Reconstruct the changelog with the new content
|
||||
updated_changelog = parsed_lines + changelog_text[prev_version_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()
|
||||
|
||||
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)
|
||||
def overwrite_changelog_section(changelog_text: str, new_content: str):
|
||||
print(f"Starting overwrite_changelog_section")
|
||||
header = fetch_changelog_header(changelog_text)
|
||||
body = generate_changelog_section(changelog_text, new_content)
|
||||
print(f"Header: {header}")
|
||||
return header + body
|
||||
|
||||
print(f"{CHANGELOG_PATH} updated successfully!")
|
||||
|
||||
try:
|
||||
print(f"Reading changelog from: {CHANGELOG_PATH}")
|
||||
with open(CHANGELOG_PATH, 'r') as f:
|
||||
changelog_content = f.read()
|
||||
|
||||
print(f"Changelog content length: {len(changelog_content)} characters")
|
||||
print("First 200 characters of changelog:")
|
||||
print(changelog_content[:200])
|
||||
print("----------------------------------------------------------------------------------")
|
||||
|
||||
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()
|
||||
@@ -1,112 +0,0 @@
|
||||
name: Changeset Converter
|
||||
run-name: Changeset Conversion
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
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 == 'workflow_dispatch' ||
|
||||
(
|
||||
github.event_name == 'pull_request' &&
|
||||
github.event.pull_request.merged == true &&
|
||||
github.event.pull_request.base.ref == 'main' &&
|
||||
github.actor != 'github-actions'
|
||||
)
|
||||
runs-on: ubuntu-latest`
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Check user for team affiliation
|
||||
id: team_check
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
uses: morfien101/actions-authorized-user@4a3cfbf0bcb3cafe4a71710a278920c5d94bb38b
|
||||
with:
|
||||
username: ${{ github.actor }}
|
||||
team: "deployer"
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Check if user is authorized
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
run: |
|
||||
if [ "${{ steps.team_check.outputs.authorized }}" != "true" ]; then
|
||||
echo "User is not authorized to run this workflow."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Git Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
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
|
||||
@@ -6,7 +6,7 @@ on:
|
||||
release-type:
|
||||
description: "Choose release type (release or pre-release)"
|
||||
required: true
|
||||
default: "release"
|
||||
default: "pre-release"
|
||||
type: choice
|
||||
options:
|
||||
- pre-release
|
||||
@@ -19,11 +19,11 @@ permissions:
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
test:
|
||||
uses: ./.github/workflows/test.yml
|
||||
# test:
|
||||
# uses: ./.github/workflows/test.yml
|
||||
|
||||
publish:
|
||||
needs: test
|
||||
# needs: test
|
||||
name: Publish Extension
|
||||
runs-on: ubuntu-latest
|
||||
environment: publish
|
||||
@@ -75,8 +75,8 @@ jobs:
|
||||
VERSION=v${{ steps.get_version.outputs.version }}
|
||||
echo "tag=$VERSION" >> $GITHUB_OUTPUT
|
||||
echo "Tagging with $VERSION"
|
||||
git tag "$VERSION"
|
||||
git push origin "$VERSION"
|
||||
# git tag "$VERSION"
|
||||
# git push origin "$VERSION"
|
||||
|
||||
- name: Package and Publish Extension
|
||||
env:
|
||||
@@ -87,29 +87,39 @@ jobs:
|
||||
vsce package --out "cline-${{ steps.get_version.outputs.version }}.vsix"
|
||||
|
||||
if [ "${{ github.event.inputs.release-type }}" = "pre-release" ]; then
|
||||
npm run publish:marketplace:prerelease
|
||||
# npm run publish:marketplace:prerelease
|
||||
echo "Successfully published pre-release version ${{ steps.get_version.outputs.version }} to VS Code Marketplace and Open VSX Registry"
|
||||
else
|
||||
npm run publish:marketplace
|
||||
# npm run publish:marketplace
|
||||
echo "Successfully published release version ${{ steps.get_version.outputs.version }} to VS Code Marketplace and Open VSX Registry"
|
||||
fi
|
||||
|
||||
# - name: Get Changelog Entry
|
||||
# id: changelog
|
||||
# uses: mindsers/changelog-reader-action@v2
|
||||
# with:
|
||||
# # This expects a standard Keep a Changelog format
|
||||
# # "latest" means it will read whichever is the most recent version
|
||||
# # set in "## [1.2.3] - 2025-01-28" style
|
||||
# version: latest
|
||||
# - name: Create Changelog Entry
|
||||
# id: changesets
|
||||
# uses: changesets/action@v1
|
||||
# env:
|
||||
# GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
tag_name: ${{ steps.create_tag.outputs.tag }}
|
||||
files: "*.vsix"
|
||||
# body: ${{ steps.changelog.outputs.content }}
|
||||
generate_release_notes: true
|
||||
prerelease: ${{ github.event.inputs.release-type == 'pre-release' }}
|
||||
- name: Create Changelog Entry
|
||||
id: changesets
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
VERSION: ${{ steps.get_version.outputs.version }}
|
||||
run: |
|
||||
python .github/scripts/overwrite_changeset_changelog.py
|
||||
|
||||
- name: Get Changelog Entry
|
||||
id: changelog
|
||||
uses: mindsers/changelog-reader-action@v2
|
||||
with:
|
||||
version: ${{ steps.get_version.outputs.version }}
|
||||
|
||||
# - name: Create GitHub Release
|
||||
# uses: softprops/action-gh-release@v1
|
||||
# with:
|
||||
# tag_name: ${{ steps.create_tag.outputs.tag }}
|
||||
# files: "*.vsix"
|
||||
# # body: ${{ steps.fetch-changelog.outputs.content }}
|
||||
# generate_release_notes: true
|
||||
# prerelease: ${{ github.event.inputs.release-type == 'pre-release' }}
|
||||
# env:
|
||||
# GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -1,187 +0,0 @@
|
||||
name: Tests
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
workflow_call:
|
||||
|
||||
# Set default permissions for all jobs
|
||||
permissions:
|
||||
contents: read # Needed to check out code
|
||||
checks: write # Needed to report test results
|
||||
pull-requests: write # Needed to add comments/annotations to PRs
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js environment
|
||||
uses: actions/setup-node@v4
|
||||
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
|
||||
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
|
||||
|
||||
- name: Type Check
|
||||
run: npm run check-types
|
||||
|
||||
- name: ESLint Check
|
||||
run: npm run lint
|
||||
|
||||
- name: Prettier / Format Check
|
||||
run: npm run format
|
||||
|
||||
# Build the extension before running tests
|
||||
- name: Build Extension
|
||||
run: npm run compile
|
||||
|
||||
# Disabling due to compatability with test framework and ESM modules
|
||||
# - name: Unit Tests
|
||||
# run: npm run test:unit
|
||||
|
||||
# Run extension tests with coverage
|
||||
- name: Extension Tests with Coverage
|
||||
id: extension_coverage
|
||||
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 }}
|
||||
+1
-8
@@ -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
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"extension": ["ts"],
|
||||
"spec": "src/**/__tests__/*.ts",
|
||||
"require": ["ts-node/register", "source-map-support/register"],
|
||||
"recursive": true
|
||||
}
|
||||
+9
-83
@@ -1,84 +1,10 @@
|
||||
# Changelog
|
||||
|
||||
## [3.9.2]
|
||||
|
||||
- Add recommended models for Cline provider
|
||||
- Add ability to detect when user edits files manually so Cline knows to re-read, leading to reduced diff edit errors
|
||||
- Add improvements to file mention searching for faster searching
|
||||
- Add scoring logic to file mentions to sort and exlcude results based on relevance
|
||||
- Add Support for Bytedance Doubao (Thanks Tunixer!)
|
||||
- Fix to prevent duplicate BOM (Thanks bamps53!)
|
||||
|
||||
## [3.9.1]
|
||||
|
||||
- Add Gemini 2.5 Pro Preview 03-25 to Google Provider
|
||||
|
||||
## [3.9.0]
|
||||
|
||||
- Add Enable extended thinking for LiteLLM provider (Thanks @jorgegarciarey!)
|
||||
- Add a tab for configuring local MCP Servers
|
||||
- Fix issue with DeepSeek API provider token counting + context management
|
||||
- Fix issues with checkpoints hanging under certain conditions
|
||||
|
||||
## [3.8.6]
|
||||
|
||||
- Add UI for adding remote servers
|
||||
- Add Mentions Feature Guide and update related documentation
|
||||
- Fix bug where menu would open in sidebar and open tab
|
||||
- Fix issue with Cline accounts not showing user info in popout tabs
|
||||
- Fix bug where menu buttons wouldn't open view in sidebar
|
||||
|
||||
## [3.8.5]
|
||||
|
||||
- Add support for remote MCP Servers using SSE
|
||||
- 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
|
||||
|
||||
## [3.8.0]
|
||||
|
||||
- Add 'Add to Cline' as an option when you right-click in a file or the terminal, making it easier to add context to your current task
|
||||
- Add 'Fix with Cline' code action - when you see a lightbulb icon in your editor, you can now select 'Fix with Cline' to send the code and associated errors for Cline to fix. (Cursor users can also use the 'Quick Fix (CMD + .)' menu to see this option)
|
||||
- Add Account view to display billing and usage history for Cline account users. You can now keep track of credits used and transaction history right in the extension!
|
||||
- Add 'Sort underling provider routing' setting to Cline/OpenRouter allowing you to sort provider used by throughput, price, latency, or the default (combination of price and uptime)
|
||||
- Improve rich MCP display with dynamic image loading and support for GIFs
|
||||
- Add 'Documentation' menu item to easily access Cline's docs
|
||||
- Add OpenRouter's new usage_details feature for more reliable cost reporting
|
||||
- Display total space Cline takes on disk next to 'Delete all Tasks' button in History view
|
||||
- Fix 'Context Window Exceeded' error for OpenRouter/Cline Accounts (additional support coming soon)
|
||||
- Fix bug where OpenRouter model ID would be set to invalid value
|
||||
- Add button to delete MCP servers in a failure state
|
||||
|
||||
## [3.7.1]
|
||||
|
||||
- Fix issue with 'See more' button in task header not showing when starting new tasks
|
||||
- Fix issue with checkpoints using local git commit hooks
|
||||
- Tests
|
||||
- Tests
|
||||
- Tests
|
||||
|
||||
## [3.7.0]
|
||||
|
||||
@@ -250,8 +176,8 @@
|
||||
## [3.1.0]
|
||||
|
||||
- Added checkpoints: Snapshots of workspace are automatically created whenever Cline uses a tool
|
||||
- Compare changes: Hover over any tool use to see a diff between the snapshot and current workspace state
|
||||
- Restore options: Choose to restore just the task state, just the workspace files, or both
|
||||
- Compare changes: Hover over any tool use to see a diff between the snapshot and current workspace state
|
||||
- Restore options: Choose to restore just the task state, just the workspace files, or both
|
||||
- New 'See new changes' button appears after task completion, providing an overview of all workspace changes
|
||||
- Task header now shows disk space usage with a delete button to help manage snapshot storage
|
||||
|
||||
@@ -433,10 +359,10 @@
|
||||
## [1.8.0]
|
||||
|
||||
- You can now use '@' in the textarea to add context!
|
||||
- @url: Paste in a URL for the extension to fetch and convert to markdown, useful when you want to give Claude the latest docs!
|
||||
- @problems: Add workspace errors and warnings for Claude to fix, no more back-and-forth about debugging
|
||||
- @file: Adds a file's contents so you don't have to waste API requests approving read file (+ type to search files)
|
||||
- @folder: Adds folder's files all at once to speed up your workflow even more
|
||||
- @url: Paste in a URL for the extension to fetch and convert to markdown, useful when you want to give Claude the latest docs!
|
||||
- @problems: Add workspace errors and warnings for Claude to fix, no more back-and-forth about debugging
|
||||
- @file: Adds a file's contents so you don't have to waste API requests approving read file (+ type to search files)
|
||||
- @folder: Adds folder's files all at once to speed up your workflow even more
|
||||
|
||||
## [1.7.0]
|
||||
|
||||
|
||||
@@ -31,36 +31,6 @@ If you're planning to work on a bigger feature, please create a [feature request
|
||||
- Run `npm run test` to run tests locally
|
||||
- Before submitting PR, run `npm run format:fix` to format your code
|
||||
|
||||
3. **Linux-specific Setup**
|
||||
VS Code extension tests on Linux require the following system libraries:
|
||||
|
||||
- `libatk1.0-0`
|
||||
- `libatk-bridge2.0-0`
|
||||
- `libxkbfile1`
|
||||
- `libx11-xcb1`
|
||||
- `libxcomposite1`
|
||||
- `libxdamage1`
|
||||
- `libxfixes3`
|
||||
- `libxrandr2`
|
||||
- `libgbm1`
|
||||
- `libdrm2`
|
||||
- `libgtk-3-0`
|
||||
- `dbus`
|
||||
- `xvfb`
|
||||
|
||||
These libraries provide necessary GUI components and system services for the test environment.
|
||||
|
||||
For example, on Debian-based distributions (e.g., Ubuntu), you can install these libraries using apt:
|
||||
```bash
|
||||
sudo apt update
|
||||
sudo apt install -y \
|
||||
libatk1.0-0 libatk-bridge2.0-0 libxkbfile1 libx11-xcb1 \
|
||||
libxcomposite1 libxdamage1 libxfixes3 libxrandr2 libgbm1 \
|
||||
libdrm2 libgtk-3-0 dbus xvfb
|
||||
```
|
||||
|
||||
- Run `npm run test:ci` to run tests locally
|
||||
|
||||
## Writing and Submitting Code
|
||||
|
||||
Anyone can contribute code to Cline, but we ask that you follow these guidelines to ensure your contributions can be smoothly integrated:
|
||||
|
||||
@@ -24,7 +24,7 @@ English | <a href="https://github.com/cline/cline/blob/main/locales/es/README.md
|
||||
<a href="https://github.com/cline/cline/discussions/categories/feature-requests?discussions_q=is%3Aopen+category%3A%22Feature+Requests%22+sort%3Atop" target="_blank"><strong>Feature Requests</strong></a>
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://docs.cline.bot/getting-started/for-new-coders" target="_blank"><strong>Getting Started</strong></a>
|
||||
<a href="https://docs.cline.bot/getting-started/getting-started-new-coders" target="_blank"><strong>Getting Started</strong></a>
|
||||
</td>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -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@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
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
name: Tests
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
workflow_call:
|
||||
|
||||
# Set default permissions for all jobs
|
||||
permissions:
|
||||
contents: read # Needed to check out code
|
||||
checks: write # Needed to report test results
|
||||
pull-requests: write # Needed to add comments/annotations to PRs
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- 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
|
||||
|
||||
- name: Type Check
|
||||
run: npm run check-types
|
||||
|
||||
- name: ESLint Check
|
||||
run: npm run lint
|
||||
|
||||
- name: Prettier / Format Check
|
||||
run: npm run format
|
||||
|
||||
- name: Extension Tests
|
||||
run: xvfb-run -a npm run test
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'?"
|
||||
|
||||
|
||||
@@ -20,9 +20,8 @@ Cline is your AI assistant that can:
|
||||
|
||||
2. **Provide Context**
|
||||
|
||||
- Use @ mentions to add files, folders, URLs, diagnostics, terminal output, and more
|
||||
- Example: "@/src/components/App.tsx"
|
||||
- See the [Mentions Feature Guide](./mentions-guide.md) for details
|
||||
- Use @ mentions to add files, folders, or URLs
|
||||
- Example: "@file:src/components/App.tsx"
|
||||
|
||||
3. **Review Changes**
|
||||
- Cline will show diffs before making changes
|
||||
@@ -55,7 +54,7 @@ Cline is your AI assistant that can:
|
||||
|
||||
## Available Tools
|
||||
|
||||
For the most up-to-date implementation details, you can view the full source code in the [Cline repository](https://github.com/cline/cline/blob/main/src/core/task/index.ts).
|
||||
For the most up-to-date implementation details, you can view the full source code in the [Cline repository](https://github.com/cline/cline/blob/main/src/core/Cline.ts).
|
||||
|
||||
Cline has access to the following tools for various tasks:
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Cline – OpenRouter 排名第一的 AI 工具
|
||||
# Cline – OpenRouter 排名第一
|
||||
|
||||
<p align="center">
|
||||
<img src="https://media.githubusercontent.com/media/cline/cline/main/assets/docs/demo.gif" width="100%" />
|
||||
@@ -20,7 +20,7 @@
|
||||
<a href="https://github.com/cline/cline/discussions/categories/feature-requests?discussions_q=is%3Aopen+category%3A%22Feature+Requests%22+sort%3Atop" target="_blank"><strong>功能请求</strong></a>
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://docs.cline.bot/getting-started/for-new-coders" target="_blank"><strong>新手上路</strong></a>
|
||||
<a href="https://cline.bot/join-us" target="_blank"><strong>我们正在招聘!</strong></a>
|
||||
</td>
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -33,12 +33,12 @@
|
||||
1. 输入你的任务并添加图像,将模型转换为功能应用程序或通过截图修复错误。
|
||||
2. Cline 首先分析你的文件结构和源代码 AST,运行正则表达式搜索,并阅读相关文件以了解现有项目。通过仔细管理添加到上下文中的信息,Cline 即使在大型复杂项目中也能提供有价值的帮助,而不会使上下文窗口过载。
|
||||
3. 一旦 Cline 获得所需信息,他可以:
|
||||
- 创建和编辑文件 + 监控 linter/编译器错误,从而主动修复诸如缺少导入和语法错误等问题。
|
||||
- 直接在你的终端中执行命令并监控其输出,从而在编辑文件后对开发服务器问题做出反应。
|
||||
- 对于 Web 开发任务,Cline 可以在无头浏览器中启动网站,点击、输入、滚动并捕获截图和控制台日志,从而修复运行时错误和视觉错误。
|
||||
- 创建和编辑文件 + 监控 linter/编译器错误,从而主动修复诸如缺少导入和语法错误等问题。
|
||||
- 直接在你的终端中执行命令并监控其输出,从而在编辑文件后对开发服务器问题做出反应。
|
||||
- 对于 Web 开发任务,Cline 可以在无头浏览器中启动网站,点击、输入、滚动并捕获截图和控制台日志,从而修复运行时错误和视觉错误。
|
||||
4. 当任务完成时,Cline 将通过终端命令如 `open -a "Google Chrome" index.html` 向你展示结果,你可以通过点击按钮运行该命令。
|
||||
|
||||
> [!TIP]
|
||||
> [!提示]
|
||||
> 使用 `CMD/CTRL + Shift + P` 快捷键打开命令面板并输入 "Cline: Open In New Tab" 将扩展作为标签在编辑器中打开。这让你可以与文件资源管理器并排使用 Cline,更清楚地看到他如何改变你的工作空间。
|
||||
|
||||
---
|
||||
|
||||
@@ -2,48 +2,46 @@
|
||||
|
||||
## 我們的承諾
|
||||
|
||||
為了營造開放且友善的環境,我們身為貢獻者與維護者,承諾讓參與本專案及社群的體驗,對每個人都不帶有騷擾,不論其年齡、體型、身心障礙、族裔、性徵、性別認同與表現、經驗程度、教育程度、社經地位、國籍、個人外表、種族、宗教信仰、或性傾向。
|
||||
為了促進一個開放和歡迎的環境,我們作為貢獻者和維護者承諾,使我們的項目和社區的參與對每個人來說都是一個無騷擾的體驗,不論年齡、體型、殘疾、種族、性別特徵、性別認同和表達、經驗水平、教育程度、社會經濟地位、國籍、個人外貌、種族、宗教或性取向。
|
||||
|
||||
## 我們的準則
|
||||
## 我們的標準
|
||||
|
||||
有助於創造正面環境的行為包括:
|
||||
有助於創造積極環境的行為示例包括:
|
||||
|
||||
- 使用友善和包容的語言
|
||||
- 尊重不同的觀點與經驗
|
||||
- 優雅地接受建設性批評
|
||||
- 著重於對社群最有利的事情
|
||||
- 對其他社群成員展現同理心
|
||||
- 使用歡迎和包容的語言
|
||||
- 尊重不同的觀點和經驗
|
||||
- 優雅地接受建設性的批評
|
||||
- 專注於對社區最有利的事情
|
||||
- 對其他社區成員表示同情
|
||||
|
||||
參與者不可接受的行為包括:
|
||||
參與者不可接受的行為示例包括:
|
||||
|
||||
- 使用帶有性暗示的言語或影像,以及不受歡迎的性關注或騷擾
|
||||
- 挑釁、羞辱/貶低他人的評論,以及人身或政治攻擊
|
||||
- 公開或私下的騷擾行為
|
||||
- 未經他人明確許可,公開他人的私人資料,如實體或電子郵件地址
|
||||
- 其他在專業環境中可被合理認定為不恰當的行為
|
||||
- 使用性化語言或圖像以及不受歡迎的性注意或挑逗
|
||||
- 騷擾、侮辱/貶低性評論和個人或政治攻擊
|
||||
- 公開或私下騷擾
|
||||
- 未經明確許可發布他人的私人信息,例如物理或電子地址
|
||||
- 其他在專業環境中合理認為不適當的行為
|
||||
|
||||
## 我們的責任
|
||||
|
||||
專案維護者有責任釐清可接受行為的標準,並應對任何不可接受的行為採取適當且公平的糾正措施。
|
||||
項目維護者有責任澄清可接受行為的標準,並預期對任何不可接受行為的實例採取適當和公平的糾正行動。
|
||||
|
||||
專案維護者有權利和責任移除、編輯或拒絕不符合本行為準則的評論、提交、程式碼、維基編輯、議題和其他貢獻,或暫時或永久封鎖任何他們認為有不當、威脅、冒犯或有害行為的貢獻者。
|
||||
項目維護者有權利和責任刪除、編輯或拒絕與本行為準則不符的評論、提交、代碼、維基編輯、問題和其他貢獻,或暫時或永久禁止任何他們認為不適當、威脅、冒犯或有害的貢獻者。
|
||||
|
||||
## 範疇
|
||||
## 範圍
|
||||
|
||||
本行為準則適用於專案空間及公開場合,當個人代表本專案或其社群時都必須遵守。代表本專案或社群的情況包括:使用官方專案電子郵件地址、透過官方社群媒體帳號發文,或在線上或實體活動中擔任指定代表。專案維護者可進一步定義並釐清專案代表的其他情況。
|
||||
此行為準則適用於項目空間內以及當個人代表項目或其社區時的公共空間。代表項目或社區的示例包括使用官方項目電子郵件地址、通過官方社交媒體帳戶發布或作為在線或離線活動的指定代表。項目的代表可能由項目維護者進一步定義和澄清。
|
||||
|
||||
## 執行
|
||||
|
||||
如發生辱罵、騷擾或其他不可接受的行為,請透過 hi@cline.bot 聯絡專案團隊回報。所有申訴都將被審查和調查,並做出必要且合適的回應。專案團隊有義務為事件回報者保密。具體執行政策的更多細節可能另行公佈。
|
||||
濫用、騷擾或其他不可接受行為的實例可以通過聯繫項目團隊 hi@cline.bot 來報告。所有投訴將被審查和調查,並將根據情況作出必要和適當的回應。項目團隊有義務對事件的報告者保密。具體執行政策的詳細信息可能會單獨發布。
|
||||
|
||||
未遵守或未切實執行本行為準則的專案維護者,可能會面臨由專案領導團隊其他成員所決定的暫時或永久的處置。
|
||||
未能善意遵循或執行行為準則的項目維護者可能會面臨由項目領導層其他成員決定的暫時或永久後果。
|
||||
|
||||
## 來源說明
|
||||
## 歸屬
|
||||
|
||||
本行為準則改編自[貢獻者公約][homepage]第 1.4 版,可在此查閱:
|
||||
https://www.contributor-covenant.org/version/1/4/code-of-conduct.html
|
||||
此行為準則改編自 [Contributor Covenant][homepage],版本 1.4,可在 https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 獲得。
|
||||
|
||||
[homepage]: https://www.contributor-covenant.org
|
||||
|
||||
關於本行為準則的常見問題解答,請參考:
|
||||
https://www.contributor-covenant.org/faq
|
||||
有關此行為準則的常見問題的答案,請參見 https://www.contributor-covenant.org/faq
|
||||
|
||||
@@ -1,85 +1,82 @@
|
||||
# 貢獻至 Cline
|
||||
# 貢獻於 Cline
|
||||
|
||||
我們非常感謝您有意願貢獻至 Cline。無論是修正程式錯誤、新增功能或改善文件,每一份貢獻都能讓 Cline 更加出色!為了維持社群的活力與友善,所有成員都必須遵守我們的[行為準則](CODE_OF_CONDUCT.md)。
|
||||
我們很高興您有興趣為 Cline 做出貢獻。無論您是修復錯誤、添加功能還是改進我們的文檔,每一個貢獻都讓 Cline 更加智能!為了保持我們的社區充滿活力和歡迎,所有成員必須遵守我們的[行為準則](CODE_OF_CONDUCT.md)。
|
||||
|
||||
## 回報程式錯誤或問題
|
||||
## 報告錯誤或問題
|
||||
|
||||
程式錯誤回報能幫助 Cline 變得更好!在建立新的議題之前,請先[搜尋現有議題](https://github.com/cline/cline/issues),避免重複。當您準備好回報程式錯誤時,請前往我們的[議題頁面](https://github.com/cline/cline/issues/new/choose),您會找到協助填寫相關資訊的範本。
|
||||
錯誤報告有助於讓 Cline 對每個人都更好!在創建新問題之前,請[搜索現有問題](https://github.com/cline/cline/issues)以避免重複。當您準備報告錯誤時,請前往我們的[問題頁面](https://github.com/cline/cline/issues/new/choose),您會找到一個模板來幫助您填寫相關信息。
|
||||
|
||||
<blockquote class='warning-note'>
|
||||
🔐 <b>重要:</b> 若您發現安全性漏洞,請使用 <a href="https://github.com/cline/cline/security/advisories/new">GitHub 安全性工具進行私密回報</a>。
|
||||
🔐 <b>重要:</b> 如果您發現安全漏洞,請使用<a href="https://github.com/cline/cline/security/advisories/new">Github 安全工具私下報告</a>。
|
||||
</blockquote>
|
||||
|
||||
## 決定要處理的工作
|
||||
## 決定要做什麼
|
||||
|
||||
想找適合第一次貢獻的工作嗎?請檢視標示為[「good first issue」](https://github.com/cline/cline/labels/good%20first%20issue)或[「help wanted」](https://github.com/cline/cline/labels/help%20wanted)的議題。這些議題特別適合新手貢獻者,我們也非常歡迎您的協助!
|
||||
尋找一個好的首次貢獻?查看標有["good first issue"](https://github.com/cline/cline/labels/good%20first%20issue)或["help wanted"](https://github.com/cline/cline/labels/help%20wanted)的問題。這些是專門為新貢獻者和我們希望得到幫助的領域策劃的!
|
||||
|
||||
我們也歡迎對[文件](https://github.com/cline/cline/tree/main/docs)的貢獻!無論是修正錯字、改善現有指南或建立新的教學內容,我們都期待能建立一個由社群共同維護的知識庫,協助每個人充分運用 Cline。您可以從 `/docs` 開始,尋找需要改善的地方。
|
||||
我們也歡迎對我們[文檔](https://github.com/cline/cline/tree/main/docs)的貢獻!無論是修正錯別字、改進現有指南還是創建新的教育內容 - 我們希望建立一個由社區驅動的資源庫,幫助每個人充分利用 Cline。您可以從深入研究 `/docs` 並尋找需要改進的領域開始。
|
||||
|
||||
若您計畫處理較大的功能,請先建立一個[功能請求](https://github.com/cline/cline/discussions/categories/feature-requests?discussions_q=is%3Aopen+category%3A%22Feature+Requests%22+sort%3Atop),以便我們討論該功能是否符合 Cline 的願景。
|
||||
如果您計劃開發一個更大的功能,請先創建一個[功能請求](https://github.com/cline/cline/discussions/categories/feature-requests?discussions_q=is%3Aopen+category%3A%22Feature+Requests%22+sort%3Atop),以便我們討論它是否符合 Cline 的願景。
|
||||
|
||||
## 開發環境設定
|
||||
## 開發設置
|
||||
|
||||
1. **VS Code 擴充套件**
|
||||
- 開啟專案時,VS Code 會提示您安裝建議的擴充套件
|
||||
- 這些擴充套件是開發所需,請接受所有安裝提示
|
||||
- 若您已關閉提示,可從擴充套件面板手動安裝
|
||||
1. **VS Code 擴展**
|
||||
|
||||
2. **本機開發**
|
||||
- 執行 `npm run install:all` 安裝相依套件
|
||||
- 執行 `npm run test` 在本機執行測試
|
||||
- 提交 PR 前,執行 `npm run format:fix` 格式化您的程式碼
|
||||
- 打開項目時,VS Code 會提示您安裝推薦的擴展
|
||||
- 這些擴展是開發所需的 - 請接受所有安裝提示
|
||||
- 如果您忽略了提示,可以從擴展面板手動安裝它們
|
||||
|
||||
## 撰寫與提交程式碼
|
||||
2. **本地開發**
|
||||
- 運行 `npm run install:all` 安裝依賴項
|
||||
- 運行 `npm run test` 本地運行測試
|
||||
- 提交 PR 之前,運行 `npm run format:fix` 格式化您的代碼
|
||||
|
||||
任何人都可以貢獻程式碼至 Cline,但我們要求您遵守以下指引,以確保您的貢獻能順利整合:
|
||||
## 編寫和提交代碼
|
||||
|
||||
1. **保持 Pull Request 聚焦**
|
||||
- 每個 PR 限制在單一功能或錯誤修正
|
||||
- 將較大的變更拆分成較小且相關的 PR
|
||||
- 將變更拆分成邏輯性的提交,以便獨立審查
|
||||
任何人都可以為 Cline 貢獻代碼,但我們要求您遵循以下指南,以確保您的貢獻能夠順利集成:
|
||||
|
||||
2. **程式碼品質**
|
||||
- 執行 `npm run lint` 檢查程式碼風格
|
||||
- 執行 `npm run format` 自動格式化程式碼
|
||||
- 所有 PR 必須通過包含程式碼風格檢查與格式化的 CI 檢查
|
||||
1. **保持 Pull Requests 集中**
|
||||
|
||||
- 將 PR 限制在單個功能或錯誤修復
|
||||
- 將較大的更改拆分為較小的相關 PR
|
||||
- 將更改分為邏輯提交,可以獨立審查
|
||||
|
||||
2. **代碼質量**
|
||||
|
||||
- 運行 `npm run lint` 檢查代碼風格
|
||||
- 運行 `npm run format` 自動格式化代碼
|
||||
- 所有 PR 必須通過包括 lint 和格式化在內的 CI 檢查
|
||||
- 提交前解決所有 ESLint 警告或錯誤
|
||||
- 遵循 TypeScript 最佳實務並維持型別安全
|
||||
- 遵循 TypeScript 最佳實踐並保持類型安全
|
||||
|
||||
3. **測試**
|
||||
- 為新功能新增測試
|
||||
- 執行 `npm test` 確保所有測試通過
|
||||
- 若您的變更影響現有測試,請更新測試
|
||||
- 適當時包含單元測試與整合測試
|
||||
|
||||
4. **使用 Changesets 管理版本**
|
||||
- 使用 `npm run changeset` 為任何面向使用者的變更建立 changeset
|
||||
- 選擇適當的版本升級:
|
||||
- `major` 重大變更 (1.0.0 → 2.0.0)
|
||||
- `minor` 新功能 (1.0.0 → 1.1.0)
|
||||
- `patch` 錯誤修正 (1.0.0 → 1.0.1)
|
||||
- 撰寫清晰且描述性的 changeset 訊息,說明影響
|
||||
- 僅文件變更不需建立 changeset
|
||||
- 為新功能添加測試
|
||||
- 運行 `npm test` 確保所有測試通過
|
||||
- 如果您的更改影響現有測試,請更新它們
|
||||
- 在適當的地方包括單元測試和集成測試
|
||||
|
||||
5. **提交指引**
|
||||
- 撰寫清晰且描述性的提交訊息
|
||||
- 使用慣用提交格式(例如:「feat:」、「fix:」、「docs:」)
|
||||
- 在提交中引用相關議題,使用 #issue-number
|
||||
4. **提交指南**
|
||||
|
||||
6. **提交前檢查**
|
||||
- 將您的分支 rebase 到最新的 main
|
||||
- 確保您的分支可以成功建置
|
||||
- 再次確認所有測試通過
|
||||
- 檢查您的變更是否包含除錯程式碼或 console 紀錄
|
||||
- 撰寫清晰、描述性的提交消息
|
||||
- 使用常規提交格式(例如 "feat:"、"fix:"、"docs:")
|
||||
- 在提交中引用相關問題,使用 #issue-number
|
||||
|
||||
7. **Pull Request 說明**
|
||||
- 清楚描述您的變更內容
|
||||
- 包含測試變更的步驟
|
||||
- 列出任何重大變更
|
||||
- 若有使用者介面變更,請附上截圖
|
||||
5. **提交前**
|
||||
|
||||
- 將您的分支重新基於最新的 main
|
||||
- 確保您的分支成功構建
|
||||
- 仔細檢查所有測試是否通過
|
||||
- 檢查您的更改是否有任何調試代碼或控制台日誌
|
||||
|
||||
6. **Pull Request 描述**
|
||||
- 清楚地描述您的更改內容
|
||||
- 包括測試更改的步驟
|
||||
- 列出任何重大更改
|
||||
- 為 UI 更改添加截圖
|
||||
|
||||
## 貢獻協議
|
||||
|
||||
提交 Pull Request 即表示您同意您的貢獻將依照專案相同的授權條款([Apache 2.0](LICENSE))進行授權。
|
||||
通過提交 pull request,您同意您的貢獻將根據與項目相同的許可證([Apache 2.0](LICENSE))進行許可。
|
||||
|
||||
請記住:貢獻至 Cline 不只是撰寫程式碼,更是成為塑造 AI 輔助開發未來的社群一份子。讓我們一起打造令人驚艷的成果吧!🚀
|
||||
記住:貢獻於 Cline 不僅僅是編寫代碼 - 這是關於成為一個塑造 AI 輔助開發未來的社區的一部分。讓我們一起創造一些驚人的東西!🚀
|
||||
|
||||
+71
-97
@@ -1,8 +1,4 @@
|
||||
<div align="center"><sub>
|
||||
<a href="https://github.com/cline/cline/blob/main/README.md" target="_blank">English</a> | <a href="https://github.com/cline/cline/blob/main/locales/es/README.md" target="_blank">Español</a> | <a href="https://github.com/cline/cline/blob/main/locales/de/README.md" target="_blank">Deutsch</a> | <a href="https://github.com/cline/cline/blob/main/locales/ja/README.md" target="_blank">日本語</a> | <a href="https://github.com/cline/cline/blob/main/locales/zh-cn/README.md" target="_blank">简体中文</a> | 繁體中文 | <a href="https://github.com/cline/cline/blob/main/locales/ko/README.md" target="_blank">한국어</a>
|
||||
</sub></div>
|
||||
|
||||
# Cline – OpenRouter 第一名的 AI 工具
|
||||
# Cline – OpenRouter 上的 \#1
|
||||
|
||||
<p align="center">
|
||||
<img src="https://media.githubusercontent.com/media/cline/cline/main/assets/docs/demo.gif" width="100%" />
|
||||
@@ -12,7 +8,7 @@
|
||||
<table>
|
||||
<tbody>
|
||||
<td align="center">
|
||||
<a href="https://marketplace.visualstudio.com/items?itemName=saoudrizwan.claude-dev" target="_blank"><strong>從 VS Marketplace 下載</strong></a>
|
||||
<a href="https://marketplace.visualstudio.com/items?itemName=saoudrizwan.claude-dev" target="_blank"><strong>在 VS Marketplace 下載</strong></a>
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://discord.gg/cline" target="_blank"><strong>Discord</strong></a>
|
||||
@@ -21,29 +17,29 @@
|
||||
<a href="https://www.reddit.com/r/cline/" target="_blank"><strong>r/cline</strong></a>
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://github.com/cline/cline/discussions/categories/feature-requests?discussions_q=is%3Aopen+category%3A%22Feature+Requests%22+sort%3Atop" target="_blank"><strong>功能建議</strong></a>
|
||||
<a href="https://github.com/cline/cline/discussions/categories/feature-requests?discussions_q=is%3Aopen+category%3A%22Feature+Requests%22+sort%3Atop" target="_blank"><strong>功能請求</strong></a>
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://docs.cline.bot/getting-started/getting-started-new-coders" target="_blank"><strong>新手上路</strong></a>
|
||||
<a href="https://cline.bot/join-us" target="_blank"><strong>我們正在招聘!</strong></a>
|
||||
</td>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
認識 Cline,一個可以使用您的**命令列介面** (CLI) 和**程式編輯器** (Editor) 的 AI 助理。
|
||||
認識 Cline,一個可以使用你的 **CLI** 和 **編輯器** 的 AI 助手。
|
||||
|
||||
感謝 [Claude 3.7 Sonnet 的代理式程式設計能力](https://www.anthropic.com/claude/sonnet),Cline 能夠逐步處理複雜的軟體開發任務。透過能讓他建立和編輯檔案、探索大型專案、使用瀏覽器,以及執行終端機指令(在您授權後)的工具,他能以超越程式碼自動完成或技術支援的方式協助您。Cline 甚至能使用模型上下文協定(Model Context Protocol,MCP)來建立新工具並擴展自己的功能。雖然自主 AI 腳本傳統上會在沙箱環境中執行,但這個擴充套件提供了人機互動的圖形介面,讓您可以核准每個檔案變更和終端機指令,提供一個安全且容易使用的方式來探索代理式 AI 的潛力。
|
||||
感謝 [Claude 3.7 Sonnet 的代理編碼能力](https://www.anthropic.com/claude/sonnet),Cline 可以一步步處理複雜的軟件開發任務。通過允許他創建和編輯文件、探索大型項目、使用瀏覽器和執行終端命令(在你授予權限後),他可以提供超越代碼完成或技術支持的幫助。Cline 甚至可以使用 Model Context Protocol (MCP) 創建新工具並擴展自己的能力。雖然自主 AI 腳本傳統上在沙盒環境中運行,但此擴展提供了一個人機交互的 GUI 來批准每個文件更改和終端命令,提供了一種安全且可訪問的方式來探索代理 AI 的潛力。
|
||||
|
||||
1. 輸入您的任務,並可以加入圖片來將設計稿轉換成功能性應用程式,或使用截圖來修正錯誤。
|
||||
2. Cline 會先分析您的檔案結構和程式碼 AST、執行正規表達式搜尋,並讀取相關檔案,以便在現有專案中快速掌握狀況。透過仔細管理加入上下文的資訊,Cline 可以在不超過上下文視窗的情況下,為大型且複雜的專案提供有價值的協助。
|
||||
3. 一旦 Cline 取得所需資訊後,他可以:
|
||||
- 建立和編輯檔案,並在過程中監控程式碼檢查工具/編譯器的錯誤,讓他能主動修正缺少的匯入語句和語法錯誤等問題。
|
||||
- 直接在您的終端機中執行指令並監控其輸出,讓他能夠在編輯檔案後回應開發伺服器的問題。
|
||||
- 對於網頁開發任務,Cline 可以在無頭瀏覽器中啟動網站、點選、輸入、捲動並擷取螢幕截圖和主控台記錄,讓他能修正執行時錯誤和視覺問題。
|
||||
4. 當任務完成時,Cline 會以終端機指令(如 `open -a "Google Chrome" index.html`)向您呈現結果,您只需點選按鈕即可執行。
|
||||
1. 輸入你的任務並添加圖像,將模型轉換為功能應用程序或通過截圖修復錯誤。
|
||||
2. Cline 首先分析你的文件結構和源代碼 AST,運行正則表達式搜索,並閱讀相關文件以了解現有項目。通過仔細管理添加到上下文中的信息,Cline 即使在大型複雜項目中也能提供有價值的幫助,而不會使上下文窗口過載。
|
||||
3. 一旦 Cline 獲得所需信息,他可以:
|
||||
- 創建和編輯文件 + 監控 linter/編譯器錯誤,從而主動修復諸如缺少導入和語法錯誤等問題。
|
||||
- 直接在你的終端中執行命令並監控其輸出,從而在編輯文件後對開發服務器問題做出反應。
|
||||
- 對於 Web 開發任務,Cline 可以在無頭瀏覽器中啟動網站,點擊、輸入、滾動並捕獲截圖和控制台日誌,從而修復運行時錯誤和視覺錯誤。
|
||||
4. 當任務完成時,Cline 將通過終端命令如 `open -a "Google Chrome" index.html` 向你展示結果,你可以通過點擊按鈕運行該命令。
|
||||
|
||||
> [!TIP]
|
||||
> 使用 `CMD/CTRL + Shift + P` 快速鍵開啟命令選擇區,輸入「Cline: Open In New Tab」即可在編輯器中以分頁方式開啟擴充套件。這讓您可以同時檢視檔案總管,並更清楚地看到 Cline 如何變更您的工作區。
|
||||
> [!提示]
|
||||
> 使用 `CMD/CTRL + Shift + P` 快捷鍵打開命令面板並輸入 "Cline: Open In New Tab" 將擴展作為標籤在編輯器中打開。這讓你可以與文件資源管理器並排使用 Cline,更清楚地看到他如何改變你的工作空間。
|
||||
|
||||
---
|
||||
|
||||
@@ -51,137 +47,115 @@
|
||||
|
||||
### 使用任何 API 和模型
|
||||
|
||||
Cline 支援 OpenRouter、Anthropic、OpenAI、Google Gemini、AWS Bedrock、Azure 和 GCP Vertex 等 API 提供者。您也可以設定任何與 OpenAI 相容的 API,或透過 LM Studio/Ollama 使用本機模型。若您使用 OpenRouter,此擴充套件會擷取他們最新的模型列表,讓您能在新模型推出時立即使用。
|
||||
Cline 支持 OpenRouter、Anthropic、OpenAI、Google Gemini、AWS Bedrock、Azure 和 GCP Vertex 等 API 提供商。你還可以配置任何兼容 OpenAI 的 API,或通過 LM Studio/Ollama 使用本地模型。如果你使用 OpenRouter,擴展會獲取他們的最新模型列表,讓你在新模型可用時立即使用。
|
||||
|
||||
此擴充套件也會追蹤整個任務迴圈和個別請求的 token 總數和 API 使用成本,讓您隨時掌握費用支出。
|
||||
擴展還會跟蹤整個任務循環和單個請求的總令牌和 API 使用成本,讓你在每一步都了解支出情況。
|
||||
|
||||
<!-- 透明像素以在浮動圖像後創建換行 -->
|
||||
|
||||
<!-- 透明像素用於浮動圖片後的換行 -->
|
||||
<img width="2000" height="0" src="https://github.com/user-attachments/assets/ee14e6f7-20b8-4391-9091-8e8e25561929"><br>
|
||||
|
||||
<img align="left" width="370" src="https://github.com/user-attachments/assets/81be79a8-1fdb-4028-9129-5fe055e01e76">
|
||||
|
||||
### 在終端機中執行指令
|
||||
### 在終端中運行命令
|
||||
|
||||
感謝 [VSCode v1.93 的終端機整合更新](https://code.visualstudio.com/updates/v1_93#_terminal-shell-integration-api),Cline 可以直接在您的終端機中執行指令並接收輸出。這讓他能執行各種任務,從安裝套件和執行建置腳本到部署應用程式、管理資料庫和執行測試,同時適應您的開發環境和工具鏈,以正確完成工作。
|
||||
感謝 VSCode v1.93 中的新 [終端 shell 集成更新](https://code.visualstudio.com/updates/v1_93#_terminal-shell-integration-api),Cline 可以直接在你的終端中執行命令並接收輸出。這使他能夠執行廣泛的任務,從安裝包和運行構建腳本到部署應用程序、管理數據庫和執行測試,同時適應你的開發環境和工具鏈以正確完成工作。
|
||||
|
||||
對於開發伺服器等長時間執行的程序,使用「繼續執行中的程序」按鈕讓 Cline 在指令於背景執行時繼續任務。當 Cline 工作時,他會收到任何新的終端機輸出通知,讓他能回應可能出現的問題,例如編輯檔案時的編譯錯誤。
|
||||
對於長時間運行的進程如開發服務器,使用“在運行時繼續”按鈕讓 Cline 在命令後台運行時繼續任務。當 Cline 工作時,他會在過程中收到任何新的終端輸出通知,讓他對可能出現的問題做出反應,例如編輯文件時的編譯時錯誤。
|
||||
|
||||
<!-- 透明像素以在浮動圖像後創建換行 -->
|
||||
|
||||
<!-- 透明像素用於浮動圖片後的換行 -->
|
||||
<img width="2000" height="0" src="https://github.com/user-attachments/assets/ee14e6f7-20b8-4391-9091-8e8e25561929"><br>
|
||||
|
||||
<img align="right" width="400" src="https://github.com/user-attachments/assets/c5977833-d9b8-491e-90f9-05f9cd38c588">
|
||||
|
||||
### 建立和編輯檔案
|
||||
### 創建和編輯文件
|
||||
|
||||
Cline 可以直接在您的編輯器中建立和編輯檔案,並顯示變更的差異檢視。您可以直接在差異檢視編輯器中編輯或還原 Cline 的變更,或在聊天中提供意見回饋,直到您滿意結果為止。Cline 也會監控程式碼檢查工具/編譯器的錯誤(缺少的匯入語句、語法錯誤等),讓他能自行修正過程中出現的問題。
|
||||
Cline 可以直接在你的編輯器中創建和編輯文件,向你展示更改的差異視圖。你可以直接在差異視圖編輯器中編輯或恢復 Cline 的更改,或在聊天中提供反饋,直到你對結果滿意。Cline 還會監控 linter/編譯器錯誤(缺少導入、語法錯誤等),以便他在過程中自行修復出現的問題。
|
||||
|
||||
所有 Cline 做的變更都會記錄在您檔案的時間軸中,提供簡單的方式來追蹤和還原修改。
|
||||
Cline 所做的所有更改都會記錄在你的文件時間軸中,提供了一種簡單的方法來跟蹤和恢復修改(如果需要)。
|
||||
|
||||
<!-- 透明像素以在浮動圖像後創建換行 -->
|
||||
|
||||
<!-- 透明像素用於浮動圖片後的換行 -->
|
||||
<img width="2000" height="0" src="https://github.com/user-attachments/assets/ee14e6f7-20b8-4391-9091-8e8e25561929"><br>
|
||||
|
||||
<img align="left" width="370" src="https://github.com/user-attachments/assets/bc2e85ba-dfeb-4fe6-9942-7cfc4703cbe5">
|
||||
|
||||
### 使用瀏覽器
|
||||
|
||||
透過 Claude 3.5 Sonnet 的新[電腦使用](https://www.anthropic.com/news/3-5-models-and-computer-use)功能,Cline 可以啟動瀏覽器、點選元素、輸入文字和捲動,在每個步驟擷取螢幕截圖和主控台記錄。這讓互動式除錯、端對端測試,甚至一般網頁使用成為可能!這讓他能獨立修正視覺問題和執行時錯誤,而不需要您手動複製錯誤記錄。
|
||||
借助 Claude 3.5 Sonnet 的新 [計算機使用](https://www.anthropic.com/news/3-5-models-and-computer-use) 功能,Cline 可以啟動瀏覽器,點擊元素,輸入文本和滾動,在每一步捕獲截圖和控制台日誌。這允許進行交互式調試、端到端測試,甚至是一般的網頁使用!這使他能夠自主修復視覺錯誤和運行時問題,而無需你親自操作和複製粘貼錯誤日誌。
|
||||
|
||||
試著請 Cline 「測試應用程式」,觀察他如何執行 `npm run dev`、在瀏覽器中啟動您的本機開發伺服器,並執行一系列測試來確認一切正常運作。[點此觀看示範](https://x.com/sdrzn/status/1850880547825823989)。
|
||||
試試讓 Cline “測試應用程序”,看看他如何運行 `npm run dev` 命令,在瀏覽器中啟動你本地運行的開發服務器,並執行一系列測試以確認一切正常。[在這裡查看演示。](https://x.com/sdrzn/status/1850880547825823989)
|
||||
|
||||
<!-- 透明像素以在浮動圖像後創建換行 -->
|
||||
|
||||
<!-- 透明像素用於浮動圖片後的換行 -->
|
||||
<img width="2000" height="0" src="https://github.com/user-attachments/assets/ee14e6f7-20b8-4391-9091-8e8e25561929"><br>
|
||||
|
||||
<img align="right" width="350" src="https://github.com/user-attachments/assets/ac0efa14-5c1f-4c26-a42d-9d7c56f5fadd">
|
||||
|
||||
### 「新增一個工具來...」
|
||||
### “添加一個工具……”
|
||||
|
||||
感謝[模型上下文協定](https://github.com/modelcontextprotocol),Cline 可以透過自訂工具擴展他的功能。雖然您可以使用[社群製作的伺服器](https://github.com/modelcontextprotocol/servers),但 Cline 可以改為建立專門為您的工作流程量身打造的工具。只要請 Cline 「新增工具」,他就會處理所有事情,從建立新的 MCP 伺服器到將其安裝到擴充套件中。這些自訂工具就會成為 Cline 工具箱的一部分,隨時可用於未來的任務。
|
||||
感謝 [Model Context Protocol](https://github.com/modelcontextprotocol),Cline 可以通過自定義工具擴展他的能力。雖然你可以使用 [社區製作的服務器](https://github.com/modelcontextprotocol/servers),但 Cline 可以創建和安裝適合你特定工作流程的工具。只需讓 Cline “添加一個工具”,他將處理所有事情,從創建新的 MCP 服務器到將其安裝到擴展中。這些自定義工具將成為 Cline 工具包的一部分,準備在未來的任務中使用。
|
||||
|
||||
- 「新增一個擷取 Jira 工單的工具」:取得工單驗收條件並讓 Cline 開始工作
|
||||
- 「新增一個管理 AWS EC2 的工具」:檢查伺服器指標並調整執行個體規模
|
||||
- 「新增一個擷取最新 PagerDuty 事件的工具」:取得詳細資訊並請 Cline 修復錯誤
|
||||
- “添加一個獲取 Jira 工單的工具”:檢索工單 AC 並讓 Cline 開始工作
|
||||
- “添加一個管理 AWS EC2 的工具”:檢查服務器指標並上下擴展實例
|
||||
- “添加一個獲取最新 PagerDuty 事件的工具”:獲取詳細信息並讓 Cline 修復錯誤
|
||||
|
||||
<!-- 透明像素以在浮動圖像後創建換行 -->
|
||||
|
||||
<!-- 透明像素用於浮動圖片後的換行 -->
|
||||
<img width="2000" height="0" src="https://github.com/user-attachments/assets/ee14e6f7-20b8-4391-9091-8e8e25561929"><br>
|
||||
|
||||
<img align="left" width="360" src="https://github.com/user-attachments/assets/7fdf41e6-281a-4b4b-ac19-020b838b6970">
|
||||
|
||||
### 新增上下文
|
||||
### 添加上下文
|
||||
|
||||
**`@url`:**貼上網址讓擴充套件擷取並轉換為 Markdown,當您想給 Cline 最新文件時很有用
|
||||
**`@problems`:**新增工作區的錯誤和警告(「問題」面板)給 Cline 修正
|
||||
**`@file`:**新增檔案內容,讓您不必浪費 API 請求來核准讀取檔案(+ 輸入以搜尋檔案)
|
||||
**`@folder`:**一次新增整個資料夾的檔案,讓您的工作流程更快速
|
||||
**`@url`:** 粘貼一個 URL 以供擴展獲取並轉換為 markdown,當你想給 Cline 提供最新文檔時非常有用
|
||||
|
||||
**`@problems`:** 添加工作區錯誤和警告(“問題”面板)以供 Cline 修復
|
||||
|
||||
**`@file`:** 添加文件內容,這樣你就不必浪費 API 請求批准讀取文件(+ 輸入以搜索文件)
|
||||
|
||||
**`@folder`:** 一次添加文件夾的文件,以進一步加快你的工作流程
|
||||
|
||||
<!-- 透明像素以在浮動圖像後創建換行 -->
|
||||
|
||||
<!-- 透明像素用於浮動圖片後的換行 -->
|
||||
<img width="2000" height="0" src="https://github.com/user-attachments/assets/ee14e6f7-20b8-4391-9091-8e8e25561929"><br>
|
||||
|
||||
<img align="right" width="350" src="https://github.com/user-attachments/assets/140c8606-d3bf-41b9-9a1f-4dbf0d4c90cb">
|
||||
|
||||
### 檢查點:比較和還原
|
||||
### 檢查點:比較和恢復
|
||||
|
||||
當 Cline 處理任務時,擴充套件會在每個步驟擷取您工作區的快照。您可以使用「比較」按鈕檢視快照與目前工作區的差異,並使用「還原」按鈕回到該時間點。
|
||||
當 Cline 完成任務時,擴展會在每一步拍攝你的工作區快照。你可以使用“比較”按鈕查看快照和當前工作區之間的差異,並使用“恢復”按鈕回滾到該點。
|
||||
|
||||
例如,在使用本機網頁伺服器時,您可以使用「僅還原工作區」來快速測試應用程式的不同版本,然後在找到想要繼續開發的版本時使用「還原任務和工作區」。這讓您能安全地探索不同方法而不會失去進度。
|
||||
例如,當使用本地 Web 服務器時,你可以使用“僅恢復工作區”快速測試應用程序的不同版本,然後在找到要繼續構建的版本時使用“恢復任務和工作區”。這讓你可以安全地探索不同的方法而不會丟失進度。
|
||||
|
||||
<!-- 透明像素以在浮動圖像後創建換行 -->
|
||||
|
||||
<!-- 透明像素用於浮動圖片後的換行 -->
|
||||
<img width="2000" height="0" src="https://github.com/user-attachments/assets/ee14e6f7-20b8-4391-9091-8e8e25561929"><br>
|
||||
|
||||
## 貢獻
|
||||
|
||||
要為專案貢獻,請先閱讀我們的[貢獻指南](CONTRIBUTING.md)來了解基礎知識。您也可以加入我們的 [Discord](https://discord.gg/cline),在 `#contributors` 頻道與其他貢獻者交流。如果您在尋找全職工作,請檢視我們[職涯頁面](https://cline.bot/join-us)上的職缺!
|
||||
要為項目做出貢獻,請從我們的 [貢獻指南](CONTRIBUTING.md) 開始,了解基礎知識。你還可以加入我們的 [Discord](https://discord.gg/cline) 在 `#contributors` 頻道與其他貢獻者聊天。如果你正在尋找全職工作,請查看我們在 [招聘頁面](https://cline.bot/join-us) 上的開放職位!
|
||||
|
||||
<details>
|
||||
<summary>本機開發說明</summary>
|
||||
<summary>本地開發說明</summary>
|
||||
|
||||
1. 複製程式碼庫(需要 [git-lfs](https://git-lfs.com/)):
|
||||
|
||||
```bash
|
||||
git clone https://github.com/cline/cline.git
|
||||
```
|
||||
|
||||
2. 在 VSCode 中開啟專案:
|
||||
|
||||
```bash
|
||||
code cline
|
||||
```
|
||||
|
||||
3. 安裝擴充套件和網頁介面所需的相依套件:
|
||||
|
||||
```bash
|
||||
npm run install:all
|
||||
```
|
||||
|
||||
4. 按下 `F5`(或選擇「執行」->「開始除錯」)來啟動並開啟一個已載入擴充套件的新 VSCode 視窗。(如果建置專案時遇到問題,您可能需要安裝 [esbuild problem matchers 擴充套件](https://marketplace.visualstudio.com/items?itemName=connor4312.esbuild-problem-matchers))
|
||||
1. 克隆倉庫 _(需要 [git-lfs](https://git-lfs.com/))_:
|
||||
```bash
|
||||
git clone https://github.com/cline/cline.git
|
||||
```
|
||||
2. 在 VSCode 中打開項目:
|
||||
```bash
|
||||
code cline
|
||||
```
|
||||
3. 安裝擴展和 webview-gui 的必要依賴:
|
||||
```bash
|
||||
npm run install:all
|
||||
```
|
||||
4. 按 `F5`(或 `運行`->`開始調試`)啟動以打開一個加載了擴展的新 VSCode 窗口。(如果你在構建項目時遇到問題,可能需要安裝 [esbuild problem matchers 擴展](https://marketplace.visualstudio.com/items?itemName=connor4312.esbuild-problem-matchers))
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>建立 Pull Request</summary>
|
||||
|
||||
1. 在建立 PR 前,產生一個 changeset 項目:
|
||||
|
||||
```bash
|
||||
npm run changeset
|
||||
```
|
||||
|
||||
這會提示您填寫:
|
||||
- 變更類型(major、minor、patch)
|
||||
- `major` → 重大變更(1.0.0 → 2.0.0)
|
||||
- `minor` → 新功能(1.0.0 → 1.1.0)
|
||||
- `patch` → 錯誤修正(1.0.0 → 1.0.1)
|
||||
- 您的變更說明
|
||||
|
||||
2. 提交您的變更和產生的 `.changeset` 檔案
|
||||
|
||||
3. 推送您的分支並在 GitHub 上建立 PR。我們的 CI 會:
|
||||
- 執行測試和檢查
|
||||
- Changesetbot 會建立一個顯示版本影響的評論
|
||||
- 當合併到 main 時,changesetbot 會建立一個 Version Packages PR
|
||||
- 當 Version Packages PR 合併時,就會發布新版本
|
||||
|
||||
</details>
|
||||
|
||||
## 授權條款
|
||||
## 許可證
|
||||
|
||||
[Apache 2.0 © 2025 Cline Bot Inc.](./LICENSE)
|
||||
|
||||
Generated
+127
-2067
File diff suppressed because it is too large
Load Diff
+13
-97
@@ -2,8 +2,12 @@
|
||||
"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.9.2",
|
||||
"version": "3.7.0",
|
||||
"icon": "assets/icons/icon.png",
|
||||
"galleryBanner": {
|
||||
"color": "#617A91",
|
||||
"theme": "dark"
|
||||
},
|
||||
"engines": {
|
||||
"vscode": "^1.84.0"
|
||||
},
|
||||
@@ -69,7 +73,7 @@
|
||||
{
|
||||
"command": "cline.mcpButtonClicked",
|
||||
"title": "MCP Servers",
|
||||
"icon": "$(server)"
|
||||
"icon": "$(extensions)"
|
||||
},
|
||||
{
|
||||
"command": "cline.historyButtonClicked",
|
||||
@@ -81,11 +85,6 @@
|
||||
"title": "Open in Editor",
|
||||
"icon": "$(link-external)"
|
||||
},
|
||||
{
|
||||
"command": "cline.accountButtonClicked",
|
||||
"title": "Account",
|
||||
"icon": "$(account)"
|
||||
},
|
||||
{
|
||||
"command": "cline.settingsButtonClicked",
|
||||
"title": "Settings",
|
||||
@@ -95,27 +94,6 @@
|
||||
"command": "cline.openInNewTab",
|
||||
"title": "Open In New Tab",
|
||||
"category": "Cline"
|
||||
},
|
||||
{
|
||||
"command": "cline.dev.createTestTasks",
|
||||
"title": "Create Test Tasks",
|
||||
"category": "Cline",
|
||||
"when": "cline.isDevMode"
|
||||
},
|
||||
{
|
||||
"command": "cline.addToChat",
|
||||
"title": "Add to Cline",
|
||||
"category": "Cline"
|
||||
},
|
||||
{
|
||||
"command": "cline.addTerminalOutputToChat",
|
||||
"title": "Add to Cline",
|
||||
"category": "Cline"
|
||||
},
|
||||
{
|
||||
"command": "cline.fixWithCline",
|
||||
"title": "Fix with Cline",
|
||||
"category": "Cline"
|
||||
}
|
||||
],
|
||||
"menus": {
|
||||
@@ -141,59 +119,9 @@
|
||||
"when": "view == claude-dev.SidebarProvider"
|
||||
},
|
||||
{
|
||||
"command": "cline.accountButtonClicked",
|
||||
"command": "cline.settingsButtonClicked",
|
||||
"group": "navigation@5",
|
||||
"when": "view == claude-dev.SidebarProvider"
|
||||
},
|
||||
{
|
||||
"command": "cline.settingsButtonClicked",
|
||||
"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"
|
||||
}
|
||||
],
|
||||
"editor/context": [
|
||||
{
|
||||
"command": "cline.addToChat",
|
||||
"group": "navigation",
|
||||
"when": "editorHasSelection"
|
||||
}
|
||||
],
|
||||
"terminal/context": [
|
||||
{
|
||||
"command": "cline.addTerminalOutputToChat",
|
||||
"group": "navigation"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -302,9 +230,6 @@
|
||||
"format": "prettier . --check",
|
||||
"format:fix": "prettier . --write",
|
||||
"test": "vscode-test",
|
||||
"test:ci": "node scripts/test-ci.js",
|
||||
"test:unit": "TS_NODE_PROJECT='./tsconfig.unit-test.json' mocha",
|
||||
"test:coverage": "vscode-test --coverage",
|
||||
"install:all": "npm install && cd webview-ui && npm install",
|
||||
"dev:webview": "cd webview-ui && npm run dev",
|
||||
"build:webview": "cd webview-ui && npm run build",
|
||||
@@ -318,16 +243,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/proxyquire": "^1.3.31",
|
||||
"@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",
|
||||
@@ -339,10 +259,8 @@
|
||||
"husky": "^9.1.7",
|
||||
"npm-run-all": "^4.1.5",
|
||||
"prettier": "^3.3.3",
|
||||
"proxyquire": "^2.1.3",
|
||||
"should": "^13.2.3",
|
||||
"sinon": "^19.0.2",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "^5.4.5"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -353,24 +271,22 @@
|
||||
"@google-cloud/vertexai": "^1.9.3",
|
||||
"@google/generative-ai": "^0.18.0",
|
||||
"@mistralai/mistralai": "^1.5.0",
|
||||
"@modelcontextprotocol/sdk": "^1.7.0",
|
||||
"@opentelemetry/api": "^1.4.1",
|
||||
"@opentelemetry/exporter-trace-otlp-http": "^0.39.1",
|
||||
"@opentelemetry/resources": "^1.30.1",
|
||||
"@opentelemetry/sdk-node": "^0.39.1",
|
||||
"@opentelemetry/sdk-trace-node": "^1.30.1",
|
||||
"@opentelemetry/semantic-conventions": "^1.30.0",
|
||||
"@modelcontextprotocol/sdk": "^1.0.1",
|
||||
"@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",
|
||||
"chokidar": "^4.0.1",
|
||||
"clone-deep": "^4.0.1",
|
||||
"default-shell": "^2.2.0",
|
||||
"delay": "^6.0.0",
|
||||
"diff": "^5.2.0",
|
||||
"execa": "^9.5.2",
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"firebase": "^11.2.0",
|
||||
"fzf": "^0.5.2",
|
||||
"get-folder-size": "^5.0.0",
|
||||
"globby": "^14.0.2",
|
||||
"ignore": "^7.0.3",
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
const { execSync } = require("child_process")
|
||||
const process = require("process")
|
||||
|
||||
try {
|
||||
if (process.platform === "linux") {
|
||||
console.log("Detected Linux environment.")
|
||||
|
||||
execSync("which xvfb-run", { stdio: "ignore" })
|
||||
|
||||
console.log("xvfb-run is installed. Running tests with xvfb-run...")
|
||||
execSync("xvfb-run -a npm run test", { stdio: "inherit" })
|
||||
} else {
|
||||
console.log("Non-Linux environment detected. Running tests normally.")
|
||||
execSync("npm run test", { stdio: "inherit" })
|
||||
}
|
||||
} catch (error) {
|
||||
if (process.platform === "linux") {
|
||||
console.error(
|
||||
`Error: xvfb-run is not installed.\n` +
|
||||
`Please install it using the following command:\n` +
|
||||
` Debian/Ubuntu: sudo apt install xvfb\n` +
|
||||
` RHEL/CentOS: sudo yum install xvfb\n` +
|
||||
` Arch Linux: sudo pacman -S xvfb`,
|
||||
)
|
||||
} else {
|
||||
console.error("Error running tests:", error.message)
|
||||
}
|
||||
process.exit(1)
|
||||
}
|
||||
@@ -15,7 +15,6 @@ import { RequestyHandler } from "./providers/requesty"
|
||||
import { TogetherHandler } from "./providers/together"
|
||||
import { QwenHandler } from "./providers/qwen"
|
||||
import { MistralHandler } from "./providers/mistral"
|
||||
import { DoubaoHandler } from "./providers/doubao"
|
||||
import { VsCodeLmHandler } from "./providers/vscode-lm"
|
||||
import { ClineHandler } from "./providers/cline"
|
||||
import { LiteLlmHandler } from "./providers/litellm"
|
||||
@@ -62,8 +61,6 @@ export function buildApiHandler(configuration: ApiConfiguration): ApiHandler {
|
||||
return new TogetherHandler(options)
|
||||
case "qwen":
|
||||
return new QwenHandler(options)
|
||||
case "doubao":
|
||||
return new DoubaoHandler(options)
|
||||
case "mistral":
|
||||
return new MistralHandler(options)
|
||||
case "vscode-lm":
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -7,12 +7,7 @@ import { ApiHandlerOptions, bedrockDefaultModelId, BedrockModelId, bedrockModels
|
||||
import { calculateApiCostOpenAI } from "../../utils/cost"
|
||||
import { ApiStream } from "../transform/stream"
|
||||
import { fromNodeProviderChain } from "@aws-sdk/credential-providers"
|
||||
import {
|
||||
BedrockRuntimeClient,
|
||||
ConversationRole,
|
||||
ConverseStreamCommand,
|
||||
InvokeModelWithResponseStreamCommand,
|
||||
} from "@aws-sdk/client-bedrock-runtime"
|
||||
import { BedrockRuntimeClient, InvokeModelWithResponseStreamCommand } from "@aws-sdk/client-bedrock-runtime"
|
||||
|
||||
// https://docs.anthropic.com/en/api/claude-on-amazon-bedrock
|
||||
export class AwsBedrockHandler implements ApiHandler {
|
||||
@@ -25,22 +20,16 @@ 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
|
||||
if (modelId.includes("amazon.nova")) {
|
||||
yield* this.createNovaMessage(systemPrompt, messages, modelId, model)
|
||||
return
|
||||
}
|
||||
|
||||
// Check if this is a Deepseek model
|
||||
if (modelId.includes("deepseek")) {
|
||||
yield* this.createDeepseekMessage(systemPrompt, messages, modelId, model)
|
||||
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 +239,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}`
|
||||
@@ -473,192 +462,4 @@ export class AwsBedrockHandler implements ApiHandler {
|
||||
// Approximate 4 characters per token
|
||||
return Math.ceil(text.length / 4)
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a message using Amazon Nova models through AWS Bedrock
|
||||
* Implements support for Nova Micro, Nova Lite, and Nova Pro models
|
||||
*/
|
||||
private async *createNovaMessage(
|
||||
systemPrompt: string,
|
||||
messages: Anthropic.Messages.MessageParam[],
|
||||
modelId: string,
|
||||
model: { id: BedrockModelId; info: ModelInfo },
|
||||
): ApiStream {
|
||||
// Get Bedrock client with proper credentials
|
||||
const client = await this.getBedrockClient()
|
||||
|
||||
// Format messages for Nova model
|
||||
const formattedMessages = this.formatNovaMessages(messages)
|
||||
|
||||
// Prepare request for Nova model
|
||||
const command = new ConverseStreamCommand({
|
||||
modelId: modelId,
|
||||
messages: formattedMessages,
|
||||
system: systemPrompt ? [{ text: systemPrompt }] : undefined,
|
||||
inferenceConfig: {
|
||||
maxTokens: model.info.maxTokens || 5000,
|
||||
temperature: 0,
|
||||
// topP: 0.9, // Alternative: use topP instead of temperature
|
||||
},
|
||||
})
|
||||
|
||||
// Execute the streaming request and handle response
|
||||
try {
|
||||
const response = await client.send(command)
|
||||
|
||||
if (response.stream) {
|
||||
let hasReportedInputTokens = false
|
||||
|
||||
for await (const chunk of response.stream) {
|
||||
// Handle metadata events with token usage information
|
||||
if (chunk.metadata?.usage) {
|
||||
// Report complete token usage from the model itself
|
||||
const inputTokens = chunk.metadata.usage.inputTokens || 0
|
||||
const outputTokens = chunk.metadata.usage.outputTokens || 0
|
||||
yield {
|
||||
type: "usage",
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
totalCost: calculateApiCostOpenAI(model.info, inputTokens, outputTokens, 0, 0),
|
||||
}
|
||||
hasReportedInputTokens = true
|
||||
}
|
||||
|
||||
// Handle content delta (text generation)
|
||||
if (chunk.contentBlockDelta?.delta?.text) {
|
||||
yield {
|
||||
type: "text",
|
||||
text: chunk.contentBlockDelta.delta.text,
|
||||
}
|
||||
}
|
||||
|
||||
// Handle reasoning content if present
|
||||
if (chunk.contentBlockDelta?.delta?.reasoningContent?.text) {
|
||||
yield {
|
||||
type: "reasoning",
|
||||
reasoning: chunk.contentBlockDelta.delta.reasoningContent.text,
|
||||
}
|
||||
}
|
||||
|
||||
// Handle errors
|
||||
if (chunk.internalServerException) {
|
||||
yield {
|
||||
type: "text",
|
||||
text: `[ERROR] Internal server error: ${chunk.internalServerException.message}`,
|
||||
}
|
||||
} else if (chunk.modelStreamErrorException) {
|
||||
yield {
|
||||
type: "text",
|
||||
text: `[ERROR] Model stream error: ${chunk.modelStreamErrorException.message}`,
|
||||
}
|
||||
} else if (chunk.validationException) {
|
||||
yield {
|
||||
type: "text",
|
||||
text: `[ERROR] Validation error: ${chunk.validationException.message}`,
|
||||
}
|
||||
} else if (chunk.throttlingException) {
|
||||
yield {
|
||||
type: "text",
|
||||
text: `[ERROR] Throttling error: ${chunk.throttlingException.message}`,
|
||||
}
|
||||
} else if (chunk.serviceUnavailableException) {
|
||||
yield {
|
||||
type: "text",
|
||||
text: `[ERROR] Service unavailable: ${chunk.serviceUnavailableException.message}`,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error processing Nova model response:", error)
|
||||
yield {
|
||||
type: "text",
|
||||
text: `[ERROR] Failed to process Nova response: ${error instanceof Error ? error.message : String(error)}`,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats messages for Amazon Nova models according to the SDK specification
|
||||
*/
|
||||
private formatNovaMessages(messages: Anthropic.Messages.MessageParam[]): { role: ConversationRole; content: any[] }[] {
|
||||
return messages.map((message) => {
|
||||
// Determine role (user or assistant)
|
||||
const role = message.role === "user" ? ConversationRole.USER : ConversationRole.ASSISTANT
|
||||
|
||||
// Process content based on type
|
||||
let content: any[] = []
|
||||
|
||||
if (typeof message.content === "string") {
|
||||
// Simple text content
|
||||
content = [{ text: message.content }]
|
||||
} else if (Array.isArray(message.content)) {
|
||||
// Convert Anthropic content format to Nova content format
|
||||
content = message.content
|
||||
.map((item) => {
|
||||
// Text content
|
||||
if (item.type === "text") {
|
||||
return { text: item.text }
|
||||
}
|
||||
|
||||
// Image content
|
||||
if (item.type === "image") {
|
||||
// Handle different image source formats
|
||||
let imageData: Uint8Array
|
||||
let format = "jpeg" // default format
|
||||
|
||||
// Extract format from media_type if available
|
||||
if (item.source.media_type) {
|
||||
// Extract format from media_type (e.g., "image/jpeg" -> "jpeg")
|
||||
const formatMatch = item.source.media_type.match(/image\/(\w+)/)
|
||||
if (formatMatch && formatMatch[1]) {
|
||||
format = formatMatch[1]
|
||||
// Ensure format is one of the allowed values
|
||||
if (!["png", "jpeg", "gif", "webp"].includes(format)) {
|
||||
format = "jpeg" // Default to jpeg if not supported
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get image data
|
||||
try {
|
||||
if (typeof item.source.data === "string") {
|
||||
// Handle base64 encoded data
|
||||
const base64Data = item.source.data.replace(/^data:image\/\w+;base64,/, "")
|
||||
imageData = new Uint8Array(Buffer.from(base64Data, "base64"))
|
||||
} else if (item.source.data && typeof item.source.data === "object") {
|
||||
// Try to convert to Uint8Array
|
||||
imageData = new Uint8Array(Buffer.from(item.source.data as any))
|
||||
} else {
|
||||
console.error("Unsupported image data format")
|
||||
return null // Skip this item if format is not supported
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Could not convert image data to Uint8Array:", error)
|
||||
return null // Skip this item if conversion fails
|
||||
}
|
||||
|
||||
return {
|
||||
image: {
|
||||
format,
|
||||
source: {
|
||||
bytes: imageData,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Return null for unsupported content types
|
||||
return null
|
||||
})
|
||||
.filter(Boolean) // Remove any null items
|
||||
}
|
||||
|
||||
// Return formatted message
|
||||
return {
|
||||
role,
|
||||
content,
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -35,11 +30,8 @@ export class ClineHandler implements ApiHandler {
|
||||
this.getModel(),
|
||||
this.options.o3MiniReasoningEffort,
|
||||
this.options.thinkingBudgetTokens,
|
||||
this.options.openRouterProviderSorting,
|
||||
)
|
||||
|
||||
let didOutputUsage: boolean = false
|
||||
|
||||
for await (const chunk of stream) {
|
||||
// openrouter returns an error object instead of the openai sdk throwing an error
|
||||
if ("error" in chunk) {
|
||||
@@ -70,25 +62,11 @@ export class ClineHandler implements ApiHandler {
|
||||
reasoning: delta.reasoning,
|
||||
}
|
||||
}
|
||||
|
||||
if (!didOutputUsage && chunk.usage) {
|
||||
yield {
|
||||
type: "usage",
|
||||
inputTokens: chunk.usage.prompt_tokens || 0,
|
||||
outputTokens: chunk.usage.completion_tokens || 0,
|
||||
// @ts-ignore-next-line
|
||||
totalCost: chunk.usage.cost || 0,
|
||||
}
|
||||
didOutputUsage = true
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to generation endpoint if usage chunk not returned
|
||||
if (!didOutputUsage) {
|
||||
const apiStreamUsage = await this.getApiStreamUsage()
|
||||
if (apiStreamUsage) {
|
||||
yield apiStreamUsage
|
||||
}
|
||||
const apiStreamUsage = await this.getApiStreamUsage()
|
||||
if (apiStreamUsage) {
|
||||
yield apiStreamUsage
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -36,15 +36,14 @@ export class DeepSeekHandler implements ApiHandler {
|
||||
}
|
||||
const deepUsage = usage as DeepSeekUsage
|
||||
|
||||
const inputTokens = deepUsage?.prompt_tokens || 0 // sum of cache hits and misses
|
||||
const inputTokens = deepUsage?.prompt_tokens || 0
|
||||
const outputTokens = deepUsage?.completion_tokens || 0
|
||||
const cacheReadTokens = deepUsage?.prompt_cache_hit_tokens || 0
|
||||
const cacheWriteTokens = deepUsage?.prompt_cache_miss_tokens || 0
|
||||
const totalCost = calculateApiCostOpenAI(info, inputTokens, outputTokens, cacheWriteTokens, cacheReadTokens)
|
||||
const nonCachedInputTokens = Math.max(0, inputTokens - cacheReadTokens - cacheWriteTokens) // this will always be 0
|
||||
yield {
|
||||
type: "usage",
|
||||
inputTokens: nonCachedInputTokens,
|
||||
inputTokens: inputTokens,
|
||||
outputTokens: outputTokens,
|
||||
cacheWriteTokens: cacheWriteTokens,
|
||||
cacheReadTokens: cacheReadTokens,
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
import { ApiHandler } from ".."
|
||||
import { ApiHandlerOptions, doubaoDefaultModelId, DoubaoModelId, doubaoModels, ModelInfo } from "../../shared/api"
|
||||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
import OpenAI from "openai"
|
||||
import { convertToOpenAiMessages } from "../transform/openai-format"
|
||||
import { ApiStream } from "../transform/stream"
|
||||
|
||||
export class DoubaoHandler implements ApiHandler {
|
||||
private options: ApiHandlerOptions
|
||||
private client: OpenAI
|
||||
constructor(options: ApiHandlerOptions) {
|
||||
this.options = options
|
||||
this.client = new OpenAI({
|
||||
baseURL: "https://ark.cn-beijing.volces.com/api/v3/",
|
||||
apiKey: this.options.doubaoApiKey,
|
||||
})
|
||||
}
|
||||
|
||||
getModel(): { id: DoubaoModelId; info: ModelInfo } {
|
||||
const modelId = this.options.apiModelId
|
||||
if (modelId && modelId in doubaoModels) {
|
||||
const id = modelId as DoubaoModelId
|
||||
return { id, info: doubaoModels[id] }
|
||||
}
|
||||
return {
|
||||
id: doubaoDefaultModelId,
|
||||
info: doubaoModels[doubaoDefaultModelId],
|
||||
}
|
||||
}
|
||||
|
||||
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
|
||||
const model = this.getModel()
|
||||
let openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [
|
||||
{ role: "system", content: systemPrompt },
|
||||
...convertToOpenAiMessages(messages),
|
||||
]
|
||||
const stream = await this.client.chat.completions.create({
|
||||
model: model.id,
|
||||
max_completion_tokens: model.info.maxTokens,
|
||||
messages: openAiMessages,
|
||||
stream: true,
|
||||
stream_options: { include_usage: true },
|
||||
temperature: 0,
|
||||
})
|
||||
|
||||
for await (const chunk of stream) {
|
||||
const delta = chunk.choices[0]?.delta
|
||||
if (delta?.content) {
|
||||
yield {
|
||||
type: "text",
|
||||
text: delta.content,
|
||||
}
|
||||
}
|
||||
|
||||
if (chunk.usage) {
|
||||
yield {
|
||||
type: "usage",
|
||||
inputTokens: chunk.usage.prompt_tokens || 0,
|
||||
outputTokens: chunk.usage.completion_tokens || 0,
|
||||
// @ts-ignore-next-line
|
||||
cacheReadTokens: chunk.usage.prompt_cache_hit_tokens || 0,
|
||||
// @ts-ignore-next-line
|
||||
cacheWriteTokens: chunk.usage.prompt_cache_miss_tokens || 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,62 +25,22 @@ export class LiteLlmHandler implements ApiHandler {
|
||||
}
|
||||
const modelId = this.options.liteLlmModelId || liteLlmDefaultModelId
|
||||
const isOminiModel = modelId.includes("o1-mini") || modelId.includes("o3-mini")
|
||||
|
||||
// Configuration for extended thinking
|
||||
const budgetTokens = this.options.thinkingBudgetTokens || 0
|
||||
const reasoningOn = budgetTokens !== 0 ? true : false
|
||||
const thinkingConfig = reasoningOn ? { type: "enabled", budget_tokens: budgetTokens } : undefined
|
||||
|
||||
let temperature: number | undefined = 0
|
||||
|
||||
if (isOminiModel && reasoningOn) {
|
||||
temperature = undefined // Thinking mode doesn't support temperature
|
||||
if (isOminiModel) {
|
||||
temperature = undefined // does not support temperature
|
||||
}
|
||||
|
||||
// Define cache control object if prompt caching is enabled
|
||||
const cacheControl = this.options.liteLlmUsePromptCache ? { cache_control: { type: "ephemeral" } } : undefined
|
||||
|
||||
// Add cache_control to system message if enabled
|
||||
const enhancedSystemMessage = {
|
||||
...systemMessage,
|
||||
...(cacheControl && cacheControl),
|
||||
}
|
||||
|
||||
// Find the last two user messages to apply caching
|
||||
const userMsgIndices = formattedMessages.reduce(
|
||||
(acc, msg, index) => (msg.role === "user" ? [...acc, index] : acc),
|
||||
[] as number[],
|
||||
)
|
||||
const lastUserMsgIndex = userMsgIndices[userMsgIndices.length - 1] ?? -1
|
||||
const secondLastUserMsgIndex = userMsgIndices[userMsgIndices.length - 2] ?? -1
|
||||
|
||||
// Apply cache_control to the last two user messages if enabled
|
||||
const enhancedMessages = formattedMessages.map((message, index) => {
|
||||
if ((index === lastUserMsgIndex || index === secondLastUserMsgIndex) && cacheControl) {
|
||||
return {
|
||||
...message,
|
||||
...cacheControl,
|
||||
}
|
||||
}
|
||||
return message
|
||||
})
|
||||
|
||||
const stream = await this.client.chat.completions.create({
|
||||
model: this.options.liteLlmModelId || liteLlmDefaultModelId,
|
||||
messages: [enhancedSystemMessage, ...enhancedMessages],
|
||||
messages: [systemMessage, ...formattedMessages],
|
||||
temperature,
|
||||
stream: true,
|
||||
stream_options: { include_usage: true },
|
||||
...(thinkingConfig && { thinking: thinkingConfig }), // Add thinking configuration when applicable
|
||||
})
|
||||
|
||||
const inputCost = (await this.calculateCost(1e6, 0)) || 0
|
||||
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",
|
||||
@@ -122,45 +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
|
||||
|
||||
// Extract cache-related information if available
|
||||
// Need to use type assertion since these properties are not in the standard OpenAI types
|
||||
const usage = chunk.usage as {
|
||||
prompt_tokens: number
|
||||
completion_tokens: number
|
||||
cache_creation_input_tokens?: number
|
||||
prompt_cache_miss_tokens?: number
|
||||
cache_read_input_tokens?: number
|
||||
prompt_cache_hit_tokens?: number
|
||||
}
|
||||
|
||||
const cacheWriteTokens = usage.cache_creation_input_tokens || usage.prompt_cache_miss_tokens || 0
|
||||
const cacheReadTokens = usage.cache_read_input_tokens || usage.prompt_cache_hit_tokens || 0
|
||||
|
||||
yield {
|
||||
type: "usage",
|
||||
inputTokens: usage.prompt_tokens || 0,
|
||||
outputTokens: usage.completion_tokens || 0,
|
||||
cacheWriteTokens: cacheWriteTokens > 0 ? cacheWriteTokens : undefined,
|
||||
cacheReadTokens: cacheReadTokens > 0 ? cacheReadTokens : undefined,
|
||||
totalCost,
|
||||
inputTokens: chunk.usage.prompt_tokens || 0,
|
||||
outputTokens: chunk.usage.completion_tokens || 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,15 +26,14 @@ export class OpenAiNativeHandler implements ApiHandler {
|
||||
}
|
||||
|
||||
private async *yieldUsage(info: ModelInfo, usage: OpenAI.Completions.CompletionUsage | undefined): ApiStream {
|
||||
const inputTokens = usage?.prompt_tokens || 0 // sum of cache hits and misses
|
||||
const inputTokens = usage?.prompt_tokens || 0
|
||||
const outputTokens = usage?.completion_tokens || 0
|
||||
const cacheReadTokens = usage?.prompt_tokens_details?.cached_tokens || 0
|
||||
const cacheWriteTokens = 0
|
||||
const totalCost = calculateApiCostOpenAI(info, inputTokens, outputTokens, cacheWriteTokens, cacheReadTokens)
|
||||
const nonCachedInputTokens = Math.max(0, inputTokens - cacheReadTokens - cacheWriteTokens)
|
||||
yield {
|
||||
type: "usage",
|
||||
inputTokens: nonCachedInputTokens,
|
||||
inputTokens: inputTokens,
|
||||
outputTokens: outputTokens,
|
||||
cacheWriteTokens: cacheWriteTokens,
|
||||
cacheReadTokens: cacheReadTokens,
|
||||
|
||||
@@ -15,8 +15,7 @@ export class OpenAiHandler implements ApiHandler {
|
||||
constructor(options: ApiHandlerOptions) {
|
||||
this.options = options
|
||||
// Azure API shape slightly differs from the core API shape: https://github.com/openai/openai-node?tab=readme-ov-file#microsoft-azure-openai
|
||||
// Use azureApiVersion to determine if this is an Azure endpoint, since the URL may not always contain 'azure.com'
|
||||
if (this.options.azureApiVersion || this.options.openAiBaseUrl?.toLowerCase().includes("azure.com")) {
|
||||
if (this.options.openAiBaseUrl?.toLowerCase().includes("azure.com")) {
|
||||
this.client = new AzureOpenAI({
|
||||
baseURL: this.options.openAiBaseUrl,
|
||||
apiKey: this.options.openAiApiKey,
|
||||
@@ -34,7 +33,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 +41,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 +56,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,6 +1,6 @@
|
||||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
import axios from "axios"
|
||||
import { setTimeout as setTimeoutPromise } from "node:timers/promises"
|
||||
import delay from "delay"
|
||||
import OpenAI from "openai"
|
||||
import { ApiHandler } from "../"
|
||||
import { ApiHandlerOptions, ModelInfo, openRouterDefaultModelId, openRouterDefaultModelInfo } from "../../shared/api"
|
||||
@@ -37,11 +37,8 @@ export class OpenRouterHandler implements ApiHandler {
|
||||
this.getModel(),
|
||||
this.options.o3MiniReasoningEffort,
|
||||
this.options.thinkingBudgetTokens,
|
||||
this.options.openRouterProviderSorting,
|
||||
)
|
||||
|
||||
let didOutputUsage: boolean = false
|
||||
|
||||
for await (const chunk of stream) {
|
||||
// openrouter returns an error object instead of the openai sdk throwing an error
|
||||
if ("error" in chunk) {
|
||||
@@ -72,31 +69,17 @@ export class OpenRouterHandler implements ApiHandler {
|
||||
reasoning: delta.reasoning,
|
||||
}
|
||||
}
|
||||
|
||||
if (!didOutputUsage && chunk.usage) {
|
||||
yield {
|
||||
type: "usage",
|
||||
inputTokens: chunk.usage.prompt_tokens || 0,
|
||||
outputTokens: chunk.usage.completion_tokens || 0,
|
||||
// @ts-ignore-next-line
|
||||
totalCost: chunk.usage.cost || 0,
|
||||
}
|
||||
didOutputUsage = true
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to generation endpoint if usage chunk not returned
|
||||
if (!didOutputUsage) {
|
||||
const apiStreamUsage = await this.getApiStreamUsage()
|
||||
if (apiStreamUsage) {
|
||||
yield apiStreamUsage
|
||||
}
|
||||
const apiStreamUsage = await this.getApiStreamUsage()
|
||||
if (apiStreamUsage) {
|
||||
yield apiStreamUsage
|
||||
}
|
||||
}
|
||||
|
||||
async getApiStreamUsage(): Promise<ApiStreamUsageChunk | undefined> {
|
||||
if (this.lastGenerationId) {
|
||||
await setTimeoutPromise(500) // FIXME: necessary delay to ensure generation endpoint is ready
|
||||
await delay(500) // FIXME: necessary delay to ensure generation endpoint is ready
|
||||
try {
|
||||
const generationIterator = this.fetchGenerationDetails(this.lastGenerationId)
|
||||
const generation = (await generationIterator.next()).value
|
||||
|
||||
@@ -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),
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -13,7 +13,6 @@ export async function createOpenRouterStream(
|
||||
model: { id: string; info: ModelInfo },
|
||||
o3MiniReasoningEffort?: string,
|
||||
thinkingBudgetTokens?: number,
|
||||
openRouterProviderSorting?: string,
|
||||
) {
|
||||
// Convert Anthropic messages to OpenAI format
|
||||
let openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [
|
||||
@@ -142,12 +141,10 @@ export async function createOpenRouterStream(
|
||||
top_p: topP,
|
||||
messages: openAiMessages,
|
||||
stream: true,
|
||||
stream_options: { include_usage: true },
|
||||
transforms: shouldApplyMiddleOutTransform ? ["middle-out"] : undefined,
|
||||
include_reasoning: true,
|
||||
...(model.id === "openai/o3-mini" ? { reasoning_effort: o3MiniReasoningEffort || "medium" } : {}),
|
||||
...(reasoning ? { reasoning } : {}),
|
||||
...(openRouterProviderSorting ? { provider: { sort: openRouterProviderSorting } } : {}),
|
||||
})
|
||||
|
||||
return stream
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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.
|
||||
```
|
||||
@@ -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
|
||||
|
||||
@@ -1,120 +0,0 @@
|
||||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
import { ClineApiReqInfo, ClineMessage } from "../../shared/ExtensionMessage"
|
||||
import { ApiHandler } from "../../api"
|
||||
import { OpenAiHandler } from "../../api/providers/openai"
|
||||
|
||||
export class ContextManager {
|
||||
getNewContextMessagesAndMetadata(
|
||||
apiConversationHistory: Anthropic.Messages.MessageParam[],
|
||||
clineMessages: ClineMessage[],
|
||||
api: ApiHandler,
|
||||
conversationHistoryDeletedRange: [number, number] | undefined,
|
||||
previousApiReqIndex: number,
|
||||
) {
|
||||
let updatedConversationHistoryDeletedRange = false
|
||||
|
||||
// If the previous API request's total token usage is close to the context window, truncate the conversation history to free up space for the new request
|
||||
if (previousApiReqIndex >= 0) {
|
||||
const previousRequest = clineMessages[previousApiReqIndex]
|
||||
if (previousRequest && previousRequest.text) {
|
||||
const { tokensIn, tokensOut, cacheWrites, cacheReads }: ClineApiReqInfo = JSON.parse(previousRequest.text)
|
||||
const totalTokens = (tokensIn || 0) + (tokensOut || 0) + (cacheWrites || 0) + (cacheReads || 0)
|
||||
let contextWindow = api.getModel().info.contextWindow || 128_000
|
||||
// FIXME: hack to get anyone using openai compatible with deepseek to have the proper context window instead of the default 128k. We need a way for the user to specify the context window for models they input through openai compatible
|
||||
if (api instanceof OpenAiHandler && api.getModel().id.toLowerCase().includes("deepseek")) {
|
||||
contextWindow = 64_000
|
||||
}
|
||||
let maxAllowedSize: number
|
||||
switch (contextWindow) {
|
||||
case 64_000: // deepseek models
|
||||
maxAllowedSize = contextWindow - 27_000
|
||||
break
|
||||
case 128_000: // most models
|
||||
maxAllowedSize = contextWindow - 30_000
|
||||
break
|
||||
case 200_000: // claude models
|
||||
maxAllowedSize = contextWindow - 40_000
|
||||
break
|
||||
default:
|
||||
maxAllowedSize = Math.max(contextWindow - 40_000, contextWindow * 0.8) // for deepseek, 80% of 64k meant only ~10k buffer which was too small and resulted in users getting context window errors.
|
||||
}
|
||||
|
||||
// This is the most reliable way to know when we're close to hitting the context window.
|
||||
if (totalTokens >= maxAllowedSize) {
|
||||
// Since the user may switch between models with different context windows, truncating half may not be enough (ie if switching from claude 200k to deepseek 64k, half truncation will only remove 100k tokens, but we need to remove much more)
|
||||
// So if totalTokens/2 is greater than maxAllowedSize, we truncate 3/4 instead of 1/2
|
||||
// FIXME: truncating the conversation in a way that is optimal for prompt caching AND takes into account multi-context window complexity is something we need to improve
|
||||
const keep = totalTokens / 2 > maxAllowedSize ? "quarter" : "half"
|
||||
|
||||
// NOTE: it's okay that we overwriteConversationHistory in resume task since we're only ever removing the last user message and not anything in the middle which would affect this range
|
||||
conversationHistoryDeletedRange = this.getNextTruncationRange(
|
||||
apiConversationHistory,
|
||||
conversationHistoryDeletedRange,
|
||||
keep,
|
||||
)
|
||||
|
||||
updatedConversationHistoryDeletedRange = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// conversationHistoryDeletedRange is updated only when we're close to hitting the context window, so we don't continuously break the prompt cache
|
||||
const truncatedConversationHistory = this.getTruncatedMessages(apiConversationHistory, conversationHistoryDeletedRange)
|
||||
|
||||
return {
|
||||
conversationHistoryDeletedRange: conversationHistoryDeletedRange,
|
||||
updatedConversationHistoryDeletedRange: updatedConversationHistoryDeletedRange,
|
||||
truncatedConversationHistory: truncatedConversationHistory,
|
||||
}
|
||||
}
|
||||
|
||||
public getNextTruncationRange(
|
||||
apiMessages: Anthropic.Messages.MessageParam[],
|
||||
currentDeletedRange: [number, number] | undefined,
|
||||
keep: "half" | "quarter",
|
||||
): [number, number] {
|
||||
// Since we always keep the first message, currentDeletedRange[0] will always be 1 (for now until we have a smarter truncation algorithm)
|
||||
const rangeStartIndex = 1
|
||||
const startOfRest = currentDeletedRange ? currentDeletedRange[1] + 1 : 1
|
||||
|
||||
let messagesToRemove: number
|
||||
if (keep === "half") {
|
||||
// Remove half of remaining user-assistant pairs
|
||||
// We first calculate half of the messages then divide by 2 to get the number of pairs.
|
||||
// After flooring, we multiply by 2 to get the number of messages.
|
||||
// Note that this will also always be an even number.
|
||||
messagesToRemove = Math.floor((apiMessages.length - startOfRest) / 4) * 2 // Keep even number
|
||||
} else {
|
||||
// Remove 3/4 of remaining user-assistant pairs
|
||||
// We calculate 3/4ths of the messages then divide by 2 to get the number of pairs.
|
||||
// After flooring, we multiply by 2 to get the number of messages.
|
||||
// Note that this will also always be an even number.
|
||||
messagesToRemove = Math.floor(((apiMessages.length - startOfRest) * 3) / 4 / 2) * 2
|
||||
}
|
||||
|
||||
let rangeEndIndex = startOfRest + messagesToRemove - 1
|
||||
|
||||
// Make sure the last message being removed is a user message, so that the next message after the initial task message is an assistant message. This preservers the user-assistant-user-assistant structure.
|
||||
// NOTE: anthropic format messages are always user-assistant-user-assistant, while openai format messages can have multiple user messages in a row (we use anthropic format throughout cline)
|
||||
if (apiMessages[rangeEndIndex].role !== "user") {
|
||||
rangeEndIndex -= 1
|
||||
}
|
||||
|
||||
// this is an inclusive range that will be removed from the conversation history
|
||||
return [rangeStartIndex, rangeEndIndex]
|
||||
}
|
||||
|
||||
public getTruncatedMessages(
|
||||
messages: Anthropic.Messages.MessageParam[],
|
||||
deletedRange: [number, number] | undefined,
|
||||
): Anthropic.Messages.MessageParam[] {
|
||||
if (!deletedRange) {
|
||||
return messages
|
||||
}
|
||||
|
||||
const [start, end] = deletedRange
|
||||
// the range is inclusive - both start and end indices and everything in between will be removed from the final result.
|
||||
// NOTE: if you try to console log these, don't forget that logging a reference to an array may not provide the same result as logging a slice() snapshot of that array at that exact moment. The following DOES in fact include the latest assistant message.
|
||||
return [...messages.slice(0, start), ...messages.slice(end + 1)]
|
||||
}
|
||||
}
|
||||
@@ -1,155 +0,0 @@
|
||||
import { ContextManager } from "../ContextManager"
|
||||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
import { expect } from "chai"
|
||||
|
||||
describe("ContextManager", () => {
|
||||
function createMessages(count: number): Anthropic.Messages.MessageParam[] {
|
||||
const messages: Anthropic.Messages.MessageParam[] = []
|
||||
|
||||
messages.push({
|
||||
role: "user",
|
||||
content: "Initial task message",
|
||||
})
|
||||
|
||||
let role: "user" | "assistant" = "assistant"
|
||||
for (let i = 1; i < count; i++) {
|
||||
messages.push({
|
||||
role,
|
||||
content: `Message ${i}`,
|
||||
})
|
||||
role = role === "user" ? "assistant" : "user"
|
||||
}
|
||||
|
||||
return messages
|
||||
}
|
||||
|
||||
describe("getNextTruncationRange", () => {
|
||||
let contextManager: ContextManager
|
||||
|
||||
beforeEach(() => {
|
||||
contextManager = new ContextManager()
|
||||
})
|
||||
|
||||
it("first truncation with half keep", () => {
|
||||
const messages = createMessages(11)
|
||||
const result = contextManager.getNextTruncationRange(messages, undefined, "half")
|
||||
|
||||
expect(result).to.deep.equal([1, 4])
|
||||
})
|
||||
|
||||
it("first truncation with quarter keep", () => {
|
||||
const messages = createMessages(11)
|
||||
const result = contextManager.getNextTruncationRange(messages, undefined, "quarter")
|
||||
|
||||
expect(result).to.deep.equal([1, 6])
|
||||
})
|
||||
|
||||
it("sequential truncation with half keep", () => {
|
||||
const messages = createMessages(21)
|
||||
const firstRange = contextManager.getNextTruncationRange(messages, undefined, "half")
|
||||
expect(firstRange).to.deep.equal([1, 10])
|
||||
|
||||
// Pass the previous range for sequential truncation
|
||||
const secondRange = contextManager.getNextTruncationRange(messages, firstRange, "half")
|
||||
expect(secondRange).to.deep.equal([1, 14])
|
||||
})
|
||||
|
||||
it("sequential truncation with quarter keep", () => {
|
||||
const messages = createMessages(41)
|
||||
const firstRange = contextManager.getNextTruncationRange(messages, undefined, "quarter")
|
||||
|
||||
const secondRange = contextManager.getNextTruncationRange(messages, firstRange, "quarter")
|
||||
|
||||
expect(secondRange[0]).to.equal(1)
|
||||
expect(secondRange[1]).to.be.greaterThan(firstRange[1])
|
||||
})
|
||||
|
||||
it("ensures the last message in range is a user message", () => {
|
||||
const messages = createMessages(14)
|
||||
const result = contextManager.getNextTruncationRange(messages, undefined, "half")
|
||||
|
||||
// Check if the message at the end of range is a user message
|
||||
const lastRemovedMessage = messages[result[1]]
|
||||
expect(lastRemovedMessage.role).to.equal("user")
|
||||
|
||||
// Check if the next message after the range is an assistant message
|
||||
const nextMessage = messages[result[1] + 1]
|
||||
expect(nextMessage.role).to.equal("assistant")
|
||||
})
|
||||
|
||||
it("handles small message arrays", () => {
|
||||
const messages = createMessages(3)
|
||||
const result = contextManager.getNextTruncationRange(messages, undefined, "half")
|
||||
|
||||
expect(result).to.deep.equal([1, 0])
|
||||
})
|
||||
|
||||
it("preserves the message structure when truncating", () => {
|
||||
const messages = createMessages(20)
|
||||
const result = contextManager.getNextTruncationRange(messages, undefined, "half")
|
||||
|
||||
// Get messages after removing the range
|
||||
const effectiveMessages = [...messages.slice(0, result[0]), ...messages.slice(result[1] + 1)]
|
||||
|
||||
// Check first message and alternating pattern
|
||||
expect(effectiveMessages[0].role).to.equal("user")
|
||||
for (let i = 1; i < effectiveMessages.length; i++) {
|
||||
const expectedRole = i % 2 === 1 ? "assistant" : "user"
|
||||
expect(effectiveMessages[i].role).to.equal(expectedRole)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe("getTruncatedMessages", () => {
|
||||
let contextManager: ContextManager
|
||||
|
||||
beforeEach(() => {
|
||||
contextManager = new ContextManager()
|
||||
})
|
||||
|
||||
it("returns original messages when no range is provided", () => {
|
||||
const messages = createMessages(3)
|
||||
|
||||
const result = contextManager.getTruncatedMessages(messages, undefined)
|
||||
expect(result).to.deep.equal(messages)
|
||||
})
|
||||
|
||||
it("correctly removes messages in the specified range", () => {
|
||||
const messages = createMessages(5)
|
||||
|
||||
const range: [number, number] = [1, 3]
|
||||
const result = contextManager.getTruncatedMessages(messages, range)
|
||||
|
||||
expect(result).to.have.lengthOf(2)
|
||||
expect(result[0]).to.deep.equal(messages[0])
|
||||
expect(result[1]).to.deep.equal(messages[4])
|
||||
})
|
||||
|
||||
it("works with a range that starts at the first message after task", () => {
|
||||
const messages = createMessages(4)
|
||||
|
||||
const range: [number, number] = [1, 2]
|
||||
const result = contextManager.getTruncatedMessages(messages, range)
|
||||
|
||||
expect(result).to.have.lengthOf(2)
|
||||
expect(result[0]).to.deep.equal(messages[0])
|
||||
expect(result[1]).to.deep.equal(messages[3])
|
||||
})
|
||||
|
||||
it("correctly handles removing a range while preserving alternation pattern", () => {
|
||||
const messages = createMessages(5)
|
||||
|
||||
const range: [number, number] = [1, 2]
|
||||
const result = contextManager.getTruncatedMessages(messages, range)
|
||||
|
||||
expect(result).to.have.lengthOf(3)
|
||||
expect(result[0]).to.deep.equal(messages[0])
|
||||
expect(result[1]).to.deep.equal(messages[3])
|
||||
expect(result[2]).to.deep.equal(messages[4])
|
||||
|
||||
expect(result[0].role).to.equal("user")
|
||||
expect(result[1].role).to.equal("assistant")
|
||||
expect(result[2].role).to.equal("user")
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,10 +0,0 @@
|
||||
export function checkIsOpenRouterContextWindowError(error: any): boolean {
|
||||
return error.code === 400 && error.message?.includes("context length")
|
||||
}
|
||||
|
||||
export function checkIsAnthropicContextWindowError(response: any): boolean {
|
||||
return (
|
||||
response?.error?.error?.type === "invalid_request_error" &&
|
||||
response?.error?.error?.message?.includes("prompt is too long")
|
||||
)
|
||||
}
|
||||
@@ -1,271 +0,0 @@
|
||||
import { describe, it, beforeEach, afterEach } from "mocha"
|
||||
import { expect } from "chai"
|
||||
import * as sinon from "sinon"
|
||||
import * as vscode from "vscode"
|
||||
import * as path from "path"
|
||||
import { FileContextTracker } from "./FileContextTracker"
|
||||
import * as diskModule from "../storage/disk"
|
||||
import type { TaskMetadata, ControllerLike, FileMetadataEntry } from "./FileContextTrackerTypes"
|
||||
|
||||
describe("FileContextTracker", () => {
|
||||
let sandbox: sinon.SinonSandbox
|
||||
let mockController: ControllerLike
|
||||
let mockContext: vscode.ExtensionContext
|
||||
let mockWorkspace: sinon.SinonStub
|
||||
let mockFileSystemWatcher: any
|
||||
let tracker: FileContextTracker
|
||||
let taskId: string
|
||||
let mockTaskMetadata: TaskMetadata
|
||||
let getTaskMetadataStub: sinon.SinonStub
|
||||
let saveTaskMetadataStub: sinon.SinonStub
|
||||
|
||||
beforeEach(() => {
|
||||
sandbox = sinon.createSandbox()
|
||||
|
||||
// Mock vscode workspace
|
||||
mockWorkspace = sandbox.stub(vscode.workspace, "workspaceFolders").value([
|
||||
{
|
||||
uri: {
|
||||
fsPath: "/mock/workspace",
|
||||
},
|
||||
} as vscode.WorkspaceFolder,
|
||||
])
|
||||
|
||||
// Mock file system watcher
|
||||
mockFileSystemWatcher = {
|
||||
dispose: sandbox.stub(),
|
||||
onDidChange: sandbox.stub().returns({ dispose: () => {} }),
|
||||
}
|
||||
|
||||
// Use a function replacement instead of a direct stub
|
||||
const originalCreateFileSystemWatcher = vscode.workspace.createFileSystemWatcher
|
||||
vscode.workspace.createFileSystemWatcher = function () {
|
||||
return mockFileSystemWatcher
|
||||
} as any
|
||||
|
||||
// Mock controller and context
|
||||
mockContext = {
|
||||
globalStorageUri: { fsPath: "/mock/storage" },
|
||||
} as unknown as vscode.ExtensionContext
|
||||
|
||||
mockController = {
|
||||
context: mockContext,
|
||||
}
|
||||
|
||||
// Mock disk module functions
|
||||
mockTaskMetadata = { files_in_context: [] }
|
||||
getTaskMetadataStub = sandbox.stub(diskModule, "getTaskMetadata").resolves(mockTaskMetadata)
|
||||
saveTaskMetadataStub = sandbox.stub(diskModule, "saveTaskMetadata").resolves()
|
||||
|
||||
// Create tracker instance
|
||||
taskId = "test-task-id"
|
||||
tracker = new FileContextTracker(mockController, taskId)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
sandbox.restore()
|
||||
})
|
||||
|
||||
it("should add a record when a file is read by a tool", async () => {
|
||||
const filePath = "src/test-file.ts"
|
||||
|
||||
await tracker.trackFileContext(filePath, "read_tool")
|
||||
|
||||
// Verify getTaskMetadata was called
|
||||
expect(getTaskMetadataStub.calledOnce).to.be.true
|
||||
expect(getTaskMetadataStub.firstCall.args[1]).to.equal(taskId)
|
||||
|
||||
// Verify saveTaskMetadata was called with the correct data
|
||||
expect(saveTaskMetadataStub.calledOnce).to.be.true
|
||||
|
||||
const savedMetadata = saveTaskMetadataStub.firstCall.args[2]
|
||||
expect(savedMetadata.files_in_context.length).to.equal(1)
|
||||
|
||||
const fileEntry = savedMetadata.files_in_context[0]
|
||||
expect(fileEntry.path).to.equal(filePath)
|
||||
expect(fileEntry.record_state).to.equal("active")
|
||||
expect(fileEntry.record_source).to.equal("read_tool")
|
||||
expect(fileEntry.cline_read_date).to.be.a("number")
|
||||
expect(fileEntry.cline_edit_date).to.be.null
|
||||
})
|
||||
|
||||
it("should add a record when a file is edited by Cline", async () => {
|
||||
const filePath = "src/test-file.ts"
|
||||
|
||||
await tracker.trackFileContext(filePath, "cline_edited")
|
||||
|
||||
// Verify saveTaskMetadata was called with the correct data
|
||||
expect(saveTaskMetadataStub.calledOnce).to.be.true
|
||||
const savedMetadata = saveTaskMetadataStub.firstCall.args[2]
|
||||
|
||||
// Check that we have at least one entry in files_in_context
|
||||
expect(savedMetadata.files_in_context).to.be.an("array").that.is.not.empty
|
||||
|
||||
// Find the active entry for this file
|
||||
const activeEntry = savedMetadata.files_in_context.find(
|
||||
(entry: FileMetadataEntry) => entry.path === filePath && entry.record_state === "active",
|
||||
)
|
||||
|
||||
// Assert that we found an active entry
|
||||
expect(activeEntry).to.exist
|
||||
|
||||
// Now check the properties of the active entry
|
||||
expect(activeEntry.path).to.equal(filePath)
|
||||
expect(activeEntry.record_state).to.equal("active")
|
||||
expect(activeEntry.record_source).to.equal("cline_edited")
|
||||
expect(activeEntry.cline_read_date).to.be.a("number")
|
||||
expect(activeEntry.cline_edit_date).to.be.a("number")
|
||||
})
|
||||
|
||||
it("should add a record when a file is mentioned", async () => {
|
||||
const filePath = "src/test-file.ts"
|
||||
|
||||
await tracker.trackFileContext(filePath, "file_mentioned")
|
||||
|
||||
// Verify saveTaskMetadata was called with the correct data
|
||||
const savedMetadata = saveTaskMetadataStub.firstCall.args[2]
|
||||
const fileEntry = savedMetadata.files_in_context[0]
|
||||
|
||||
expect(fileEntry.path).to.equal(filePath)
|
||||
expect(fileEntry.record_state).to.equal("active")
|
||||
expect(fileEntry.record_source).to.equal("file_mentioned")
|
||||
expect(fileEntry.cline_read_date).to.be.a("number")
|
||||
expect(fileEntry.cline_edit_date).to.be.null
|
||||
})
|
||||
|
||||
it("should add a record when a file is edited by the user", async () => {
|
||||
const filePath = "src/test-file.ts"
|
||||
|
||||
await tracker.trackFileContext(filePath, "user_edited")
|
||||
|
||||
// Verify saveTaskMetadata was called with the correct data
|
||||
const savedMetadata = saveTaskMetadataStub.firstCall.args[2]
|
||||
const fileEntry = savedMetadata.files_in_context[0]
|
||||
|
||||
expect(fileEntry.path).to.equal(filePath)
|
||||
expect(fileEntry.record_state).to.equal("active")
|
||||
expect(fileEntry.record_source).to.equal("user_edited")
|
||||
expect(fileEntry.user_edit_date).to.be.a("number")
|
||||
|
||||
// Verify the file was added to recentlyModifiedFiles
|
||||
const modifiedFiles = tracker.getAndClearRecentlyModifiedFiles()
|
||||
expect(modifiedFiles).to.include(filePath)
|
||||
})
|
||||
|
||||
it("should mark existing entries as stale when adding a new entry for the same file", async () => {
|
||||
const filePath = "src/test-file.ts"
|
||||
|
||||
// Add an initial entry
|
||||
mockTaskMetadata.files_in_context = [
|
||||
{
|
||||
path: filePath,
|
||||
record_state: "active",
|
||||
record_source: "read_tool",
|
||||
cline_read_date: Date.now() - 1000, // 1 second ago
|
||||
cline_edit_date: null,
|
||||
user_edit_date: null,
|
||||
},
|
||||
]
|
||||
|
||||
// Track a new operation on the same file
|
||||
await tracker.trackFileContext(filePath, "cline_edited")
|
||||
|
||||
// Verify the metadata now has two entries - one stale and one active
|
||||
const savedMetadata = saveTaskMetadataStub.firstCall.args[2]
|
||||
expect(savedMetadata.files_in_context.length).to.equal(2)
|
||||
|
||||
// First entry should be marked as stale
|
||||
expect(savedMetadata.files_in_context[0].record_state).to.equal("stale")
|
||||
|
||||
// New entry should be active
|
||||
const newEntry = savedMetadata.files_in_context[1]
|
||||
expect(newEntry.record_state).to.equal("active")
|
||||
expect(newEntry.record_source).to.equal("cline_edited")
|
||||
})
|
||||
|
||||
it("should setup a file watcher for tracked files", async () => {
|
||||
const filePath = "src/test-file.ts"
|
||||
|
||||
// Create a spy to track if createFileSystemWatcher was called
|
||||
const createWatcherSpy = sinon.spy(vscode.workspace, "createFileSystemWatcher")
|
||||
|
||||
await tracker.trackFileContext(filePath, "read_tool")
|
||||
|
||||
// Verify createFileSystemWatcher was called
|
||||
expect(createWatcherSpy.called).to.be.true
|
||||
createWatcherSpy.restore()
|
||||
|
||||
// Verify onDidChange was called to set up the change listener
|
||||
expect(mockFileSystemWatcher.onDidChange.called).to.be.true
|
||||
})
|
||||
|
||||
it("should track user edits when file watcher detects changes", async () => {
|
||||
const filePath = "src/test-file.ts"
|
||||
|
||||
// First track the file to set up the watcher
|
||||
await tracker.trackFileContext(filePath, "read_tool")
|
||||
|
||||
// Reset the stubs to check the next calls
|
||||
getTaskMetadataStub.resetHistory()
|
||||
saveTaskMetadataStub.resetHistory()
|
||||
|
||||
// Create a spy on trackFileContext to verify it's called with the right parameters
|
||||
const trackFileContextSpy = sandbox.spy(tracker, "trackFileContext")
|
||||
|
||||
// Get the callback that was registered with onDidChange
|
||||
const callback = mockFileSystemWatcher.onDidChange.firstCall.args[0]
|
||||
|
||||
// Directly call the callback to simulate a file change event
|
||||
callback(vscode.Uri.file(path.resolve("/mock/workspace", filePath)))
|
||||
|
||||
// Verify trackFileContext was called with the right parameters
|
||||
expect(trackFileContextSpy.calledWith(filePath, "user_edited")).to.be.true
|
||||
|
||||
// Verify the file was added to recentlyModifiedFiles
|
||||
const modifiedFiles = tracker.getAndClearRecentlyModifiedFiles()
|
||||
expect(modifiedFiles).to.include(filePath)
|
||||
})
|
||||
|
||||
it("should not track Cline edits as user edits", async () => {
|
||||
const filePath = "src/test-file.ts"
|
||||
|
||||
// First track the file to set up the watcher
|
||||
await tracker.trackFileContext(filePath, "read_tool")
|
||||
|
||||
// Mark the file as edited by Cline
|
||||
tracker.markFileAsEditedByCline(filePath)
|
||||
|
||||
// Reset the stubs to check the next calls
|
||||
getTaskMetadataStub.resetHistory()
|
||||
saveTaskMetadataStub.resetHistory()
|
||||
|
||||
// Create a spy on trackFileContext to verify it's not called
|
||||
const trackFileContextSpy = sandbox.spy(tracker, "trackFileContext")
|
||||
|
||||
// Get the callback that was registered with onDidChange
|
||||
const callback = mockFileSystemWatcher.onDidChange.firstCall.args[0]
|
||||
|
||||
// Directly call the callback to simulate a file change event
|
||||
callback(vscode.Uri.file(path.resolve("/mock/workspace", filePath)))
|
||||
|
||||
// Verify trackFileContext was not called with user_edited
|
||||
expect(trackFileContextSpy.calledWith(filePath, "user_edited")).to.be.false
|
||||
|
||||
// Verify the file was not added to recentlyModifiedFiles
|
||||
const modifiedFiles = tracker.getAndClearRecentlyModifiedFiles()
|
||||
expect(modifiedFiles).to.not.include(filePath)
|
||||
})
|
||||
|
||||
it("should dispose file watchers when dispose is called", async () => {
|
||||
const filePath = "src/test-file.ts"
|
||||
|
||||
// Track a file to set up the watcher
|
||||
await tracker.trackFileContext(filePath, "read_tool")
|
||||
|
||||
// Call dispose
|
||||
tracker.dispose()
|
||||
|
||||
// Verify the watcher was disposed
|
||||
expect(mockFileSystemWatcher.dispose.called).to.be.true
|
||||
})
|
||||
})
|
||||
@@ -1,187 +0,0 @@
|
||||
import * as path from "path"
|
||||
import * as vscode from "vscode"
|
||||
import { getTaskMetadata, saveTaskMetadata } from "../storage/disk"
|
||||
import type { FileMetadataEntry, ControllerLike } from "./FileContextTrackerTypes"
|
||||
|
||||
// This class is responsible for tracking file operations that may result in stale context.
|
||||
// If a user modifies a file outside of Cline, the context may become stale and need to be updated.
|
||||
// We do not want Cline to reload the context every time a file is modified, so we use this class merely
|
||||
// to inform Cline that the change has occurred, and tell Cline to reload the file before making
|
||||
// any changes to it. This fixes an issue with diff editing, where Cline was unable to complete a diff edit.
|
||||
// a diff edit because the file was modified since Cline last read it.
|
||||
|
||||
// FileContextTracker
|
||||
//
|
||||
// This class is responsible for tracking file operations.
|
||||
// If the full contents of a file are pass to Cline via a tool, mention, or edit, the file is marked as active.
|
||||
// If a file is modified outside of Cline, we detect and track this change to prevent stale context.
|
||||
export class FileContextTracker {
|
||||
readonly taskId: string
|
||||
private controllerRef: WeakRef<ControllerLike>
|
||||
|
||||
// File tracking and watching
|
||||
private fileWatchers = new Map<string, vscode.FileSystemWatcher>()
|
||||
private recentlyModifiedFiles = new Set<string>()
|
||||
private recentlyEditedByCline = new Set<string>()
|
||||
|
||||
constructor(controller: ControllerLike, taskId: string) {
|
||||
this.controllerRef = new WeakRef(controller)
|
||||
this.taskId = taskId
|
||||
}
|
||||
|
||||
// While a task is ref'd by a controller, it will always have access to the extension context
|
||||
// This error is thrown if the controller derefs the task after e.g., aborting the task
|
||||
private context(): vscode.ExtensionContext {
|
||||
const context = this.controllerRef.deref()?.context
|
||||
if (!context) {
|
||||
throw new Error("Unable to access extension context")
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
// Gets the current working directory or returns undefined if it cannot be determined
|
||||
private getCwd(): string | undefined {
|
||||
const cwd = vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath).at(0)
|
||||
if (!cwd) {
|
||||
console.info("No workspace folder available - cannot determine current working directory")
|
||||
}
|
||||
return cwd
|
||||
}
|
||||
|
||||
// File watchers are set up for each file that is tracked in the task metadata.
|
||||
async setupFileWatcher(filePath: string) {
|
||||
// Only setup watcher if it doesn't already exist for this file
|
||||
if (this.fileWatchers.has(filePath)) {
|
||||
return
|
||||
}
|
||||
|
||||
const cwd = this.getCwd()
|
||||
if (!cwd) {
|
||||
return
|
||||
}
|
||||
|
||||
// Create a file system watcher for this specific file
|
||||
const fileUri = vscode.Uri.file(path.resolve(cwd, filePath))
|
||||
const watcher = vscode.workspace.createFileSystemWatcher(
|
||||
new vscode.RelativePattern(path.dirname(fileUri.fsPath), path.basename(fileUri.fsPath)),
|
||||
)
|
||||
|
||||
// Track file changes
|
||||
watcher.onDidChange(() => {
|
||||
if (this.recentlyEditedByCline.has(filePath)) {
|
||||
this.recentlyEditedByCline.delete(filePath) // This was an edit by Cline, no need to inform Cline
|
||||
} else {
|
||||
this.recentlyModifiedFiles.add(filePath) // This was a user edit, we will inform Cline
|
||||
this.trackFileContext(filePath, "user_edited") // Update the task metadata with file tracking
|
||||
}
|
||||
})
|
||||
|
||||
// Store the watcher so we can dispose it later
|
||||
this.fileWatchers.set(filePath, watcher)
|
||||
}
|
||||
|
||||
// Tracks a file operation in metadata and sets up a watcher for the file
|
||||
// This is the main entry point for FileContextTracker and is called when a file is passed to Cline via a tool, mention, or edit.
|
||||
async trackFileContext(filePath: string, operation: "read_tool" | "user_edited" | "cline_edited" | "file_mentioned") {
|
||||
try {
|
||||
const cwd = this.getCwd()
|
||||
if (!cwd) {
|
||||
return
|
||||
}
|
||||
|
||||
const context = this.context()
|
||||
// Add file to metadata
|
||||
await this.addFileToFileContextTracker(context, this.taskId, filePath, operation)
|
||||
|
||||
// Set up file watcher for this file
|
||||
await this.setupFileWatcher(filePath)
|
||||
} catch (error) {
|
||||
console.error("Failed to track file operation:", error)
|
||||
}
|
||||
}
|
||||
|
||||
// Adds a file to the metadata tracker
|
||||
// This handles the business logic of determining if the file is new, stale, or active.
|
||||
// It also updates the metadata with the latest read/edit dates.
|
||||
async addFileToFileContextTracker(
|
||||
context: vscode.ExtensionContext,
|
||||
taskId: string,
|
||||
filePath: string,
|
||||
source: FileMetadataEntry["record_source"],
|
||||
) {
|
||||
try {
|
||||
const metadata = await getTaskMetadata(context, taskId)
|
||||
const now = Date.now()
|
||||
|
||||
// Mark existing entries for this file as stale
|
||||
metadata.files_in_context.forEach((entry) => {
|
||||
if (entry.path === filePath && entry.record_state === "active") {
|
||||
entry.record_state = "stale"
|
||||
}
|
||||
})
|
||||
|
||||
// Helper to get the latest date for a specific field and file
|
||||
const getLatestDateForField = (path: string, field: keyof FileMetadataEntry): number | null => {
|
||||
const relevantEntries = metadata.files_in_context
|
||||
.filter((entry) => entry.path === path && entry[field])
|
||||
.sort((a, b) => (b[field] as number) - (a[field] as number))
|
||||
|
||||
return relevantEntries.length > 0 ? (relevantEntries[0][field] as number) : null
|
||||
}
|
||||
|
||||
let newEntry: FileMetadataEntry = {
|
||||
path: filePath,
|
||||
record_state: "active",
|
||||
record_source: source,
|
||||
cline_read_date: getLatestDateForField(filePath, "cline_read_date"),
|
||||
cline_edit_date: getLatestDateForField(filePath, "cline_edit_date"),
|
||||
user_edit_date: getLatestDateForField(filePath, "user_edit_date"),
|
||||
}
|
||||
|
||||
switch (source) {
|
||||
// user_edited: The user has edited the file
|
||||
case "user_edited":
|
||||
newEntry.user_edit_date = now
|
||||
this.recentlyModifiedFiles.add(filePath)
|
||||
break
|
||||
|
||||
// cline_edited: Cline has edited the file
|
||||
case "cline_edited":
|
||||
newEntry.cline_read_date = now
|
||||
newEntry.cline_edit_date = now
|
||||
break
|
||||
|
||||
// read_tool/file_mentioned: Cline has read the file via a tool or file mention
|
||||
case "read_tool":
|
||||
case "file_mentioned":
|
||||
newEntry.cline_read_date = now
|
||||
break
|
||||
}
|
||||
|
||||
metadata.files_in_context.push(newEntry)
|
||||
await saveTaskMetadata(context, taskId, metadata)
|
||||
} catch (error) {
|
||||
console.error("Failed to add file to metadata:", error)
|
||||
}
|
||||
}
|
||||
|
||||
// Returns (and then clears) the set of recently modified files
|
||||
getAndClearRecentlyModifiedFiles(): string[] {
|
||||
const files = Array.from(this.recentlyModifiedFiles)
|
||||
this.recentlyModifiedFiles.clear()
|
||||
return files
|
||||
}
|
||||
|
||||
// Marks a file as edited by Cline to prevent false positives in file watchers
|
||||
markFileAsEditedByCline(filePath: string): void {
|
||||
this.recentlyEditedByCline.add(filePath)
|
||||
}
|
||||
|
||||
// Disposes all file watchers
|
||||
dispose(): void {
|
||||
for (const watcher of this.fileWatchers.values()) {
|
||||
watcher.dispose()
|
||||
}
|
||||
this.fileWatchers.clear()
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
import * as vscode from "vscode"
|
||||
|
||||
// Type definitions for FileContextTracker
|
||||
export interface FileMetadataEntry {
|
||||
path: string
|
||||
record_state: "active" | "stale"
|
||||
record_source: "read_tool" | "user_edited" | "cline_edited" | "file_mentioned"
|
||||
cline_read_date: number | null
|
||||
cline_edit_date: number | null
|
||||
user_edit_date?: number | null
|
||||
}
|
||||
|
||||
export interface TaskMetadata {
|
||||
files_in_context: FileMetadataEntry[]
|
||||
}
|
||||
|
||||
// Interface for the controller to avoid direct dependency
|
||||
export interface ControllerLike {
|
||||
context: vscode.ExtensionContext
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -10,7 +10,6 @@ import { diagnosticsToProblemsString } from "../../integrations/diagnostics"
|
||||
import { getLatestTerminalOutput } from "../../integrations/terminal/get-latest-output"
|
||||
import { getCommitInfo } from "../../utils/git"
|
||||
import { getWorkingState } from "../../utils/git"
|
||||
import { FileContextTracker } from "../context-tracking/FileContextTracker"
|
||||
|
||||
export function openMention(mention?: string): void {
|
||||
if (!mention) {
|
||||
@@ -39,12 +38,7 @@ export function openMention(mention?: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
export async function parseMentions(
|
||||
text: string,
|
||||
cwd: string,
|
||||
urlContentFetcher: UrlContentFetcher,
|
||||
fileContextTracker?: FileContextTracker,
|
||||
): Promise<string> {
|
||||
export async function parseMentions(text: string, cwd: string, urlContentFetcher: UrlContentFetcher): Promise<string> {
|
||||
const mentions: Set<string> = new Set()
|
||||
let parsedText = text.replace(mentionRegexGlobal, (match, mention) => {
|
||||
mentions.add(mention)
|
||||
@@ -78,10 +72,7 @@ export async function parseMentions(
|
||||
}
|
||||
}
|
||||
|
||||
// Filter out duplicate mentions while preserving order
|
||||
const uniqueMentions = Array.from(new Set(mentions))
|
||||
|
||||
for (const mention of uniqueMentions) {
|
||||
for (const mention of mentions) {
|
||||
if (mention.startsWith("http")) {
|
||||
let result: string
|
||||
if (launchBrowserError) {
|
||||
@@ -104,10 +95,6 @@ export async function parseMentions(
|
||||
parsedText += `\n\n<folder_content path="${mentionPath}">\n${content}\n</folder_content>`
|
||||
} else {
|
||||
parsedText += `\n\n<file_content path="${mentionPath}">\n${content}\n</file_content>`
|
||||
// Track that this file was mentioned and its content was included
|
||||
if (fileContextTracker) {
|
||||
await fileContextTracker.trackFileContext(mentionPath, "file_mentioned")
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (mention.endsWith("/")) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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?
|
||||
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
|
||||
/*
|
||||
We can't implement a dynamically updating sliding window as it would break prompt cache
|
||||
every time. To maintain the benefits of caching, we need to keep conversation history
|
||||
static. This operation should be performed as infrequently as possible. If a user reaches
|
||||
a 200k context, we can assume that the first half is likely irrelevant to their current task.
|
||||
Therefore, this function should only be called when absolutely necessary to fit within
|
||||
context limits, not as a continuous process.
|
||||
*/
|
||||
// export function truncateHalfConversation(
|
||||
// messages: Anthropic.Messages.MessageParam[],
|
||||
// ): Anthropic.Messages.MessageParam[] {
|
||||
// // API expects messages to be in user-assistant order, and tool use messages must be followed by tool results. We need to maintain this structure while truncating.
|
||||
|
||||
// // Always keep the first Task message (this includes the project's file structure in environment_details)
|
||||
// const truncatedMessages = [messages[0]]
|
||||
|
||||
// // Remove half of user-assistant pairs
|
||||
// const messagesToRemove = Math.floor(messages.length / 4) * 2 // has to be even number
|
||||
|
||||
// const remainingMessages = messages.slice(messagesToRemove + 1) // has to start with assistant message since tool result cannot follow assistant message with no tool use
|
||||
// truncatedMessages.push(...remainingMessages)
|
||||
|
||||
// return truncatedMessages
|
||||
// }
|
||||
|
||||
/*
|
||||
getNextTruncationRange: Calculates the next range of messages to be "deleted"
|
||||
- Takes the full messages array and optional current deleted range
|
||||
- Always preserves the first message (task message)
|
||||
- Removes 1/2 of remaining messages (rounded down to even number) after current deleted range
|
||||
- Returns [startIndex, endIndex] representing inclusive range to delete
|
||||
|
||||
getTruncatedMessages: Constructs the truncated array using the deleted range
|
||||
- Takes full messages array and optional deleted range
|
||||
- Returns new array with messages in deleted range removed
|
||||
- Preserves order and structure of remaining messages
|
||||
|
||||
The range is represented as [startIndex, endIndex] where both indices are inclusive
|
||||
The functions maintain the original array integrity while allowing progressive truncation
|
||||
through the deletedRange parameter
|
||||
|
||||
Usage example:
|
||||
const messages = [user1, assistant1, user2, assistant2, user3, assistant3];
|
||||
let deletedRange = getNextTruncationRange(messages); // [1,2] (assistant1,user2)
|
||||
let truncated = getTruncatedMessages(messages, deletedRange);
|
||||
// [user1, assistant2, user3, assistant3]
|
||||
|
||||
deletedRange = getNextTruncationRange(messages, deletedRange); // [2,3] (assistant2,user3)
|
||||
truncated = getTruncatedMessages(messages, deletedRange);
|
||||
// [user1, assistant3]
|
||||
*/
|
||||
|
||||
export function getNextTruncationRange(
|
||||
messages: Anthropic.Messages.MessageParam[],
|
||||
currentDeletedRange: [number, number] | undefined = undefined,
|
||||
keep: "half" | "quarter" = "half",
|
||||
): [number, number] {
|
||||
// Since we always keep the first message, currentDeletedRange[0] will always be 1 (for now until we have a smarter truncation algorithm)
|
||||
const rangeStartIndex = 1
|
||||
const startOfRest = currentDeletedRange ? currentDeletedRange[1] + 1 : 1
|
||||
|
||||
let messagesToRemove: number
|
||||
if (keep === "half") {
|
||||
// Remove half of user-assistant pairs
|
||||
messagesToRemove = Math.floor((messages.length - startOfRest) / 4) * 2 // Keep even number
|
||||
} else {
|
||||
// Remove 3/4 of user-assistant pairs
|
||||
messagesToRemove = Math.floor((messages.length - startOfRest) / 8) * 3 * 2
|
||||
}
|
||||
|
||||
let rangeEndIndex = startOfRest + messagesToRemove - 1
|
||||
|
||||
// Make sure the last message being removed is a user message, so that the next message after the initial task message is an assistant message. This preservers the user-assistant-user-assistant structure.
|
||||
// NOTE: anthropic format messages are always user-assistant-user-assistant, while openai format messages can have multiple user messages in a row (we use anthropic format throughout cline)
|
||||
if (messages[rangeEndIndex].role !== "user") {
|
||||
rangeEndIndex -= 1
|
||||
}
|
||||
|
||||
// this is an inclusive range that will be removed from the conversation history
|
||||
return [rangeStartIndex, rangeEndIndex]
|
||||
}
|
||||
|
||||
export function getTruncatedMessages(
|
||||
messages: Anthropic.Messages.MessageParam[],
|
||||
deletedRange: [number, number] | undefined,
|
||||
): Anthropic.Messages.MessageParam[] {
|
||||
if (!deletedRange) {
|
||||
return messages
|
||||
}
|
||||
|
||||
const [start, end] = deletedRange
|
||||
// the range is inclusive - both start and end indices and everything in between will be removed from the final result.
|
||||
// NOTE: if you try to console log these, don't forget that logging a reference to an array may not provide the same result as logging a slice() snapshot of that array at that exact moment. The following DOES in fact include the latest assistant message.
|
||||
return [...messages.slice(0, start), ...messages.slice(end + 1)]
|
||||
}
|
||||
@@ -1,109 +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 interface FileMetadataEntry {
|
||||
path: string
|
||||
record_state: "active" | "stale"
|
||||
record_source: "read_tool" | "user_edited" | "cline_edited" | "file_mentioned"
|
||||
cline_read_date: number | null
|
||||
cline_edit_date: number | null
|
||||
user_edit_date?: number | null
|
||||
}
|
||||
|
||||
export interface TaskMetadata {
|
||||
files_in_context: FileMetadataEntry[]
|
||||
}
|
||||
|
||||
export const GlobalFileNames = {
|
||||
apiConversationHistory: "api_conversation_history.json",
|
||||
uiMessages: "ui_messages.json",
|
||||
openRouterModels: "openrouter_models.json",
|
||||
mcpSettings: "cline_mcp_settings.json",
|
||||
clineRules: ".clinerules",
|
||||
taskMetadata: "task_metadata.json",
|
||||
}
|
||||
|
||||
export async function ensureTaskDirectoryExists(context: vscode.ExtensionContext, taskId: string): Promise<string> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
export async function getTaskMetadata(context: vscode.ExtensionContext, taskId: string): Promise<TaskMetadata> {
|
||||
const filePath = path.join(await ensureTaskDirectoryExists(context, taskId), GlobalFileNames.taskMetadata)
|
||||
try {
|
||||
if (await fileExistsAtPath(filePath)) {
|
||||
return JSON.parse(await fs.readFile(filePath, "utf8"))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to read task metadata:", error)
|
||||
}
|
||||
return { files_in_context: [] }
|
||||
}
|
||||
|
||||
export async function saveTaskMetadata(context: vscode.ExtensionContext, taskId: string, metadata: TaskMetadata) {
|
||||
try {
|
||||
const taskDir = await ensureTaskDirectoryExists(context, taskId)
|
||||
const filePath = path.join(taskDir, GlobalFileNames.taskMetadata)
|
||||
await fs.writeFile(filePath, JSON.stringify(metadata, null, 2))
|
||||
} catch (error) {
|
||||
console.error("Failed to save task metadata:", error)
|
||||
}
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
export type SecretKey =
|
||||
| "apiKey"
|
||||
| "clineApiKey"
|
||||
| "openRouterApiKey"
|
||||
| "awsAccessKey"
|
||||
| "awsSecretKey"
|
||||
| "awsSessionToken"
|
||||
| "openAiApiKey"
|
||||
| "geminiApiKey"
|
||||
| "openAiNativeApiKey"
|
||||
| "deepSeekApiKey"
|
||||
| "requestyApiKey"
|
||||
| "togetherApiKey"
|
||||
| "qwenApiKey"
|
||||
| "doubaoApiKey"
|
||||
| "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"
|
||||
| "liteLlmUsePromptCache"
|
||||
| "qwenApiLine"
|
||||
| "requestyModelId"
|
||||
| "togetherModelId"
|
||||
| "mcpMarketplaceCatalog"
|
||||
| "telemetrySetting"
|
||||
| "asksageApiUrl"
|
||||
| "thinkingBudgetTokens"
|
||||
| "planActSeparateModelsSetting"
|
||||
@@ -1,432 +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,
|
||||
doubaoApiKey,
|
||||
mistralApiKey,
|
||||
azureApiVersion,
|
||||
openRouterModelId,
|
||||
openRouterModelInfo,
|
||||
openRouterProviderSorting,
|
||||
lastShownAnnouncementId,
|
||||
customInstructions,
|
||||
taskHistory,
|
||||
autoApprovalSettings,
|
||||
browserSettings,
|
||||
chatSettings,
|
||||
vsCodeLmModelSelector,
|
||||
liteLlmBaseUrl,
|
||||
liteLlmModelId,
|
||||
liteLlmUsePromptCache,
|
||||
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, "doubaoApiKey") as Promise<string | undefined>,
|
||||
getSecret(context, "mistralApiKey") as Promise<string | undefined>,
|
||||
getGlobalState(context, "azureApiVersion") as Promise<string | undefined>,
|
||||
getGlobalState(context, "openRouterModelId") as Promise<string | undefined>,
|
||||
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, "liteLlmUsePromptCache") as Promise<boolean | undefined>,
|
||||
getGlobalState(context, "userInfo") as Promise<UserInfo | undefined>,
|
||||
getGlobalState(context, "previousModeApiProvider") as Promise<ApiProvider | undefined>,
|
||||
getGlobalState(context, "previousModeModelId") as Promise<string | undefined>,
|
||||
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,
|
||||
doubaoApiKey,
|
||||
mistralApiKey,
|
||||
azureApiVersion,
|
||||
openRouterModelId,
|
||||
openRouterModelInfo,
|
||||
openRouterProviderSorting,
|
||||
vsCodeLmModelSelector,
|
||||
o3MiniReasoningEffort,
|
||||
thinkingBudgetTokens,
|
||||
liteLlmBaseUrl,
|
||||
liteLlmModelId,
|
||||
liteLlmApiKey,
|
||||
liteLlmUsePromptCache,
|
||||
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,
|
||||
doubaoApiKey,
|
||||
mistralApiKey,
|
||||
azureApiVersion,
|
||||
openRouterModelId,
|
||||
openRouterModelInfo,
|
||||
openRouterProviderSorting,
|
||||
vsCodeLmModelSelector,
|
||||
liteLlmBaseUrl,
|
||||
liteLlmModelId,
|
||||
liteLlmApiKey,
|
||||
liteLlmUsePromptCache,
|
||||
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, "doubaoApiKey", doubaoApiKey)
|
||||
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, "liteLlmUsePromptCache", liteLlmUsePromptCache)
|
||||
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",
|
||||
"doubaoApiKey",
|
||||
"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
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,288 +0,0 @@
|
||||
import * as vscode from "vscode"
|
||||
import * as fs from "fs/promises"
|
||||
import * as path from "path"
|
||||
import { Controller } from "../../core/controller"
|
||||
import { HistoryItem } from "../../shared/HistoryItem"
|
||||
import { ClineMessage } from "../../shared/ExtensionMessage"
|
||||
|
||||
/**
|
||||
* Registers development-only commands for task manipulation.
|
||||
* These are only activated in development mode.
|
||||
*/
|
||||
export function registerTaskCommands(context: vscode.ExtensionContext, controller: Controller): vscode.Disposable[] {
|
||||
return [
|
||||
vscode.commands.registerCommand("cline.dev.createTestTasks", async () => {
|
||||
const count = await vscode.window.showInputBox({
|
||||
title: "Test Tasks",
|
||||
prompt: "How many test tasks to create?",
|
||||
value: "10",
|
||||
})
|
||||
|
||||
if (!count) {
|
||||
return
|
||||
}
|
||||
|
||||
const tasksCount = parseInt(count)
|
||||
const globalStoragePath = context.globalStorageUri.fsPath
|
||||
const tasksDir = path.join(globalStoragePath, "tasks")
|
||||
|
||||
vscode.window.withProgress(
|
||||
{
|
||||
location: vscode.ProgressLocation.Notification,
|
||||
title: `Creating ${tasksCount} test tasks...`,
|
||||
cancellable: false,
|
||||
},
|
||||
async (progress) => {
|
||||
for (let i = 0; i < tasksCount; i++) {
|
||||
// Generate a timestamp to ensure unique IDs
|
||||
const timestamp = Date.now() + i
|
||||
const taskId = `${timestamp}`
|
||||
const taskDir = path.join(tasksDir, taskId)
|
||||
|
||||
await fs.mkdir(taskDir, { recursive: true })
|
||||
|
||||
// Generate a task prompt
|
||||
const taskName = getRandomTaskName(i)
|
||||
|
||||
// Create realistic message sequence
|
||||
const messages = createRealisticMessageSequence(timestamp, taskName, i)
|
||||
|
||||
// Create API conversation history file
|
||||
await fs.writeFile(
|
||||
path.join(taskDir, "api_conversation_history.json"),
|
||||
JSON.stringify(
|
||||
[
|
||||
{
|
||||
role: "user",
|
||||
content: [{ type: "text", text: `<task>\n${taskName}\n</task>` }],
|
||||
},
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `I'll help you ${taskName.toLowerCase()}. Let me break this down into steps.`,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
null,
|
||||
2,
|
||||
),
|
||||
)
|
||||
|
||||
// Create UI messages file with realistic message sequence
|
||||
await fs.writeFile(path.join(taskDir, "ui_messages.json"), JSON.stringify(messages, null, 2))
|
||||
|
||||
// Create history item to be shown in the HistoryView
|
||||
const historyItem: HistoryItem = {
|
||||
id: taskId,
|
||||
ts: timestamp,
|
||||
task: taskName,
|
||||
tokensIn: Math.floor(100 + Math.random() * 900), // Random token count from 100-1000
|
||||
tokensOut: Math.floor(200 + Math.random() * 1800), // Random token count from 200-2000
|
||||
cacheWrites: i % 3 === 0 ? Math.floor(50 + Math.random() * 150) : undefined, // Only add cache writes to every 3rd task
|
||||
cacheReads: i % 3 === 0 ? Math.floor(20 + Math.random() * 80) : undefined, // Only add cache reads to every 3rd task
|
||||
totalCost: Number((0.0001 + Math.random() * 0.01).toFixed(5)), // Random cost from $0.0001 to $0.0101
|
||||
size: 1024 * 1024, // 1MB
|
||||
}
|
||||
|
||||
// Update task history in global state
|
||||
await controller.updateTaskHistory(historyItem)
|
||||
|
||||
progress.report({ increment: 100 / tasksCount })
|
||||
}
|
||||
|
||||
// Update the UI to show the new tasks
|
||||
await controller.postStateToWebview()
|
||||
|
||||
vscode.window.showInformationMessage(`Created ${tasksCount} test tasks`)
|
||||
},
|
||||
)
|
||||
}),
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a realistic sequence of messages that would occur in a typical task
|
||||
*/
|
||||
function createRealisticMessageSequence(baseTimestamp: number, taskPrompt: string, taskIndex: number): ClineMessage[] {
|
||||
// Use an incrementing timestamp to ensure messages appear in sequence
|
||||
let timestamp = baseTimestamp
|
||||
const getNextTimestamp = () => {
|
||||
timestamp += 1000 // Add 1 second between messages
|
||||
return timestamp
|
||||
}
|
||||
|
||||
// Variables to make different test tasks look unique
|
||||
const fileName = getRandomFileName(taskIndex)
|
||||
const commitHash = `commit${taskIndex}${Math.floor(Math.random() * 1000000).toString(16)}`
|
||||
|
||||
// Create a realistic message sequence
|
||||
const messages: ClineMessage[] = [
|
||||
// Initial task message - uses "say" with "text" which is the format used in Cline.ts
|
||||
{
|
||||
ts: baseTimestamp,
|
||||
type: "say",
|
||||
say: "text",
|
||||
text: taskPrompt,
|
||||
},
|
||||
|
||||
// API request started
|
||||
{
|
||||
ts: getNextTimestamp(),
|
||||
type: "say",
|
||||
say: "api_req_started",
|
||||
text: JSON.stringify({
|
||||
request: `<task>\n${taskPrompt}\n</task>`,
|
||||
tokensIn: Math.floor(100 + Math.random() * 200),
|
||||
tokensOut: Math.floor(300 + Math.random() * 500),
|
||||
}),
|
||||
},
|
||||
|
||||
// Reasoning message
|
||||
{
|
||||
ts: getNextTimestamp(),
|
||||
type: "say",
|
||||
say: "reasoning",
|
||||
text: `I'll approach this task by breaking it down into manageable steps. First, I'll analyze the requirements, then create a plan, and finally implement the solution systematically.`,
|
||||
},
|
||||
|
||||
// Text response
|
||||
{
|
||||
ts: getNextTimestamp(),
|
||||
type: "say",
|
||||
say: "text",
|
||||
text: `I'll help you with this task. Let me start by creating the necessary files and implementing the core functionality.`,
|
||||
},
|
||||
]
|
||||
|
||||
// Add task-specific messages based on index modulo to create variety
|
||||
const messageType = taskIndex % 5
|
||||
|
||||
if (messageType === 0 || messageType === 2) {
|
||||
// Tool use - file operations
|
||||
messages.push({
|
||||
ts: getNextTimestamp(),
|
||||
type: "say",
|
||||
say: "tool",
|
||||
text: JSON.stringify({
|
||||
tool: "newFileCreated",
|
||||
path: fileName,
|
||||
content: `// Sample code for ${taskPrompt}`,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
if (messageType === 1 || messageType === 3) {
|
||||
// Command execution
|
||||
messages.push(
|
||||
{
|
||||
ts: getNextTimestamp(),
|
||||
type: "ask",
|
||||
ask: "command",
|
||||
text: `ls -la`,
|
||||
},
|
||||
{
|
||||
ts: getNextTimestamp(),
|
||||
type: "say",
|
||||
say: "command_output",
|
||||
text: `total 24\ndrwxr-xr-x 3 user staff 96 Mar 10 12:34 .\ndrwxr-xr-x 8 user staff 256 Mar 10 12:30 ..\n-rw-r--r-- 1 user staff 158 Mar 10 12:34 ${fileName}`,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
if (messageType === 2 || messageType === 4) {
|
||||
// Browser actions
|
||||
messages.push(
|
||||
{
|
||||
ts: getNextTimestamp(),
|
||||
type: "ask",
|
||||
ask: "browser_action_launch",
|
||||
text: `https://example.com`,
|
||||
},
|
||||
{
|
||||
ts: getNextTimestamp(),
|
||||
type: "say",
|
||||
say: "browser_action_result",
|
||||
text: JSON.stringify({
|
||||
logs: "Page loaded successfully",
|
||||
screenshot:
|
||||
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==",
|
||||
}),
|
||||
},
|
||||
{
|
||||
ts: getNextTimestamp(),
|
||||
type: "say",
|
||||
say: "browser_action",
|
||||
text: JSON.stringify({
|
||||
action: "close",
|
||||
}),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// Add checkpoint
|
||||
messages.push({
|
||||
ts: getNextTimestamp(),
|
||||
type: "say",
|
||||
say: "checkpoint_created",
|
||||
lastCheckpointHash: commitHash,
|
||||
})
|
||||
|
||||
// Add completion result (all tasks end with this)
|
||||
messages.push({
|
||||
ts: getNextTimestamp(),
|
||||
type: "say",
|
||||
say: "completion_result",
|
||||
text: `I've completed the task to ${taskPrompt.toLowerCase()}. The implementation includes all the required functionality and meets the specifications. ${"x".repeat(1024 * 1024)}`, // 1MB file
|
||||
lastCheckpointHash: commitHash,
|
||||
})
|
||||
|
||||
return messages
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a random task name for test data
|
||||
*/
|
||||
function getRandomTaskName(index: number): string {
|
||||
const tasks = [
|
||||
"Create a simple todo application",
|
||||
"Build a weather forecast widget",
|
||||
"Implement a markdown parser",
|
||||
"Design a responsive landing page",
|
||||
"Develop a currency converter",
|
||||
"Create a file upload component",
|
||||
"Build a data visualization dashboard",
|
||||
"Implement a search functionality",
|
||||
"Create a user authentication system",
|
||||
"Design a dark mode toggle",
|
||||
"Build a countdown timer",
|
||||
"Create a drag and drop interface",
|
||||
"Implement form validation",
|
||||
"Design a multi-step wizard",
|
||||
"Create a notification system",
|
||||
]
|
||||
|
||||
return tasks[index % tasks.length] + ` (Test ${index + 1})`
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a random file name for test data
|
||||
*/
|
||||
function getRandomFileName(index: number): string {
|
||||
const files = [
|
||||
"index.html",
|
||||
"styles.css",
|
||||
"script.js",
|
||||
"app.jsx",
|
||||
"main.ts",
|
||||
"utils.py",
|
||||
"config.json",
|
||||
"server.js",
|
||||
"data.csv",
|
||||
"README.md",
|
||||
]
|
||||
|
||||
return files[index % files.length]
|
||||
}
|
||||
+11
-12
@@ -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",
|
||||
})
|
||||
|
||||
+40
-246
@@ -1,14 +1,14 @@
|
||||
// The module 'vscode' contains the VS Code extensibility API
|
||||
// Import the module and reference it with the alias vscode in your code below
|
||||
import { setTimeout as setTimeoutPromise } from "node:timers/promises"
|
||||
import delay from "delay"
|
||||
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,32 @@ export function activate(context: vscode.ExtensionContext) {
|
||||
Logger.initialize(outputChannel)
|
||||
Logger.log("Cline extension activated")
|
||||
|
||||
const sidebarWebview = new WebviewProvider(context, outputChannel)
|
||||
|
||||
vscode.commands.executeCommand("setContext", "cline.isDevMode", IS_DEV && IS_DEV === "true")
|
||||
const sidebarProvider = new ClineProvider(context, outputChannel)
|
||||
|
||||
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 +63,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 +74,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,10 +85,10 @@ 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)
|
||||
await delay(100)
|
||||
await vscode.commands.executeCommand("workbench.action.lockEditorGroup")
|
||||
}
|
||||
|
||||
@@ -112,58 +96,29 @@ 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.accountLoginClicked", () => {
|
||||
sidebarProvider.postMessageToWebview({
|
||||
type: "action",
|
||||
action: "accountLoginClicked",
|
||||
})
|
||||
}),
|
||||
)
|
||||
@@ -192,15 +147,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 +171,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
|
||||
}
|
||||
@@ -232,168 +187,7 @@ export function activate(context: vscode.ExtensionContext) {
|
||||
}
|
||||
context.subscriptions.push(vscode.window.registerUriHandler({ handleUri }))
|
||||
|
||||
// Register size testing commands in development mode
|
||||
if (IS_DEV && IS_DEV === "true") {
|
||||
// Use dynamic import to avoid loading the module in production
|
||||
import("./dev/commands/tasks")
|
||||
.then((module) => {
|
||||
const devTaskCommands = module.registerTaskCommands(context, sidebarWebview.controller)
|
||||
context.subscriptions.push(...devTaskCommands)
|
||||
Logger.log("Cline dev task commands registered")
|
||||
})
|
||||
.catch((error) => {
|
||||
Logger.log("Failed to register dev task commands: " + error)
|
||||
})
|
||||
}
|
||||
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand("cline.addToChat", async (range?: vscode.Range, diagnostics?: vscode.Diagnostic[]) => {
|
||||
const editor = vscode.window.activeTextEditor
|
||||
if (!editor) {
|
||||
return
|
||||
}
|
||||
|
||||
// Use provided range if available, otherwise use current selection
|
||||
// (vscode command passes an argument in the first param by default, so we need to ensure it's a Range object)
|
||||
const textRange = range instanceof vscode.Range ? range : editor.selection
|
||||
const selectedText = editor.document.getText(textRange)
|
||||
|
||||
if (!selectedText) {
|
||||
return
|
||||
}
|
||||
|
||||
// Get the file path and language ID
|
||||
const filePath = editor.document.uri.fsPath
|
||||
const languageId = editor.document.languageId
|
||||
|
||||
const visibleWebview = WebviewProvider.getVisibleInstance()
|
||||
await visibleWebview?.controller.addSelectedCodeToChat(
|
||||
selectedText,
|
||||
filePath,
|
||||
languageId,
|
||||
Array.isArray(diagnostics) ? diagnostics : undefined,
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand("cline.addTerminalOutputToChat", async () => {
|
||||
const terminal = vscode.window.activeTerminal
|
||||
if (!terminal) {
|
||||
return
|
||||
}
|
||||
|
||||
// Save current clipboard content
|
||||
const tempCopyBuffer = await vscode.env.clipboard.readText()
|
||||
|
||||
try {
|
||||
// Copy the *existing* terminal selection (without selecting all)
|
||||
await vscode.commands.executeCommand("workbench.action.terminal.copySelection")
|
||||
|
||||
// Get copied content
|
||||
let terminalContents = (await vscode.env.clipboard.readText()).trim()
|
||||
|
||||
// Restore original clipboard content
|
||||
await vscode.env.clipboard.writeText(tempCopyBuffer)
|
||||
|
||||
if (!terminalContents) {
|
||||
// No terminal content was copied (either nothing selected or some error)
|
||||
return
|
||||
}
|
||||
|
||||
// [Optional] Any additional logic to process multi-line content can remain here
|
||||
// For example:
|
||||
/*
|
||||
const lines = terminalContents.split("\n")
|
||||
const lastLine = lines.pop()?.trim()
|
||||
if (lastLine) {
|
||||
let i = lines.length - 1
|
||||
while (i >= 0 && !lines[i].trim().startsWith(lastLine)) {
|
||||
i--
|
||||
}
|
||||
terminalContents = lines.slice(Math.max(i, 0)).join("\n")
|
||||
}
|
||||
*/
|
||||
|
||||
// Send to sidebar provider
|
||||
const visibleWebview = WebviewProvider.getVisibleInstance()
|
||||
await visibleWebview?.controller.addSelectedTerminalOutputToChat(terminalContents, terminal.name)
|
||||
} catch (error) {
|
||||
// Ensure clipboard is restored even if an error occurs
|
||||
await vscode.env.clipboard.writeText(tempCopyBuffer)
|
||||
console.error("Error getting terminal contents:", error)
|
||||
vscode.window.showErrorMessage("Failed to get terminal contents")
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
// Register code action provider
|
||||
context.subscriptions.push(
|
||||
vscode.languages.registerCodeActionsProvider(
|
||||
"*",
|
||||
new (class implements vscode.CodeActionProvider {
|
||||
public static readonly providedCodeActionKinds = [vscode.CodeActionKind.QuickFix]
|
||||
|
||||
provideCodeActions(
|
||||
document: vscode.TextDocument,
|
||||
range: vscode.Range,
|
||||
context: vscode.CodeActionContext,
|
||||
): vscode.CodeAction[] {
|
||||
// Expand range to include surrounding 3 lines
|
||||
const expandedRange = new vscode.Range(
|
||||
Math.max(0, range.start.line - 3),
|
||||
0,
|
||||
Math.min(document.lineCount - 1, range.end.line + 3),
|
||||
document.lineAt(Math.min(document.lineCount - 1, range.end.line + 3)).text.length,
|
||||
)
|
||||
|
||||
const addAction = new vscode.CodeAction("Add to Cline", vscode.CodeActionKind.QuickFix)
|
||||
addAction.command = {
|
||||
command: "cline.addToChat",
|
||||
title: "Add to Cline",
|
||||
arguments: [expandedRange, context.diagnostics],
|
||||
}
|
||||
|
||||
const fixAction = new vscode.CodeAction("Fix with Cline", vscode.CodeActionKind.QuickFix)
|
||||
fixAction.command = {
|
||||
command: "cline.fixWithCline",
|
||||
title: "Fix with Cline",
|
||||
arguments: [expandedRange, context.diagnostics],
|
||||
}
|
||||
|
||||
// Only show actions when there are errors
|
||||
if (context.diagnostics.length > 0) {
|
||||
return [addAction, fixAction]
|
||||
} else {
|
||||
return []
|
||||
}
|
||||
}
|
||||
})(),
|
||||
{
|
||||
providedCodeActionKinds: [vscode.CodeActionKind.QuickFix],
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
// Register the command handler
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand("cline.fixWithCline", async (range: vscode.Range, diagnostics: any[]) => {
|
||||
const editor = vscode.window.activeTextEditor
|
||||
if (!editor) {
|
||||
return
|
||||
}
|
||||
|
||||
const selectedText = editor.document.getText(range)
|
||||
const filePath = editor.document.uri.fsPath
|
||||
const languageId = editor.document.languageId
|
||||
|
||||
// Send to sidebar provider with diagnostics
|
||||
const visibleWebview = WebviewProvider.getVisibleInstance()
|
||||
await visibleWebview?.controller.fixWithCline(selectedText, filePath, languageId, diagnostics)
|
||||
}),
|
||||
)
|
||||
|
||||
return createClineAPI(outputChannel, sidebarWebview.controller)
|
||||
return createClineAPI(outputChannel, sidebarProvider)
|
||||
}
|
||||
|
||||
// This method is called when your extension is deactivated
|
||||
|
||||
@@ -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,20 +157,16 @@ 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
|
||||
|
||||
console.info(`Creating checkpoint commit with message: ${commitMessage}`)
|
||||
const result = await git.commit(commitMessage, {
|
||||
"--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 +241,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 +282,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 +293,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 +301,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 +346,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")
|
||||
|
||||
@@ -79,15 +79,6 @@ export class DiffViewProvider {
|
||||
if (!this.relPath || !this.activeLineController || !this.fadedOverlayController) {
|
||||
throw new Error("Required values not set")
|
||||
}
|
||||
|
||||
// --- Fix to prevent duplicate BOM ---
|
||||
// Strip potential BOM from incoming content. VS Code's `applyEdit` might implicitly handle the BOM
|
||||
// when replacing from the start (0,0), and we want to avoid duplication.
|
||||
// Final BOM is handled in `saveChanges`.
|
||||
if (accumulatedContent.startsWith("\ufeff")) {
|
||||
accumulatedContent = accumulatedContent.slice(1) // Remove the BOM character
|
||||
}
|
||||
|
||||
this.newContent = accumulatedContent
|
||||
const accumulatedLines = accumulatedContent.split("\n")
|
||||
if (!isFinal) {
|
||||
@@ -167,6 +158,7 @@ export class DiffViewProvider {
|
||||
await updatedDocument.save()
|
||||
}
|
||||
|
||||
// await delay(100)
|
||||
// get text after save in case there is any auto-formatting done by the editor
|
||||
const postSaveContent = updatedDocument.getText()
|
||||
|
||||
|
||||
@@ -63,6 +63,7 @@ export async function fetchOpenGraphData(url: string): Promise<OpenGraphData> {
|
||||
type: data.ogType,
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error fetching Open Graph data for ${url}:`, error)
|
||||
// Return basic information based on the URL
|
||||
try {
|
||||
const urlObj = new URL(url)
|
||||
@@ -99,7 +100,8 @@ export async function isImageUrl(url: string): Promise<boolean> {
|
||||
const contentType = response.headers["content-type"]
|
||||
return contentType && contentType.startsWith("image/")
|
||||
} catch (error) {
|
||||
console.error(`Error checking if URL is an image: ${url}`, error)
|
||||
// If we can't determine, fall back to checking the file extension
|
||||
return /\.(jpg|jpeg|png|gif|webp|bmp|svg|tiff|tif|avif)$/i.test(url)
|
||||
return /\.(jpg|jpeg|png|gif|webp|svg)$/i.test(url)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -1,118 +0,0 @@
|
||||
import axios, { AxiosRequestConfig, AxiosResponse } from "axios"
|
||||
import { Controller } from "../../core/controller"
|
||||
import type { BalanceResponse, PaymentTransaction, UsageTransaction } from "../../shared/ClineAccount"
|
||||
|
||||
export class ClineAccountService {
|
||||
private readonly baseUrl = "https://api.cline.bot/v1"
|
||||
private controllerRef: WeakRef<Controller>
|
||||
|
||||
constructor(controller: Controller) {
|
||||
this.controllerRef = new WeakRef(controller)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the user's Cline Account key from the apiConfiguration
|
||||
*/
|
||||
private async getClineApiKey(): Promise<string | undefined> {
|
||||
const provider = this.controllerRef.deref()
|
||||
if (!provider) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const { apiConfiguration } = await provider.getStateToPostToWebview()
|
||||
return apiConfiguration?.clineApiKey
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to make authenticated requests to the Cline API
|
||||
* @param endpoint The API endpoint to call (without the base URL)
|
||||
* @param config Additional axios request configuration
|
||||
* @returns The API response data
|
||||
* @throws Error if the API key is not found or the request fails
|
||||
*/
|
||||
private async authenticatedRequest<T>(endpoint: string, config: AxiosRequestConfig = {}): Promise<T> {
|
||||
const clineApiKey = await this.getClineApiKey()
|
||||
|
||||
if (!clineApiKey) {
|
||||
throw new Error("Cline API key not found")
|
||||
}
|
||||
|
||||
const url = `${this.baseUrl}${endpoint}`
|
||||
const requestConfig: AxiosRequestConfig = {
|
||||
...config,
|
||||
headers: {
|
||||
Authorization: `Bearer ${clineApiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
...config.headers,
|
||||
},
|
||||
}
|
||||
|
||||
const response: AxiosResponse<T> = await axios.get(url, requestConfig)
|
||||
|
||||
if (!response.data) {
|
||||
throw new Error(`Invalid response from ${endpoint} API`)
|
||||
}
|
||||
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the user's current credit balance
|
||||
*/
|
||||
async fetchBalance(): Promise<BalanceResponse | undefined> {
|
||||
try {
|
||||
const data = await this.authenticatedRequest<BalanceResponse>("/user/credits/balance")
|
||||
|
||||
// Post to webview
|
||||
await this.controllerRef.deref()?.postMessageToWebview({
|
||||
type: "userCreditsBalance",
|
||||
userCreditsBalance: data,
|
||||
})
|
||||
|
||||
return data
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch balance:", error)
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the user's usage transactions
|
||||
*/
|
||||
async fetchUsageTransactions(): Promise<UsageTransaction[] | undefined> {
|
||||
try {
|
||||
const data = await this.authenticatedRequest<UsageTransaction[]>("/user/credits/usage")
|
||||
|
||||
// Post to webview
|
||||
await this.controllerRef.deref()?.postMessageToWebview({
|
||||
type: "userCreditsUsage",
|
||||
userCreditsUsage: data,
|
||||
})
|
||||
|
||||
return data
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch usage transactions:", error)
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the user's payment transactions
|
||||
*/
|
||||
async fetchPaymentTransactions(): Promise<PaymentTransaction[] | undefined> {
|
||||
try {
|
||||
const data = await this.authenticatedRequest<PaymentTransaction[]>("/user/credits/payments")
|
||||
|
||||
// Post to webview
|
||||
await this.controllerRef.deref()?.postMessageToWebview({
|
||||
type: "userCreditsPayments",
|
||||
userCreditsPayments: data,
|
||||
})
|
||||
|
||||
return data
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch payment transactions:", error)
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import { Browser, Page, ScreenshotOptions, TimeoutError, launch } from "puppetee
|
||||
// @ts-ignore
|
||||
import PCR from "puppeteer-chromium-resolver"
|
||||
import pWaitFor from "p-wait-for"
|
||||
import { setTimeout as setTimeoutPromise } from "node:timers/promises"
|
||||
import delay from "delay"
|
||||
import { fileExistsAtPath } from "../../utils/fs"
|
||||
import { BrowserActionResult } from "../../shared/ExtensionMessage"
|
||||
import { BrowserSettings } from "../../shared/BrowserSettings"
|
||||
@@ -212,7 +212,7 @@ export class BrowserSession {
|
||||
interval: 100,
|
||||
}).catch(() => {})
|
||||
|
||||
const options: ScreenshotOptions = {
|
||||
let options: ScreenshotOptions = {
|
||||
encoding: "base64",
|
||||
|
||||
// clip: {
|
||||
@@ -295,7 +295,7 @@ export class BrowserSession {
|
||||
}
|
||||
|
||||
lastHTMLSize = currentHTMLSize
|
||||
await setTimeoutPromise(checkDurationMsecs)
|
||||
await delay(checkDurationMsecs)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -314,7 +314,7 @@ export class BrowserSession {
|
||||
this.currentMousePosition = coordinate
|
||||
|
||||
// Small delay to check if click triggered any network activity
|
||||
await setTimeoutPromise(100)
|
||||
await delay(100)
|
||||
|
||||
if (hasNetworkActivity) {
|
||||
// If we detected network activity, wait for navigation/loading
|
||||
@@ -346,7 +346,7 @@ export class BrowserSession {
|
||||
behavior: "auto",
|
||||
})
|
||||
})
|
||||
await setTimeoutPromise(300)
|
||||
await delay(300)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -358,7 +358,7 @@ export class BrowserSession {
|
||||
behavior: "auto",
|
||||
})
|
||||
})
|
||||
await setTimeoutPromise(300)
|
||||
await delay(300)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import * as path from "path"
|
||||
import { arePathsEqual } from "../../utils/path"
|
||||
|
||||
export async function listFiles(dirPath: string, recursive: boolean, limit: number): Promise<[string[], boolean]> {
|
||||
// First resolve the path normally - path.resolve doesn't care about glob special characters
|
||||
const absolutePath = path.resolve(dirPath)
|
||||
// Do not allow listing files in root or home directory, which cline tends to want to do when the user's prompt is vague.
|
||||
const root = process.platform === "win32" ? path.parse(absolutePath).root : "/"
|
||||
@@ -49,7 +48,6 @@ export async function listFiles(dirPath: string, recursive: boolean, limit: numb
|
||||
}
|
||||
|
||||
// * globs all files in one dir, ** globs files in nested directories
|
||||
// For non-recursive listing, we still use a simple pattern
|
||||
const filePaths = recursive ? await globbyLevelByLevel(limit, options) : (await globby("*", options)).slice(0, limit)
|
||||
|
||||
return [filePaths, filePaths.length >= limit]
|
||||
@@ -68,8 +66,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) {
|
||||
@@ -82,11 +80,7 @@ async function globbyLevelByLevel(limit: number, options?: Options) {
|
||||
}
|
||||
results.add(file)
|
||||
if (file.endsWith("/")) {
|
||||
// Escape parentheses in the path to prevent glob pattern interpretation
|
||||
// This is crucial for NextJS folder naming conventions which use parentheses like (auth), (dashboard)
|
||||
// Without escaping, glob treats parentheses as special pattern grouping characters
|
||||
const escapedFile = file.replace(/\(/g, "\\(").replace(/\)/g, "\\)")
|
||||
queue.push(`${escapedFile}*`)
|
||||
queue.push(`${file}*`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+166
-225
@@ -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,
|
||||
@@ -8,13 +8,13 @@ import {
|
||||
ReadResourceResultSchema,
|
||||
} from "@modelcontextprotocol/sdk/types.js"
|
||||
import chokidar, { FSWatcher } from "chokidar"
|
||||
import { setTimeout as setTimeoutPromise } from "node:timers/promises"
|
||||
import delay from "delay"
|
||||
import deepEqual from "fast-deep-equal"
|
||||
import * as fs from "fs/promises"
|
||||
import * as path from "path"
|
||||
import * as vscode from "vscode"
|
||||
import { z } from "zod"
|
||||
import { Controller } from "../../core/controller"
|
||||
import { ClineProvider, GlobalFileNames } from "../../core/webview/ClineProvider"
|
||||
import {
|
||||
DEFAULT_MCP_TIMEOUT_SECONDS,
|
||||
McpMode,
|
||||
@@ -29,62 +29,37 @@ 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
|
||||
|
||||
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 +74,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 +83,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 +102,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 +135,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 +154,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 +190,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 +271,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 +299,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 +311,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 +332,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 +353,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 +361,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 +401,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
|
||||
}
|
||||
@@ -468,7 +414,7 @@ export class McpHub {
|
||||
connection.server.status = "connecting"
|
||||
connection.server.error = ""
|
||||
await this.notifyWebviewOfServerChanges()
|
||||
await setTimeoutPromise(500) // artificial delay to show user that server is restarting
|
||||
await delay(500) // artificial delay to show user that server is restarting
|
||||
try {
|
||||
await this.deleteConnection(serverName)
|
||||
// Try to connect again using existing config
|
||||
@@ -490,7 +436,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 +457,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 +567,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 +588,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 +600,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 +615,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 +625,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 +655,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.`)
|
||||
}
|
||||
|
||||
@@ -61,7 +61,7 @@ interface SearchResult {
|
||||
|
||||
const MAX_RESULTS = 300
|
||||
|
||||
export async function getBinPath(vscodeAppRoot: string): Promise<string | undefined> {
|
||||
async function getBinPath(vscodeAppRoot: string): Promise<string | undefined> {
|
||||
const checkPath = async (pkgFolder: string) => {
|
||||
const fullPath = path.join(vscodeAppRoot, pkgFolder, binName)
|
||||
return (await fileExistsAtPath(fullPath)) ? fullPath : undefined
|
||||
|
||||
@@ -1,174 +0,0 @@
|
||||
import * as vscode from "vscode"
|
||||
import * as path from "path"
|
||||
import * as fs from "fs"
|
||||
import * as childProcess from "child_process"
|
||||
import * as readline from "readline"
|
||||
import { getBinPath } from "../ripgrep"
|
||||
import type { Fzf, FzfResultItem } from "fzf"
|
||||
|
||||
// Wrapper function for childProcess.spawn
|
||||
export type SpawnFunction = typeof childProcess.spawn
|
||||
export const getSpawnFunction = (): SpawnFunction => childProcess.spawn
|
||||
|
||||
export async function executeRipgrepForFiles(
|
||||
rgPath: string,
|
||||
workspacePath: string,
|
||||
limit: number = 5000,
|
||||
): Promise<{ path: string; type: "file" | "folder"; label?: string }[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
// Arguments for ripgrep to list files, follow symlinks, include hidden, and exclude common directories
|
||||
const args = [
|
||||
"--files",
|
||||
"--follow",
|
||||
"--hidden",
|
||||
"-g",
|
||||
"!**/{node_modules,.git,.github,out,dist,__pycache__,.venv,.env,venv,env,.cache,tmp,temp}/**",
|
||||
workspacePath,
|
||||
]
|
||||
|
||||
// Spawn the ripgrep process with the specified arguments
|
||||
const rgProcess = getSpawnFunction()(rgPath, args)
|
||||
const rl = readline.createInterface({ input: rgProcess.stdout })
|
||||
|
||||
// Array to store file results and Set to track unique directories
|
||||
const fileResults: { path: string; type: "file" | "folder"; label?: string }[] = []
|
||||
const dirSet = new Set<string>()
|
||||
let count = 0
|
||||
|
||||
// Handle each line of output from ripgrep (each line is a file path)
|
||||
rl.on("line", (line) => {
|
||||
if (count >= limit) {
|
||||
rl.close()
|
||||
rgProcess.kill()
|
||||
return
|
||||
}
|
||||
|
||||
// Convert absolute path to a relative path from workspace root
|
||||
const relativePath = path.relative(workspacePath, line)
|
||||
|
||||
// Add file result to array
|
||||
fileResults.push({
|
||||
path: relativePath,
|
||||
type: "file",
|
||||
label: path.basename(relativePath),
|
||||
})
|
||||
|
||||
// Extract and add parent directories to the set
|
||||
let dirPath = path.dirname(relativePath)
|
||||
while (dirPath && dirPath !== "." && dirPath !== "/") {
|
||||
dirSet.add(dirPath)
|
||||
dirPath = path.dirname(dirPath)
|
||||
}
|
||||
|
||||
count++
|
||||
})
|
||||
|
||||
// Capture any error output from ripgrep
|
||||
let errorOutput = ""
|
||||
rgProcess.stderr.on("data", (data) => (errorOutput += data.toString()))
|
||||
|
||||
// When ripgrep finishes or is closed
|
||||
rl.on("close", () => {
|
||||
if (errorOutput && fileResults.length === 0) {
|
||||
reject(new Error(`ripgrep process error: ${errorOutput.trim()}`))
|
||||
return
|
||||
}
|
||||
|
||||
// Transform directory paths from Set into structured results
|
||||
const dirResults = Array.from(dirSet, (dirPath): { path: string; type: "folder"; label?: string } => ({
|
||||
path: dirPath,
|
||||
type: "folder",
|
||||
label: path.basename(dirPath),
|
||||
}))
|
||||
|
||||
// Resolve combined results of files and directories
|
||||
resolve([...fileResults, ...dirResults])
|
||||
})
|
||||
|
||||
// Handle process-level errors
|
||||
rgProcess.on("error", (error) => reject(new Error(`ripgrep process error: ${error.message}`)))
|
||||
})
|
||||
}
|
||||
|
||||
export async function searchWorkspaceFiles(
|
||||
query: string,
|
||||
workspacePath: string,
|
||||
limit: number = 20,
|
||||
): Promise<{ path: string; type: "file" | "folder"; label?: string }[]> {
|
||||
try {
|
||||
const rgPath = await getBinPath(vscode.env.appRoot)
|
||||
|
||||
if (!rgPath) {
|
||||
throw new Error("Could not find ripgrep binary")
|
||||
}
|
||||
|
||||
// Get all files and directories
|
||||
const allItems = await executeRipgrepForFiles(rgPath, workspacePath, 5000)
|
||||
|
||||
// If no query, just return the top items
|
||||
if (!query.trim()) {
|
||||
return allItems.slice(0, limit)
|
||||
}
|
||||
|
||||
// Match Scoring - Prioritize the label (filename) by including it twice in the search string
|
||||
// Use multiple tiebreakers in order of importance: Match score, then length of match (shorter=better)
|
||||
// Get more (2x) results than needed for filtering, we pick the top half after sorting
|
||||
const fzfModule = await import("fzf")
|
||||
const fzf = new fzfModule.Fzf(allItems, {
|
||||
selector: (item: { label?: string; path: string }) => `${item.label || ""} ${item.label || ""} ${item.path}`,
|
||||
tiebreakers: [OrderbyMatchScore, fzfModule.byLengthAsc],
|
||||
limit: limit * 2,
|
||||
})
|
||||
|
||||
// The min threshold value will require some testing and tuning as the scores are exponential, and exagerated
|
||||
const MIN_SCORE_THRESHOLD = 100
|
||||
|
||||
// Filter results by score and map to original items
|
||||
// Use exponential scaling for normalization
|
||||
// This gives a more dramatic difference between good and bad matches
|
||||
const filteredResults = fzf
|
||||
.find(query)
|
||||
.filter(({ score }: { score: number }) => Math.exp(score / 20) >= MIN_SCORE_THRESHOLD)
|
||||
.slice(0, limit)
|
||||
|
||||
// Verify if the path exists and is actually a directory
|
||||
const verifiedResultsPromises = filteredResults.map(
|
||||
async ({ item }: { item: { path: string; type: "file" | "folder"; label?: string } }) => {
|
||||
const fullPath = path.join(workspacePath, item.path)
|
||||
let type = item.type
|
||||
|
||||
try {
|
||||
const stats = await fs.promises.lstat(fullPath)
|
||||
type = stats.isDirectory() ? "folder" : "file"
|
||||
} catch {
|
||||
// Keep original type if path doesn't exist
|
||||
}
|
||||
|
||||
return { ...item, type }
|
||||
},
|
||||
)
|
||||
|
||||
return await Promise.all(verifiedResultsPromises)
|
||||
} catch (error) {
|
||||
console.error("Error in searchWorkspaceFiles:", error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
// Custom match scoring for results ordering
|
||||
// Candidate score tiebreaker - fewer gaps between matched characters scores higher
|
||||
export const OrderbyMatchScore = (a: FzfResultItem<any>, b: FzfResultItem<any>) => {
|
||||
const countGaps = (positions: Iterable<number>) => {
|
||||
let gaps = 0,
|
||||
prev = -Infinity
|
||||
for (const pos of positions) {
|
||||
if (prev !== -Infinity && pos - prev > 1) {
|
||||
gaps++
|
||||
}
|
||||
prev = pos
|
||||
}
|
||||
return gaps
|
||||
}
|
||||
|
||||
return countGaps(a.positions) - countGaps(b.positions)
|
||||
}
|
||||
@@ -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,18 +18,12 @@ 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
|
||||
TOKEN_USAGE: "task.tokens",
|
||||
// Tracks switches between plan and act modes
|
||||
MODE_SWITCH: "task.mode",
|
||||
// Tracks when users select an option from AI-generated followup questions
|
||||
OPTION_SELECTED: "task.option_selected",
|
||||
// Tracks when users type a custom response instead of selecting an option from AI-generated followup questions
|
||||
OPTIONS_IGNORED: "task.options_ignored",
|
||||
// Tracks usage of the git-based checkpoint system (shadow_git_initialized, commit_created, branch_created, branch_deleted_active, branch_deleted_inactive, restored)
|
||||
CHECKPOINT_USED: "task.checkpoint_used",
|
||||
// Tracks when tools (like file operations, commands) are used
|
||||
@@ -40,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: {
|
||||
@@ -244,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
|
||||
@@ -403,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
|
||||
@@ -461,40 +420,6 @@ class PostHogClient {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Records when a user selects an option from AI-generated followup questions
|
||||
* @param taskId Unique identifier for the task
|
||||
* @param qty The quantity of options that were presented
|
||||
* @param mode The mode in which the option was selected ("plan" or "act")
|
||||
*/
|
||||
public captureOptionSelected(taskId: string, qty: number, mode: "plan" | "act") {
|
||||
this.capture({
|
||||
event: PostHogClient.EVENTS.TASK.OPTION_SELECTED,
|
||||
properties: {
|
||||
taskId,
|
||||
qty,
|
||||
mode,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Records when a user types a custom response instead of selecting one of the AI-generated followup questions
|
||||
* @param taskId Unique identifier for the task
|
||||
* @param qty The quantity of options that were presented
|
||||
* @param mode The mode in which the custom response was provided ("plan" or "act")
|
||||
*/
|
||||
public captureOptionsIgnored(taskId: string, qty: number, mode: "plan" | "act") {
|
||||
this.capture({
|
||||
event: PostHogClient.EVENTS.TASK.OPTIONS_IGNORED,
|
||||
properties: {
|
||||
taskId,
|
||||
qty,
|
||||
mode,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
public isTelemetryEnabled(): boolean {
|
||||
return this.telemetryEnabled
|
||||
}
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
export interface BalanceResponse {
|
||||
currentBalance: number
|
||||
}
|
||||
|
||||
export interface UsageTransaction {
|
||||
spentAt: string
|
||||
credits: string
|
||||
modelProvider: string
|
||||
model: string
|
||||
promptTokens: string
|
||||
completionTokens: string
|
||||
}
|
||||
|
||||
export interface PaymentTransaction {
|
||||
paidAt: string
|
||||
amountCents: string
|
||||
credits: string
|
||||
}
|
||||
@@ -8,7 +8,6 @@ import { ChatSettings } from "./ChatSettings"
|
||||
import { HistoryItem } from "./HistoryItem"
|
||||
import { McpServer, McpMarketplaceCatalog, McpMarketplaceItem, McpDownloadResponse } from "./mcp"
|
||||
import { TelemetrySetting } from "./TelemetrySetting"
|
||||
import type { BalanceResponse, UsageTransaction, PaymentTransaction } from "../shared/ClineAccount"
|
||||
|
||||
// webview will hold state
|
||||
export interface ExtensionMessage {
|
||||
@@ -35,16 +34,7 @@ export interface ExtensionMessage {
|
||||
| "openGraphData"
|
||||
| "isImageUrlResult"
|
||||
| "didUpdateSettings"
|
||||
| "addRemoteServerResult"
|
||||
| "userCreditsBalance"
|
||||
| "userCreditsUsage"
|
||||
| "userCreditsPayments"
|
||||
| "totalTasksSize"
|
||||
| "addToInput"
|
||||
| "relativePathsResponse" // Handles single and multiple path responses
|
||||
| "fileSearchResults"
|
||||
text?: string
|
||||
paths?: (string | null)[] // Used for relativePathsResponse
|
||||
action?:
|
||||
| "chatButtonClicked"
|
||||
| "mcpButtonClicked"
|
||||
@@ -53,7 +43,6 @@ export interface ExtensionMessage {
|
||||
| "didBecomeVisible"
|
||||
| "accountLoginClicked"
|
||||
| "accountLogoutClicked"
|
||||
| "accountButtonClicked"
|
||||
invoke?: Invoke
|
||||
state?: ExtensionState
|
||||
images?: string[]
|
||||
@@ -80,21 +69,6 @@ export interface ExtensionMessage {
|
||||
}
|
||||
url?: string
|
||||
isImage?: boolean
|
||||
userCreditsBalance?: BalanceResponse
|
||||
userCreditsUsage?: UsageTransaction[]
|
||||
userCreditsPayments?: PaymentTransaction[]
|
||||
totalTasksSize?: number | null
|
||||
mentionsRequestId?: string
|
||||
results?: Array<{
|
||||
path: string
|
||||
type: "file" | "folder"
|
||||
label?: string
|
||||
}>
|
||||
addRemoteServerResult?: {
|
||||
success: boolean
|
||||
serverName: string
|
||||
error?: string
|
||||
}
|
||||
}
|
||||
|
||||
export type Invoke = "sendMessage" | "primaryButtonClick" | "secondaryButtonClick"
|
||||
@@ -104,27 +78,27 @@ export type Platform = "aix" | "darwin" | "freebsd" | "linux" | "openbsd" | "sun
|
||||
export const DEFAULT_PLATFORM = "unknown"
|
||||
|
||||
export interface ExtensionState {
|
||||
version: string
|
||||
apiConfiguration?: ApiConfiguration
|
||||
customInstructions?: string
|
||||
uriScheme?: string
|
||||
currentTaskItem?: HistoryItem
|
||||
checkpointTrackerErrorMessage?: string
|
||||
clineMessages: ClineMessage[]
|
||||
taskHistory: HistoryItem[]
|
||||
shouldShowAnnouncement: boolean
|
||||
autoApprovalSettings: AutoApprovalSettings
|
||||
browserSettings: BrowserSettings
|
||||
chatSettings: ChatSettings
|
||||
checkpointTrackerErrorMessage?: string
|
||||
clineMessages: ClineMessage[]
|
||||
currentTaskItem?: HistoryItem
|
||||
customInstructions?: string
|
||||
mcpMarketplaceEnabled?: boolean
|
||||
planActSeparateModelsSetting: boolean
|
||||
platform: Platform
|
||||
shouldShowAnnouncement: boolean
|
||||
taskHistory: HistoryItem[]
|
||||
telemetrySetting: TelemetrySetting
|
||||
uriScheme?: string
|
||||
userInfo?: {
|
||||
displayName: string | null
|
||||
email: string | null
|
||||
photoURL: string | null
|
||||
}
|
||||
version: string
|
||||
mcpMarketplaceEnabled?: boolean
|
||||
telemetrySetting: TelemetrySetting
|
||||
planActSeparateModelsSetting: boolean
|
||||
vscMachineId: string
|
||||
}
|
||||
|
||||
@@ -145,7 +119,7 @@ export interface ClineMessage {
|
||||
|
||||
export type ClineAsk =
|
||||
| "followup"
|
||||
| "plan_mode_respond"
|
||||
| "plan_mode_response"
|
||||
| "command"
|
||||
| "command_output"
|
||||
| "completion_result"
|
||||
|
||||
@@ -8,7 +8,6 @@ import { TelemetrySetting } from "./TelemetrySetting"
|
||||
|
||||
export interface WebviewMessage {
|
||||
type:
|
||||
| "addRemoteServer"
|
||||
| "apiConfiguration"
|
||||
| "webviewDidLaunch"
|
||||
| "newTask"
|
||||
@@ -46,7 +45,6 @@ export interface WebviewMessage {
|
||||
| "getLatestState"
|
||||
| "accountLoginClicked"
|
||||
| "accountLogoutClicked"
|
||||
| "showAccountViewClicked"
|
||||
| "authStateChanged"
|
||||
| "authCallback"
|
||||
| "fetchMcpMarketplace"
|
||||
@@ -63,15 +61,9 @@ export interface WebviewMessage {
|
||||
| "invoke"
|
||||
| "updateSettings"
|
||||
| "clearAllTaskHistory"
|
||||
| "fetchUserCreditsData"
|
||||
| "optionsResponse"
|
||||
| "requestTotalTasksSize"
|
||||
| "taskFeedback"
|
||||
| "getRelativePaths" // Handles single and multiple URI resolution
|
||||
| "searchFiles"
|
||||
// | "relaunchChromeDebugMode"
|
||||
text?: string
|
||||
uris?: string[] // Used for getRelativePaths
|
||||
disabled?: boolean
|
||||
askResponse?: ClineAskResponse
|
||||
apiConfiguration?: ApiConfiguration
|
||||
@@ -86,8 +78,7 @@ export interface WebviewMessage {
|
||||
timeout?: number
|
||||
// For toggleToolAutoApprove
|
||||
serverName?: string
|
||||
serverUrl?: string
|
||||
toolNames?: string[]
|
||||
toolName?: string
|
||||
autoApprove?: boolean
|
||||
|
||||
// For auth
|
||||
@@ -98,14 +89,8 @@ export interface WebviewMessage {
|
||||
planActSeparateModelsSetting?: boolean
|
||||
telemetrySetting?: TelemetrySetting
|
||||
customInstructionsSetting?: string
|
||||
// For task feedback
|
||||
feedbackType?: TaskFeedbackType
|
||||
mentionsRequestId?: string
|
||||
query?: string
|
||||
}
|
||||
|
||||
export type ClineAskResponse = "yesButtonClicked" | "noButtonClicked" | "messageResponse"
|
||||
|
||||
export type ClineCheckpointRestore = "task" | "workspace" | "taskAndWorkspace"
|
||||
|
||||
export type TaskFeedbackType = "thumbs_up" | "thumbs_down"
|
||||
|
||||
@@ -1,191 +0,0 @@
|
||||
import { expect } from "chai"
|
||||
|
||||
import { mentionRegex, mentionRegexGlobal } from "../context-mentions"
|
||||
|
||||
interface TestResult {
|
||||
actual: string | null
|
||||
expected: string | null
|
||||
}
|
||||
|
||||
function testMention(input: string, expected: string | null): TestResult {
|
||||
const match = mentionRegex.exec(input)
|
||||
return {
|
||||
actual: match ? match[0] : null,
|
||||
expected,
|
||||
}
|
||||
}
|
||||
|
||||
function assertMatch(result: TestResult) {
|
||||
expect(result.actual).eq(result.expected)
|
||||
return true
|
||||
}
|
||||
|
||||
describe("Mention Regex", () => {
|
||||
describe("Windows Path Support", () => {
|
||||
it("matches simple Windows paths", () => {
|
||||
const cases: Array<[string, string]> = [
|
||||
["@/C:\\folder\\file.txt", "@/C:\\folder\\file.txt"],
|
||||
["@/C:\\file.txt", "@/C:\\file.txt"],
|
||||
]
|
||||
|
||||
cases.forEach(([input, expected]) => {
|
||||
const result = testMention(input, expected)
|
||||
assertMatch(result)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("Edge Cases", () => {
|
||||
it("handles edge cases correctly", () => {
|
||||
const cases: Array<[string, string]> = [
|
||||
["@/C:\\Users\\name\\path\\to\\文件夹\\file.txt", "@/C:\\Users\\name\\path\\to\\文件夹\\file.txt"],
|
||||
["@/path123/file-name_2.0.txt", "@/path123/file-name_2.0.txt"],
|
||||
]
|
||||
|
||||
cases.forEach(([input, expected]) => {
|
||||
const result = testMention(input, expected)
|
||||
assertMatch(result)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("Existing Functionality", () => {
|
||||
it("matches Unix paths", () => {
|
||||
const cases: Array<[string, string]> = [
|
||||
["@/usr/local/bin/file", "@/usr/local/bin/file"],
|
||||
["@/path/to/file.txt", "@/path/to/file.txt"],
|
||||
["@//etc/host", "@//etc/host"],
|
||||
]
|
||||
|
||||
cases.forEach(([input, expected]) => {
|
||||
const result = testMention(input, expected)
|
||||
assertMatch(result)
|
||||
})
|
||||
})
|
||||
|
||||
it("matches URLs", () => {
|
||||
const cases: Array<[string, string]> = [
|
||||
["@http://example.com", "@http://example.com"],
|
||||
["@https://example.com/path/to/file.html", "@https://example.com/path/to/file.html"],
|
||||
["@ftp://server.example.com/file.zip", "@ftp://server.example.com/file.zip"],
|
||||
]
|
||||
|
||||
cases.forEach(([input, expected]) => {
|
||||
const result = testMention(input, expected)
|
||||
assertMatch(result)
|
||||
})
|
||||
})
|
||||
|
||||
it("matches git hashes", () => {
|
||||
const cases: Array<[string, string]> = [
|
||||
["@abcdef1234567890abcdef1234567890abcdef12", "@abcdef1234567890abcdef1234567890abcdef12"],
|
||||
]
|
||||
|
||||
cases.forEach(([input, expected]) => {
|
||||
const result = testMention(input, expected)
|
||||
assertMatch(result)
|
||||
})
|
||||
})
|
||||
|
||||
it("matches special keywords", () => {
|
||||
const cases: Array<[string, string]> = [
|
||||
["@problems", "@problems"],
|
||||
["@git-changes", "@git-changes"],
|
||||
["@terminal", "@terminal"],
|
||||
]
|
||||
|
||||
cases.forEach(([input, expected]) => {
|
||||
const result = testMention(input, expected)
|
||||
assertMatch(result)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("Invalid Patterns", () => {
|
||||
it("rejects invalid patterns", () => {
|
||||
const cases: Array<[string, null]> = [
|
||||
["C:\\folder\\file.txt", null],
|
||||
["@", null],
|
||||
["@ C:\\file.txt", null],
|
||||
]
|
||||
|
||||
cases.forEach(([input, expected]) => {
|
||||
const result = testMention(input, expected)
|
||||
assertMatch(result)
|
||||
})
|
||||
})
|
||||
|
||||
it("matches only until invalid characters", () => {
|
||||
const result = testMention("@/C:\\folder\\file.txt invalid suffix", "@/C:\\folder\\file.txt")
|
||||
assertMatch(result)
|
||||
})
|
||||
})
|
||||
|
||||
describe("In Context", () => {
|
||||
it("matches mentions within text", () => {
|
||||
const cases: Array<[string, string]> = [
|
||||
["Check the file at @/C:\\folder\\file.txt for details.", "@/C:\\folder\\file.txt"],
|
||||
["Review @problems and @git-changes.", "@problems"],
|
||||
["Multiple: @/file1.txt and @/C:\\file2.txt and @terminal", "@/file1.txt"],
|
||||
]
|
||||
|
||||
cases.forEach(([input, expected]) => {
|
||||
const result = testMention(input, expected)
|
||||
assertMatch(result)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("Multiple Mentions", () => {
|
||||
it("finds all mentions in a string using global regex", () => {
|
||||
const text = "Check @/path/file1.txt and @/C:\\folder\\file2.txt and report any @problems to @git-changes"
|
||||
const matches = text.match(mentionRegexGlobal)
|
||||
expect(matches).deep.eq(["@/path/file1.txt", "@/C:\\folder\\file2.txt", "@problems", "@git-changes"])
|
||||
})
|
||||
})
|
||||
|
||||
describe("Special Characters in Paths", () => {
|
||||
it("handles special characters in file paths", () => {
|
||||
const cases: Array<[string, string]> = [
|
||||
["@/path/with-dash/file_underscore.txt", "@/path/with-dash/file_underscore.txt"],
|
||||
["@/C:\\folder+plus\\file(parens)[]brackets.txt", "@/C:\\folder+plus\\file(parens)[]brackets.txt"],
|
||||
["@/path/with/file#hash%percent.txt", "@/path/with/file#hash%percent.txt"],
|
||||
["@/path/with/file@symbol$dollar.txt", "@/path/with/file@symbol$dollar.txt"],
|
||||
]
|
||||
|
||||
cases.forEach(([input, expected]) => {
|
||||
const result = testMention(input, expected)
|
||||
assertMatch(result)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("Mixed Path Types in Single String", () => {
|
||||
it("correctly identifies the first path in a string with multiple path types", () => {
|
||||
const text = "Check both @/unix/path and @/C:\\windows\\path for details."
|
||||
const result = mentionRegex.exec(text) || []
|
||||
expect(result[0]).eq("@/unix/path")
|
||||
|
||||
// Test starting from after the first match
|
||||
const secondSearchStart = text.indexOf("@/C:")
|
||||
const secondResult = mentionRegex.exec(text.substring(secondSearchStart)) || []
|
||||
expect(secondResult[0]).eq("@/C:\\windows\\path")
|
||||
})
|
||||
})
|
||||
|
||||
describe("Non-Latin Character Support", () => {
|
||||
it("handles international characters in paths", () => {
|
||||
const cases: Array<[string, string]> = [
|
||||
["@/path/to/你好/file.txt", "@/path/to/你好/file.txt"],
|
||||
["@/C:\\用户\\документы\\файл.txt", "@/C:\\用户\\документы\\файл.txt"],
|
||||
["@/путь/к/файлу.txt", "@/путь/к/файлу.txt"],
|
||||
["@/C:\\folder\\file_äöü.txt", "@/C:\\folder\\file_äöü.txt"],
|
||||
]
|
||||
|
||||
cases.forEach(([input, expected]) => {
|
||||
const result = testMention(input, expected)
|
||||
assertMatch(result)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
+9
-129
@@ -12,7 +12,6 @@ export type ApiProvider =
|
||||
| "together"
|
||||
| "deepseek"
|
||||
| "qwen"
|
||||
| "doubao"
|
||||
| "mistral"
|
||||
| "vscode-lm"
|
||||
| "cline"
|
||||
@@ -25,16 +24,13 @@ 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
|
||||
liteLlmUsePromptCache?: boolean
|
||||
anthropicBaseUrl?: string
|
||||
openRouterApiKey?: string
|
||||
openRouterModelId?: string
|
||||
openRouterModelInfo?: ModelInfo
|
||||
openRouterProviderSorting?: string
|
||||
awsAccessKey?: string
|
||||
awsSecretKey?: string
|
||||
awsSessionToken?: string
|
||||
@@ -63,7 +59,6 @@ export interface ApiHandlerOptions {
|
||||
togetherApiKey?: string
|
||||
togetherModelId?: string
|
||||
qwenApiKey?: string
|
||||
doubaoApiKey?: string
|
||||
mistralApiKey?: string
|
||||
azureApiVersion?: string
|
||||
vsCodeLmModelSelector?: any
|
||||
@@ -97,7 +92,6 @@ export interface ModelInfo {
|
||||
|
||||
export interface OpenAiCompatibleModelInfo extends ModelInfo {
|
||||
temperature?: number
|
||||
isR1FormatRequired?: boolean
|
||||
}
|
||||
|
||||
// Anthropic
|
||||
@@ -164,33 +158,6 @@ export const anthropicModels = {
|
||||
export type BedrockModelId = keyof typeof bedrockModels
|
||||
export const bedrockDefaultModelId: BedrockModelId = "anthropic.claude-3-7-sonnet-20250219-v1:0"
|
||||
export const bedrockModels = {
|
||||
"amazon.nova-pro-v1:0": {
|
||||
maxTokens: 5000,
|
||||
contextWindow: 300_000,
|
||||
supportsImages: true,
|
||||
supportsComputerUse: false,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 0.8,
|
||||
outputPrice: 3.2,
|
||||
},
|
||||
"amazon.nova-lite-v1:0": {
|
||||
maxTokens: 5000,
|
||||
contextWindow: 300_000,
|
||||
supportsImages: true,
|
||||
supportsComputerUse: false,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 0.06,
|
||||
outputPrice: 0.24,
|
||||
},
|
||||
"amazon.nova-micro-v1:0": {
|
||||
maxTokens: 5000,
|
||||
contextWindow: 128_000,
|
||||
supportsImages: false,
|
||||
supportsComputerUse: false,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 0.035,
|
||||
outputPrice: 0.14,
|
||||
},
|
||||
"anthropic.claude-3-7-sonnet-20250219-v1:0": {
|
||||
maxTokens: 8192,
|
||||
contextWindow: 200_000,
|
||||
@@ -371,22 +338,14 @@ 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,
|
||||
outputPrice: 0,
|
||||
},
|
||||
"gemini-2.5-pro-preview-03-25": {
|
||||
maxTokens: 65536,
|
||||
contextWindow: 1_048_576,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 1.25,
|
||||
outputPrice: 10,
|
||||
},
|
||||
"gemini-2.0-flash-thinking-exp-01-21": {
|
||||
maxTokens: 65_536,
|
||||
contextWindow: 1_048_576,
|
||||
@@ -450,7 +409,6 @@ export const openAiModelInfoSaneDefaults: OpenAiCompatibleModelInfo = {
|
||||
contextWindow: 128_000,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: false,
|
||||
isR1FormatRequired: false,
|
||||
inputPrice: 0,
|
||||
outputPrice: 0,
|
||||
temperature: 0,
|
||||
@@ -461,22 +419,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.5-pro-preview-03-25": {
|
||||
maxTokens: 65536,
|
||||
contextWindow: 1_048_576,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 1.25,
|
||||
outputPrice: 10,
|
||||
},
|
||||
"gemini-2.0-flash-001": {
|
||||
maxTokens: 8192,
|
||||
contextWindow: 1_048_576,
|
||||
@@ -635,19 +577,11 @@ export const openAiNativeModels = {
|
||||
outputPrice: 0.6,
|
||||
cacheReadsPrice: 0.075,
|
||||
},
|
||||
"chatgpt-4o-latest": {
|
||||
maxTokens: 16_384,
|
||||
contextWindow: 128_000,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 5,
|
||||
outputPrice: 15,
|
||||
},
|
||||
"gpt-4.5-preview": {
|
||||
maxTokens: 16_384,
|
||||
contextWindow: 128_000,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: true,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 75,
|
||||
outputPrice: 150,
|
||||
},
|
||||
@@ -667,8 +601,8 @@ export const deepSeekModels = {
|
||||
maxTokens: 8_000,
|
||||
contextWindow: 64_000,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: true, // supports context caching, but not in the way anthropic does it (deepseek reports input tokens and reads/writes in the same usage report) FIXME: we need to show users cache stats how deepseek does it
|
||||
inputPrice: 0, // technically there is no input price, it's all either a cache hit or miss (ApiOptions will not show this). Input is the sum of cache reads and writes
|
||||
supportsPromptCache: true,
|
||||
inputPrice: 0.27,
|
||||
outputPrice: 1.1,
|
||||
cacheWritesPrice: 0.27,
|
||||
cacheReadsPrice: 0.07,
|
||||
@@ -677,8 +611,8 @@ export const deepSeekModels = {
|
||||
maxTokens: 8_000,
|
||||
contextWindow: 64_000,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: true, // supports context caching, but not in the way anthropic does it (deepseek reports input tokens and reads/writes in the same usage report) FIXME: we need to show users cache stats how deepseek does it
|
||||
inputPrice: 0, // technically there is no input price, it's all either a cache hit or miss (ApiOptions will not show this)
|
||||
supportsPromptCache: true,
|
||||
inputPrice: 0.55,
|
||||
outputPrice: 2.19,
|
||||
cacheWritesPrice: 0.55,
|
||||
cacheReadsPrice: 0.14,
|
||||
@@ -1117,34 +1051,6 @@ export const mainlandQwenModels = {
|
||||
},
|
||||
} as const satisfies Record<string, ModelInfo>
|
||||
|
||||
// Doubao
|
||||
// https://www.volcengine.com/docs/82379/1298459
|
||||
// https://console.volcengine.com/ark/region:ark+cn-beijing/openManagement
|
||||
export type DoubaoModelId = keyof typeof doubaoModels
|
||||
export const doubaoDefaultModelId: DoubaoModelId = "doubao-1-5-pro-256k-250115"
|
||||
export const doubaoModels = {
|
||||
"doubao-1-5-pro-256k-250115": {
|
||||
maxTokens: 12_288,
|
||||
contextWindow: 256_000,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 0.7,
|
||||
outputPrice: 1.3,
|
||||
cacheWritesPrice: 0,
|
||||
cacheReadsPrice: 0,
|
||||
},
|
||||
"doubao-1-5-pro-32k-250115": {
|
||||
maxTokens: 12_288,
|
||||
contextWindow: 32_000,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 0.11,
|
||||
outputPrice: 0.3,
|
||||
cacheWritesPrice: 0,
|
||||
cacheReadsPrice: 0,
|
||||
},
|
||||
} as const satisfies Record<string, ModelInfo>
|
||||
|
||||
// Mistral
|
||||
// https://docs.mistral.ai/getting-started/models/models_overview/
|
||||
export type MistralModelId = keyof typeof mistralModels
|
||||
@@ -1182,14 +1088,6 @@ export const mistralModels = {
|
||||
inputPrice: 0.1,
|
||||
outputPrice: 0.1,
|
||||
},
|
||||
"mistral-small-latest": {
|
||||
maxTokens: 131_000,
|
||||
contextWindow: 131_000,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 0.1,
|
||||
outputPrice: 0.3,
|
||||
},
|
||||
"mistral-small-2501": {
|
||||
maxTokens: 32_000,
|
||||
contextWindow: 32_000,
|
||||
@@ -1240,11 +1138,9 @@ export const liteLlmModelInfoSaneDefaults: ModelInfo = {
|
||||
maxTokens: -1,
|
||||
contextWindow: 128_000,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: true,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 0,
|
||||
outputPrice: 0,
|
||||
cacheWritesPrice: 0,
|
||||
cacheReadsPrice: 0,
|
||||
}
|
||||
|
||||
// AskSage Models
|
||||
@@ -1459,20 +1355,4 @@ export const sambanovaModels = {
|
||||
inputPrice: 0,
|
||||
outputPrice: 0,
|
||||
},
|
||||
"QwQ-32B": {
|
||||
maxTokens: 4096,
|
||||
contextWindow: 16_000,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
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>
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -1,202 +0,0 @@
|
||||
import { describe, it } from "mocha"
|
||||
import should from "should"
|
||||
import sinon from "sinon"
|
||||
import { Readable } from "stream"
|
||||
import type { FzfResultItem } from "fzf"
|
||||
import * as childProcess from "child_process"
|
||||
import * as vscode from "vscode"
|
||||
import * as fs from "fs"
|
||||
import * as path from "path"
|
||||
import * as fileSearch from "../../../services/search/file-search"
|
||||
import * as ripgrep from "../../../services/ripgrep"
|
||||
|
||||
describe("File Search", function () {
|
||||
let sandbox: sinon.SinonSandbox
|
||||
let spawnStub: sinon.SinonStub
|
||||
|
||||
beforeEach(function () {
|
||||
sandbox = sinon.createSandbox()
|
||||
spawnStub = sandbox.stub()
|
||||
|
||||
// Create a wrapper function that matches the signature of childProcess.spawn
|
||||
const spawnWrapper: typeof childProcess.spawn = function (command, options) {
|
||||
return spawnStub(command, options)
|
||||
}
|
||||
|
||||
sandbox.stub(fileSearch, "getSpawnFunction").returns(spawnWrapper)
|
||||
// Use replaceGetter instead of stub().value() for non-configurable properties
|
||||
sandbox.replaceGetter(vscode.env, "appRoot", () => "mock/app/root")
|
||||
sandbox.stub(fs.promises, "lstat").resolves({ isDirectory: () => false } as fs.Stats)
|
||||
sandbox.stub(ripgrep, "getBinPath").resolves("mock/ripgrep/path")
|
||||
})
|
||||
|
||||
afterEach(function () {
|
||||
sandbox.restore()
|
||||
})
|
||||
|
||||
describe("executeRipgrepForFiles", function () {
|
||||
it("should correctly process and return file and folder results", async function () {
|
||||
const mockFiles = ["file1.txt", "folder1/file2.js", "folder1/subfolder/file3.py"]
|
||||
|
||||
// Create a proper mock for the child process
|
||||
const mockStdout = new Readable({
|
||||
read() {
|
||||
this.push(mockFiles.join("\n"))
|
||||
this.push(null) // Signal the end of the stream
|
||||
},
|
||||
})
|
||||
|
||||
const mockStderr = new Readable({
|
||||
read() {
|
||||
this.push(null) // Empty stream
|
||||
},
|
||||
})
|
||||
|
||||
spawnStub.returns({
|
||||
stdout: mockStdout,
|
||||
stderr: mockStderr,
|
||||
on: sinon.stub().returns({}),
|
||||
} as unknown as childProcess.ChildProcess)
|
||||
|
||||
// Instead of stubbing path functions, we'll stub the executeRipgrepForFiles function
|
||||
// to return a predictable result for this test
|
||||
const expectedResult: { path: string; type: "file" | "folder"; label?: string }[] = [
|
||||
{ path: "file1.txt", type: "file", label: "file1.txt" },
|
||||
{ path: "folder1/file2.js", type: "file", label: "file2.js" },
|
||||
{ path: "folder1/subfolder/file3.py", type: "file", label: "file3.py" },
|
||||
{ path: "folder1", type: "folder", label: "folder1" },
|
||||
{ path: "folder1/subfolder", type: "folder", label: "subfolder" },
|
||||
]
|
||||
|
||||
// Create a new stub for executeRipgrepForFiles
|
||||
sandbox.stub(fileSearch, "executeRipgrepForFiles").resolves(expectedResult)
|
||||
|
||||
const result = await fileSearch.executeRipgrepForFiles("mock/path", "/workspace", 5000)
|
||||
|
||||
should(result).be.an.Array()
|
||||
// Don't assert on the exact length as it may vary
|
||||
|
||||
const files = result.filter((item) => item.type === "file")
|
||||
const folders = result.filter((item) => item.type === "folder")
|
||||
|
||||
// Verify we have at least the expected files and folders
|
||||
should(files.length).be.greaterThanOrEqual(3)
|
||||
should(folders.length).be.greaterThanOrEqual(2)
|
||||
|
||||
should(files[0]).have.properties({
|
||||
path: "file1.txt",
|
||||
type: "file",
|
||||
label: "file1.txt",
|
||||
})
|
||||
|
||||
should(folders).containDeep([
|
||||
{ path: "folder1", type: "folder", label: "folder1" },
|
||||
{ path: "folder1/subfolder", type: "folder", label: "subfolder" },
|
||||
])
|
||||
})
|
||||
|
||||
it("should handle errors from ripgrep", async function () {
|
||||
const mockError = "Mock ripgrep error"
|
||||
|
||||
// Create proper mock streams for error case
|
||||
const mockStdout = new Readable({
|
||||
read() {
|
||||
this.push(null) // Empty stream
|
||||
},
|
||||
})
|
||||
|
||||
const mockStderr = new Readable({
|
||||
read() {
|
||||
this.push(mockError)
|
||||
this.push(null) // Signal the end of the stream
|
||||
},
|
||||
})
|
||||
|
||||
spawnStub.returns({
|
||||
stdout: mockStdout,
|
||||
stderr: mockStderr,
|
||||
on: function (event: string, callback: Function) {
|
||||
if (event === "error") {
|
||||
callback(new Error(mockError))
|
||||
}
|
||||
return this
|
||||
},
|
||||
} as unknown as childProcess.ChildProcess)
|
||||
|
||||
await should(fileSearch.executeRipgrepForFiles("mock/path", "/workspace", 5000)).be.rejectedWith(
|
||||
`ripgrep process error: ${mockError}`,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("searchWorkspaceFiles", function () {
|
||||
it("should return top N results for empty query", async function () {
|
||||
const mockItems: { path: string; type: "file" | "folder"; label?: string }[] = [
|
||||
{ path: "file1.txt", type: "file", label: "file1.txt" },
|
||||
{ path: "folder1", type: "folder", label: "folder1" },
|
||||
{ path: "file2.js", type: "file", label: "file2.js" },
|
||||
]
|
||||
|
||||
// Directly stub the searchWorkspaceFiles function for this test
|
||||
// This avoids issues with the executeRipgrepForFiles function
|
||||
const searchStub = sandbox.stub(fileSearch, "searchWorkspaceFiles")
|
||||
searchStub.withArgs("", "/workspace", 2).resolves(mockItems.slice(0, 2))
|
||||
|
||||
const result = await fileSearch.searchWorkspaceFiles("", "/workspace", 2)
|
||||
|
||||
should(result).be.an.Array()
|
||||
should(result).have.length(2)
|
||||
should(result).deepEqual(mockItems.slice(0, 2))
|
||||
})
|
||||
|
||||
it("should apply fuzzy matching for non-empty query", async function () {
|
||||
const mockItems: { path: string; type: "file" | "folder"; label?: string }[] = [
|
||||
{ path: "file1.txt", type: "file", label: "file1.txt" },
|
||||
{ path: "folder1/important.js", type: "file", label: "important.js" },
|
||||
{ path: "file2.js", type: "file", label: "file2.js" },
|
||||
]
|
||||
|
||||
sandbox.stub(fileSearch, "executeRipgrepForFiles").resolves(mockItems)
|
||||
const fzfStub = {
|
||||
find: sinon.stub().returns([{ item: mockItems[1], score: 0 }]),
|
||||
}
|
||||
// Create a mock for the fzf module
|
||||
const fzfModuleStub = {
|
||||
Fzf: sinon.stub().returns(fzfStub),
|
||||
byLengthAsc: sinon.stub(),
|
||||
}
|
||||
|
||||
// Use a more reliable approach to mock dynamic imports
|
||||
// This replaces the actual implementation of searchWorkspaceFiles to avoid the dynamic import
|
||||
sandbox.stub(fileSearch, "searchWorkspaceFiles").callsFake(async (query, workspacePath, limit) => {
|
||||
if (!query.trim()) {
|
||||
return mockItems.slice(0, limit)
|
||||
}
|
||||
|
||||
// Simulate the fuzzy search behavior
|
||||
return [mockItems[1]]
|
||||
})
|
||||
|
||||
const result = await fileSearch.searchWorkspaceFiles("imp", "/workspace", 2)
|
||||
|
||||
should(result).be.an.Array()
|
||||
should(result).have.length(1)
|
||||
should(result[0]).have.properties({
|
||||
path: "folder1/important.js",
|
||||
type: "file",
|
||||
label: "important.js",
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("OrderbyMatchScore", function () {
|
||||
it("should prioritize results with fewer gaps between matched characters", function () {
|
||||
const mockItemA: FzfResultItem<any> = { item: {}, positions: new Set([0, 1, 2, 5]), start: 0, end: 5, score: 0 }
|
||||
const mockItemB: FzfResultItem<any> = { item: {}, positions: new Set([0, 2, 4, 6]), start: 0, end: 6, score: 0 }
|
||||
|
||||
const result = fileSearch.OrderbyMatchScore(mockItemA, mockItemB)
|
||||
|
||||
should(result).be.lessThan(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,5 @@
|
||||
import * as path from "path"
|
||||
import os from "os"
|
||||
import * as vscode from "vscode"
|
||||
|
||||
/*
|
||||
The Node.js 'path' module resolves and normalizes paths differently depending on the platform:
|
||||
@@ -100,13 +99,3 @@ export function getReadablePath(cwd: string, relPath?: string): string {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const getWorkspacePath = (defaultCwdPath = "") => {
|
||||
const cwdPath = vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath).at(0) || defaultCwdPath
|
||||
const currentFileUri = vscode.window.activeTextEditor?.document.uri
|
||||
if (currentFileUri) {
|
||||
const workspaceFolder = vscode.workspace.getWorkspaceFolder(currentFileUri)
|
||||
return workspaceFolder?.uri.fsPath || cwdPath
|
||||
}
|
||||
return cwdPath
|
||||
}
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
import path from "path"
|
||||
import getFolderSize from "get-folder-size"
|
||||
|
||||
/**
|
||||
* Gets the total size of tasks and checkpoints directories
|
||||
* @param storagePath The base storage path (typically globalStorageUri.fsPath)
|
||||
* @returns The total size in bytes, or null if calculation fails
|
||||
*/
|
||||
export async function getTotalTasksSize(storagePath: string): Promise<number | null> {
|
||||
const tasksDir = path.join(storagePath, "tasks")
|
||||
const checkpointsDir = path.join(storagePath, "checkpoints")
|
||||
|
||||
try {
|
||||
const tasksSize = await getFolderSize.loose(tasksDir)
|
||||
const checkpointsSize = await getFolderSize.loose(checkpointsDir)
|
||||
return tasksSize + checkpointsSize
|
||||
} catch (error) {
|
||||
console.error("Failed to calculate total task size:", error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user