Compare commits

..
Author SHA1 Message Date
nighttrek f59ef0c43c refactor(telemetry): Remove email from user identification
To enhance user privacy and avoid sending unnecessary Personally Identifiable Information (PII), the user's email address is no longer included in telemetry identification events.

The `TelemetryService` now strips the `email` property from the user information object before passing it to the various telemetry providers.

This change includes:
- Introducing an `AnonymousClineAccountUserInfo` type that omits the `email` field.
- Updating the `ITelemetryProvider` interface and its implementations (PostHog, OpenTelemetry) to use this new anonymous type for user identification calls.
2025-11-03 13:03:40 -08:00
Ara ea9b8fe0b1 Enable Terminal Timeouts by defualt (#7171)
* Remove redundant test code that's very confusing

* feat: add vscodeTerminalExecutionMode to TaskConfig and apply timeout

Add vscodeTerminalExecutionMode parameter to TaskConfig and thread it through ToolExecutor and Task classes. Apply default 30-second timeout to commands executed in backgroundExec mode, similar to existing yolo mode behavior.

This change enables different execution strategies for VSCode terminal commands and ensures background executions have appropriate timeout protection to prevent hanging processes.
2025-11-03 11:33:10 -08:00
canvrno 05213f2a71 fix: Remove orphaned tool_results after truncation (#7225) 2025-11-03 10:25:16 -08:00
Ara d5bad1357d fix: Support interleaved thinking for miniMax provider (#7162)
* fix: interleaved thinking

* Adding native tool calling
2025-11-03 09:31:08 -08:00
Saoud Rizwan a4b1549dac Revise title in README.md
Updated the title of the README file and removed the subtitle.
2025-11-02 23:29:49 -08:00
Andrei EternalandAndrei Edell e015ce94c0 cli polish ahead of release - disable doctor, better node errors (#7215)
Co-authored-by: Andrei Edell <andrei@nugbase.com>
2025-11-02 14:52:54 -08:00
Wahaj Ahmed KhanandWahaj Ahmed Khan be076cf407 feat: Add support for Claude 4.5 Sonnet in SAP AI Core provider (#7217)
- Add anthropic--claude-4.5-sonnet model definition to sapAiCoreModels
- Update SAP AI Core provider to handle Claude 4.5 Sonnet in anthropicModels array
- Enable caching support for Claude 4.5 Sonnet using converse-stream endpoint
- Add Claude 4.5 Sonnet to streamCompletionSonnet37 method for proper response handling

Fixes #7216

Co-authored-by: Wahaj Ahmed Khan <“wahaj.khan@sap.com”>
2025-11-01 20:01:04 -07:00
Nick Baumann 580db36476 Add provider field to tool usage telemetry events (#7214)
- Add provider parameter to captureToolUsage() and captureDiffEditFailure() methods in TelemetryService
- Update all 11 tool handlers to extract and pass provider information
- Extract provider using plan/act mode differentiation from state manager
- Update UIHelpers.ts captureTelemetry wrapper to include provider
- Update test file to include provider parameter in test calls

This enables tracking which API provider (anthropic, openai, etc.) was used for each tool execution.
2025-10-31 18:31:45 -07:00
Sarah Fortune 8f8b98bb58 Add global rules and workflows to the remote config schema (#7198)
Add a way for the admin to configure global cline rules and workflows for their users.
2025-10-31 10:24:40 -07:00
CandiedUniverse 36022438cb 🪝Hooks: Exclude .clinerules/hooks/ files from Rules feature (#7202)
* Exclude .clinerules/hooks/ files from Rules feature

* Escape whitespace in paths correctly when discovering hooks/ directories
2025-10-31 10:18:51 -07:00
Bee c7afb61e28 fix: react-remark rendering in ModelDescriptionMarkdown (#7205)
* fix: react-remark rendering in ModelDescriptionMarkdown

- Add useRemark hook to properly parse and render markdown content that was removed in last git commit
- Extract props interface to ModelDescriptionMarkdownProps for better type safety
- Add useEffect to reactively update markdown when content changes
- Set fixed height (h-20) for collapsed state to improve layout consistency
- Replace raw markdown text display with processed reactContent

This change ensures markdown formatting (links, bold, italics, etc.) is correctly rendered in model descriptions instead of showing raw markdown syntax.

* changeset
2025-10-31 10:03:27 -07:00
73 changed files with 1108 additions and 1430 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
Remove orphaned tool_results after truncation
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
Fix: render model description in markdown
+19 -19
View File
@@ -3,7 +3,7 @@
## Overview
Cline hooks allow you to execute custom scripts at specific points in the agentic workflow. Hooks can be placed in either:
- **Global hooks directory**: `~/Documents/Cline/Rules/Hooks/` (applies to all workspaces)
- **Global hooks directory**: `~/Documents/Cline/Hooks/` (applies to all workspaces)
- **Workspace hooks directory**: `.clinerules/hooks/` (applies to specific workspace)
Hooks run automatically when enabled.
@@ -20,50 +20,50 @@ Hooks run automatically when enabled.
### TaskStart Hook
- **When**: Runs when a NEW task is started (not when resuming)
- **Purpose**: Initialize task context, validate task requirements, set up environment
- **Global Location**: `~/Documents/Cline/Rules/Hooks/TaskStart` (all platforms)
- **Global Location**: `~/Documents/Cline/Hooks/TaskStart` (all platforms)
- **Workspace Location**: `.clinerules/hooks/TaskStart` (all platforms)
### TaskResume Hook
- **When**: Runs when an EXISTING task is resumed (after user clicks resume button)
- **Purpose**: Validate resumed task state, restore context, check for changes since last run
- **Global Location**: `~/Documents/Cline/Rules/Hooks/TaskResume` (all platforms)
- **Global Location**: `~/Documents/Cline/Hooks/TaskResume` (all platforms)
- **Workspace Location**: `.clinerules/hooks/TaskResume` (all platforms)
### TaskCancel Hook
- **When**: Runs when a task is cancelled by the user (only if there's actual active work or work was started)
- **Purpose**: Clean up resources, log cancellation, save state
- **Global Location**: `~/Documents/Cline/Rules/Hooks/TaskCancel` (all platforms)
- **Global Location**: `~/Documents/Cline/Hooks/TaskCancel` (all platforms)
- **Workspace Location**: `.clinerules/hooks/TaskCancel` (all platforms)
- **Note**: This hook is NOT cancellable and will complete even if the task is being aborted
### TaskComplete Hook
- **When**: Runs when a task is marked as complete
- **Purpose**: Log completion status, perform final cleanup, generate reports
- **Global Location**: `~/Documents/Cline/Rules/Hooks/TaskComplete` (all platforms)
- **Global Location**: `~/Documents/Cline/Hooks/TaskComplete` (all platforms)
- **Workspace Location**: `.clinerules/hooks/TaskComplete` (all platforms)
### UserPromptSubmit Hook
- **When**: Runs when the user submits a prompt/message (initial task, resume, or feedback)
- **Purpose**: Validate user input, preprocess prompts, add context to user messages
- **Global Location**: `~/Documents/Cline/Rules/Hooks/UserPromptSubmit` (all platforms)
- **Global Location**: `~/Documents/Cline/Hooks/UserPromptSubmit` (all platforms)
- **Workspace Location**: `.clinerules/hooks/UserPromptSubmit` (all platforms)
### PreToolUse Hook
- **When**: Runs BEFORE a tool is executed
- **Purpose**: Validate parameters, block execution, or add context
- **Global Location**: `~/Documents/Cline/Rules/Hooks/PreToolUse` (all platforms)
- **Global Location**: `~/Documents/Cline/Hooks/PreToolUse` (all platforms)
- **Workspace Location**: `.clinerules/hooks/PreToolUse` (all platforms)
### PostToolUse Hook
- **When**: Runs AFTER a tool completes
- **Purpose**: Observe results, track patterns, or add context
- **Global Location**: `~/Documents/Cline/Rules/Hooks/PostToolUse` (all platforms)
- **Global Location**: `~/Documents/Cline/Hooks/PostToolUse` (all platforms)
- **Workspace Location**: `.clinerules/hooks/PostToolUse` (all platforms)
### PreCompact Hook
- **When**: Runs BEFORE the conversation context is compacted/truncated
- **Purpose**: Observe compaction events, log context management, track token usage
- **Global Location**: `~/Documents/Cline/Rules/Hooks/PreCompact` (all platforms)
- **Global Location**: `~/Documents/Cline/Hooks/PreCompact` (all platforms)
- **Workspace Location**: `.clinerules/hooks/PreCompact` (all platforms)
## Cross-Platform Hook Format
@@ -92,16 +92,16 @@ This means:
**On Unix/Linux/macOS:**
```bash
# Create hook file
nano ~/Documents/Cline/Rules/Hooks/PreToolUse
nano ~/Documents/Cline/Hooks/PreToolUse
# Make executable
chmod +x ~/Documents/Cline/Rules/Hooks/PreToolUse
chmod +x ~/Documents/Cline/Hooks/PreToolUse
```
**On Windows:**
```batch
REM Create hook file (note: no file extension)
notepad %USERPROFILE%\Documents\Cline\Rules\Hooks\PreToolUse
notepad %USERPROFILE%\Documents\Cline\Hooks\PreToolUse
```
## Context Injection Timing
@@ -328,7 +328,7 @@ echo '{"cancel": false}'
Cline supports two levels of hooks:
### Global Hooks
- **Location**: `~/Documents/Cline/Rules/Hooks/` (macOS/Linux) or `%USERPROFILE%\Documents\Cline\Rules\Hooks\` (Windows)
- **Location**: `~/Documents/Cline/Hooks/` (macOS/Linux) or `%USERPROFILE%\Documents\Cline\Hooks\` (Windows)
- **Scope**: Apply to ALL workspaces and projects
- **Use Case**: Organization-wide policies, personal preferences, universal validations
- **Priority**: Order not guaranteed when combined with workspace hooks
@@ -355,17 +355,17 @@ When multiple hooks exist (global and/or workspace):
### Setting Up Global Hooks
1. The global hooks directory is automatically created at:
- macOS/Linux: `~/Documents/Cline/Rules/Hooks/`
- Windows: `%USERPROFILE%\Documents\Cline\Rules\Hooks\`
- macOS/Linux: `~/Documents/Cline/Hooks/`
- Windows: `%USERPROFILE%\Documents\Cline\Hooks\`
2. Add your hook script:
```bash
# Unix/Linux/macOS
nano ~/Documents/Cline/Rules/Hooks/PreToolUse
chmod +x ~/Documents/Cline/Rules/Hooks/PreToolUse
nano ~/Documents/Cline/Hooks/PreToolUse
chmod +x ~/Documents/Cline/Hooks/PreToolUse
# Windows
notepad %USERPROFILE%\Documents\Cline\Rules\Hooks\PreToolUse
notepad %USERPROFILE%\Documents\Cline\Hooks\PreToolUse
```
3. Enable hooks in Cline settings
@@ -375,7 +375,7 @@ When multiple hooks exist (global and/or workspace):
**Global Hook** (applies to all projects):
```bash
#!/usr/bin/env bash
# ~/Documents/Cline/Rules/Hooks/PreToolUse
# ~/Documents/Cline/Hooks/PreToolUse
# Universal rule: Never delete package.json
input=$(cat)
tool_name=$(echo "$input" | jq -r '.preToolUse.toolName')
+1 -1
View File
@@ -2,7 +2,7 @@
English | <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/zh-tw/README.md" target="_blank">繁體中文</a> | <a href="https://github.com/cline/cline/blob/main/locales/ko/README.md" target="_blank">한국어</a>
</sub></div>
# Cline \#1 on OpenRouter
# Cline
<p align="center">
<img src="https://media.githubusercontent.com/media/cline/cline/main/assets/docs/demo.gif" width="100%" />
+2 -2
View File
@@ -182,7 +182,7 @@ see the manual page: man cline`,
rootCmd.AddCommand(cli.NewVersionCommand())
rootCmd.AddCommand(cli.NewAuthCommand())
rootCmd.AddCommand(cli.NewLogsCommand())
rootCmd.AddCommand(cli.NewDoctorCommand())
// rootCmd.AddCommand(cli.NewDoctorCommand()) // Disabled for now
if err := rootCmd.ExecuteContext(context.Background()); err != nil {
os.Exit(1)
@@ -345,4 +345,4 @@ func getContentFromStdinAndArgs(args []string) (string, error) {
}
return content.String(), nil
}
}
+60 -66
View File
@@ -1,68 +1,62 @@
{
"name": "cline",
"version": "1.0.0-nightly.18",
"description": "Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more",
"main": "cline-core.js",
"bin": {
"cline": "./bin/cline",
"cline-host": "./bin/cline-host"
},
"man": "./man/cline.1",
"scripts": {
"postinstall": "node postinstall.js"
},
"bundleDependencies": [
"@grpc/grpc-js",
"@grpc/reflection",
"better-sqlite3",
"grpc-health-check",
"open",
"vscode-uri"
],
"engines": {
"node": ">=18.0.0"
},
"keywords": [
"cline",
"claude",
"dev",
"mcp",
"openrouter",
"coding",
"agent",
"autonomous",
"chatgpt",
"sonnet",
"ai",
"llama",
"cli"
],
"author": {
"name": "Cline Bot Inc."
},
"license": "Apache-2.0",
"repository": {
"type": "git",
"url": "https://github.com/cline/cline"
},
"homepage": "https://cline.bot",
"bugs": {
"url": "https://github.com/cline/cline/issues"
},
"dependencies": {
"@grpc/grpc-js": "^1.13.3",
"@grpc/reflection": "^1.0.4",
"better-sqlite3": "^12.2.0",
"grpc-health-check": "^2.0.2",
"open": "^10.1.2",
"vscode-uri": "^3.1.0"
},
"os": [
"darwin",
"linux"
],
"cpu": [
"x64",
"arm64"
]
"name": "cline",
"version": "1.0.3",
"description": "Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more",
"main": "cline-core.js",
"bin": {
"cline": "./bin/cline",
"cline-host": "./bin/cline-host"
},
"man": "./man/cline.1",
"scripts": {
"postinstall": "node postinstall.js"
},
"bundleDependencies": [
"@grpc/grpc-js",
"@grpc/reflection",
"better-sqlite3",
"grpc-health-check",
"open",
"vscode-uri"
],
"engines": {
"node": ">=20.0.0"
},
"keywords": [
"cline",
"claude",
"dev",
"mcp",
"openrouter",
"coding",
"agent",
"autonomous",
"chatgpt",
"sonnet",
"ai",
"llama",
"cli"
],
"author": {
"name": "Cline Bot Inc."
},
"license": "Apache-2.0",
"repository": {
"type": "git",
"url": "https://github.com/cline/cline"
},
"homepage": "https://cline.bot",
"bugs": {
"url": "https://github.com/cline/cline/issues"
},
"dependencies": {
"@grpc/grpc-js": "^1.13.3",
"@grpc/reflection": "^1.0.4",
"better-sqlite3": "^12.2.0",
"grpc-health-check": "^2.0.2",
"open": "^10.1.2",
"vscode-uri": "^3.1.0"
},
"os": ["darwin", "linux"],
"cpu": ["x64", "arm64"]
}
+3 -3
View File
@@ -208,9 +208,9 @@ func listLogFiles(logsDir string) ([]logFileInfo, error) {
})
}
// Sort by created time (newest first)
// Sort by created time (oldest first)
sort.Slice(logs, func(i, j int) bool {
return logs[i].created.After(logs[j].created)
return logs[i].created.Before(logs[j].created)
})
return logs, nil
@@ -379,4 +379,4 @@ func renderLogsTable(logs []logFileInfo, markForDeletion bool) error {
fmt.Println()
return nil
}
}
+2 -2
View File
@@ -375,7 +375,7 @@ func showFailureMessage(channel string) {
func getCacheFilePath() string {
configDir := filepath.Join(os.Getenv("HOME"), ".cline", "data")
return filepath.Join(configDir, ".update-cache")
return filepath.Join(configDir, "cli-update-cache")
}
func loadCache() (cacheData, error) {
@@ -406,4 +406,4 @@ func saveCache(cache cacheData) error {
}
return os.WriteFile(cacheFile, data, 0644)
}
}
+27 -1
View File
@@ -4,7 +4,9 @@ import (
"context"
"fmt"
"net"
"os/exec"
"strconv"
"strings"
"time"
"google.golang.org/grpc"
@@ -124,6 +126,16 @@ func NormalizeAddressForGRPC(address string) (string, error) {
return address, nil
}
// GetNodeVersion returns the current Node.js version, or "unknown" if unable to detect
func GetNodeVersion() string {
cmd := exec.Command("node", "--version")
output, err := cmd.Output()
if err != nil {
return "unknown"
}
return strings.TrimSpace(string(output))
}
// RetryOperation performs an operation with retry logic
func RetryOperation(maxRetries int, timeoutPerAttempt time.Duration, operation func() error) error {
var lastErr error
@@ -155,5 +167,19 @@ func RetryOperation(maxRetries int, timeoutPerAttempt time.Duration, operation f
}
}
return fmt.Errorf("operation failed after %d attempts: %w", maxRetries, lastErr)
return fmt.Errorf(`operation failed to after %d attempts: %w
This is usually caused by an incompatible Node.js version
REQUIREMENTS:
• Node.js version 20+ is required
• Current Node.js version: %s
DEBUGGING STEPS:
1. View recent logs: cline log list
2. Logs are available in: ~/.cline/logs/
3. The most recent cline-core log file is usually valuable
For additional help, visit: https://github.com/cline/cline/issues
`, maxRetries, lastErr, GetNodeVersion())
}
-1
View File
@@ -94,7 +94,6 @@ message OpenRouterModelInfo {
optional ThinkingConfig thinking_config = 10;
optional bool supports_global_endpoint = 11;
repeated ModelTier tiers = 12;
optional string name = 13;
}
// Shared response message for model information
+155 -53
View File
@@ -1,34 +1,39 @@
import { Anthropic } from "@anthropic-ai/sdk"
import OpenAI from "openai"
import type { ChatCompletionTool } from "openai/resources/chat/completions"
import { Tool as AnthropicTool } from "@anthropic-ai/sdk/resources/index"
import { Stream as AnthropicStream } from "@anthropic-ai/sdk/streaming"
import { MinimaxModelId, ModelInfo, minimaxDefaultModelId, minimaxModels } from "@/shared/api"
import { ClineTool } from "@/shared/tools"
import { ApiHandler, CommonApiHandlerOptions } from "../index"
import { withRetry } from "../retry"
import { convertToOpenAiMessages } from "../transform/openai-format"
import { ApiStream } from "../transform/stream"
import { getOpenAIToolParams, ToolCallProcessor } from "../transform/tool-call-processor"
interface MinimaxHandlerOptions extends CommonApiHandlerOptions {
minimaxApiKey?: string
minimaxApiLine?: string
apiModelId?: string
thinkingBudgetTokens?: number
}
export class MinimaxHandler implements ApiHandler {
private client: OpenAI | undefined
private options: MinimaxHandlerOptions
private client: Anthropic | undefined
constructor(private readonly options: MinimaxHandlerOptions) {}
constructor(options: MinimaxHandlerOptions) {
this.options = options
}
private ensureClient(): OpenAI {
private ensureClient(): Anthropic {
if (!this.client) {
if (!this.options.minimaxApiKey) {
throw new Error("MiniMax API key is required")
}
try {
this.client = new OpenAI({
baseURL:
this.options.minimaxApiLine === "china" ? "https://api.minimaxi.com/v1" : "https://api.minimax.io/v1",
this.client = new Anthropic({
apiKey: this.options.minimaxApiKey,
baseURL:
this.options.minimaxApiLine === "china"
? "https://api.minimaxi.com/anthropic"
: "https://api.minimax.io/anthropic",
})
} catch (error) {
throw new Error(`Error creating MiniMax client: ${error.message}`)
@@ -38,60 +43,157 @@ export class MinimaxHandler implements ApiHandler {
}
@withRetry()
async *createMessage(
systemPrompt: string,
messages: Anthropic.Messages.MessageParam[],
tools?: ChatCompletionTool[],
): ApiStream {
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[], tools?: ClineTool[]): ApiStream {
const client = this.ensureClient()
const model = this.getModel()
const openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [
{ role: "system", content: systemPrompt },
...convertToOpenAiMessages(messages),
]
// Tools are available only when native tools are enabled
const nativeToolsOn = tools?.length && tools?.length > 0
const stream = await client.chat.completions.create({
// MiniMax M2 uses Anthropic API format
// Note: According to MiniMax docs, some Anthropic parameters like 'thinking' are ignored
// but we'll include the standard Anthropic streaming pattern for consistency
const stream: AnthropicStream<Anthropic.RawMessageStreamEvent> = await client.messages.create({
model: model.id,
messages: openAiMessages,
max_tokens: model.info.maxTokens,
max_tokens: model.info.maxTokens || 8192,
temperature: 1.0, // MiniMax recommends 1.0, range is (0.0, 1.0]
system: [{ text: systemPrompt, type: "text" }],
messages,
stream: true,
stream_options: { include_usage: true },
...getOpenAIToolParams(tools),
tools: nativeToolsOn ? (tools as AnthropicTool[]) : undefined,
tool_choice: nativeToolsOn ? { type: "any" } : undefined,
})
const toolCallProcessor = new ToolCallProcessor()
let thinkingDeltaAccumulator = ""
const lastStartedToolCall = { id: "", name: "", arguments: "" }
for await (const chunk of stream) {
const delta = chunk.choices[0]?.delta
if (delta?.content) {
yield {
type: "text",
text: delta.content,
}
}
switch (chunk?.type) {
case "message_start":
// tells us cache reads/writes/input/output
const usage = chunk.message.usage
yield {
type: "usage",
inputTokens: usage.input_tokens || 0,
outputTokens: usage.output_tokens || 0,
cacheWriteTokens: usage.cache_creation_input_tokens || undefined,
cacheReadTokens: usage.cache_read_input_tokens || undefined,
}
break
case "message_delta":
// tells us stop_reason, stop_sequence, and output tokens along the way and at the end of the message
yield {
type: "usage",
inputTokens: 0,
outputTokens: chunk.usage.output_tokens || 0,
}
break
case "message_stop":
// no usage data, just an indicator that the message is done
break
case "content_block_start":
switch (chunk.content_block.type) {
case "thinking":
yield {
type: "reasoning",
reasoning: chunk.content_block.thinking || "",
}
const thinking = chunk.content_block.thinking
const signature = chunk.content_block.signature
if (thinking && signature) {
yield {
type: "ant_thinking",
thinking,
signature,
}
}
break
case "redacted_thinking":
// Content is encrypted, and we don't want to pass placeholder text back to the API
yield {
type: "reasoning",
reasoning: "[Redacted thinking block]",
}
yield {
type: "ant_redacted_thinking",
data: chunk.content_block.data,
}
break
case "tool_use":
if (chunk.content_block.id && chunk.content_block.name) {
// Store tool call information for streaming
lastStartedToolCall.id = chunk.content_block.id
lastStartedToolCall.name = chunk.content_block.name
lastStartedToolCall.arguments = ""
}
break
case "text":
// we may receive multiple text blocks, in which case just insert a line break between them
if (chunk.index > 0) {
yield {
type: "text",
text: "\n",
}
}
yield {
type: "text",
text: chunk.content_block.text,
}
break
}
break
case "content_block_delta":
switch (chunk.delta.type) {
case "thinking_delta":
// 'reasoning' type just displays in the UI, but ant_thinking will be used to send the thinking traces back to the API
yield {
type: "reasoning",
reasoning: chunk.delta.thinking,
}
thinkingDeltaAccumulator += chunk.delta.thinking
break
case "signature_delta":
// It's used when sending the thinking block back to the API
// API expects this in completed form, not as array of deltas
if (thinkingDeltaAccumulator && chunk.delta.signature) {
yield {
type: "ant_thinking",
thinking: thinkingDeltaAccumulator,
signature: chunk.delta.signature,
}
}
break
case "text_delta":
yield {
type: "text",
text: chunk.delta.text,
}
break
case "input_json_delta":
if (lastStartedToolCall.id && lastStartedToolCall.name && chunk.delta.partial_json) {
// Convert Anthropic tool_use to OpenAI-compatible format for internal processing
yield {
type: "tool_calls",
tool_call: {
...lastStartedToolCall,
function: {
...lastStartedToolCall,
id: lastStartedToolCall.id,
name: lastStartedToolCall.name,
arguments: chunk.delta.partial_json,
},
},
}
}
break
}
break
if (delta && "reasoning_content" in delta && delta.reasoning_content) {
yield {
type: "reasoning",
reasoning: (delta.reasoning_content as string | undefined) || "",
}
}
if (delta?.tool_calls) {
yield* toolCallProcessor.processToolCallDeltas(delta.tool_calls)
}
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,
}
case "content_block_stop":
lastStartedToolCall.id = ""
lastStartedToolCall.name = ""
lastStartedToolCall.arguments = ""
break
}
}
}
+3
View File
@@ -555,6 +555,7 @@ export class SapAiCoreHandler implements ApiHandler {
}
const anthropicModels = [
"anthropic--claude-4.5-sonnet",
"anthropic--claude-4-sonnet",
"anthropic--claude-4-opus",
"anthropic--claude-3.7-sonnet",
@@ -599,6 +600,7 @@ export class SapAiCoreHandler implements ApiHandler {
const secondLastMsgUserIndex = userMsgIndices[userMsgIndices.length - 2] ?? -1
if (
model.id === "anthropic--claude-4.5-sonnet" ||
model.id === "anthropic--claude-4-sonnet" ||
model.id === "anthropic--claude-4-opus" ||
model.id === "anthropic--claude-3.7-sonnet"
@@ -710,6 +712,7 @@ export class SapAiCoreHandler implements ApiHandler {
} else if (openAIModels.includes(model.id)) {
yield* this.streamCompletionGPT(response.data, model)
} else if (
model.id === "anthropic--claude-4.5-sonnet" ||
model.id === "anthropic--claude-4-sonnet" ||
model.id === "anthropic--claude-4-opus" ||
model.id === "anthropic--claude-3.7-sonnet"
@@ -434,13 +434,6 @@ export class ContextManager {
}
}
// Add any orphaned tool_results not in toolUseIds (shouldn't happen, but be safe)
for (const [toolUseId, toolResult] of toolResultMap) {
if (!processedToolResults.has(toolUseId)) {
newContent.push(toolResult)
}
}
// Add all other blocks
newContent.push(...otherBlocks)
@@ -464,6 +457,21 @@ export class ContextManager {
const secondChunk = messages.slice(startFromIndex) // get remaining messages within context
const messagesToUpdate = [...firstChunk, ...secondChunk]
// Remove orphaned tool_results from the first message after truncation (if it's a user message)
if (startFromIndex > 2 && messagesToUpdate.length > 2) {
const firstMessageAfterTruncation = messagesToUpdate[2]
if (firstMessageAfterTruncation.role === "user" && Array.isArray(firstMessageAfterTruncation.content)) {
const hasToolResults = firstMessageAfterTruncation.content.some((block) => block.type === "tool_result")
if (hasToolResults) {
// Clone and filter out all tool_result blocks
messagesToUpdate[2] = cloneDeep(firstMessageAfterTruncation)
;(messagesToUpdate[2].content as Anthropic.Messages.ContentBlockParam[]) = (
firstMessageAfterTruncation.content as Anthropic.Messages.ContentBlockParam[]
).filter((block) => block.type !== "tool_result")
}
}
}
// we need the mapping from the local indices in messagesToUpdate to the global array of updates in this.contextHistoryUpdates
const originalIndices = [
...Array(2).keys(),
@@ -153,5 +153,48 @@ describe("ContextManager", () => {
expect(result[1].role).to.equal("assistant")
expect(result[2].role).to.equal("user")
})
it("removes orphaned tool_results after truncation", () => {
// Create messages with tool_use and tool_result blocks
const messages: Anthropic.Messages.MessageParam[] = [
{ role: "user", content: "Initial task" },
{ role: "assistant", content: "Response 1" },
// Assistant message with tool_use that will be truncated
{
role: "assistant",
content: [
{ type: "text", text: "Using a tool" },
{ type: "tool_use", id: "tool_123", name: "read_file", input: { path: "test.ts" } },
],
},
// User message with tool_result - should have tool_result removed after truncation
{
role: "user",
content: [
{ type: "tool_result", tool_use_id: "tool_123", content: "file content here" },
{ type: "text", text: "Additional user text" },
],
},
{ role: "assistant", content: "Response 2" },
]
// Truncate to remove the assistant message with tool_use
const range: [number, number] = [2, 2]
const result = contextManager.getTruncatedMessages(messages, range)
// Should have 4 messages (original 5 minus 1 truncated)
expect(result).to.have.lengthOf(4)
// The user message at index 2 should have tool_result removed but text preserved
const userMessageAfterTruncation = result[2]
expect(userMessageAfterTruncation.role).to.equal("user")
expect(Array.isArray(userMessageAfterTruncation.content)).to.be.true
const content = userMessageAfterTruncation.content as Anthropic.Messages.ContentBlockParam[]
// Should only have the text block, not the tool_result
expect(content).to.have.lengthOf(1)
expect(content[0].type).to.equal("text")
expect((content[0] as Anthropic.Messages.TextBlockParam).text).to.equal("Additional user text")
})
})
})
@@ -40,7 +40,10 @@ export const getLocalClineRules = async (cwd: string, toggles: ClineRulesToggles
if (await fileExistsAtPath(clineRulesFilePath)) {
if (await isDirectory(clineRulesFilePath)) {
try {
const rulesFilePaths = await readDirectory(clineRulesFilePath, [[".clinerules", "workflows"]])
const rulesFilePaths = await readDirectory(clineRulesFilePath, [
[".clinerules", "workflows"],
[".clinerules", "hooks"],
])
const rulesFilesTotalContent = await getRuleFilesTotalContent(rulesFilePaths, cwd, toggles)
if (rulesFilesTotalContent) {
@@ -84,6 +87,7 @@ export async function refreshClineRulesToggles(
const localClineRulesFilePath = path.resolve(workingDirectory, GlobalFileNames.clineRules)
const updatedLocalToggles = await synchronizeRuleToggles(localClineRulesFilePath, localClineRulesToggles, "", [
[".clinerules", "workflows"],
[".clinerules", "hooks"],
])
controller.stateManager.setWorkspaceState("localClineRulesToggles", updatedLocalToggles)
+4 -4
View File
@@ -855,9 +855,9 @@ export class Controller {
const vscodeTerminalExecutionMode = this.stateManager.getGlobalStateKey("vscodeTerminalExecutionMode")
const defaultTerminalProfile = this.stateManager.getGlobalSettingsKey("defaultTerminalProfile")
const isNewUser = this.stateManager.getGlobalStateKey("isNewUser")
// Can be undefined but is set to either true or false by the migration that runs on extension launch in extension.ts
const welcomeViewCompleted = !!this.stateManager.getGlobalStateKey("welcomeViewCompleted")
const welcomeViewCompleted = Boolean(
this.stateManager.getGlobalStateKey("welcomeViewCompleted") || this.authService.getInfo()?.user?.uid,
)
const customPrompt = this.stateManager.getGlobalSettingsKey("customPrompt")
const mcpResponsesCollapsed = this.stateManager.getGlobalStateKey("mcpResponsesCollapsed")
const terminalOutputLineLimit = this.stateManager.getGlobalSettingsKey("terminalOutputLineLimit")
@@ -934,7 +934,7 @@ export class Controller {
vscodeTerminalExecutionMode: vscodeTerminalExecutionMode,
defaultTerminalProfile,
isNewUser,
welcomeViewCompleted,
welcomeViewCompleted: welcomeViewCompleted as boolean, // Can be undefined but is set to either true or false by the migration that runs on extension launch in extension.ts
mcpResponsesCollapsed,
terminalOutputLineLimit,
maxConsecutiveMistakes,
@@ -88,7 +88,6 @@ export async function refreshOpenRouterModels(controller: Controller): Promise<R
for (const rawModel of rawModels as OpenRouterRawModelInfo[]) {
const supportThinking = rawModel.supported_parameters?.some((p) => p === "include_reasoning")
const modelInfo: ModelInfo = {
name: rawModel.name,
maxTokens: rawModel.top_provider?.max_completion_tokens ?? 0,
contextWindow: rawModel.context_length ?? 0,
supportsImages: rawModel.architecture?.modality?.includes("image") ?? false,
+3 -1
View File
@@ -1,6 +1,7 @@
import { ChildProcess, spawn } from "child_process"
import { EventEmitter } from "events"
import { HookProcessRegistry } from "./HookProcessRegistry"
import { escapeShellPath } from "./shell-escape"
// Maximum total output size (stdout + stderr combined)
const MAX_HOOK_OUTPUT_SIZE = 1024 * 1024 // 1MB
@@ -101,7 +102,8 @@ export class HookProcess extends EventEmitter {
// This is the git-style approach: the shell interprets the shebang line
// and executes the appropriate interpreter (bash, node, python, etc.)
// On Unix: detached=true creates a process group, allowing us to kill all children
this.childProcess = spawn(this.scriptPath, [], {
const escapedScriptPath = escapeShellPath(this.scriptPath)
this.childProcess = spawn(escapedScriptPath, [], {
stdio: ["pipe", "pipe", "pipe"],
shell: true, // Use shell on all platforms for shebang interpretation
detached: process.platform !== "win32", // Create process group on Unix
@@ -0,0 +1,285 @@
import { describe, it } from "mocha"
import "should"
import { escapeShellPath } from "../shell-escape"
describe("Shell Path Escaping", () => {
const originalPlatform = process.platform
// Helper to temporarily set platform
const setPlatform = (platform: NodeJS.Platform) => {
Object.defineProperty(process, "platform", {
value: platform,
writable: true,
configurable: true,
})
}
// Restore platform after tests
after(() => {
Object.defineProperty(process, "platform", {
value: originalPlatform,
writable: true,
configurable: true,
})
})
describe("Unix/Linux/macOS path escaping", () => {
before(() => {
setPlatform("darwin") // macOS, but same escaping as Linux
})
it("should handle paths without special characters", () => {
const path = "/Users/user/Documents/Cline/Hooks/PreToolUse"
const escaped = escapeShellPath(path)
escaped.should.equal("'/Users/user/Documents/Cline/Hooks/PreToolUse'")
})
it("should handle paths with spaces", () => {
const path = "/Users/user/My Project/Hooks/PreToolUse"
const escaped = escapeShellPath(path)
escaped.should.equal("'/Users/user/My Project/Hooks/PreToolUse'")
})
it("should handle paths with multiple spaces", () => {
const path = "/Users/user/My Test Project/Hooks/PreToolUse"
const escaped = escapeShellPath(path)
escaped.should.equal("'/Users/user/My Test Project/Hooks/PreToolUse'")
})
it("should handle paths with single quotes", () => {
const path = "/Users/user/Test's Project/Hooks/PreToolUse"
const escaped = escapeShellPath(path)
// Single quote is escaped as '\'' (close quote, escaped quote, open quote)
escaped.should.equal("'/Users/user/Test'\\''s Project/Hooks/PreToolUse'")
})
it("should handle paths with multiple single quotes", () => {
const path = "/Users/user/Test's Project's Hooks/PreToolUse"
const escaped = escapeShellPath(path)
escaped.should.equal("'/Users/user/Test'\\''s Project'\\''s Hooks/PreToolUse'")
})
it("should handle paths with double quotes", () => {
const path = '/Users/user/Test "Quoted" Project/Hooks/PreToolUse'
const escaped = escapeShellPath(path)
// Double quotes are safe inside single quotes
escaped.should.equal("'/Users/user/Test \"Quoted\" Project/Hooks/PreToolUse'")
})
it("should handle paths with special shell characters", () => {
const path = "/Users/user/Test$Project/Hooks/PreToolUse"
const escaped = escapeShellPath(path)
// Special characters like $ are safe inside single quotes
escaped.should.equal("'/Users/user/Test$Project/Hooks/PreToolUse'")
})
it("should handle paths with backticks", () => {
const path = "/Users/user/Test`Project/Hooks/PreToolUse"
const escaped = escapeShellPath(path)
// Backticks are safe inside single quotes
escaped.should.equal("'/Users/user/Test`Project/Hooks/PreToolUse'")
})
it("should handle paths with parentheses", () => {
const path = "/Users/user/Test (Project)/Hooks/PreToolUse"
const escaped = escapeShellPath(path)
escaped.should.equal("'/Users/user/Test (Project)/Hooks/PreToolUse'")
})
it("should handle paths with ampersands", () => {
const path = "/Users/user/Test & Project/Hooks/PreToolUse"
const escaped = escapeShellPath(path)
escaped.should.equal("'/Users/user/Test & Project/Hooks/PreToolUse'")
})
it("should handle paths with semicolons", () => {
const path = "/Users/user/Test;Project/Hooks/PreToolUse"
const escaped = escapeShellPath(path)
escaped.should.equal("'/Users/user/Test;Project/Hooks/PreToolUse'")
})
it("should handle paths with pipes", () => {
const path = "/Users/user/Test|Project/Hooks/PreToolUse"
const escaped = escapeShellPath(path)
escaped.should.equal("'/Users/user/Test|Project/Hooks/PreToolUse'")
})
it("should handle global hooks directory with spaces", () => {
const path = "/Users/user name/Documents/Cline/Hooks/PreToolUse"
const escaped = escapeShellPath(path)
escaped.should.equal("'/Users/user name/Documents/Cline/Hooks/PreToolUse'")
})
it("should handle workspace hooks with spaces in root", () => {
const path = "/Users/user/My Example Project/.clinerules/hooks/PreToolUse"
const escaped = escapeShellPath(path)
escaped.should.equal("'/Users/user/My Example Project/.clinerules/hooks/PreToolUse'")
})
it("should handle paths with newlines (edge case)", () => {
const path = "/Users/user/Test\nProject/Hooks/PreToolUse"
const escaped = escapeShellPath(path)
// Newlines are safe inside single quotes
escaped.should.equal("'/Users/user/Test\nProject/Hooks/PreToolUse'")
})
it("should handle paths with tabs", () => {
const path = "/Users/user/Test\tProject/Hooks/PreToolUse"
const escaped = escapeShellPath(path)
escaped.should.equal("'/Users/user/Test\tProject/Hooks/PreToolUse'")
})
})
describe("Windows path escaping", () => {
before(() => {
setPlatform("win32")
})
it("should handle paths without special characters", () => {
const path = "C:\\Users\\user\\Documents\\Cline\\Hooks\\PreToolUse"
const escaped = escapeShellPath(path)
escaped.should.equal('"C:\\Users\\user\\Documents\\Cline\\Hooks\\PreToolUse"')
})
it("should handle paths with spaces", () => {
const path = "C:\\Users\\user\\My Project\\Hooks\\PreToolUse"
const escaped = escapeShellPath(path)
escaped.should.equal('"C:\\Users\\user\\My Project\\Hooks\\PreToolUse"')
})
it("should handle paths with multiple spaces", () => {
const path = "C:\\Users\\user\\My Test Project\\Hooks\\PreToolUse"
const escaped = escapeShellPath(path)
escaped.should.equal('"C:\\Users\\user\\My Test Project\\Hooks\\PreToolUse"')
})
it("should handle paths with double quotes", () => {
const path = 'C:\\Users\\user\\Test "Quoted" Project\\Hooks\\PreToolUse'
const escaped = escapeShellPath(path)
// Double quotes are escaped by doubling them
escaped.should.equal('"C:\\Users\\user\\Test ""Quoted"" Project\\Hooks\\PreToolUse"')
})
it("should handle paths with backslashes before quotes", () => {
const path = 'C:\\Users\\user\\Test\\"Project\\Hooks\\PreToolUse'
const escaped = escapeShellPath(path)
// Backslash before quote needs to be doubled, then quote is doubled
escaped.should.equal('"C:\\Users\\user\\Test\\\\""Project\\Hooks\\PreToolUse"')
})
it("should handle paths with single quotes", () => {
const path = "C:\\Users\\user\\Test's Project\\Hooks\\PreToolUse"
const escaped = escapeShellPath(path)
// Single quotes are safe inside double quotes on Windows
escaped.should.equal('"C:\\Users\\user\\Test\'s Project\\Hooks\\PreToolUse"')
})
it("should handle paths with special characters", () => {
const path = "C:\\Users\\user\\Test$Project\\Hooks\\PreToolUse"
const escaped = escapeShellPath(path)
// Most special characters are safe inside double quotes on Windows
escaped.should.equal('"C:\\Users\\user\\Test$Project\\Hooks\\PreToolUse"')
})
it("should handle paths with parentheses", () => {
const path = "C:\\Users\\user\\Test (Project)\\Hooks\\PreToolUse"
const escaped = escapeShellPath(path)
escaped.should.equal('"C:\\Users\\user\\Test (Project)\\Hooks\\PreToolUse"')
})
it("should handle paths with ampersands", () => {
const path = "C:\\Users\\user\\Test & Project\\Hooks\\PreToolUse"
const escaped = escapeShellPath(path)
escaped.should.equal('"C:\\Users\\user\\Test & Project\\Hooks\\PreToolUse"')
})
it("should handle global hooks directory with spaces", () => {
const path = "C:\\Users\\user name\\Documents\\Cline\\Hooks\\PreToolUse"
const escaped = escapeShellPath(path)
escaped.should.equal('"C:\\Users\\user name\\Documents\\Cline\\Hooks\\PreToolUse"')
})
it("should handle workspace hooks with spaces in root", () => {
const path = "C:\\Users\\user\\My Example Project\\.clinerules\\hooks\\PreToolUse"
const escaped = escapeShellPath(path)
escaped.should.equal('"C:\\Users\\user\\My Example Project\\.clinerules\\hooks\\PreToolUse"')
})
it("should handle UNC paths with spaces", () => {
const path = "\\\\server\\share\\My Project\\Hooks\\PreToolUse"
const escaped = escapeShellPath(path)
escaped.should.equal('"\\\\server\\share\\My Project\\Hooks\\PreToolUse"')
})
})
describe("Real-world scenarios", () => {
it("should handle typical macOS global hooks path with space in username", () => {
setPlatform("darwin")
const path = "/Users/John Doe/Documents/Cline/Hooks/PreToolUse"
const escaped = escapeShellPath(path)
escaped.should.equal("'/Users/John Doe/Documents/Cline/Hooks/PreToolUse'")
})
it("should handle typical Windows global hooks path with space in username", () => {
setPlatform("win32")
const path = "C:\\Users\\John Doe\\Documents\\Cline\\Hooks\\PreToolUse"
const escaped = escapeShellPath(path)
escaped.should.equal('"C:\\Users\\John Doe\\Documents\\Cline\\Hooks\\PreToolUse"')
})
it("should handle workspace with company name and spaces", () => {
setPlatform("darwin")
const path = "/Users/user/Projects/ACME Corp Project/.clinerules/hooks/PreToolUse"
const escaped = escapeShellPath(path)
escaped.should.equal("'/Users/user/Projects/ACME Corp Project/.clinerules/hooks/PreToolUse'")
})
it("should handle workspace with version numbers and spaces", () => {
setPlatform("darwin")
const path = "/Users/user/Projects/My Project v2.0/.clinerules/hooks/PreToolUse"
const escaped = escapeShellPath(path)
escaped.should.equal("'/Users/user/Projects/My Project v2.0/.clinerules/hooks/PreToolUse'")
})
it("should handle workspace with mixed special characters", () => {
setPlatform("darwin")
const path = "/Users/user/Projects/Test's (New) Project v2.0/.clinerules/hooks/PreToolUse"
const escaped = escapeShellPath(path)
escaped.should.equal("'/Users/user/Projects/Test'\\''s (New) Project v2.0/.clinerules/hooks/PreToolUse'")
})
})
describe("Multi-root workspace scenarios", () => {
it("should handle multiple roots with spaces (macOS)", () => {
setPlatform("darwin")
const roots = [
"/Users/user/My Frontend Project/.clinerules/hooks/PreToolUse",
"/Users/user/My Backend Project/.clinerules/hooks/PreToolUse",
"/Users/user/Shared Utils/.clinerules/hooks/PreToolUse",
]
const escaped = roots.map(escapeShellPath)
escaped.should.deepEqual([
"'/Users/user/My Frontend Project/.clinerules/hooks/PreToolUse'",
"'/Users/user/My Backend Project/.clinerules/hooks/PreToolUse'",
"'/Users/user/Shared Utils/.clinerules/hooks/PreToolUse'",
])
})
it("should handle multiple roots with spaces (Windows)", () => {
setPlatform("win32")
const roots = [
"C:\\Users\\user\\My Frontend Project\\.clinerules\\hooks\\PreToolUse",
"C:\\Users\\user\\My Backend Project\\.clinerules\\hooks\\PreToolUse",
"C:\\Users\\user\\Shared Utils\\.clinerules\\hooks\\PreToolUse",
]
const escaped = roots.map(escapeShellPath)
escaped.should.deepEqual([
'"C:\\Users\\user\\My Frontend Project\\.clinerules\\hooks\\PreToolUse"',
'"C:\\Users\\user\\My Backend Project\\.clinerules\\hooks\\PreToolUse"',
'"C:\\Users\\user\\Shared Utils\\.clinerules\\hooks\\PreToolUse"',
])
})
})
})
+2 -2
View File
@@ -400,7 +400,7 @@ class StdioHookRunner<Name extends HookName> extends HookRunner<Name> {
/**
* Combines multiple hook runners and executes them in parallel.
*
* Used in multi-root workspaces where both global hooks (from ~/Documents/Cline/Rules/Hooks/)
* Used in multi-root workspaces where both global hooks (from ~/Documents/Cline/Hooks/)
* and workspace-specific hooks (from each workspace's .clinerules/hooks/) exist for the
* same hook type.
*
@@ -537,7 +537,7 @@ export class HookFactory {
/**
* @returns A list of paths to scripts for the given hook name.
* Includes both global hooks (from ~/Documents/Cline/Rules/Hooks/) and workspace hooks
* Includes both global hooks (from ~/Documents/Cline/Hooks/) and workspace hooks
* (from .clinerules/hooks/ in each workspace root).
*/
private static async findHookScripts(hookName: HookName): Promise<string[]> {
+67
View File
@@ -0,0 +1,67 @@
/**
* Platform-specific shell escaping utilities for hook script paths.
* Ensures paths with spaces and special characters work correctly when
* executed through a shell (shell: true in spawn).
*/
/**
* Escapes a path for safe use in a Windows shell command.
* Handles spaces, quotes, and other special characters.
*
* Windows shell (cmd.exe) rules:
* - Wrap path in double quotes
* - Escape double quotes by doubling them ("")
* - Backslashes before quotes need to be doubled
*
* @param path The file path to escape
* @returns The escaped path safe for Windows shell execution
*/
function escapeWindowsShellPath(path: string): string {
// Escape backslashes that precede quotes
let escaped = path.replace(/\\"/g, '\\\\"')
// Escape standalone double quotes by doubling them
escaped = escaped.replace(/"/g, '""')
// Wrap in double quotes
return `"${escaped}"`
}
/**
* Escapes a path for safe use in a Unix shell command (sh, bash, zsh).
* Handles spaces, quotes, apostrophes, and other special characters.
*
* Unix shell rules:
* - Wrap path in single quotes (safest for most characters)
* - Single quotes inside path are escaped as '\''
* (close quote, escaped quote, open quote)
*
* @param path The file path to escape
* @returns The escaped path safe for Unix shell execution
*/
function escapeUnixShellPath(path: string): string {
// Replace single quotes with '\'' (close quote, escaped quote, open quote)
const escaped = path.replace(/'/g, "'\\''")
// Wrap in single quotes
return `'${escaped}'`
}
/**
* Escapes a file path for safe shell execution on any platform.
* This is critical when using spawn() with shell: true and paths that
* may contain spaces or special characters.
*
* Use cases:
* - Global hooks directory: ~/Documents/Cline/Hooks/
* - Workspace hooks: /path/to/My Project/.clinerules/hooks/
* - Multi-root workspaces: each root's .clinerules/hooks/
*
* Examples:
* - "/Users/user/My Project/hooks/PreToolUse" → "'/Users/user/My Project/hooks/PreToolUse'"
* - "C:\Users\user\My Project\hooks\PreToolUse" → '"C:\Users\user\My Project\hooks\PreToolUse"'
* - "/path/with 'quotes'/hooks/PreToolUse" → "'/path/with '\''quotes'\'' /hooks/PreToolUse'"
*
* @param path The file path to escape
* @returns The escaped path safe for shell execution on the current platform
*/
export function escapeShellPath(path: string): string {
return process.platform === "win32" ? escapeWindowsShellPath(path) : escapeUnixShellPath(path)
}
@@ -109,6 +109,8 @@ export class ClineToolSet {
*/
public static getNativeConverter(providerId: string) {
switch (providerId) {
case "minimax":
return toolSpecInputSchema
case "anthropic":
return toolSpecInputSchema
case "gemini":
+4 -6
View File
@@ -107,16 +107,14 @@ export async function ensureMcpServersDirectoryExists(): Promise<string> {
}
export async function ensureHooksDirectoryExists(): Promise<string> {
const rulesDir = await ensureRulesDirectoryExists()
const clineHooksDir = path.join(rulesDir, "Hooks")
const userDocumentsPath = await getDocumentsPath()
const clineHooksDir = path.join(userDocumentsPath, "Cline", "Hooks")
try {
await fs.mkdir(clineHooksDir, { recursive: true })
return clineHooksDir
} catch (_error) {
// If mkdir fails, return a fallback path based on the Rules directory fallback
// This matches the pattern of other ensure*DirectoryExists functions
return path.join(rulesDir, "Hooks")
return path.join(os.homedir(), "Documents", "Cline", "Hooks") // in case creating a directory in documents fails for whatever reason (e.g. permissions) - this is fine because we will fail gracefully with a path that does not exist
}
return clineHooksDir
}
export async function ensureSettingsDirectoryExists(): Promise<string> {
+2
View File
@@ -82,6 +82,7 @@ export class ToolExecutor {
private cwd: string,
private taskId: string,
private ulid: string,
private vscodeTerminalExecutionMode: "vscodeTerminal" | "backgroundExec",
// Workspace Management
private workspaceManager: WorkspaceRootManager | undefined,
@@ -135,6 +136,7 @@ export class ToolExecutor {
mode: this.stateManager.getGlobalSettingsKey("mode"),
strictPlanModeEnabled: this.stateManager.getGlobalSettingsKey("strictPlanModeEnabled"),
yoloModeToggled: this.stateManager.getGlobalSettingsKey("yoloModeToggled"),
vscodeTerminalExecutionMode: this.vscodeTerminalExecutionMode,
cwd: this.cwd,
workspaceManager: this.workspaceManager,
isMultiRootEnabled: this.isMultiRootEnabled,
+1 -85
View File
@@ -71,7 +71,6 @@ import { isLocalModel, isNextGenModelFamily } from "@utils/model-utils"
import { arePathsEqual, getDesktopDir } from "@utils/path"
import { filterExistingFiles } from "@utils/tabFiltering"
import cloneDeep from "clone-deep"
import { execa } from "execa"
import Mutex from "p-mutex"
import pWaitFor from "p-wait-for"
import * as path from "path"
@@ -525,6 +524,7 @@ export class Task {
cwd,
this.taskId,
this.ulid,
this.terminalExecutionMode,
this.workspaceManager,
isMultiRootEnabled(this.stateManager),
this.say.bind(this),
@@ -1458,83 +1458,6 @@ export class Task {
}
// Tools
/**
* Executes a command directly in Node.js using execa
* This is used in test mode to capture the full output without using the VS Code terminal
* Commands are automatically terminated after 30 seconds using Promise.race
*/
private async executeCommandInNode(command: string): Promise<[boolean, ToolResponse]> {
try {
// Create a child process
const childProcess = execa(command, {
shell: true,
cwd: this.cwd,
reject: false,
all: true, // Merge stdout and stderr
})
// Set up variables to collect output
let output = ""
// Collect output in real-time
if (childProcess.all) {
childProcess.all.on("data", (data) => {
output += data.toString()
})
}
// Create a timeout promise that rejects after 30 seconds
const timeoutPromise = new Promise<never>((_, reject) => {
setTimeout(() => {
if (childProcess.pid) {
childProcess.kill("SIGKILL") // Use SIGKILL for more forceful termination
}
reject(new Error("Command timeout after 30s"))
}, 30000)
})
// Race between command completion and timeout
const result = await Promise.race([childProcess, timeoutPromise]).catch((_error) => {
// If we get here due to timeout, return a partial result with timeout flag
Logger.info(`Command timed out after 30s: ${command}`)
return {
stdout: "",
stderr: "",
exitCode: 124, // Standard timeout exit code
timedOut: true,
}
})
// Check if timeout occurred
const wasTerminated = result.timedOut === true
// Use collected output or result output
if (!output) {
output = result.stdout || result.stderr || ""
}
Logger.info(`Command executed in Node: ${command}\nOutput:\n${output}`)
// Add termination message if the command was terminated
if (wasTerminated) {
output += "\nCommand was taking a while to run so it was auto terminated after 30s"
}
// Format the result similar to terminal output
return [
false,
`Command executed${wasTerminated ? " (terminated after 30s)" : ""} with exit code ${
result.exitCode
}.${output.length > 0 ? `\nOutput:\n${output}` : ""}`,
]
} catch (error) {
// Handle any errors that might occur
const errorMessage = error instanceof Error ? error.message : String(error)
return [false, `Error executing command: ${errorMessage}`]
}
}
async executeCommandTool(command: string, timeoutSeconds: number | undefined): Promise<[boolean, ToolResponse]> {
// For Cline CLI subagents, we want to parse and process the command to ensure flags are correct
const isSubagent = isSubagentCommand(command)
@@ -1547,13 +1470,6 @@ export class Task {
Logger.info("IS_TEST: " + isInTestMode())
// Check if we're in test mode
if (isInTestMode()) {
// In test mode, execute the command directly in Node
Logger.info("Executing command in Node: " + command)
return this.executeCommandInNode(command)
}
// Force subagents to use background terminal (hidden execution)
Logger.info("Executing command in terminal: " + command)
@@ -45,6 +45,11 @@ export class AccessMcpResourceHandler implements IFullyManagedTool {
const server_name: string | undefined = block.params.server_name
const uri: string | undefined = block.params.uri
// Extract provider using the proven pattern from ReportBugHandler
const apiConfig = config.services.stateManager.getApiConfiguration()
const currentMode = config.services.stateManager.getGlobalSettingsKey("mode")
const provider = (currentMode === "plan" ? apiConfig.planModeApiProvider : apiConfig.actModeApiProvider) as string
// Validate required parameters
if (!server_name) {
config.taskState.consecutiveMistakeCount++
@@ -75,7 +80,7 @@ export class AccessMcpResourceHandler implements IFullyManagedTool {
await config.callbacks.say("use_mcp_server", completeMessage, undefined, undefined, false)
// Capture telemetry
telemetryService.captureToolUsage(config.ulid, block.name, config.api.getModel().id, true, true)
telemetryService.captureToolUsage(config.ulid, block.name, config.api.getModel().id, provider, true, true)
} else {
// Manual approval flow
const notificationMessage = `Cline wants to access ${uri || "unknown resource"} on ${server_name || "unknown server"}`
@@ -87,10 +92,10 @@ export class AccessMcpResourceHandler implements IFullyManagedTool {
const didApprove = await ToolResultUtils.askApprovalAndPushFeedback("use_mcp_server", completeMessage, config)
if (!didApprove) {
telemetryService.captureToolUsage(config.ulid, block.name, config.api.getModel().id, false, false)
telemetryService.captureToolUsage(config.ulid, block.name, config.api.getModel().id, provider, false, false)
return formatResponse.toolDenied()
} else {
telemetryService.captureToolUsage(config.ulid, block.name, config.api.getModel().id, false, true)
telemetryService.captureToolUsage(config.ulid, block.name, config.api.getModel().id, provider, false, true)
}
}
@@ -941,12 +941,17 @@ export class ApplyPatchHandler implements IFullyManagedTool {
message: ClineSayTool,
primaryFile: string,
): Promise<boolean> {
// Extract provider using the proven pattern from ReportBugHandler
const apiConfig = config.services.stateManager.getApiConfiguration()
const currentMode = config.services.stateManager.getGlobalSettingsKey("mode")
const provider = (currentMode === "plan" ? apiConfig.planModeApiProvider : apiConfig.actModeApiProvider) as string
const messageStr = JSON.stringify(message)
const shouldAutoApprove = await config.callbacks.shouldAutoApproveToolWithPath(block.name, primaryFile)
if (shouldAutoApprove) {
await config.callbacks.say("tool", messageStr, undefined, undefined, false)
telemetryService.captureToolUsage(config.ulid, block.name, config.api.getModel().id, true, true)
telemetryService.captureToolUsage(config.ulid, block.name, config.api.getModel().id, provider, true, true)
return true
}
@@ -966,7 +971,7 @@ export class ApplyPatchHandler implements IFullyManagedTool {
const approved = response === "yesButtonClicked"
config.taskState.didRejectTool = !approved
telemetryService.captureToolUsage(config.ulid, block.name, config.api.getModel().id, false, approved)
telemetryService.captureToolUsage(config.ulid, block.name, config.api.getModel().id, provider, false, approved)
return approved
}
@@ -16,6 +16,9 @@ import type { TaskConfig } from "../types/TaskConfig"
import type { StronglyTypedUIHelpers } from "../types/UIHelpers"
import { ToolResultUtils } from "../utils/ToolResultUtils"
// Default timeout for commands in yolo mode and background exec mode
const DEFAULT_COMMAND_TIMEOUT_SECONDS = 30
export class ExecuteCommandToolHandler implements IFullyManagedTool {
readonly name = ClineDefaultTool.BASH
@@ -50,6 +53,11 @@ export class ExecuteCommandToolHandler implements IFullyManagedTool {
const timeoutParam: string | undefined = block.params.timeout
let timeoutSeconds: number | undefined
// Extract provider using the proven pattern from ReportBugHandler
const apiConfig = config.services.stateManager.getApiConfiguration()
const currentMode = config.services.stateManager.getGlobalSettingsKey("mode")
const provider = (currentMode === "plan" ? apiConfig.planModeApiProvider : apiConfig.actModeApiProvider) as string
// Validate required parameters
if (!command) {
config.taskState.consecutiveMistakeCount++
@@ -63,14 +71,10 @@ export class ExecuteCommandToolHandler implements IFullyManagedTool {
config.taskState.consecutiveMistakeCount = 0
// Handling of timeout while in yolo mode
if (config.yoloModeToggled) {
if (!timeoutParam) {
timeoutSeconds = 30
} else {
const parsedTimeoutParam = parseInt(timeoutParam, 10)
timeoutSeconds = isNaN(parsedTimeoutParam) || parsedTimeoutParam <= 0 ? 30 : parsedTimeoutParam
}
// Handling of timeout while in yolo mode or background exec mode
if (config.yoloModeToggled || config.vscodeTerminalExecutionMode === "backgroundExec") {
const parsed = timeoutParam ? parseInt(timeoutParam, 10) : NaN
timeoutSeconds = parsed > 0 ? parsed : DEFAULT_COMMAND_TIMEOUT_SECONDS
}
// Pre-process command for certain models
@@ -154,7 +158,15 @@ export class ExecuteCommandToolHandler implements IFullyManagedTool {
await config.callbacks.removeLastPartialMessageIfExistsWithType("ask", "command")
await config.callbacks.say("command", actualCommand, undefined, undefined, false)
didAutoApprove = true
telemetryService.captureToolUsage(config.ulid, block.name, config.api.getModel().id, true, true, workspaceContext)
telemetryService.captureToolUsage(
config.ulid,
block.name,
config.api.getModel().id,
provider,
true,
true,
workspaceContext,
)
} else {
// Manual approval flow
showNotificationForApproval(
@@ -172,13 +184,22 @@ export class ExecuteCommandToolHandler implements IFullyManagedTool {
config.ulid,
block.name,
config.api.getModel().id,
provider,
false,
false,
workspaceContext,
)
return formatResponse.toolDenied()
}
telemetryService.captureToolUsage(config.ulid, block.name, config.api.getModel().id, false, true, workspaceContext)
telemetryService.captureToolUsage(
config.ulid,
block.name,
config.api.getModel().id,
provider,
false,
true,
workspaceContext,
)
}
// Setup timeout notification for long-running auto-approved commands
@@ -50,6 +50,11 @@ export class ListCodeDefinitionNamesToolHandler implements IFullyManagedTool {
async execute(config: TaskConfig, block: ToolUse): Promise<ToolResponse> {
const relDirPath: string | undefined = block.params.path
// Extract provider using the proven pattern from ReportBugHandler
const apiConfig = config.services.stateManager.getApiConfiguration()
const currentMode = config.services.stateManager.getGlobalSettingsKey("mode")
const provider = (currentMode === "plan" ? apiConfig.planModeApiProvider : apiConfig.actModeApiProvider) as string
// Validate required parameters
const pathValidation = this.validator.assertRequiredParams(block, "path")
if (!pathValidation.ok) {
@@ -83,7 +88,7 @@ export class ListCodeDefinitionNamesToolHandler implements IFullyManagedTool {
await config.callbacks.say("tool", completeMessage, undefined, undefined, false)
// Capture telemetry
telemetryService.captureToolUsage(config.ulid, block.name, config.api.getModel().id, true, true)
telemetryService.captureToolUsage(config.ulid, block.name, config.api.getModel().id, provider, true, true)
} else {
// Manual approval flow
const notificationMessage = `Cline wants to analyze code definitions in ${getWorkspaceBasename(absolutePath, "ListCodeDefinitionNamesToolHandler.notification")}`
@@ -95,10 +100,10 @@ export class ListCodeDefinitionNamesToolHandler implements IFullyManagedTool {
const didApprove = await ToolResultUtils.askApprovalAndPushFeedback("tool", completeMessage, config)
if (!didApprove) {
telemetryService.captureToolUsage(config.ulid, block.name, config.api.getModel().id, false, false)
telemetryService.captureToolUsage(config.ulid, block.name, config.api.getModel().id, provider, false, false)
return formatResponse.toolDenied()
} else {
telemetryService.captureToolUsage(config.ulid, block.name, config.api.getModel().id, false, true)
telemetryService.captureToolUsage(config.ulid, block.name, config.api.getModel().id, provider, false, true)
}
}
@@ -56,6 +56,11 @@ export class ListFilesToolHandler implements IFullyManagedTool {
const recursiveRaw: string | undefined = block.params.recursive
const recursive = recursiveRaw?.toLowerCase() === "true"
// Extract provider using the proven pattern from ReportBugHandler
const apiConfig = config.services.stateManager.getApiConfiguration()
const currentMode = config.services.stateManager.getGlobalSettingsKey("mode")
const provider = (currentMode === "plan" ? apiConfig.planModeApiProvider : apiConfig.actModeApiProvider) as string
// Validate required parameters
const pathValidation = this.validator.assertRequiredParams(block, "path")
if (!pathValidation.ok) {
@@ -100,7 +105,15 @@ export class ListFilesToolHandler implements IFullyManagedTool {
await config.callbacks.say("tool", completeMessage, undefined, undefined, false)
// Capture telemetry
telemetryService.captureToolUsage(config.ulid, block.name, config.api.getModel().id, true, true, workspaceContext)
telemetryService.captureToolUsage(
config.ulid,
block.name,
config.api.getModel().id,
provider,
true,
true,
workspaceContext,
)
} else {
// Manual approval flow
const notificationMessage = `Cline wants to view directory ${getWorkspaceBasename(absolutePath, "ListFilesToolHandler.notification")}/`
@@ -116,6 +129,7 @@ export class ListFilesToolHandler implements IFullyManagedTool {
config.ulid,
block.name,
config.api.getModel().id,
provider,
false,
false,
workspaceContext,
@@ -126,6 +140,7 @@ export class ListFilesToolHandler implements IFullyManagedTool {
config.ulid,
block.name,
config.api.getModel().id,
provider,
false,
true,
workspaceContext,
@@ -52,6 +52,11 @@ export class ReadFileToolHandler implements IFullyManagedTool {
async execute(config: TaskConfig, block: ToolUse): Promise<ToolResponse> {
const relPath: string | undefined = block.params.path
// Extract provider information for telemetry
const apiConfig = config.services.stateManager.getApiConfiguration()
const currentMode = config.services.stateManager.getGlobalSettingsKey("mode")
const provider = (currentMode === "plan" ? apiConfig.planModeApiProvider : apiConfig.actModeApiProvider) as string
// Validate required parameters
const pathValidation = this.validator.assertRequiredParams(block, "path")
if (!pathValidation.ok) {
@@ -98,7 +103,15 @@ export class ReadFileToolHandler implements IFullyManagedTool {
await config.callbacks.say("tool", completeMessage, undefined, undefined, false)
// Capture telemetry
telemetryService.captureToolUsage(config.ulid, block.name, config.api.getModel().id, true, true, workspaceContext)
telemetryService.captureToolUsage(
config.ulid,
block.name,
config.api.getModel().id,
provider,
true,
true,
workspaceContext,
)
} else {
// Manual approval flow
const notificationMessage = `Cline wants to read ${getWorkspaceBasename(absolutePath, "ReadFileToolHandler.notification")}`
@@ -114,6 +127,7 @@ export class ReadFileToolHandler implements IFullyManagedTool {
config.ulid,
block.name,
config.api.getModel().id,
provider,
false,
false,
workspaceContext,
@@ -124,6 +138,7 @@ export class ReadFileToolHandler implements IFullyManagedTool {
config.ulid,
block.name,
config.api.getModel().id,
provider,
false,
true,
workspaceContext,
@@ -207,6 +207,11 @@ export class SearchFilesToolHandler implements IFullyManagedTool {
const regex: string | undefined = block.params.regex
const filePattern: string | undefined = block.params.file_pattern
// Extract provider information for telemetry
const apiConfig = config.services.stateManager.getApiConfiguration()
const currentMode = config.services.stateManager.getGlobalSettingsKey("mode")
const provider = (currentMode === "plan" ? apiConfig.planModeApiProvider : apiConfig.actModeApiProvider) as string
// Validate required parameters
const pathValidation = this.validator.assertRequiredParams(block, "path")
if (!pathValidation.ok) {
@@ -306,7 +311,15 @@ export class SearchFilesToolHandler implements IFullyManagedTool {
await config.callbacks.say("tool", completeMessage, undefined, undefined, false)
// Capture telemetry
telemetryService.captureToolUsage(config.ulid, block.name, config.api.getModel().id, true, true, workspaceContext)
telemetryService.captureToolUsage(
config.ulid,
block.name,
config.api.getModel().id,
provider,
true,
true,
workspaceContext,
)
} else {
// Manual approval flow
const notificationMessage = `Cline wants to search files for ${regex}`
@@ -322,6 +335,7 @@ export class SearchFilesToolHandler implements IFullyManagedTool {
config.ulid,
block.name,
config.api.getModel().id,
provider,
false,
false,
workspaceContext,
@@ -332,6 +346,7 @@ export class SearchFilesToolHandler implements IFullyManagedTool {
config.ulid,
block.name,
config.api.getModel().id,
provider,
false,
true,
workspaceContext,
@@ -47,6 +47,11 @@ export class UseMcpToolHandler implements IFullyManagedTool {
const tool_name: string | undefined = block.params.tool_name
const mcp_arguments: string | undefined = block.params.arguments
// Extract provider information for telemetry
const apiConfig = config.services.stateManager.getApiConfiguration()
const currentMode = config.services.stateManager.getGlobalSettingsKey("mode")
const provider = (currentMode === "plan" ? apiConfig.planModeApiProvider : apiConfig.actModeApiProvider) as string
// Validate required parameters
if (!server_name) {
config.taskState.consecutiveMistakeCount++
@@ -90,7 +95,7 @@ export class UseMcpToolHandler implements IFullyManagedTool {
await config.callbacks.say("use_mcp_server", completeMessage, undefined, undefined, false)
// Capture telemetry
telemetryService.captureToolUsage(config.ulid, block.name, config.api.getModel().id, true, true)
telemetryService.captureToolUsage(config.ulid, block.name, config.api.getModel().id, provider, true, true)
} else {
// Manual approval flow
const notificationMessage = `Cline wants to use ${tool_name || "unknown tool"} on ${server_name || "unknown server"}`
@@ -102,10 +107,10 @@ export class UseMcpToolHandler implements IFullyManagedTool {
const didApprove = await ToolResultUtils.askApprovalAndPushFeedback("use_mcp_server", completeMessage, config)
if (!didApprove) {
telemetryService.captureToolUsage(config.ulid, block.name, config.api.getModel().id, false, false)
telemetryService.captureToolUsage(config.ulid, block.name, config.api.getModel().id, provider, false, false)
return formatResponse.toolDenied()
} else {
telemetryService.captureToolUsage(config.ulid, block.name, config.api.getModel().id, false, true)
telemetryService.captureToolUsage(config.ulid, block.name, config.api.getModel().id, provider, false, true)
}
}
@@ -39,6 +39,11 @@ export class WebFetchToolHandler implements IFullyManagedTool {
try {
const url: string | undefined = block.params.url
// Extract provider information for telemetry
const apiConfig = config.services.stateManager.getApiConfiguration()
const currentMode = config.services.stateManager.getGlobalSettingsKey("mode")
const provider = (currentMode === "plan" ? apiConfig.planModeApiProvider : apiConfig.actModeApiProvider) as string
// Validate required parameter
if (!url) {
config.taskState.consecutiveMistakeCount++
@@ -59,7 +64,7 @@ export class WebFetchToolHandler implements IFullyManagedTool {
// Auto-approve flow
await config.callbacks.removeLastPartialMessageIfExistsWithType("ask", "tool")
await config.callbacks.say("tool", completeMessage, undefined, undefined, false)
telemetryService.captureToolUsage(config.ulid, "web_fetch", config.api.getModel().id, true, true)
telemetryService.captureToolUsage(config.ulid, "web_fetch", config.api.getModel().id, provider, true, true)
} else {
// Manual approval flow
showNotificationForApproval(
@@ -70,10 +75,10 @@ export class WebFetchToolHandler implements IFullyManagedTool {
const didApprove = await ToolResultUtils.askApprovalAndPushFeedback("tool", completeMessage, config)
if (!didApprove) {
telemetryService.captureToolUsage(config.ulid, block.name, config.api.getModel().id, false, false)
telemetryService.captureToolUsage(config.ulid, block.name, config.api.getModel().id, provider, false, false)
return formatResponse.toolDenied()
} else {
telemetryService.captureToolUsage(config.ulid, block.name, config.api.getModel().id, false, true)
telemetryService.captureToolUsage(config.ulid, block.name, config.api.getModel().id, provider, false, true)
}
}
@@ -92,6 +92,11 @@ export class WriteToFileToolHandler implements IFullyManagedTool {
const rawContent = block.params.content // for write_to_file
const rawDiff = block.params.diff // for replace_in_file
// Extract provider information for telemetry
const apiConfig = config.services.stateManager.getApiConfiguration()
const currentMode = config.services.stateManager.getGlobalSettingsKey("mode")
const provider = (currentMode === "plan" ? apiConfig.planModeApiProvider : apiConfig.actModeApiProvider) as string
// Validate required parameters based on tool type
if (!rawRelPath) {
config.taskState.consecutiveMistakeCount++
@@ -169,7 +174,15 @@ export class WriteToFileToolHandler implements IFullyManagedTool {
await config.callbacks.say("tool", completeMessage, undefined, undefined, false)
// Capture telemetry
telemetryService.captureToolUsage(config.ulid, block.name, config.api.getModel().id, true, true, workspaceContext)
telemetryService.captureToolUsage(
config.ulid,
block.name,
config.api.getModel().id,
provider,
true,
true,
workspaceContext,
)
// we need an artificial delay to let the diagnostics catch up to the changes
await setTimeoutPromise(3_500)
@@ -218,6 +231,7 @@ export class WriteToFileToolHandler implements IFullyManagedTool {
config.ulid,
block.name,
config.api.getModel().id,
provider,
false,
false,
workspaceContext,
@@ -247,6 +261,7 @@ export class WriteToFileToolHandler implements IFullyManagedTool {
config.ulid,
block.name,
config.api.getModel().id,
provider,
false,
true,
workspaceContext,
+1
View File
@@ -33,6 +33,7 @@ export interface TaskConfig {
mode: Mode
strictPlanModeEnabled: boolean
yoloModeToggled: boolean
vscodeTerminalExecutionMode: "vscodeTerminal" | "backgroundExec"
context: vscode.ExtensionContext
// Multi-workspace support (optional for backward compatibility)
+6 -1
View File
@@ -58,7 +58,12 @@ export function createUIHelpers(config: TaskConfig): StronglyTypedUIHelpers {
return response === "yesButtonClicked"
},
captureTelemetry: (toolName: ClineDefaultTool, autoApproved: boolean, approved: boolean) => {
telemetryService.captureToolUsage(config.ulid, toolName, config.api.getModel().id, autoApproved, approved)
// Extract provider information for telemetry
const apiConfig = config.services.stateManager.getApiConfiguration()
const currentMode = config.services.stateManager.getGlobalSettingsKey("mode")
const provider = (currentMode === "plan" ? apiConfig.planModeApiProvider : apiConfig.actModeApiProvider) as string
telemetryService.captureToolUsage(config.ulid, toolName, config.api.getModel().id, provider, autoApproved, approved)
},
showNotificationIfEnabled: (message: string) => {
showNotificationForApproval(message, config.autoApprovalSettings.enableNotifications)
@@ -16,6 +16,7 @@ export const TASK_CONFIG_KEYS = [
"mode",
"strictPlanModeEnabled",
"yoloModeToggled",
"vscodeTerminalExecutionMode",
"context",
"taskState",
"messageState",
+3 -7
View File
@@ -3,7 +3,6 @@ import { type EmptyRequest, String } from "@shared/proto/cline/common"
import { ClineEnv } from "@/config"
import { Controller } from "@/core/controller"
import { getRequestRegistry, type StreamingResponseHandler } from "@/core/controller/grpc-handler"
import { setWelcomeViewCompleted } from "@/core/controller/state/setWelcomeViewCompleted"
import { HostProvider } from "@/hosts/host-provider"
import { telemetryService } from "@/services/telemetry"
import { openExternal } from "@/utils/env"
@@ -230,9 +229,8 @@ export class AuthService {
})
}
async createAuthRequest(strict = false): Promise<String> {
// In strict mode, we do not open a new auth window if already authenticated
if (strict && this._authenticated) {
async createAuthRequest(): Promise<String> {
if (this._authenticated) {
this.sendAuthStatusUpdate()
return String.create({ value: "Already authenticated" })
}
@@ -281,13 +279,11 @@ export class AuthService {
this._authenticated = this._clineAuthInfo?.idToken !== undefined
telemetryService.captureAuthSucceeded(this._provider.name)
await setWelcomeViewCompleted(this._controller, { value: true })
await this.sendAuthStatusUpdate()
} catch (error) {
console.error("Error signing in with custom token:", error)
telemetryService.captureAuthFailed(this._provider.name)
throw error
} finally {
await this.sendAuthStatusUpdate()
}
}
-2
View File
@@ -1,7 +1,6 @@
import { String } from "@shared/proto/cline/common"
import { ClineEnv } from "@/config"
import { Controller } from "@/core/controller"
import { setWelcomeViewCompleted } from "@/core/controller/state/setWelcomeViewCompleted"
import { WebviewProvider } from "@/core/webview"
import { CLINE_API_ENDPOINT } from "@/shared/cline/api"
import { AuthService } from "./AuthService"
@@ -122,7 +121,6 @@ export class AuthServiceMock extends AuthService {
override async handleAuthCallback(_token: string, _provider: string): Promise<void> {
try {
this._authenticated = true
await setWelcomeViewCompleted(this._controller, { value: true })
await this.sendAuthStatusUpdate()
} catch (error) {
console.error("Error signing in with custom token:", error)
@@ -193,7 +193,7 @@ describe("Telemetry system is abstracted and can easily switch between providers
noOpTelemetryService.identifyAccount(MOCK_USER_INFO)
noOpTelemetryService.captureTaskCompleted("task-789")
noOpTelemetryService.captureModelSelected("gpt-4", "openai", "task-789")
noOpTelemetryService.captureToolUsage("task-789", "write_to_file", "gpt-4", false, true)
noOpTelemetryService.captureToolUsage("task-789", "write_to_file", "gpt-4", "openai", false, true)
// Test provider methods directly
noOpProvider.log("test_event", { test: "property" })
+11 -2
View File
@@ -393,10 +393,13 @@ export class TelemetryService {
...this.telemetryMetadata,
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { email, ...anonymousUserInfo } = userInfo
// Update all providers with error isolation
this.providers.forEach((provider) => {
try {
provider.identifyUser(userInfo, propertiesWithMetadata)
provider.identifyUser(anonymousUserInfo, propertiesWithMetadata)
} catch (error) {
console.error(`[TelemetryService] Provider failed for user identification:`, error)
}
@@ -684,6 +687,7 @@ export class TelemetryService {
* @param ulid Unique identifier for the task
* @param tool Name of the tool being used
* @param modelId The model ID being used
* @param provider The API provider being used
* @param autoApproved Whether the tool was auto-approved based on settings
* @param success Whether the tool execution was successful
* @param workspaceContext Optional workspace context for multi-root workspace tracking
@@ -692,6 +696,7 @@ export class TelemetryService {
ulid: string,
tool: string,
modelId: string,
provider: string,
autoApproved: boolean,
success: boolean,
workspaceContext?: {
@@ -709,6 +714,7 @@ export class TelemetryService {
autoApproved,
success,
modelId,
provider,
// Workspace context (optional)
...(workspaceContext && {
workspace_multi_root_enabled: workspaceContext.isMultiRootEnabled,
@@ -782,15 +788,18 @@ export class TelemetryService {
/**
* Records when a diff edit (replace_in_file) operation fails
* @param ulid Unique identifier for the task
* @param modelId The model ID being used
* @param provider The API provider being used
* @param errorType Type of error that occurred (e.g., "search_not_found", "invalid_format")
*/
public captureDiffEditFailure(ulid: string, modelId: string, errorType?: string) {
public captureDiffEditFailure(ulid: string, modelId: string, provider: string, errorType?: string) {
this.capture({
event: TelemetryService.EVENTS.TASK.DIFF_EDIT_FAILED,
properties: {
ulid,
errorType,
modelId,
provider,
},
})
}
@@ -5,6 +5,8 @@
import type { ClineAccountUserInfo } from "../../auth/AuthService"
export type AnonymousClineAccountUserInfo = Omit<ClineAccountUserInfo, "email">
/**
* JSON-serializable primitive types for telemetry properties
*/
@@ -68,7 +70,7 @@ export interface ITelemetryProvider {
* @param userInfo The user's information
* @param properties Optional additional JSON-serializable properties
*/
identifyUser(userInfo: ClineAccountUserInfo, properties?: TelemetryProperties): void
identifyUser(userInfo: AnonymousClineAccountUserInfo, properties?: TelemetryProperties): void
/**
* Update telemetry opt-in/out status
@@ -4,8 +4,12 @@ import * as vscode from "vscode"
import { HostProvider } from "@/hosts/host-provider"
import { getDistinctId, setDistinctId } from "@/services/logging/distinctId"
import { Setting } from "@/shared/proto/index.host"
import type { ClineAccountUserInfo } from "../../../auth/AuthService"
import type { ITelemetryProvider, TelemetryProperties, TelemetrySettings } from "../ITelemetryProvider"
import type {
AnonymousClineAccountUserInfo,
ITelemetryProvider,
TelemetryProperties,
TelemetrySettings,
} from "../ITelemetryProvider"
import { OpenTelemetryClientProvider } from "./OpenTelemetryClientProvider"
/**
@@ -113,14 +117,13 @@ export class OpenTelemetryTelemetryProvider implements ITelemetryProvider {
}
}
public identifyUser(userInfo: ClineAccountUserInfo, properties: TelemetryProperties = {}): void {
public identifyUser(userInfo: AnonymousClineAccountUserInfo, properties: TelemetryProperties = {}): void {
const distinctId = getDistinctId()
// Only identify user if telemetry is enabled and user ID is different than the currently set distinct ID
if (this.isEnabled() && userInfo && userInfo?.id !== distinctId) {
// Store user attributes for future events
this.userAttributes = {
user_id: userInfo.id,
user_email: userInfo.email || "",
user_name: userInfo.displayName || "",
...this.flattenProperties(properties),
}
@@ -4,8 +4,12 @@ import { HostProvider } from "@/hosts/host-provider"
import { getDistinctId, setDistinctId } from "@/services/logging/distinctId"
import { Setting } from "@/shared/proto/index.host"
import { posthogConfig } from "../../../../shared/services/config/posthog-config"
import type { ClineAccountUserInfo } from "../../../auth/AuthService"
import type { ITelemetryProvider, TelemetryProperties, TelemetrySettings } from "../ITelemetryProvider"
import type {
AnonymousClineAccountUserInfo,
ITelemetryProvider,
TelemetryProperties,
TelemetrySettings,
} from "../ITelemetryProvider"
/**
* PostHog implementation of the telemetry provider interface
* Handles PostHog-specific analytics tracking
@@ -90,7 +94,7 @@ export class PostHogTelemetryProvider implements ITelemetryProvider {
})
}
public identifyUser(userInfo: ClineAccountUserInfo, properties: TelemetryProperties = {}): void {
public identifyUser(userInfo: AnonymousClineAccountUserInfo, properties: TelemetryProperties = {}): void {
const distinctId = getDistinctId()
// Only identify user if telemetry is enabled and user ID is different than the currently set distinct ID
if (this.isEnabled() && userInfo && userInfo?.id !== distinctId) {
@@ -98,7 +102,6 @@ export class PostHogTelemetryProvider implements ITelemetryProvider {
distinctId: userInfo.id,
properties: {
uuid: userInfo.id,
email: userInfo.email,
name: userInfo.displayName,
...properties,
alias: distinctId,
+7 -1
View File
@@ -209,7 +209,6 @@ interface PriceTier {
}
export interface ModelInfo {
name?: string
maxTokens?: number
contextWindow?: number
supportsImages?: boolean
@@ -3230,6 +3229,13 @@ export const sapAiCoreDefaultModelId: SapAiCoreModelId = "anthropic--claude-3.5-
// Pricing is calculated using Capacity Units, not directly in USD
const sapAiCoreModelDescription = "Pricing is calculated using SAP's Capacity Units rather than direct USD pricing."
export const sapAiCoreModels = {
"anthropic--claude-4.5-sonnet": {
maxTokens: 8192,
contextWindow: 200_000,
supportsImages: true,
supportsPromptCache: true,
description: sapAiCoreModelDescription,
},
"anthropic--claude-4-sonnet": {
maxTokens: 8192,
contextWindow: 200_000,
@@ -330,6 +330,25 @@ describe("Remote Config Schema", () => {
openTelemetryLogBatchSize: 512,
openTelemetryLogBatchTimeout: 5000,
openTelemetryLogMaxQueueSize: 2048,
globalRules: [
{
alwaysEnabled: true,
name: "company-standards.md",
contents: "# Company Standards\n\nAll code must follow these standards...",
},
{
alwaysEnabled: false,
name: "optional-guidelines.md",
contents: "# Optional Guidelines\n\nConsider these best practices...",
},
],
globalWorkflows: [
{
alwaysEnabled: true,
name: "deployment-workflow.md",
contents: "# Deployment Workflow\n\n1. Run tests\n2. Build\n3. Deploy",
},
],
providerSettings: {
OpenAiCompatible: {
models: [
@@ -433,6 +452,19 @@ describe("Remote Config Schema", () => {
expect(result.openTelemetryLogBatchSize).to.equal(512)
expect(result.openTelemetryLogBatchTimeout).to.equal(5000)
expect(result.openTelemetryLogMaxQueueSize).to.equal(2048)
// Verify Global Instructions settings
expect(result.globalRules).to.have.lengthOf(2)
expect(result.globalRules?.[0].alwaysEnabled).to.equal(true)
expect(result.globalRules?.[0].name).to.equal("company-standards.md")
expect(result.globalRules?.[0].contents).to.include("Company Standards")
expect(result.globalRules?.[1].alwaysEnabled).to.equal(false)
expect(result.globalRules?.[1].name).to.equal("optional-guidelines.md")
expect(result.globalWorkflows).to.have.lengthOf(1)
expect(result.globalWorkflows?.[0].alwaysEnabled).to.equal(true)
expect(result.globalWorkflows?.[0].name).to.equal("deployment-workflow.md")
expect(result.globalWorkflows?.[0].contents).to.include("Deployment Workflow")
})
})
+17 -5
View File
@@ -86,11 +86,24 @@ export const AllowedMCPServerSchema = z.object({
id: z.string(),
})
// Settings for a global cline rules or workflow file.
export const GlobalInstructionsFileSchema = z.object({
// When this is enabled, the user cannot turn off this rule or workflow.
alwaysEnabled: z.boolean(),
// The name of the rules or workflow file.
name: z.string(),
// The contents of the rules or workflow file
contents: z.string(),
})
export const RemoteConfigSchema = z.object({
// The version of the remote config settings, e.g. v1
// This field is for internal use only, and won't be visible to the administrator in the UI.
version: z.string(),
// Provider specific settings
providerSettings: ProviderSettingsSchema.optional(),
// General settings not specific to any provider
telemetryEnabled: z.boolean().optional(),
@@ -119,11 +132,9 @@ export const RemoteConfigSchema = z.object({
openTelemetryLogBatchTimeout: z.number().optional(),
openTelemetryLogMaxQueueSize: z.number().optional(),
// Other top-level settings can be added here later.
// Provider specific settings
// Each provider in providerSchemasMap is automatically available as an optional field
providerSettings: ProviderSettingsSchema.optional(),
// Rules & Workflows
globalRules: z.array(GlobalInstructionsFileSchema).optional(),
globalWorkflows: z.array(GlobalInstructionsFileSchema).optional(),
})
// Type inference from schemas
@@ -135,3 +146,4 @@ export type AwsBedrockCustomModel = z.infer<typeof AwsBedrockCustomModelSchema>
export type AwsBedrockSettings = z.infer<typeof AwsBedrockSettingsSchema>
export type ProviderSettings = z.infer<typeof ProviderSettingsSchema>
export type RemoteConfig = z.infer<typeof RemoteConfigSchema>
export type GlobalInstructionsFile = z.infer<typeof GlobalInstructionsFileSchema>
+7 -7
View File
@@ -5,12 +5,11 @@ import { e2e } from "./utils/helpers"
e2e("Views - can set up API keys and navigate to Settings from Chat", async ({ sidebar }) => {
// Use the page object to interact with editor outside the sidebar
// Verify initial state
await expect(sidebar.getByRole("button", { name: "Login to Cline" })).toBeVisible()
await expect(sidebar.getByText("Bring my own API key")).toBeVisible()
await expect(sidebar.getByRole("button", { name: "Get Started for Free" })).toBeVisible()
await expect(sidebar.getByRole("button", { name: "Use your own API key" })).toBeVisible()
// Navigate to API key setup
await sidebar.getByText("Bring my own API key").click()
await sidebar.getByRole("button", { name: "Continue" }).click()
await sidebar.getByRole("button", { name: "Use your own API key" }).click()
const providerSelectorInput = sidebar.getByTestId("provider-selector-input")
@@ -34,9 +33,10 @@ e2e("Views - can set up API keys and navigate to Settings from Chat", async ({ s
await apiKeyInput.fill("test-api-key")
await expect(apiKeyInput).toHaveValue("test-api-key")
await apiKeyInput.click({ delay: 100 })
await sidebar.getByRole("button", { name: "Continue" }).click()
await expect(sidebar.getByRole("button", { name: "Login to Cline" })).not.toBeVisible()
const submitButton = sidebar.getByRole("button", { name: "Let's go!" })
await expect(submitButton).toBeEnabled()
await submitButton.click({ delay: 100 })
await expect(sidebar.getByRole("button", { name: "Get Started for Free" })).not.toBeVisible()
// Verify start up page is no longer visible
await expect(apiKeyInput).not.toBeVisible()
+1 -1
View File
@@ -7,7 +7,7 @@ e2e.describe("Diff Editor", () => {
e2e.extend({
workspaceType,
})(title, async ({ page, sidebar }) => {
await sidebar.getByRole("button", { name: "Login to Cline" }).click({ delay: 100 })
await sidebar.getByRole("button", { name: "Get Started for Free" }).click({ delay: 100 })
// Submit a message
await cleanChatView(page)
+1 -1
View File
@@ -7,7 +7,7 @@ e2e.describe("Code Actions and Editor Panel", () => {
e2e.extend({
workspaceType,
})(title, async ({ page, sidebar }) => {
await sidebar.getByRole("button", { name: "Login to Cline" }).click({ delay: 100 })
await sidebar.getByRole("button", { name: "Get Started for Free" }).click({ delay: 100 })
// Sidebar - input should start empty
const sidebarInput = sidebar.getByTestId("chat-input")
await sidebarInput.click()
+9 -1
View File
@@ -35,8 +35,16 @@ export const toggleNotifications = async (_page: Page) => {
return _page
}
export const closeBanners = async (sidebar: Page) => {
const banners = ["Get Started for Free", "Close banner and enable"]
for (const banner of banners) {
await sidebar.getByRole("button", { name: banner }).click({ delay: 100 })
}
}
export async function cleanChatView(sidebar: Page): Promise<Page> {
const signUpBtn = sidebar.getByRole("button", { name: "Login to Cline" })
const signUpBtn = sidebar.getByRole("button", { name: "Get Started for Free" })
if (await signUpBtn.isVisible()) {
await signUpBtn.click({ delay: 50 })
}
+15 -2
View File
@@ -118,10 +118,23 @@ export class E2ETestHelper {
}
public async signin(webview: Frame): Promise<void> {
await webview.getByRole("button", { name: "Login to Cline" }).click({ delay: 100 })
const byokButton = webview.getByRole("button", {
name: "Use your own API key",
})
await expect(byokButton).toBeVisible()
await byokButton.click()
// Complete setup with OpenRouter
const apiKeyInput = webview.getByRole("textbox", {
name: "OpenRouter API Key",
})
await apiKeyInput.fill("test-api-key")
await webview.getByRole("button", { name: "Let's go!" }).click()
// Verify start up page is no longer visible
await expect(webview.getByRole("button", { name: "Login to Cline" })).not.toBeVisible()
await expect(webview.locator("#api-provider div").first()).not.toBeVisible()
await expect(byokButton).not.toBeVisible()
}
public static async openClineSidebar(page: Page): Promise<void> {
+57
View File
@@ -256,4 +256,61 @@ describe("Filesystem Utilities", () => {
multiExcludeFiles.sort().should.deepEqual(rootOnlyFiles.sort())
})
it("should exclude .clinerules/hooks directory specifically", async () => {
// Create a test directory structure
const clinerulesDirTest = path.join(tmpDir, "clinerules-hooks-test")
const clinerulesDirPath = path.join(clinerulesDirTest, ".clinerules")
// Create .clinerules directory and root files
await fs.mkdir(clinerulesDirPath, { recursive: true })
await fs.writeFile(path.join(clinerulesDirPath, "config.json"), "{}")
await fs.writeFile(path.join(clinerulesDirPath, "settings.js"), "// settings")
// Create .clinerules/workflows directory and files
const workflowsDirPath = path.join(clinerulesDirPath, "workflows")
await fs.mkdir(workflowsDirPath, { recursive: true })
await fs.writeFile(path.join(workflowsDirPath, "workflow1.js"), "// workflow1")
// Create .clinerules/hooks directory and files
const hooksDirPath = path.join(clinerulesDirPath, "hooks")
await fs.mkdir(hooksDirPath, { recursive: true })
await fs.writeFile(path.join(hooksDirPath, "PreToolUse"), "#!/usr/bin/env bash")
await fs.writeFile(path.join(hooksDirPath, "PostToolUse"), "#!/usr/bin/env bash")
// Get all files WITHOUT exclusion
const allFiles = await readDirectory(clinerulesDirPath)
// Verify all files are included
allFiles.length.should.equal(5) // 2 in root + 1 in workflows + 2 in hooks
allFiles.some((file) => file.includes("PreToolUse")).should.be.true()
allFiles.some((file) => file.includes("PostToolUse")).should.be.true()
// Get files WITH hooks directory excluded
const filteredFiles = await readDirectory(clinerulesDirPath, [[".clinerules", "hooks"]])
// Verify hooks files are excluded but others remain
filteredFiles.length.should.equal(3) // 2 in root + 1 in workflows
const expectedFiles = [
path.resolve(clinerulesDirPath, "config.json"),
path.resolve(clinerulesDirPath, "settings.js"),
path.resolve(workflowsDirPath, "workflow1.js"),
]
filteredFiles.sort().should.deepEqual(expectedFiles.sort())
// Test with multiple exclusions (both workflows and hooks)
const multiExcludeFiles = await readDirectory(clinerulesDirPath, [
[".clinerules", "workflows"],
[".clinerules", "hooks"],
])
// Verify both workflows and hooks directories are excluded
multiExcludeFiles.length.should.equal(2) // only the 2 files in root
const rootOnlyFiles = [path.resolve(clinerulesDirPath, "config.json"), path.resolve(clinerulesDirPath, "settings.js")]
multiExcludeFiles.sort().should.deepEqual(rootOnlyFiles.sort())
})
})
+8 -2
View File
@@ -5,7 +5,7 @@ const CLAUDE_VERSION_MATCH_REGEX = /[-_ ]([\d](?:\.[05])?)[-_ ]?/
export function isNextGenModelProvider(providerInfo: ApiProviderInfo): boolean {
const providerId = normalize(providerInfo.providerId)
return ["cline", "anthropic", "gemini", "openrouter", "xai", "openai", "openai-native", "vercel-ai-gateway"].some(
return ["cline", "anthropic", "gemini", "openrouter", "xai", "openai", "minimax", "openai-native", "vercel-ai-gateway"].some(
(id) => providerId === id,
)
}
@@ -71,13 +71,19 @@ export function isGLMModelFamily(id: string): boolean {
)
}
export function isMinimaxModelFamily(id: string): boolean {
const modelId = normalize(id)
return modelId.includes("minimax")
}
export function isNextGenModelFamily(id: string): boolean {
const modelId = normalize(id)
return (
isClaude4PlusModelFamily(modelId) ||
isGemini2dot5ModelFamily(modelId) ||
isGrok4ModelFamily(modelId) ||
isGPT5ModelFamily(modelId)
isGPT5ModelFamily(modelId) ||
isMinimaxModelFamily(modelId)
)
}
-24
View File
@@ -15,7 +15,6 @@
"@radix-ui/react-hover-card": "^1.1.15",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-progress": "^1.1.7",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-tooltip": "^1.2.8",
"@vscode/webview-ui-toolkit": "^1.4.0",
@@ -4091,29 +4090,6 @@
}
}
},
"node_modules/@radix-ui/react-separator": {
"version": "1.1.7",
"resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.7.tgz",
"integrity": "sha512-0HEb8R9E8A+jZjvmFCy/J4xhbXy3TV+9XSnGJ3KvTtjlIUy/YQ/p6UYZvi7YbeoeXdyU9+Y3scizK6hkY37baA==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-primitive": "2.1.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-slot": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
-1
View File
@@ -23,7 +23,6 @@
"@radix-ui/react-hover-card": "^1.1.15",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-progress": "^1.1.7",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-tooltip": "^1.2.8",
"@vscode/webview-ui-toolkit": "^1.4.0",
+2 -2
View File
@@ -4,8 +4,8 @@ import AccountView from "./components/account/AccountView"
import ChatView from "./components/chat/ChatView"
import HistoryView from "./components/history/HistoryView"
import McpView from "./components/mcp/configuration/McpConfigurationView"
import OnboardingView from "./components/onboarding/OnboardingView"
import SettingsView from "./components/settings/SettingsView"
import WelcomeView from "./components/welcome/WelcomeView"
import { useClineAuth } from "./context/ClineAuthContext"
import { useExtensionState } from "./context/ExtensionStateContext"
import { Providers } from "./Providers"
@@ -54,7 +54,7 @@ const AppContent = () => {
}
if (showWelcome) {
return <OnboardingView />
return <WelcomeView />
}
return (
@@ -1,351 +0,0 @@
import type { ModelInfo } from "@shared/api"
import { AlertCircleIcon, CircleCheckIcon, CircleIcon, ListIcon, LoaderCircleIcon, StarIcon, ZapIcon } from "lucide-react"
import { useCallback, useEffect, useMemo, useState } from "react"
import ClineLogoWhite from "@/assets/ClineLogoWhite"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Item, ItemContent, ItemDescription, ItemHeader, ItemMedia, ItemTitle } from "@/components/ui/item"
import { useExtensionState } from "@/context/ExtensionStateContext"
import { cn } from "@/lib/utils"
import { AccountServiceClient, StateServiceClient } from "@/services/grpc-client"
import ApiConfigurationSection from "../settings/sections/ApiConfigurationSection"
import { useApiConfigurationHandlers } from "../settings/utils/useApiConfigurationHandlers"
import {
getCapabilities,
getOverviewLabel,
getPriceRange,
ONBOARDING_MODEL_SELECTIONS,
type OnboardingModelOption,
} from "./data-models"
import { NEW_USER_TYPE, STEP_CONFIG, USER_TYPE_SELECTIONS } from "./data-steps"
type ModelSelectionProps = {
userType: NEW_USER_TYPE.FREE | NEW_USER_TYPE.POWER
selectedModelId: string
onSelectModel: (modelId: string) => void
models?: Record<string, ModelInfo>
searchTerm: string
setSearchTerm: (term: string) => void
}
const ModelSelection = ({ userType, selectedModelId, onSelectModel, models, searchTerm, setSearchTerm }: ModelSelectionProps) => {
const modelGroups = ONBOARDING_MODEL_SELECTIONS[userType === NEW_USER_TYPE.FREE ? "free" : "power"]
const searchedModels = useMemo(() => {
if (!models || !searchTerm) {
return []
}
const flattenedModels = modelGroups.flatMap((g) => g.models.map((m) => m.id))
// Filter out embedding models and already listed models
const filtered = Object.entries(models).filter(
([id, _info]) => !id.includes("embedding") && !flattenedModels.includes(id) && id.includes(searchTerm.toLowerCase()),
)
return filtered.slice(0, 5) // Return the first 5 models
}, [models, modelGroups, searchTerm])
// Model Item Component
const ModelItem = ({ id, model, isSelected }: { id: string; model: OnboardingModelOption; isSelected: boolean }) => {
return (
<Item
className={cn("cursor-pointer hover:cursor-pointer", {
"bg-input-background/80 border border-button-background": isSelected,
})}
key={id}
onClick={() => onSelectModel(id)}
variant="outline">
<ItemHeader className="flex flex-col w-full align-baseline">
<ItemTitle className="flex w-full justify-between">
<span className="font-semibold">{model.name || id}</span>
{model.badge ? <Badge variant="info">{model.badge}</Badge> : <Badge>{getPriceRange(model)}</Badge>}
</ItemTitle>
{isSelected && (
<ItemDescription>
<span className="text-foreground/70 text-sm">Support: </span>
<span className="text-foreground text-sm">{getCapabilities(model).join(", ")}</span>
</ItemDescription>
)}
</ItemHeader>
{model.badge && isSelected && (
<ItemContent className="w-full border-t border-muted-foreground pt-5 text-ellipsis overflow-hidden">
<div className="flex flex-col gap-3">
{model.score && (
<div className="inline-flex gap-1 [&_svg]:stroke-warning [&_svg]:size-3 items-center text-sm">
<StarIcon />
<span>Model Overview: </span>
<span className="text-foreground/70">{model.score}%</span>
<span className="text-foreground/70 hidden xs:block">{getOverviewLabel(model.score)}</span>
</div>
)}
<div className="inline-flex gap-1 [&_svg]:stroke-success [&_svg]:size-3 items-center text-sm">
<ZapIcon />
<span>Speed: </span>
<span className="text-foreground/70">{model.speed}</span>
</div>
<div className="flex w-full justify-between">
<div className="inline-flex gap-1 [&_svg]:stroke-foreground [&_svg]:size-3 items-center text-sm">
<ListIcon />
<span>Context: </span>
<span className="text-foreground/70">{(model?.contextWindow || 0) / 1000}k</span>
</div>
<Badge>{getPriceRange(model)}</Badge>
</div>
</div>
</ItemContent>
)}
</Item>
)
}
return (
<div className="flex flex-col w-full items-center px-2">
<div className="flex w-full max-w-lg flex-col gap-6 my-4">
{modelGroups.map((group) => (
<div className="flex flex-col gap-3" key={group.group}>
<h4 className="text-sm font-bold text-foreground/70 uppercase mb-2">{group.group}</h4>
{group.models.map((model) => (
<ModelItem id={model.id} isSelected={selectedModelId === model.id} key={model.id} model={model} />
))}
</div>
))}
</div>
{/* SEARCH MODEL */}
<div className="flex w-full max-w-lg flex-col gap-6 my-4 border-t border-muted-foreground">
<div className="flex flex-col gap-3 mt-6" key="search-results">
<h4 className="text-sm font-bold text-foreground/70 uppercase mb-2">other options</h4>
<Input
autoFocus={false}
className="focus-visible:border-button-background"
onChange={(e) => {
if (!e.target?.value) {
onSelectModel("")
}
setSearchTerm(e.target.value)
}}
onClick={() => onSelectModel("")}
placeholder="Search model..."
type="search"
value={searchTerm}
/>
<div className="w-full flex flex-col gap-3">
{searchTerm &&
searchedModels.map(([id, info]) => {
const isSelected = selectedModelId === id
return (
<ModelItem
id={id}
isSelected={isSelected}
key={id}
model={{ id, name: info.name, ...info }}
/>
)
})}
{searchTerm.length > 0 && searchedModels.length === 0 && (
<p className="px-1 mt-1 text-sm text-foreground/70">No result found for "{searchTerm}"</p>
)}
</div>
</div>
</div>
</div>
)
}
type UserTypeSelectionProps = {
userType: NEW_USER_TYPE | undefined
onSelectUserType: (type: NEW_USER_TYPE) => void
}
const UserTypeSelectionStep = ({ userType, onSelectUserType }: UserTypeSelectionProps) => (
<div className="flex flex-col w-full items-center">
<div className="flex w-full max-w-lg flex-col gap-6 my-4">
<h3 className="text-base text-left self-start font-semibold">LETS GET STARTED</h3>
{USER_TYPE_SELECTIONS.map((option) => {
const isSelected = userType === option.type
return (
<Item
className={cn("cursor-pointer hover:cursor-pointer w-full", {
"bg-input-background/50 border border-input-foreground/30": isSelected,
})}
key={option.type}
onClick={() => onSelectUserType(option.type)}>
<ItemMedia className="[&_svg]:stroke-button-background" variant="icon">
{isSelected ? <CircleCheckIcon className="stroke-1.5" /> : <CircleIcon className="stroke-1" />}
</ItemMedia>
<ItemContent className="w-full">
<ItemTitle>{option.title}</ItemTitle>
<ItemDescription>{option.description}</ItemDescription>
</ItemContent>
</Item>
)
})}
</div>
</div>
)
type OnboardingStepContentProps = {
step: number
userType: NEW_USER_TYPE | undefined
selectedModelId: string
onSelectUserType: (type: NEW_USER_TYPE) => void
onSelectModel: (modelId: string) => void
searchTerm: string
setSearchTerm: (term: string) => void
models?: Record<string, ModelInfo>
}
const OnboardingStepContent = ({
step,
userType,
selectedModelId,
onSelectUserType,
onSelectModel,
searchTerm,
setSearchTerm,
models,
}: OnboardingStepContentProps) => {
if (step === 0) {
return <UserTypeSelectionStep onSelectUserType={onSelectUserType} userType={userType} />
}
if (step === 2) {
return null
}
if (userType === NEW_USER_TYPE.FREE || userType === NEW_USER_TYPE.POWER) {
return (
<ModelSelection
models={models}
onSelectModel={onSelectModel}
searchTerm={searchTerm}
selectedModelId={selectedModelId}
setSearchTerm={setSearchTerm}
userType={userType}
/>
)
}
// userType === NEW_USER_TYPE.BYOK
return <ApiConfigurationSection />
}
const OnboardingView = () => {
const { handleFieldsChange } = useApiConfigurationHandlers()
const { openRouterModels, hideSettings, hideAccount, setShowWelcome } = useExtensionState()
const [stepNumber, setStepNumber] = useState(0)
const [userType, setUserType] = useState<NEW_USER_TYPE>(NEW_USER_TYPE.FREE)
const [selectedModelId, setSelectedModelId] = useState("")
const [searchTerm, setSearchTerm] = useState("")
useEffect(() => {
setSearchTerm("")
const userGroup = userType === NEW_USER_TYPE.POWER ? NEW_USER_TYPE.POWER : NEW_USER_TYPE.FREE
const modelGroup = ONBOARDING_MODEL_SELECTIONS[userGroup][0]
const userGroupInitModel = modelGroup.models[0]
setSelectedModelId(userGroupInitModel.id)
}, [userType])
const finishOnboarding = useCallback(
async (updateModelId: boolean) => {
if (updateModelId && selectedModelId) {
await handleFieldsChange({
planModeOpenRouterModelId: selectedModelId,
actModeOpenRouterModelId: selectedModelId,
planModeOpenRouterModelInfo: openRouterModels[selectedModelId],
actModeOpenRouterModelInfo: openRouterModels[selectedModelId],
planModeApiProvider: "cline",
actModeApiProvider: "cline",
})
}
hideAccount()
hideSettings()
},
[hideAccount, hideSettings, handleFieldsChange, selectedModelId, openRouterModels],
)
const handleFooterAction = useCallback(
async (action: "signin" | "next" | "back" | "done" | "signup") => {
switch (action) {
case "signup":
setStepNumber(stepNumber + 1)
await AccountServiceClient.accountLoginClicked({}).catch(() => {})
await finishOnboarding(true)
break
case "signin":
await AccountServiceClient.accountLoginClicked({}).catch(() => {})
await finishOnboarding(true)
break
case "next":
setStepNumber(stepNumber + 1)
break
case "back":
setStepNumber(stepNumber - 1)
break
case "done":
await StateServiceClient.setWelcomeViewCompleted({ value: true }).catch(() => {})
setShowWelcome(false)
await finishOnboarding(false)
break
}
},
[stepNumber, finishOnboarding, setShowWelcome],
)
const stepDisplayInfo = useMemo(() => {
const step = stepNumber === 0 || stepNumber === 2 ? STEP_CONFIG[stepNumber] : null
const title = step ? step.title : userType ? STEP_CONFIG[userType].title : STEP_CONFIG[0].title
const description = step ? step.description : null
const buttons = step ? step.buttons : userType ? STEP_CONFIG[userType].buttons : STEP_CONFIG[0].buttons
return { title, description, buttons }
}, [stepNumber, userType])
return (
<div className="fixed inset-0 p-0 flex flex-col w-full">
<div className="h-full px-5 xs:mx-10 overflow-auto flex flex-col gap-7 items-center justify-center mt-10">
<ClineLogoWhite className="size-16" />
<h2 className="text-lg font-semibold p-0">{stepDisplayInfo.title}</h2>
{stepNumber === 2 && (
<div className="flex w-full max-w-lg flex-col gap-6 my-4 items-center ">
<LoaderCircleIcon className="animate-spin" />
</div>
)}
{stepDisplayInfo.description && (
<p className="text-foreground text-sm text-center m-0 p-0">{stepDisplayInfo.description}</p>
)}
<div className="flex-1 w-full flex max-w-lg overflow-y-scroll">
<OnboardingStepContent
models={openRouterModels}
onSelectModel={setSelectedModelId}
onSelectUserType={setUserType}
searchTerm={searchTerm}
selectedModelId={selectedModelId}
setSearchTerm={setSearchTerm}
step={stepNumber}
userType={userType}
/>
</div>
<footer className="flex w-full max-w-lg flex-col gap-3 my-2 px-2 overflow-hidden">
{stepDisplayInfo.buttons.map((btn) => (
<Button
className="w-full rounded-xs"
key={btn.text}
onClick={() => handleFooterAction(btn.action)}
variant={btn.variant}>
{btn.text}
</Button>
))}
{stepNumber !== 2 && (
<div className="items-center justify-center flex text-sm text-foreground gap-2 mb-3 text-pretty">
<AlertCircleIcon className="shrink-0 size-2" /> You can change this later in settings
</div>
)}
</footer>
</div>
</div>
)
}
export default OnboardingView
@@ -1,150 +0,0 @@
# Onboarding Models Pattern
## Overview
The onboarding model selection uses a **Single Source of Truth** pattern to avoid duplicating model definitions between `src/shared/api.ts` and the webview.
## Architecture
### Files Involved
1. **`src/shared/api.ts`** - Contains complete model definitions with capabilities
2. **`data-models.ts`** - Contains only UI-specific metadata (score, speed, badge)
### How It Works
```typescript
// 1. Define only UI-specific metadata
const ONBOARDING_MODEL_METADATA = {
power: {
"anthropic/claude-sonnet-4.5": {
id: "anthropic/claude-sonnet-4.5",
name: "Anthropic: Claude Sonnet 4.5",
badge: "Best",
score: 97,
speed: "Fast",
}
}
}
// 2. Reference the source model from api.ts
const MODEL_SOURCE_MAP: Record<string, ModelInfo> = {
"anthropic/claude-sonnet-4.5": openRouterDefaultModelInfo,
}
// 3. Merge them together
const model = createOnboardingModel(
ONBOARDING_MODEL_METADATA.power["anthropic/claude-sonnet-4.5"],
MODEL_SOURCE_MAP["anthropic/claude-sonnet-4.5"]
)
```
## Benefits
**No Duplication** - Model capabilities (contextWindow, prices, etc.) are only defined once in `api.ts`
**Automatic Updates** - Changes to model specs in `api.ts` automatically propagate to onboarding
**Clear Separation** - UI metadata (score, badge) is separate from technical specs
**Type Safety** - TypeScript ensures consistency between definitions
## Adding a New Model
To add a new model to the onboarding flow:
### Step 1: Add the model metadata
```typescript
const ONBOARDING_MODEL_METADATA = {
power: {
"new-provider/new-model": {
id: "new-provider/new-model",
name: "Provider: Model Name",
badge: "New", // Optional: "Best", "Trending", "Free"
score: 85, // Performance score 0-100
speed: "Fast", // "Fast", "Average", or "Slow"
}
}
}
```
### Step 2: Map to source model
If the model exists in `api.ts`:
```typescript
const MODEL_SOURCE_MAP: Record<string, ModelInfo> = {
"new-provider/new-model": providerModels["model-id"],
}
```
If the model doesn't exist in `api.ts` yet:
```typescript
const MODEL_SOURCE_MAP: Record<string, ModelInfo> = {
"new-provider/new-model": {
maxTokens: 8192,
contextWindow: 128000,
supportsImages: true,
supportsPromptCache: false,
inputPrice: 1.0,
outputPrice: 5.0,
},
}
```
### Step 3: Add to selection list
```typescript
export const ONBOARDING_MODEL_SELECTIONS = {
power: [
{
group: "frontier",
models: [
createOnboardingModel(
ONBOARDING_MODEL_METADATA.power["new-provider/new-model"],
MODEL_SOURCE_MAP["new-provider/new-model"],
),
]
}
]
}
```
## Important Notes
- **Never duplicate** `maxTokens`, `contextWindow`, `inputPrice`, `outputPrice`, etc. in onboarding metadata
- **Always reference** the source model from `api.ts` when available
- **Only add** UI-specific properties: `name`, `badge`, `score`, `speed`
- **Keep in sync** - When a model is added to `api.ts`, update the source map reference
## Migration from Old Pattern
**Before (duplicated):**
```typescript
{
id: "anthropic/claude-sonnet-4.5",
name: "Anthropic: Claude Sonnet 4.5",
badge: "Best",
score: 97,
speed: "Fast",
contextWindow: 200000, // ❌ Duplicated
supportsImages: true, // ❌ Duplicated
supportsPromptCache: true, // ❌ Duplicated
inputPrice: 3.0, // ❌ Duplicated
outputPrice: 15.0, // ❌ Duplicated
}
```
**After (referenced):**
```typescript
// Metadata only
const metadata = {
id: "anthropic/claude-sonnet-4.5",
name: "Anthropic: Claude Sonnet 4.5",
badge: "Best",
score: 97,
speed: "Fast",
}
// Source from api.ts
const source = openRouterDefaultModelInfo
// Merged automatically
const model = createOnboardingModel(metadata, source)
@@ -1,235 +0,0 @@
import type { ModelInfo } from "@shared/api"
import { cerebrasModels, openAiNativeModels, openRouterDefaultModelInfo } from "@shared/api"
import { NEW_USER_TYPE } from "./data-steps"
export interface OnboardingModelOption extends ModelInfo {
id: string
name?: string
badge?: string
supported_parameters?: string[]
score?: number
speed?: string
}
type ModelGroup = {
group: string
models: OnboardingModelOption[]
}
/**
* Onboarding-specific metadata for models
* Contains only UI-specific properties (score, speed, badge, display name)
* Model capabilities (contextWindow, prices, etc.) are pulled from api.ts
*/
interface OnboardingModelMetadata {
/** Model ID used in OpenRouter or provider-specific format */
id: string
/** Display name for the onboarding UI */
name: string
/** Badge to display (e.g., "Best", "Trending", "Free") */
badge?: string
/** Performance score (0-100) */
score: number
/** Speed indicator ("Fast", "Average", "Slow") */
speed: "Fast" | "Average" | "Slow"
}
/**
* Creates an OnboardingModelOption by merging source model data with metadata
*/
function createOnboardingModel(metadata: OnboardingModelMetadata, sourceModel: ModelInfo): OnboardingModelOption {
return {
...sourceModel,
...metadata,
}
}
/**
* Model metadata definitions - only contains onboarding-specific fields
* Actual model capabilities come from the source models in api.ts
*/
const ONBOARDING_MODEL_METADATA = {
free: {
"x-ai/grok-code-fast-1": {
id: "x-ai/grok-code-fast-1",
name: "xAI: Grok Code Fast 1",
badge: "Best",
score: 90,
speed: "Fast" as const,
},
"minimax/minimax-m1": {
id: "minimax/minimax-m1",
name: "MiniMax: MiniMax M1",
badge: "Trending",
score: 90,
speed: "Fast" as const,
},
},
power: {
"anthropic/claude-sonnet-4.5": {
id: "anthropic/claude-sonnet-4.5",
name: "Anthropic: Claude Sonnet 4.5",
badge: "Best",
score: 97,
speed: "Fast" as const,
},
"openai/gpt-5-codex": {
id: "openai/gpt-5-codex",
name: "OpenAI: GPT-5 Codex",
badge: "Best",
score: 97,
speed: "Slow" as const,
},
"z-ai/glm-4.6:exacto": {
id: "z-ai/glm-4.6:exacto",
name: "Z.AI: GLM 4.6 (exacto)",
badge: "Trending",
score: 90,
speed: "Average" as const,
},
"moonshotai/kimi-dev-72b:free": {
id: "moonshotai/kimi-dev-72b:free",
name: "MoonshotAI: Kimi Dev 72B (free)",
badge: "Free",
score: 90,
speed: "Fast" as const,
},
},
} as const
/**
* Maps model IDs to their source definitions in api.ts
* This creates the single source of truth for model capabilities
*/
const MODEL_SOURCE_MAP: Record<string, ModelInfo> = {
// Free tier models
"x-ai/grok-code-fast-1": {
// Placeholder - this model doesn't exist in api.ts yet
// Using xAI grok-4-fast-reasoning as reference
maxTokens: 30000,
contextWindow: 2000000,
supportsImages: true,
supportsPromptCache: false,
inputPrice: 0,
outputPrice: 0,
},
"minimax/minimax-m1": {
// Placeholder - this model doesn't exist in api.ts yet
// Using MiniMax-M2 as reference
maxTokens: 128000,
contextWindow: 1000000,
supportsImages: false,
supportsPromptCache: false,
inputPrice: 0,
outputPrice: 0,
},
// Power tier models - reference actual models from api.ts
"anthropic/claude-sonnet-4.5": openRouterDefaultModelInfo,
"openai/gpt-5-codex": {
// Using GPT-5 from openAiNativeModels as base
...openAiNativeModels["gpt-5-2025-08-07"],
// Override with codex-specific values if different
contextWindow: 400000,
},
"z-ai/glm-4.6:exacto": cerebrasModels["zai-glm-4.6"],
"moonshotai/kimi-dev-72b:free": {
// Placeholder - using estimated values
maxTokens: 16384,
contextWindow: 131072,
supportsImages: false,
supportsPromptCache: false,
inputPrice: 0,
outputPrice: 0,
},
}
export const ONBOARDING_MODEL_SELECTIONS: Record<"free" | "power", ModelGroup[]> = {
[NEW_USER_TYPE.FREE]: [
{
group: "free",
models: [
createOnboardingModel(
ONBOARDING_MODEL_METADATA.free["x-ai/grok-code-fast-1"],
MODEL_SOURCE_MAP["x-ai/grok-code-fast-1"],
),
createOnboardingModel(
ONBOARDING_MODEL_METADATA.free["minimax/minimax-m1"],
MODEL_SOURCE_MAP["minimax/minimax-m1"],
),
],
},
],
[NEW_USER_TYPE.POWER]: [
{
group: "frontier",
models: [
createOnboardingModel(
ONBOARDING_MODEL_METADATA.power["anthropic/claude-sonnet-4.5"],
MODEL_SOURCE_MAP["anthropic/claude-sonnet-4.5"],
),
createOnboardingModel(
ONBOARDING_MODEL_METADATA.power["openai/gpt-5-codex"],
MODEL_SOURCE_MAP["openai/gpt-5-codex"],
),
],
},
{
group: "open source",
models: [
createOnboardingModel(
ONBOARDING_MODEL_METADATA.power["z-ai/glm-4.6:exacto"],
MODEL_SOURCE_MAP["z-ai/glm-4.6:exacto"],
),
createOnboardingModel(
ONBOARDING_MODEL_METADATA.power["moonshotai/kimi-dev-72b:free"],
MODEL_SOURCE_MAP["moonshotai/kimi-dev-72b:free"],
),
],
},
],
}
export function getPriceRange(modelInfo: ModelInfo): string {
const prompt = Number(modelInfo.inputPrice ?? 0)
const completion = Number(modelInfo.outputPrice ?? 0)
const cost = prompt + completion
if (cost === 0) {
return "Free"
}
if (cost < 10) {
return "$"
}
if (cost > 50) {
return "$$$"
}
return "$$"
}
export function getOverviewLabel(overview: number): string {
if (overview >= 95) {
return "Top Performer"
}
if (overview >= 80) {
return "Great"
}
if (overview >= 60) {
return "Good"
}
if (overview >= 50) {
return "Average"
}
return "Below Average"
}
export function getCapabilities(modelInfo: ModelInfo): string[] {
const capabilities = new Set<string>()
if (modelInfo.supportsImages) {
capabilities.add("Images")
}
if (modelInfo.supportsPromptCache) {
capabilities.add("Prompt Cache")
}
capabilities.add("Tools")
return Array.from(capabilities)
}
@@ -1,54 +0,0 @@
export enum NEW_USER_TYPE {
FREE = "free",
POWER = "power",
BYOK = "byok",
}
type UserTypeSelection = {
title: string
description: string
type: NEW_USER_TYPE
}
export const STEP_CONFIG = {
0: {
title: "How will you use Cline?",
description: "Select an option below to get started.",
buttons: [
{ text: "Continue", action: "next", variant: "default" },
{ text: "Login to Cline", action: "signin", variant: "secondary" },
],
},
[NEW_USER_TYPE.FREE]: {
title: "Select a free model",
buttons: [
{ text: "Create my Account", action: "signup", variant: "default" },
{ text: "Back", action: "back", variant: "secondary" },
],
},
[NEW_USER_TYPE.POWER]: {
title: "Select your model",
buttons: [
{ text: "Create my Account", action: "signup", variant: "default" },
{ text: "Back", action: "back", variant: "secondary" },
],
},
[NEW_USER_TYPE.BYOK]: {
title: "Configure your provider",
buttons: [
{ text: "Continue", action: "done", variant: "default" },
{ text: "Back", action: "back", variant: "secondary" },
],
},
2: {
title: "Almost there!",
description: "Complete account creation in your browser. Then come back here to finish up.",
buttons: [{ text: "Back", action: "back", variant: "secondary" }],
},
} as const
export const USER_TYPE_SELECTIONS: UserTypeSelection[] = [
{ title: "Absolutely Free", description: "Get started at no cost", type: NEW_USER_TYPE.FREE },
{ title: "Frontier Model", description: "Claude 4.5, GPT-5 Codex, etc", type: NEW_USER_TYPE.POWER },
{ title: "Bring my own API key", description: "Use Cline with your provider of choice", type: NEW_USER_TYPE.BYOK },
]
@@ -1,21 +1,27 @@
import { memo } from "react"
import { memo, useEffect } from "react"
import { useRemark } from "react-remark"
import { Button } from "@/components/ui/button"
import { cn } from "@/lib/utils"
interface ModelDescriptionMarkdownProps {
markdown?: string
key: string
isExpanded: boolean
setIsExpanded: (isExpanded: boolean) => void
isPopup?: boolean
}
export const ModelDescriptionMarkdown = memo(
({
markdown,
key,
isExpanded,
setIsExpanded,
isPopup,
}: {
markdown?: string
key: string
isExpanded: boolean
setIsExpanded: (isExpanded: boolean) => void
isPopup?: boolean
}) => {
({ markdown, key, isExpanded, setIsExpanded, isPopup }: ModelDescriptionMarkdownProps) => {
// Update the markdown content when the prop changes
const [reactContent, setMarkdown] = useRemark()
useEffect(() => {
if (markdown) {
setMarkdown(markdown)
}
}, [markdown, setMarkdown])
return (
<div className="inline-block mb-0 description line-clamp-3" key={key}>
<div
@@ -23,10 +29,11 @@ export const ModelDescriptionMarkdown = memo(
"overflow-y-auto": isExpanded,
})}>
<div
className={cn("overflow-hidden line-clamp-3 text-sm", {
className={cn("overflow-hidden text-sm line-clamp-3", {
"line-clamp-none": isExpanded,
"h-20": !isExpanded,
})}>
{markdown}
{reactContent}
</div>
{!isExpanded && (
<div className="absolute bottom-0 right-0 flex items-center">
@@ -1,4 +1,4 @@
import type { ExtensionMessage } from "@shared/ExtensionMessage"
import { ExtensionMessage } from "@shared/ExtensionMessage"
import { ResetStateRequest } from "@shared/proto/cline/state"
import { VSCodeButton } from "@vscode/webview-ui-toolkit/react"
import debounce from "debounce"
@@ -6,7 +6,7 @@ import {
CheckCheck,
FlaskConical,
Info,
type LucideIcon,
LucideIcon,
SlidersHorizontal,
SquareMousePointer,
SquareTerminal,
@@ -78,6 +78,15 @@ export const SETTINGS_TABS: SettingsTab[] = [
headerText: "Terminal Settings",
icon: SquareTerminal,
},
// Only show in dev mode
{
id: "debug",
name: "Debug",
tooltipText: "Debug Tools",
headerText: "Debug",
icon: FlaskConical,
hidden: !IS_DEV,
},
{
id: "general",
name: "General",
@@ -92,15 +101,6 @@ export const SETTINGS_TABS: SettingsTab[] = [
headerText: "About",
icon: Info,
},
// Only show in dev mode
{
id: "debug",
name: "Debug",
tooltipText: "Debug Tools",
headerText: "Debug",
icon: FlaskConical,
hidden: !IS_DEV,
},
]
type SettingsViewProps = {
@@ -11,7 +11,7 @@ import { syncModeConfigurations } from "../utils/providerUtils"
import { useApiConfigurationHandlers } from "../utils/useApiConfigurationHandlers"
interface ApiConfigurationSectionProps {
renderSectionHeader?: (tabId: string) => JSX.Element | null
renderSectionHeader: (tabId: string) => JSX.Element | null
}
const ApiConfigurationSection = ({ renderSectionHeader }: ApiConfigurationSectionProps) => {
@@ -20,7 +20,7 @@ const ApiConfigurationSection = ({ renderSectionHeader }: ApiConfigurationSectio
const { handleFieldsChange } = useApiConfigurationHandlers()
return (
<div>
{renderSectionHeader?.("api-config")}
{renderSectionHeader("api-config")}
<Section>
{/* Tabs container */}
{planActSeparateModelsSetting ? (
@@ -1,6 +1,4 @@
import { Button } from "@/components/ui/button"
import { useExtensionState } from "@/context/ExtensionStateContext"
import { StateServiceClient } from "@/services/grpc-client"
import { VSCodeButton } from "@vscode/webview-ui-toolkit/react"
import Section from "../Section"
interface DebugSectionProps {
@@ -9,32 +7,26 @@ interface DebugSectionProps {
}
const DebugSection = ({ onResetState, renderSectionHeader }: DebugSectionProps) => {
const { setShowWelcome } = useExtensionState()
return (
<div>
{renderSectionHeader("debug")}
<Section>
<Button onClick={() => onResetState()} variant="danger">
<VSCodeButton
className="mt-[5px] w-auto"
onClick={() => onResetState()}
style={{ backgroundColor: "var(--vscode-errorForeground)", color: "black" }}>
Reset Workspace State
</Button>
<Button onClick={() => onResetState(true)} variant="danger">
</VSCodeButton>
<VSCodeButton
className="mt-[5px] w-auto"
onClick={() => onResetState(true)}
style={{ backgroundColor: "var(--vscode-errorForeground)", color: "black" }}>
Reset Global State
</Button>
</VSCodeButton>
<p className="text-xs mt-[5px] text-(--vscode-descriptionForeground)">
This will reset all global state and secret storage in the extension.
</p>
</Section>
<Section>
<Button
onClick={async () =>
await StateServiceClient.setWelcomeViewCompleted({ value: false })
.catch(() => {})
.finally(() => setShowWelcome(true))
}
variant="secondary">
Reset Onboarding State
</Button>
</Section>
</div>
)
}
-34
View File
@@ -1,34 +0,0 @@
import { cva, type VariantProps } from "class-variance-authority"
import type * as React from "react"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center justify-center border text-xs transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 [&_svg]:size-2",
{
variants: {
variant: {
default: "border-transparent bg-badge-background text-badge-foreground shadow hover:bg-badge-background/80",
info: "border-transparent bg-button-background/80 text-button-foreground hover:bg-button-hover",
danger: "border-transparent bg-error text-error-foreground shadow hover:bg-error/80",
outline: "text-foreground",
},
type: {
default: "rounded-md px-1 font-normal",
round: "rounded-full h-5 w-auto",
},
},
defaultVariants: {
variant: "default",
type: "default",
},
},
)
export interface BadgeProps extends React.HTMLAttributes<HTMLDivElement>, VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, type, ...props }: BadgeProps) {
return <div className={cn(badgeVariants({ variant, type }), className)} {...props} />
}
export { Badge, badgeVariants }
+12 -13
View File
@@ -4,26 +4,25 @@ import * as React from "react"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-xs transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 cursor-pointer [&_svg]:size-2",
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 cursor-pointer [&_svg]:size-2",
{
variants: {
variant: {
default:
"bg-button-background text-primary-foreground hover:bg-button-hover shadow-sm shadow-button-background/50",
default: "bg-button-background text-primary-foreground shadow hover:bg-button-background-hover",
destructive: "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
outline: "text-foreground p-0 m-0",
secondary:
"bg-button-secondary-background text-button-secondary-foreground hover:bg-button-secondary-background-hover shadow-sm shadow-button-secondary-background/50",
danger: "bg-error text-background hover:bg-error/90 shadow-sm shadow-error/50",
outline: "hover:bg-accent/10 border border-accent/20 shadow-sm shadow-accent/50",
ghost: "hover:bg-accent/10",
link: "text-link underline-offset-4 hover:underline p-0 m-0",
"bg-button-secondary-background text-button-secondary-foreground shadow-sm hover:bg-button-secondary-background-hover",
ghost: "bg-transparent border border-foreground/20 shadow-sm hover:bg-accent/10",
link: "text-link underline-offset-4 hover:underline",
text: "text-foreground",
icon: "hover:opacity-80 p-0 m-0 border-0 cursor-pointer hover:shadow-none focus:ring-0 focus:ring-offset-0",
icon: "bg-transparent hover:opacity-80 p-0 h-auto m-0 border-0 cursor-pointer hover:bg-transparent hover:shadow-none focus:ring-0 focus:ring-offset-0",
},
size: {
default: "py-1.5 px-4 [&_svg]:size-3",
sm: "py-1 px-3 text-sm [&_svg]:size-2",
xs: "p-1 text-xs [&_svg]:size-2",
lg: "py-4 px-8 [&_svg]:size-4 font-medium",
default: "h-5 p-4 [&_svg]:size-3",
sm: "h-3 rounded-md px-3 text-sm [&_svg]:size-2",
xs: "h-1 rounded-xs px-1 text-xs [&_svg]:size-2",
lg: "h-8 rounded-md px-8 [&_svg]:size-3",
icon: "px-0.5 m-0 [&_svg]:size-2",
},
},
-20
View File
@@ -1,20 +0,0 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(({ className, type, ...props }, ref) => {
return (
<input
className={cn(
"flex w-full rounded-sm border border-input-foreground/20 bg-input-background px-3 py-2 text-base text-input-foreground shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-input-placeholder focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-input-border disabled:cursor-not-allowed disabled:opacity-50 md:text-sm text-pretty text-ellipsis",
className,
)}
ref={ref}
type={type}
{...props}
/>
)
})
Input.displayName = "Input"
export { Input }
-137
View File
@@ -1,137 +0,0 @@
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import type * as React from "react"
import { Separator } from "@/components/ui/separator"
import { cn } from "@/lib/utils"
function ItemGroup({ className, ...props }: React.ComponentProps<"div">) {
return <div className={cn("group/item-group flex flex-col", className)} data-slot="item-group" role="list" {...props} />
}
function ItemSeparator({ className, ...props }: React.ComponentProps<typeof Separator>) {
return <Separator className={cn("my-0", className)} data-slot="item-separator" orientation="horizontal" {...props} />
}
const itemVariants = cva(
"group/item [a]:hover:bg-accent/50 focus-visible:border-ring focus-visible:ring-ring/50 [a]:transition-colors flex flex-wrap items-center rounded-sm border border-transparent text-sm outline-none transition-colors duration-100 focus-visible:ring-[3px]",
{
variants: {
variant: {
default: "bg-transparent",
outline: "border-input-foreground/30",
select: "bg-input-background/50 hover:bg-input-background/70 border border-input-foreground/10",
muted: "bg-muted/50",
},
size: {
default: "gap-4 p-4 ",
sm: "gap-2.5 px-4 py-3",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
)
function Item({
className,
variant = "default",
size = "default",
asChild = false,
...props
}: React.ComponentProps<"div"> & VariantProps<typeof itemVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "div"
return (
<Comp
className={cn(itemVariants({ variant, size, className }))}
data-size={size}
data-slot="item"
data-variant={variant}
{...props}
/>
)
}
const itemMediaVariants = cva(
"flex shrink-0 items-center justify-center gap-2 group-has-[[data-slot=item-description]]/item:translate-y-0.5 group-has-[[data-slot=item-description]]/item:self-start [&_svg]:pointer-events-none",
{
variants: {
variant: {
default: "bg-transparent",
icon: "bg-transparent size-8 rounded-sm [&_svg:not([class*='size-'])]:size-4",
image: "size-10 overflow-hidden rounded-sm [&_img]:size-full [&_img]:object-cover",
},
},
defaultVariants: {
variant: "default",
},
},
)
function ItemMedia({
className,
variant = "default",
selected = false,
...props
}: React.ComponentProps<"div"> & VariantProps<typeof itemMediaVariants> & { selected?: boolean }) {
return (
<div className={cn(itemMediaVariants({ variant, className }))} data-slot="item-media" data-variant={variant} {...props} />
)
}
function ItemContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
className={cn("w-full flex flex-1 flex-col gap-1 [&+[data-slot=item-content]]:flex-none", className)}
data-slot="item-content"
{...props}
/>
)
}
function ItemTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
className={cn("w-full flex items-center gap-2 text-sm font-medium leading-snug", className)}
data-slot="item-title"
{...props}
/>
)
}
function ItemDescription({ className, ...props }: React.ComponentProps<"p">) {
return (
<p
className={cn(
"w-full text-muted-foreground line-clamp-2 text-pretty text-sm font-normal leading-normal p-0 m-0",
"[&>a:hover]:text-foreground [&>a]:underline [&>a]:underline-offset-4",
className,
)}
data-slot="item-description"
{...props}
/>
)
}
function ItemActions({ className, ...props }: React.ComponentProps<"div">) {
return <div className={cn("flex items-center gap-2", className)} data-slot="item-actions" {...props} />
}
function ItemHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
className={cn("w-full flex basis-full items-center justify-between gap-2", className)}
data-slot="item-header"
{...props}
/>
)
}
function ItemFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div className={cn("flex basis-full items-center justify-between gap-2", className)} data-slot="item-footer" {...props} />
)
}
export { Item, ItemMedia, ItemContent, ItemActions, ItemGroup, ItemSeparator, ItemTitle, ItemDescription, ItemHeader, ItemFooter }
@@ -1,26 +0,0 @@
import * as SeparatorPrimitive from "@radix-ui/react-separator"
import * as React from "react"
import { cn } from "@/lib/utils"
function Separator({
className,
orientation = "horizontal",
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
className={cn(
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
className,
)}
data-slot="separator"
decorative={decorative}
orientation={orientation}
{...props}
/>
)
}
export { Separator }
@@ -80,7 +80,6 @@ export interface ExtensionStateContextType extends ExtensionState {
setMcpMarketplaceCatalog: (value: McpMarketplaceCatalog) => void
setTotalTasksSize: (value: number | null) => void
setExpandTaskHeader: (value: boolean) => void
setShowWelcome: (value: boolean) => void
// Refresh functions
refreshOpenRouterModels: () => void
@@ -317,12 +316,8 @@ export const ExtensionStateContextProvider: React.FC<{
: prevState.autoApprovalSettings,
}
// Update welcome screen state based on API configuration if welcome view not in progress
if (!newState.welcomeViewCompleted && !showWelcome) {
setShowWelcome(true)
} else if (newState.welcomeViewCompleted) {
setShowWelcome(false)
}
// Update welcome screen state based on API configuration
setShowWelcome(!newState.welcomeViewCompleted)
setDidHydrateState(true)
console.log("[DEBUG] returning new state in ESC")
@@ -682,7 +677,6 @@ export const ExtensionStateContextProvider: React.FC<{
hideAnnouncement,
setShowAnnouncement,
hideChatModelSelector,
setShowWelcome,
setShowChatModelSelector,
setShouldShowAnnouncement: (value) =>
setState((prevState) => ({
+2 -2
View File
@@ -26,7 +26,7 @@
--color-button-secondary-background: var(--vscode-button-secondaryBackground);
--color-button-secondary-background-hover: var(--vscode-button-secondaryHoverBackground);
--color-button-secondary-foreground: var(--vscode-button-secondaryForeground);
--color-muted: color-mix(in srgb, var(--vscode-toolbar-hoverBackground) 65%, transparent);
--color-muted: var(--vscode-editor-foldBackground);
--color-muted-foreground: var(--vscode-editor-foldPlaceholderForeground);
--color-menu: var(--vscode-menu-background);
--color-menu-foreground: var(--vscode-menu-foreground);
@@ -74,7 +74,7 @@
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: color-mix(in srgb, var(--vscode-toolbar-hoverBackground) 65%, transparent);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);