mirror of
https://github.com/cline/cline.git
synced 2026-09-15 13:02:17 +08:00
Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f59ef0c43c | ||
|
|
ea9b8fe0b1 | ||
|
|
05213f2a71 | ||
|
|
d5bad1357d | ||
|
|
a4b1549dac | ||
|
|
e015ce94c0 | ||
|
|
be076cf407 | ||
|
|
580db36476 | ||
|
|
8f8b98bb58 | ||
|
|
36022438cb | ||
|
|
c7afb61e28 |
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Remove orphaned tool_results after truncation
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Fix: render model description in markdown
|
||||
+19
-19
@@ -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')
|
||||
|
||||
@@ -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%" />
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"',
|
||||
])
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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[]> {
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 +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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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" })
|
||||
|
||||
@@ -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
@@ -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")
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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())
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Generated
-24
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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 }
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user