Compare commits

...
13 Commits
Author SHA1 Message Date
Igor Tceglevskii d1f26011aa local mcp config file 2025-11-13 19:25:49 -08:00
Igor Tceglevskii efb941a45f workspaceFoldef parameter for MCP servers 2025-11-08 08:42:07 -08:00
Ara 76410b42b0 Removing deprecated Fireworks Models (#7268) 2025-11-07 14:34:29 -08:00
Tomás Barreiro e4e347a8a4 Add Remote Server MCPs to the remote config (#7357) 2025-11-07 23:33:07 +01:00
canvrno 59e7c2b7b9 Fix CLI quick auth issue in docker envs (#7256) 2025-11-07 14:26:39 -08:00
Ara 5371377b21 Removing Minimax M2 from free models list (#7359) 2025-11-07 14:13:19 -08:00
canvrno 5a444dc30a Hide context window usage from env details until usage reaches elevated state (ng models only) (#7345) 2025-11-07 13:46:36 -08:00
Bee 34c48264c0 fix(models): ensure thinking config has default maxBudget value (#7319)
* fix(models): ensure thinking config has default maxBudget value

- Add ANTHROPIC_MAX_THINKING_BUDGET import and use as fallback
- Check for both "include_reasoning" and "reasoning" parameters
- Set default maxBudget when thinking is supported but value not provided
- Ensures thinking models always have valid budget configuration

This prevents issues when OpenRouter API doesn't return thinking_config.maxBudget, ensuring all thinking-enabled models have a proper budget value set as it's used to determine if a model support thinking in some part of our code.

* update thinkingConfig placeholder
2025-11-07 12:52:14 -08:00
Tomás Barreiro daf14ef181 Add LiteLLM to the remote config (#7307)
* Add Google Vertex and LiteLLM to the remote config

* Add the LiteLLM models and export types
2025-11-07 20:24:27 +01:00
Bee f1bf9b3f90 feat: new onboarding flow [ENG-1128] (#7088)
* feat(auth): new onboarding UI

- Add optional `strict` parameter to `createAuthRequest()` to prevent opening new auth windows when already authenticated
- Update onboarding flow with new UI text and button labels ("Login to Cline", "I have my own key", "Ready")
- Add new onboarding data models and step configuration for improved user experience
- Update all e2e tests to reflect new button labels and authentication flow
- Remove obsolete `closeBanners` utility function
- Refactor authentication logic to support strict mode for better control over auth window behavior

This change improves the authentication UX by preventing duplicate auth windows and provides a more streamlined onboarding experience with clearer call-to-action buttons.

* Debug buttons

* Update model list

* Add search box

* improve model selection with names and improved search

- Add name field to ModelInfo interface for better model identification
- Populate model name from OpenRouter API response
- Improve model search filtering to exclude embedding models
- Add case-insensitive search for better UX
- Enhance UI with badges for model capabilities and pricing
- Update styling for better visual hierarchy and selected state
- Display model names in search results for clarity

This improves the onboarding experience by making model selection more informative and user-friendly with better search capabilities and visual feedback.

* clean up

* async onboarding

* Update langauge

* spacing

* search result info text

* update e2e test

* update badge

* style(onboarding): adjust max-width constraint placement

Move max-width constraint from parent container to content wrapper div to improve responsive layout behavior. This ensures the full width is utilized at the top level while constraining only the scrollable content area.

Changes:
- Added w-full to root container for proper width handling
- Removed max-w-lg from middle container
- Applied max-w-lg to content wrapper instead

* Update search box placeholder text

* remove unused description field

* clean up

* Fix search box reset state

* feat: add loading screen to onboarding flow

- Add loading state (step 2) during authentication process
- Simplify welcomeViewCompleted logic by removing auth service check
- Move welcomeViewCompleted state update to auth success handler
- Display loading spinner during sign-in to improve UX
- Ensure auth status update occurs in finally block for reliability

The loading screen provides visual feedback while authentication completes, preventing users from seeing incomplete UI states during the sign-in process.

* update AuthServiceMock

* capture onboarding events

* smaller badge radius

* radius-xs = 4px & add speed label

* update to MiniMax M2
2025-11-07 10:30:50 -08:00
Bee 6e20047c3e refactor(ui): simplify flexible SettingsView (#7352)
* refactor(ui): simplify SettingsView tab system

- Remove ResizeObserver-based compact mode detection and related state
- Replace dynamic CSS class constants with inline Tailwind classes using cn utility
- Remove debounce dependency and handleTabChange callback
- Simplify tab rendering logic by removing conditional compact/full mode rendering
- Clean up unused imports (debounce, useRef) and state management
- Streamline component architecture for better maintainability
- simplify ModelDescriptionMarkdown

This refactoring reduces complexity while maintaining the same visual functionality, making the code easier to understand and maintain.

* clean up
2025-11-07 10:01:14 -08:00
celestial-vault 1d4fe88bec simplify updateApiConfiguration calls (#7299)
* simplify calls to updateApiConfiguration by moving the logic around which mode to update to the RPC; change proto field name; use new calls in MoonshotProvider, and update calls in LiteLlmProvider

* remove console logs
2025-11-07 08:12:20 -08:00
Sarah Fortune 74dbbb9d6b Use staging endpoint for MCP marketplace (#7348) 2025-11-07 00:03:02 -08:00
41 changed files with 1410 additions and 320 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
Added change to hide the context window usage message from env details when using next gen models and before the usage has reached an elevated state
+6
View File
@@ -4,6 +4,7 @@ import (
"context"
"fmt"
"strings"
"time"
"github.com/cline/cli/pkg/cli/global"
"github.com/cline/cli/pkg/cli/task"
@@ -75,6 +76,11 @@ func QuickSetupFromFlags(ctx context.Context, provider, apiKey, modelID, baseURL
}
}
// WORKAROUND: Wait for debounced state persistence to complete
// Fixes `cline auth` issue when ran in docker environments
// TODO: implement better solution w/ changes in StateManager
time.Sleep(600 * time.Millisecond)
// Success message
fmt.Printf("\n✓ Successfully configured %s provider\n", GetProviderDisplayName(providerEnum))
fmt.Printf(" Model: %s\n", finalModelID)
+2 -1
View File
@@ -96,6 +96,7 @@ 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
@@ -312,7 +313,7 @@ message ApiConfiguration {
// Request for updating API configuration (new - uses separate options and secrets)
message UpdateApiConfigurationRequestNew {
Metadata metadata = 1;
ApiConfiguration api_configuration = 2;
ApiConfiguration updates = 2;
// Required field mask specifying which fields to update.
// Field paths use dot notation with camelCase field names:
+8
View File
@@ -23,6 +23,7 @@ service StateService {
rpc updateSettingsCli(UpdateSettingsRequestCli) returns (Empty);
rpc updateTaskSettings(UpdateTaskSettingsRequest) returns (Empty);
rpc updateTelemetrySetting(TelemetrySettingRequest) returns (Empty);
rpc captureOnboardingProgress(OnboardingProgressRequest) returns (Empty);
rpc setWelcomeViewCompleted(BooleanRequest) returns (Empty);
rpc updateInfoBannerVersion(Int64Request) returns (Empty);
rpc updateModelBannerVersion(Int64Request) returns (Empty);
@@ -379,3 +380,10 @@ message ProcessInfo {
optional string version = 2;
optional int64 uptime_ms = 3;
}
message OnboardingProgressRequest {
int32 step = 1;
optional string action = 2;
optional bool completed = 3;
optional string model_selected = 4;
}
+1 -1
View File
@@ -62,7 +62,7 @@ class ClineEndpoint {
environment: Environment.staging,
appBaseUrl: "https://staging-app.cline.bot",
apiBaseUrl: "https://core-api.staging.int.cline.bot",
mcpBaseUrl: "https://api.cline.bot/v1/mcp",
mcpBaseUrl: "https://core-api.staging.int.cline.bot/v1/mcp",
firebase: {
apiKey: "AIzaSyASSwkwX1kSO8vddjZkE5N19QU9cVQ0CIk",
authDomain: "cline-staging.firebaseapp.com",
+4 -4
View File
@@ -853,9 +853,9 @@ export class Controller {
const vscodeTerminalExecutionMode = this.stateManager.getGlobalStateKey("vscodeTerminalExecutionMode")
const defaultTerminalProfile = this.stateManager.getGlobalSettingsKey("defaultTerminalProfile")
const isNewUser = this.stateManager.getGlobalStateKey("isNewUser")
const welcomeViewCompleted = Boolean(
this.stateManager.getGlobalStateKey("welcomeViewCompleted") || this.authService.getInfo()?.user?.uid,
)
// 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 customPrompt = this.stateManager.getGlobalSettingsKey("customPrompt")
const mcpResponsesCollapsed = this.stateManager.getGlobalStateKey("mcpResponsesCollapsed")
const terminalOutputLineLimit = this.stateManager.getGlobalSettingsKey("terminalOutputLineLimit")
@@ -932,7 +932,7 @@ export class Controller {
vscodeTerminalExecutionMode: vscodeTerminalExecutionMode,
defaultTerminalProfile,
isNewUser,
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
welcomeViewCompleted,
mcpResponsesCollapsed,
terminalOutputLineLimit,
maxConsecutiveMistakes,
@@ -4,7 +4,12 @@ import axios from "axios"
import cloneDeep from "clone-deep"
import fs from "fs/promises"
import path from "path"
import { CLAUDE_SONNET_1M_TIERS, openRouterClaudeSonnet41mModelId, openRouterClaudeSonnet451mModelId } from "@/shared/api"
import {
ANTHROPIC_MAX_THINKING_BUDGET,
CLAUDE_SONNET_1M_TIERS,
openRouterClaudeSonnet41mModelId,
openRouterClaudeSonnet451mModelId,
} from "@/shared/api"
import type { Controller } from ".."
type OpenRouterSupportedParams =
@@ -59,7 +64,6 @@ interface OpenRouterRawModelInfo {
input_cache_read: string
input_cache_write: string
} | null
thinking_config: Record<string, unknown> | null
supports_global_endpoint: boolean | null
tiers: any[] | null
supported_parameters?: OpenRouterSupportedParams[] | null
@@ -86,8 +90,10 @@ export async function refreshOpenRouterModels(controller: Controller): Promise<R
return undefined
}
for (const rawModel of rawModels as OpenRouterRawModelInfo[]) {
const supportThinking = rawModel.supported_parameters?.some((p) => p === "include_reasoning")
const supportThinking = rawModel.supported_parameters?.some((p) => p === "include_reasoning" || p === "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,
@@ -97,7 +103,9 @@ export async function refreshOpenRouterModels(controller: Controller): Promise<R
cacheWritesPrice: parsePrice(rawModel.pricing?.input_cache_write),
cacheReadsPrice: parsePrice(rawModel.pricing?.input_cache_read),
description: rawModel.description ?? "",
thinkingConfig: (supportThinking && rawModel.thinking_config) || undefined,
// If thinking is supported, set maxBudget with a default value as a placeholder
// to ensure it has a valid thinkingConfig that lets the application know thinking is supported.
thinkingConfig: supportThinking ? { maxBudget: ANTHROPIC_MAX_THINKING_BUDGET } : undefined,
supportsGlobalEndpoint: rawModel.supports_global_endpoint ?? undefined,
tiers: rawModel.tiers ?? undefined,
}
@@ -31,6 +31,20 @@ function parseFieldMask(updateMask: string[]): {
return { options, secrets }
}
/**
* Gets the alternate mode field name (e.g., planModeX <-> actModeX)
* @param fieldName The field name to get alternate for
* @returns The alternate mode field name or null if not a mode-specific field
*/
function getAlternateModeField(fieldName: string): string | null {
if (fieldName.startsWith("planMode")) {
return fieldName.replace("planMode", "actMode")
} else if (fieldName.startsWith("actMode")) {
return fieldName.replace("actMode", "planMode")
}
return null
}
/**
* Updates API configuration using field mask
* @param controller The controller instance
@@ -39,9 +53,9 @@ function parseFieldMask(updateMask: string[]): {
*/
export async function updateApiConfiguration(controller: Controller, request: UpdateApiConfigurationRequestNew): Promise<Empty> {
try {
const { apiConfiguration, updateMask } = request
const { updates, updateMask } = request
if (!apiConfiguration) {
if (!updates) {
throw new Error("API configuration is required")
}
@@ -49,7 +63,7 @@ export async function updateApiConfiguration(controller: Controller, request: Up
throw new Error("Update mask is required and must contain at least one path")
}
const { options: protoOptions, secrets: protoSecrets } = apiConfiguration
const { options: protoOptions, secrets: protoSecrets } = updates
// Parse the field mask to determine which fields to update
const { options: maskOptionsFields, secrets: maskSecretsFields } = parseFieldMask(updateMask)
@@ -81,6 +95,10 @@ export async function updateApiConfiguration(controller: Controller, request: Up
throw new Error(`Field "${fieldName}" specified in mask but not found in options`)
}
}
// Check if mode-specific configurations should be kept separate
const separateModeConfigs = controller.stateManager.getGlobalSettingsKey("planActSeparateModelsSetting")
// Process entries that are in the mask
for (const [key, value] of Object.entries(protoOptions)) {
if (maskOptionsFields.has(key)) {
@@ -92,6 +110,20 @@ export async function updateApiConfiguration(controller: Controller, request: Up
} else {
options[key as keyof ApiHandlerOptions] = value
}
// If mode configs should be synced, also update the alternate mode field
if (!separateModeConfigs) {
const alternateField = getAlternateModeField(key)
if (alternateField) {
if (alternateField === "planModeApiProvider") {
options.planModeApiProvider = convertProtoToApiProvider(value)
} else if (alternateField === "actModeApiProvider") {
options.actModeApiProvider = convertProtoToApiProvider(value)
} else {
options[alternateField as keyof ApiHandlerOptions] = value
}
}
}
}
}
}
@@ -0,0 +1,25 @@
import { Empty } from "@shared/proto/cline/common"
import { OnboardingProgressRequest } from "@shared/proto/cline/state"
import { telemetryService } from "../../../services/telemetry"
import type { Controller } from "../index"
/**
* Captures the onboarding progress step
* @param controller The controller instance
* @param request The request containing the step number
* @returns Empty response
*/
export async function captureOnboardingProgress(_controller: Controller, request: OnboardingProgressRequest): Promise<Empty> {
try {
telemetryService.captureOnboardingProgress({
step: Number(request.step),
model: request.modelSelected,
action: request.action,
completed: !!request.completed,
})
return Empty.create({})
} catch (error) {
console.error("Failed to set welcome view completed:", error)
throw error
}
}
+20 -4
View File
@@ -67,7 +67,7 @@ import { CLINE_MCP_TOOL_IDENTIFIER } from "@shared/mcp"
import { convertClineMessageToProto } from "@shared/proto-conversions/cline-message"
import { ClineDefaultTool } from "@shared/tools"
import { ClineAskResponse } from "@shared/WebviewMessage"
import { isLocalModel, isNextGenModelFamily } from "@utils/model-utils"
import { isClaude4PlusModelFamily, isGPT5ModelFamily, isLocalModel, isNextGenModelFamily } from "@utils/model-utils"
import { arePathsEqual, getDesktopDir } from "@utils/path"
import { filterExistingFiles } from "@utils/tabFiltering"
import cloneDeep from "clone-deep"
@@ -3450,7 +3450,7 @@ export class Task {
}
}
// Add context window usage information
// Add context window usage information (conditionally for some models)
const { contextWindow } = getContextWindowInfo(this.api)
// Get the token count from the most recent API request to accurately reflect context management
@@ -3478,8 +3478,24 @@ export class Task {
const lastApiReqTotalTokens = lastApiReqMessage ? getTotalTokensFromApiReqMessage(lastApiReqMessage) : 0
const usagePercentage = Math.round((lastApiReqTotalTokens / contextWindow) * 100)
details += "\n\n# Context Window Usage"
details += `\n${lastApiReqTotalTokens.toLocaleString()} / ${(contextWindow / 1000).toLocaleString()}K tokens used (${usagePercentage}%)`
// Determine if context window info should be displayed
const currentModelId = this.api.getModel().id
const isNextGenModel = isClaude4PlusModelFamily(currentModelId) || isGPT5ModelFamily(currentModelId)
let shouldShowContextWindow = true
// For next-gen models, only show context window usage if it exceeds a certain threshold
if (isNextGenModel) {
const autoCondenseThreshold =
(this.stateManager.getGlobalSettingsKey("autoCondenseThreshold") as number | undefined) ?? 0.75
const displayThreshold = autoCondenseThreshold - 0.15
const currentUsageRatio = lastApiReqTotalTokens / contextWindow
shouldShowContextWindow = currentUsageRatio >= displayThreshold
}
if (shouldShowContextWindow) {
details += "\n\n# Context Window Usage"
details += `\n${lastApiReqTotalTokens.toLocaleString()} / ${(contextWindow / 1000).toLocaleString()}K tokens used (${usagePercentage}%)`
}
details += "\n\n# Current Mode"
const mode = this.stateManager.getGlobalSettingsKey("mode")
+7 -3
View File
@@ -3,6 +3,7 @@ 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"
@@ -229,8 +230,9 @@ export class AuthService {
})
}
async createAuthRequest(): Promise<String> {
if (this._authenticated) {
async createAuthRequest(strict = false): Promise<String> {
// In strict mode, we do not open a new auth window if already authenticated
if (strict && this._authenticated) {
this.sendAuthStatusUpdate()
return String.create({ value: "Already authenticated" })
}
@@ -279,11 +281,13 @@ export class AuthService {
this._authenticated = this._clineAuthInfo?.idToken !== undefined
telemetryService.captureAuthSucceeded(this._provider.name)
await this.sendAuthStatusUpdate()
await setWelcomeViewCompleted(this._controller, { value: true })
} catch (error) {
console.error("Error signing in with custom token:", error)
telemetryService.captureAuthFailed(this._provider.name)
throw error
} finally {
await this.sendAuthStatusUpdate()
}
}
+2
View File
@@ -1,6 +1,7 @@
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 { fetch } from "@/shared/net"
@@ -122,6 +123,7 @@ 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)
+170 -10
View File
@@ -24,6 +24,7 @@ import {
} from "@shared/mcp"
import { convertMcpServersToProtoMcpServers } from "@shared/proto-conversions/mcp/mcp-server-conversion"
import { fileExistsAtPath } from "@utils/fs"
import { getCwd } from "@utils/path"
import { secondsToMs } from "@utils/time"
import chokidar, { FSWatcher } from "chokidar"
import deepEqual from "fast-deep-equal"
@@ -45,6 +46,7 @@ export class McpHub {
private telemetryService: TelemetryService
private settingsWatcher?: FSWatcher
private projectSettingsWatcher?: FSWatcher
private fileWatchers: Map<string, FSWatcher> = new Map()
connections: McpConnection[] = []
isConnecting: boolean = false
@@ -76,6 +78,7 @@ export class McpHub {
this.clientVersion = clientVersion
this.telemetryService = telemetryService
this.watchMcpSettingsFile()
this.watchProjectMcpSettingsFile()
this.initializeMcpServers()
}
@@ -127,26 +130,82 @@ export class McpHub {
return mcpSettingsFilePath
}
/**
* Get project MCP settings file path for current workspace
* Returns undefined if no workspace is open
*/
private async getProjectMcpSettingsFilePath(): Promise<string | undefined> {
const workspacePath = await getCwd()
if (!workspacePath) {
return undefined
}
return path.join(workspacePath, ".cline", "mcp_settings.json")
}
/**
* Read project MCP settings if they exist
* Returns empty object if file doesn't exist or is invalid
*/
private async readProjectMcpSettings(): Promise<Record<string, McpServerConfig>> {
try {
const projectPath = await this.getProjectMcpSettingsFilePath()
if (!projectPath) {
return {}
}
const exists = await fileExistsAtPath(projectPath)
if (!exists) {
return {}
}
const content = await fs.readFile(projectPath, "utf-8")
const config = JSON.parse(content)
// Validate
const result = McpSettingsSchema.safeParse(config)
if (!result.success) {
console.warn("Invalid project MCP settings, using global only:", result.error)
return {}
}
return result.data.mcpServers || {}
} catch (error) {
console.warn("Failed to read project MCP settings:", error)
return {}
}
}
private async readAndValidateMcpSettingsFile(): Promise<z.infer<typeof McpSettingsSchema> | undefined> {
try {
const settingsPath = await this.getMcpSettingsFilePath()
const content = await fs.readFile(settingsPath, "utf-8")
// Read global settings
const globalSettingsPath = await this.getMcpSettingsFilePath()
const globalContent = await fs.readFile(globalSettingsPath, "utf-8")
let globalConfig: any
let config: any
// Parse JSON file content
// Parse global JSON file content
try {
config = JSON.parse(content)
globalConfig = JSON.parse(globalContent)
} catch (_error) {
HostProvider.window.showMessage({
type: ShowMessageType.ERROR,
message: "Invalid MCP settings format. Please ensure your settings follow the correct JSON format.",
message: "Invalid global MCP settings format.",
})
return undefined
}
// Validate against schema
const result = McpSettingsSchema.safeParse(config)
// Read project settings (returns {} if not found/invalid)
const projectServers = await this.readProjectMcpSettings()
// Merge: project overrides global
const mergedConfig = {
mcpServers: {
...(globalConfig.mcpServers || {}),
...projectServers,
},
}
// Validate merged config
const result = McpSettingsSchema.safeParse(mergedConfig)
if (!result.success) {
HostProvider.window.showMessage({
type: ShowMessageType.ERROR,
@@ -192,6 +251,79 @@ export class McpHub {
})
}
private async watchProjectMcpSettingsFile(): Promise<void> {
const projectPath = await this.getProjectMcpSettingsFilePath()
if (!projectPath) {
// No workspace open, skip project watcher
return
}
// Watch both the file and .cline directory (to detect file creation)
const clineDir = path.dirname(projectPath)
const watchPaths = [projectPath]
// Also watch directory if it exists
if (await fileExistsAtPath(clineDir)) {
watchPaths.push(clineDir)
}
this.projectSettingsWatcher = chokidar.watch(watchPaths, {
persistent: true,
ignoreInitial: true,
awaitWriteFinish: {
stabilityThreshold: 100,
pollInterval: 100,
},
atomic: true,
})
this.projectSettingsWatcher.on("change", async (path) => {
// Only react to changes to mcp_settings.json
if (path.endsWith("mcp_settings.json")) {
const settings = await this.readAndValidateMcpSettingsFile()
if (settings) {
try {
await this.updateServerConnections(settings.mcpServers)
} catch (error) {
console.error("Failed to process project MCP settings change:", error)
}
}
}
})
this.projectSettingsWatcher.on("add", async (path) => {
// Detect when mcp_settings.json is created
if (path.endsWith("mcp_settings.json")) {
const settings = await this.readAndValidateMcpSettingsFile()
if (settings) {
try {
await this.updateServerConnections(settings.mcpServers)
} catch (error) {
console.error("Failed to process new project MCP settings:", error)
}
}
}
})
this.projectSettingsWatcher.on("unlink", async (path) => {
// Detect when mcp_settings.json is deleted
if (path.endsWith("mcp_settings.json")) {
const settings = await this.readAndValidateMcpSettingsFile()
if (settings) {
try {
await this.updateServerConnections(settings.mcpServers)
} catch (error) {
console.error("Failed to process project MCP settings deletion:", error)
}
}
}
})
this.projectSettingsWatcher.on("error", (error) => {
console.error("Error watching project MCP settings file:", error)
})
}
private async initializeMcpServers(): Promise<void> {
const settings = await this.readAndValidateMcpSettingsFile()
if (settings) {
@@ -203,6 +335,28 @@ export class McpHub {
return this.connections.find((conn) => conn.server.name === name)
}
/**
* Resolves variables in the cwd path
* Currently supports: ${workspaceFolder}
*/
private async resolveCwd(cwd: string | undefined): Promise<string | undefined> {
if (!cwd) {
return undefined
}
// Replace ${workspaceFolder} with the actual workspace path
if (cwd.includes("${workspaceFolder}")) {
const workspacePath = await getCwd()
if (!workspacePath) {
console.warn("Cannot resolve ${workspaceFolder}: no workspace folder open")
return cwd
}
return cwd.replace(/\$\{workspaceFolder\}/g, workspacePath)
}
return cwd
}
private async connectToServer(
name: string,
config: z.infer<typeof ServerConfigSchema>,
@@ -244,10 +398,13 @@ export class McpHub {
switch (config.type) {
case "stdio": {
// Resolve cwd variables like ${workspaceFolder}
const resolvedCwd = await this.resolveCwd(config.cwd)
transport = new StdioClientTransport({
command: config.command,
args: config.args,
cwd: config.cwd,
cwd: resolvedCwd,
env: {
// ...(config.env ? await injectEnv(config.env) : {}), // Commented out as injectEnv is not found
...getDefaultEnvironment(),
@@ -1188,5 +1345,8 @@ export class McpHub {
if (this.settingsWatcher) {
await this.settingsWatcher.close()
}
if (this.projectSettingsWatcher) {
await this.projectSettingsWatcher.close()
}
}
}
@@ -93,6 +93,7 @@ export class TelemetryService {
AUTH_SUCCEEDED: "user.auth_succeeded",
AUTH_FAILED: "user.auth_failed",
AUTH_LOGGED_OUT: "user.auth_logged_out",
ONBOARDING_PROGRESS: "user.onboarding_progress",
},
DICTATION: {
// Tracks when voice recording is started
@@ -1634,6 +1635,15 @@ export class TelemetryService {
})
}
public captureOnboardingProgress(args: { step: number; action?: string; model?: string; completed?: boolean }) {
this.capture({
event: TelemetryService.EVENTS.USER.ONBOARDING_PROGRESS,
properties: {
...args,
},
})
}
/**
* Clean up resources when the service is disposed
*/
+1 -10
View File
@@ -223,6 +223,7 @@ interface PriceTier {
}
export interface ModelInfo {
name?: string
maxTokens?: number
contextWindow?: number
supportsImages?: boolean
@@ -3766,16 +3767,6 @@ export const fireworksModels = {
description:
"Kimi K2 model gets a new version update: Agentic coding: more accurate, better generalization across scaffolds. Frontend coding: improved aesthetics and functionalities on web, 3d, and other tasks. Context length: extended from 128k to 256k, providing better long-horizon support.",
},
"accounts/fireworks/models/kimi-k2-instruct": {
maxTokens: 16384,
contextWindow: 128000,
supportsImages: false,
supportsPromptCache: false,
inputPrice: 0.6,
outputPrice: 2.5,
description:
"Kimi K2 is a state-of-the-art mixture-of-experts (MoE) language model with 32 billion activated parameters and 1 trillion total parameters. Trained with the Muon optimizer, Kimi K2 achieves exceptional performance across frontier knowledge, reasoning, and coding tasks while being meticulously optimized for agentic capabilities.",
},
"accounts/fireworks/models/qwen3-235b-a22b-instruct-2507": {
maxTokens: 32768,
contextWindow: 256000,
+35 -5
View File
@@ -87,6 +87,17 @@ export const VertexSettingsSchema = z.object({
vertexRegion: z.string().optional(),
})
export const LiteLLMModelSchema = z.object({
id: z.string(),
thinkingBudgetTokens: z.number().optional(),
promptCachingEnabled: z.boolean().optional(),
})
export const LiteLLMSchema = z.object({
models: z.array(LiteLLMModelSchema).optional(),
baseUrl: z.string().optional(),
})
// Provider settings schema
// Each provider becomes an optional field
const ProviderSettingsSchema = z.object({
@@ -94,6 +105,7 @@ const ProviderSettingsSchema = z.object({
AwsBedrock: AwsBedrockSettingsSchema.optional(),
Cline: ClineSettingsSchema.optional(),
Vertex: VertexSettingsSchema.optional(),
LiteLLM: LiteLLMSchema.optional(),
})
export const AllowedMCPServerSchema = z.object({
@@ -101,6 +113,13 @@ export const AllowedMCPServerSchema = z.object({
id: z.string(),
})
export const RemoteMCPServerSchema = z.object({
// The name of the MCP server
name: z.string(),
// The URL of the MCP server
url: 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.
@@ -125,6 +144,7 @@ export const RemoteConfigSchema = z.object({
// MCP settings
mcpMarketplaceEnabled: z.boolean().optional(),
allowedMCPServers: z.array(AllowedMCPServerSchema).optional(),
remoteMCPServers: z.array(RemoteMCPServerSchema).optional(),
// If the user is allowed to enable YOLO mode. Note this is different from the extension setting
// yoloModeEnabled, because we do not want to force YOLO enabled for the user.
@@ -153,12 +173,22 @@ export const RemoteConfigSchema = z.object({
})
// Type inference from schemas
export type RemoteConfig = z.infer<typeof RemoteConfigSchema>
export type MCPServer = z.infer<typeof AllowedMCPServerSchema>
export type OpenAiCompatibleModel = z.infer<typeof OpenAiCompatibleModelSchema>
export type RemoteMCPServer = z.infer<typeof RemoteMCPServerSchema>
export type GlobalInstructionsFile = z.infer<typeof GlobalInstructionsFileSchema>
export type ProviderSettings = z.infer<typeof ProviderSettingsSchema>
export type OpenAiCompatible = z.infer<typeof OpenAiCompatibleSchema>
export type OpenAiCompatibleModel = z.infer<typeof OpenAiCompatibleModelSchema>
export type AwsBedrockSettings = z.infer<typeof AwsBedrockSettingsSchema>
export type AwsBedrockModel = z.infer<typeof AwsBedrockModelSchema>
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>
export type VertexSettings = z.infer<typeof VertexSettingsSchema>
export type VertexModel = z.infer<typeof VertexModelSchema>
export type LiteLLMSettings = z.infer<typeof LiteLLMSchema>
export type LiteLLMModel = z.infer<typeof LiteLLMModelSchema>
+7 -7
View File
@@ -5,11 +5,12 @@ 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: "Get Started for Free" })).toBeVisible()
await expect(sidebar.getByRole("button", { name: "Use your own API key" })).toBeVisible()
await expect(sidebar.getByRole("button", { name: "Login to Cline" })).toBeVisible()
await expect(sidebar.getByText("Bring my own API key")).toBeVisible()
// Navigate to API key setup
await sidebar.getByRole("button", { name: "Use your own API key" }).click()
await sidebar.getByText("Bring my own API key").click()
await sidebar.getByRole("button", { name: "Continue" }).click()
const providerSelectorInput = sidebar.getByTestId("provider-selector-input")
@@ -33,10 +34,9 @@ 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 })
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()
await sidebar.getByRole("button", { name: "Continue" }).click()
await expect(sidebar.getByRole("button", { name: "Login to Cline" })).not.toBeVisible()
// Verify start up page is no longer visible
await expect(apiKeyInput).not.toBeVisible()
+1 -1
View File
@@ -7,7 +7,7 @@ e2e.describe("Diff Editor", () => {
e2e.extend({
workspaceType,
})(title, async ({ page, sidebar }) => {
await sidebar.getByRole("button", { name: "Get Started for Free" }).click({ delay: 100 })
await sidebar.getByRole("button", { name: "Login to Cline" }).click({ delay: 100 })
// Submit a message
await cleanChatView(page)
+1 -1
View File
@@ -7,7 +7,7 @@ e2e.describe("Code Actions and Editor Panel", () => {
e2e.extend({
workspaceType,
})(title, async ({ page, sidebar }) => {
await sidebar.getByRole("button", { name: "Get Started for Free" }).click({ delay: 100 })
await sidebar.getByRole("button", { name: "Login to Cline" }).click({ delay: 100 })
// Sidebar - input should start empty
const sidebarInput = sidebar.getByTestId("chat-input")
await sidebarInput.click()
+1 -9
View File
@@ -35,16 +35,8 @@ 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: "Get Started for Free" })
const signUpBtn = sidebar.getByRole("button", { name: "Login to Cline" })
if (await signUpBtn.isVisible()) {
await signUpBtn.click({ delay: 50 })
}
+2 -15
View File
@@ -118,23 +118,10 @@ export class E2ETestHelper {
}
public async signin(webview: Frame): Promise<void> {
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()
await webview.getByRole("button", { name: "Login to Cline" }).click({ delay: 100 })
// Verify start up page is no longer visible
await expect(webview.locator("#api-provider div").first()).not.toBeVisible()
await expect(byokButton).not.toBeVisible()
await expect(webview.getByRole("button", { name: "Login to Cline" })).not.toBeVisible()
}
public static async openClineSidebar(page: Page): Promise<void> {
+24
View File
@@ -15,6 +15,7 @@
"@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",
@@ -4090,6 +4091,29 @@
}
}
},
"node_modules/@radix-ui/react-separator": {
"version": "1.1.7",
"resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.7.tgz",
"integrity": "sha512-0HEb8R9E8A+jZjvmFCy/J4xhbXy3TV+9XSnGJ3KvTtjlIUy/YQ/p6UYZvi7YbeoeXdyU9+Y3scizK6hkY37baA==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-primitive": "2.1.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-slot": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
+1
View File
@@ -23,6 +23,7 @@
"@radix-ui/react-hover-card": "^1.1.15",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-progress": "^1.1.7",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-tooltip": "^1.2.8",
"@vscode/webview-ui-toolkit": "^1.4.0",
+2 -2
View File
@@ -4,8 +4,8 @@ import AccountView from "./components/account/AccountView"
import ChatView from "./components/chat/ChatView"
import HistoryView from "./components/history/HistoryView"
import McpView from "./components/mcp/configuration/McpConfigurationView"
import OnboardingView from "./components/onboarding/OnboardingView"
import SettingsView from "./components/settings/SettingsView"
import WelcomeView from "./components/welcome/WelcomeView"
import { useClineAuth } from "./context/ClineAuthContext"
import { useExtensionState } from "./context/ExtensionStateContext"
import { Providers } from "./Providers"
@@ -54,7 +54,7 @@ const AppContent = () => {
}
if (showWelcome) {
return <WelcomeView />
return <OnboardingView />
}
return (
@@ -0,0 +1,375 @@
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,
getSpeedLabel,
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">{getSpeedLabel(model.latency)}</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 onUserTypeClick = useCallback((userType: NEW_USER_TYPE) => {
setUserType(userType)
const action =
userType === NEW_USER_TYPE.POWER
? "power_user_selected"
: userType === NEW_USER_TYPE.FREE
? "free_user_selected"
: "byok_user_selected"
// User selection is available in step 0 only
StateServiceClient.captureOnboardingProgress({ step: 0, action })
}, [])
const onModelClick = useCallback((modelSelected: string) => {
setSelectedModelId(modelSelected)
// User selection is available in step 1 only
StateServiceClient.captureOnboardingProgress({ step: 1, modelSelected, action: "model_selected" })
}, [])
const finishOnboarding = useCallback(
async (updateModelId: boolean, step: number) => {
const modelSelected = (updateModelId && selectedModelId) || undefined
if (modelSelected) {
await handleFieldsChange({
planModeOpenRouterModelId: selectedModelId,
actModeOpenRouterModelId: selectedModelId,
planModeOpenRouterModelInfo: openRouterModels[selectedModelId],
actModeOpenRouterModelInfo: openRouterModels[selectedModelId],
planModeApiProvider: "cline",
actModeApiProvider: "cline",
})
}
hideAccount()
hideSettings()
const action = "onboarding_completed"
StateServiceClient.captureOnboardingProgress({ step, modelSelected, action, completed: true })
},
[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, stepNumber + 1)
break
case "signin":
await AccountServiceClient.accountLoginClicked({}).catch(() => {})
await finishOnboarding(true, stepNumber + 1)
break
case "next":
StateServiceClient.captureOnboardingProgress({ step: stepNumber + 1 })
setStepNumber(stepNumber + 1)
break
case "back":
StateServiceClient.captureOnboardingProgress({ step: stepNumber - 1 })
setStepNumber(stepNumber - 1)
break
case "done":
await StateServiceClient.setWelcomeViewCompleted({ value: true }).catch(() => {})
setShowWelcome(false)
await finishOnboarding(false, stepNumber)
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={onModelClick}
onSelectUserType={onUserTypeClick}
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
@@ -0,0 +1,159 @@
import type { ModelInfo } 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
latency?: number
}
type ModelGroup = {
group: string
models: OnboardingModelOption[]
}
export const ONBOARDING_MODEL_SELECTIONS: Record<"free" | "power", ModelGroup[]> = {
[NEW_USER_TYPE.FREE]: [
{
group: "free",
models: [
{
id: "x-ai/grok-code-fast-1",
name: "xAI: Grok Code Fast 1",
score: 90,
latency: 1,
badge: "Best",
contextWindow: 256_000,
supportsImages: true,
supportsPromptCache: true,
inputPrice: 0,
outputPrice: 0,
},
],
},
],
[NEW_USER_TYPE.POWER]: [
{
group: "frontier",
models: [
{
id: "anthropic/claude-sonnet-4.5",
name: "Anthropic: Claude Sonnet 4.5",
badge: "Best",
score: 97,
latency: 3,
contextWindow: 200_000,
supportsImages: true,
supportsPromptCache: true,
inputPrice: 3.0,
outputPrice: 15.0,
},
{
id: "openai/gpt-5-codex",
name: "OpenAI: GPT-5 Codex",
badge: "Best",
score: 97,
latency: 7,
contextWindow: 400_000,
supportsImages: true,
supportsPromptCache: true,
inputPrice: 1.25,
outputPrice: 10.0,
},
],
},
{
group: "open source",
models: [
{
id: "z-ai/glm-4.6:exacto",
name: "Z.AI: GLM 4.6 (exacto)",
badge: "Trending",
score: 90,
latency: 2,
contextWindow: 202_752,
supportsImages: false,
supportsPromptCache: false,
inputPrice: 0.6,
outputPrice: 2.5,
},
{
id: "moonshotai/kimi-dev-72b:free",
name: "MoonshotAI: Kimi Dev 72B (free)",
badge: "Free",
score: 90,
latency: 1,
contextWindow: 131_072,
supportsImages: false,
supportsPromptCache: false,
inputPrice: 0,
outputPrice: 0,
},
],
},
],
}
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)
}
export function getSpeedLabel(latency?: number): string {
if (!latency) {
return "Average"
}
if (latency < 1) {
return "Instant"
}
if (latency < 2) {
return "Fast"
}
if (latency > 5) {
return "Slow"
}
return "Average"
}
@@ -0,0 +1,54 @@
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,4 +1,4 @@
import { memo, useEffect } from "react"
import { memo, useEffect, useRef, useState } from "react"
import { useRemark } from "react-remark"
import { Button } from "@/components/ui/button"
import { cn } from "@/lib/utils"
@@ -6,51 +6,57 @@ 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 }: ModelDescriptionMarkdownProps) => {
// Update the markdown content when the prop changes
const [reactContent, setMarkdown] = useRemark()
export const ModelDescriptionMarkdown = memo(({ markdown, key, isPopup }: ModelDescriptionMarkdownProps) => {
// Update the markdown content when the prop changes
const [reactContent, setMarkdown] = useRemark()
const contentRef = useRef<HTMLDivElement>(null)
const [isTruncated, setIsTruncated] = useState(false)
const [isExpanded, setIsExpanded] = useState(false)
useEffect(() => {
if (markdown) {
setMarkdown(markdown)
}
}, [markdown, setMarkdown])
useEffect(() => {
if (markdown) {
setIsExpanded(false)
setMarkdown(markdown)
}
}, [markdown, setMarkdown])
return (
<div className="inline-block mb-0 description line-clamp-3" key={key}>
useEffect(() => {
if (contentRef.current && !isExpanded) {
const element = contentRef.current
// Check if content is truncated by comparing scrollHeight with clientHeight
setIsTruncated(element.scrollHeight > element.clientHeight)
}
}, [reactContent, isExpanded])
return (
<div className="inline-block mb-2 description line-clamp-3" key={key}>
<div className="relative wrap-anywhere overflow-y-hidden">
<div
className={cn("relative wrap-anywhere overflow-y-hidden", {
"overflow-y-auto": isExpanded,
})}>
<div
className={cn("overflow-hidden text-sm line-clamp-3", {
"line-clamp-none": isExpanded,
"h-20": !isExpanded,
})}>
{reactContent}
</div>
{!isExpanded && (
<div className="absolute bottom-0 right-0 flex items-center">
<div className="w-10 h-5 bg-linear-to-r from-transparent to-sidebar-background" />
<Button
className={cn("bg-sidebar-background p-0 m-0 text-sm", {
"bg-code-block-background": isPopup,
})}
onClick={() => setIsExpanded(true)}
variant="link">
See more
</Button>
</div>
)}
className={cn("overflow-hidden text-sm line-clamp-3", {
"line-clamp-none": isExpanded,
"max-h-19": !isExpanded,
})}
ref={contentRef}>
{reactContent}
</div>
{isTruncated && (
<div className="absolute bottom-0 right-0 flex items-center">
<div className="w-10 h-5 bg-linear-to-r from-transparent to-sidebar-background" />
<Button
className={cn("bg-sidebar-background p-0 m-0 text-sm", {
"bg-code-block-background": isPopup,
})}
onClick={() => setIsExpanded(!isExpanded)}
variant="link">
{isExpanded ? "See less" : "See more"}
</Button>
</div>
)}
</div>
)
},
)
</div>
)
})
ModelDescriptionMarkdown.displayName = "ModelDescriptionMarkdown"
@@ -1,21 +1,21 @@
import { ExtensionMessage } from "@shared/ExtensionMessage"
import type { ExtensionMessage } from "@shared/ExtensionMessage"
import { ResetStateRequest } from "@shared/proto/cline/state"
import { VSCodeButton } from "@vscode/webview-ui-toolkit/react"
import debounce from "debounce"
import {
CheckCheck,
FlaskConical,
Info,
LucideIcon,
type LucideIcon,
SlidersHorizontal,
SquareMousePointer,
SquareTerminal,
Wrench,
} from "lucide-react"
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import { useCallback, useEffect, useMemo, useState } from "react"
import { useEvent } from "react-use"
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
import { useExtensionState } from "@/context/ExtensionStateContext"
import { cn } from "@/lib/utils"
import { StateServiceClient } from "@/services/grpc-client"
import { getEnvironmentColor } from "@/utils/environmentColors"
import { Tab, TabContent, TabHeader, TabList, TabTrigger } from "../common/Tab"
@@ -30,15 +30,6 @@ import TerminalSettingsSection from "./sections/TerminalSettingsSection"
const IS_DEV = process.env.IS_DEV
// Styles for the tab system
const settingsTabsContainer = "flex flex-1 overflow-hidden [&.narrow_.tab-label]:hidden"
const settingsTabList =
"w-48 data-[compact=true]:w-12 shrink-0 flex flex-col overflow-y-auto overflow-x-hidden border-r border-(--vscode-sideBar-background)"
const settingsTabTrigger =
"whitespace-nowrap overflow-hidden min-w-0 h-12 px-4 py-3 box-border flex items-center border-l-2 border-transparent text-(--vscode-foreground) opacity-70 bg-transparent hover:bg-(--vscode-list-hoverBackground) data-[compact=true]:w-12 data-[compact=true]:p-4 cursor-pointer"
const settingsTabTriggerActive =
"opacity-100 border-l-2 border-l-(--vscode-focusBorder) border-t-0 border-r-0 border-b-0 bg-(--vscode-list-activeSelectionBackground)"
// Tab definitions
interface SettingsTab {
id: string
@@ -78,15 +69,6 @@ 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",
@@ -101,6 +83,15 @@ 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 = {
@@ -142,12 +133,7 @@ const SettingsView = ({ onDone, targetSection }: SettingsViewProps) => {
const { version, environment } = useExtensionState()
// Initialize active tab with memoized calculation
const initialTab = useMemo(() => targetSection || SETTINGS_TABS[0].id, [targetSection])
const [activeTab, setActiveTab] = useState<string>(initialTab)
const [isCompactMode, setIsCompactMode] = useState(true)
const containerRef = useRef<HTMLDivElement>(null)
const [activeTab, setActiveTab] = useState<string>(targetSection || SETTINGS_TABS[0].id)
// Optimized message handler with early returns
const handleMessage = useCallback((event: MessageEvent) => {
@@ -207,78 +193,31 @@ const SettingsView = ({ onDone, targetSection }: SettingsViewProps) => {
}
}, [targetSection])
// Simplified tab change handler without debugging
const handleTabChange = useCallback((tabId: string) => {
setActiveTab(tabId)
}, [])
// Optimized resize observer with debouncing
useEffect(() => {
const container = containerRef.current
if (!container) {
return
}
const checkCompactMode = debounce((width: number) => {
setIsCompactMode(width < 500)
}, 100)
const observer = new ResizeObserver((entries) => {
const entry = entries[0]
if (entry) {
checkCompactMode(entry.contentRect.width)
}
})
observer.observe(container)
return () => observer.disconnect()
}, [])
// Memoized tab item renderer
const renderTabItem = useCallback(
(tab: (typeof SETTINGS_TABS)[0]) => {
const isActive = activeTab === tab.id
const tabClassName = `${isActive ? `${settingsTabTrigger} ${settingsTabTriggerActive}` : settingsTabTrigger} focus:ring-0`
const iconContainerClassName = `flex items-center gap-2 ${isCompactMode ? "justify-center" : ""}`
const TabIcon = tab.icon
const tabContent = (
<div className={iconContainerClassName}>
<TabIcon className="w-4 h-4" />
<span className="tab-label">{tab.name}</span>
</div>
)
if (isCompactMode) {
return (
return (
<TabTrigger className="flex justify-baseline" data-testid={`tab-${tab.id}`} key={tab.id} value={tab.id}>
<Tooltip key={tab.id}>
<TooltipTrigger>
<div
className={tabClassName}
data-compact={isCompactMode}
data-testid={`tab-${tab.id}`}
data-value={tab.id}
onClick={() => handleTabChange(tab.id)}>
{tabContent}
className={cn(
"whitespace-nowrap overflow-hidden h-12 sm:py-3 box-border flex items-center border-l-2 border-transparent text-foreground opacity-70 bg-transparent hover:bg-list-hover p-4 cursor-pointer gap-2",
{
"opacity-100 border-l-2 border-l-foreground border-t-0 border-r-0 border-b-0 bg-selection":
activeTab === tab.id,
},
)}>
<tab.icon className="w-4 h-4" />
<span className="hidden sm:block">{tab.name}</span>
</div>
</TooltipTrigger>
<TooltipContent side="right">{tab.tooltipText}</TooltipContent>
</Tooltip>
)
}
return (
<TabTrigger
className={tabClassName}
data-compact={isCompactMode}
data-testid={`tab-${tab.id}`}
key={tab.id}
value={tab.id}>
{tabContent}
</TabTrigger>
)
},
[activeTab, isCompactMode, handleTabChange],
[activeTab],
)
// Memoized active content component
@@ -314,11 +253,10 @@ const SettingsView = ({ onDone, targetSection }: SettingsViewProps) => {
</div>
</TabHeader>
<div className={`${settingsTabsContainer} ${isCompactMode ? "narrow" : ""}`} ref={containerRef}>
<div className="flex flex-1 overflow-hidden">
<TabList
className={settingsTabList}
data-compact={isCompactMode}
onValueChange={handleTabChange}
className="shrink-0 flex flex-col overflow-y-auto border-r border-sidebar-background"
onValueChange={setActiveTab}
value={activeTab}>
{SETTINGS_TABS.filter((tab) => !tab.hidden).map(renderTabItem)}
</TabList>
@@ -1,5 +1,5 @@
import { geminiModels, ModelInfo } from "@shared/api"
import { Fragment, useState } from "react"
import { Fragment } from "react"
import { ModelDescriptionMarkdown } from "../ModelDescriptionMarkdown"
import {
formatPrice,
@@ -97,9 +97,6 @@ interface ModelInfoViewProps {
* This component manages its own description expansion state
*/
export const ModelInfoView = ({ selectedModelId, modelInfo, isPopup }: ModelInfoViewProps) => {
// Internal state management for description expansion
const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false)
const isGemini = Object.keys(geminiModels).includes(selectedModelId)
const hasThinkingConfig = hasThinkingBudget(modelInfo)
const hasTiers = !!modelInfo.tiers && modelInfo.tiers.length > 0
@@ -151,13 +148,7 @@ export const ModelInfoView = ({ selectedModelId, modelInfo, isPopup }: ModelInfo
const infoItems = [
modelInfo.description && (
<ModelDescriptionMarkdown
isExpanded={isDescriptionExpanded}
isPopup={isPopup}
key="description"
markdown={modelInfo.description}
setIsExpanded={setIsDescriptionExpanded}
/>
<ModelDescriptionMarkdown isPopup={isPopup} key="description" markdown={modelInfo.description} />
),
<ModelInfoSupportsItem
doesNotSupportLabel="Does not support images"
@@ -42,7 +42,7 @@ export const LiteLlmProvider = ({ showModelOptions, isPopup, currentMode }: Lite
onChange={async (value) => {
await ModelsServiceClient.updateApiConfiguration(
UpdateApiConfigurationRequestNew.create({
apiConfiguration: {
updates: {
options: {
liteLlmBaseUrl: value,
},
@@ -61,7 +61,7 @@ export const LiteLlmProvider = ({ showModelOptions, isPopup, currentMode }: Lite
onChange={async (value) => {
await ModelsServiceClient.updateApiConfiguration(
UpdateApiConfigurationRequestNew.create({
apiConfiguration: {
updates: {
secrets: {
liteLlmApiKey: value,
},
@@ -79,14 +79,17 @@ export const LiteLlmProvider = ({ showModelOptions, isPopup, currentMode }: Lite
initialValue={liteLlmModelId || ""}
onChange={async (value) => {
await ModelsServiceClient.updateApiConfiguration(
UpdateApiConfigurationRequestNew.create({
apiConfiguration: {
options:
currentMode === "plan" ? { planModeLiteLlmModelId: value } : { actModeLiteLlmModelId: value },
},
updateMask:
currentMode === "plan" ? ["options.planModeLiteLlmModelId"] : ["options.actModeLiteLlmModelId"],
}),
UpdateApiConfigurationRequestNew.create(
currentMode === "plan"
? {
updates: { options: { planModeLiteLlmModelId: value } },
updateMask: ["options.planModeLiteLlmModelId"],
}
: {
updates: { options: { actModeLiteLlmModelId: value } },
updateMask: ["options.actModeLiteLlmModelId"],
},
),
)
}}
placeholder={"e.g. anthropic/claude-sonnet-4-20250514"}
@@ -104,7 +107,7 @@ export const LiteLlmProvider = ({ showModelOptions, isPopup, currentMode }: Lite
await ModelsServiceClient.updateApiConfiguration(
UpdateApiConfigurationRequestNew.create({
apiConfiguration: {
updates: {
options: {
liteLlmUsePromptCache: isChecked,
},
@@ -170,18 +173,17 @@ export const LiteLlmProvider = ({ showModelOptions, isPopup, currentMode }: Lite
modelInfo.supportsImages = isChecked
await ModelsServiceClient.updateApiConfiguration(
UpdateApiConfigurationRequestNew.create({
apiConfiguration: {
options:
currentMode === "plan"
? { planModeLiteLlmModelInfo: modelInfo }
: { actModeLiteLlmModelInfo: modelInfo },
},
updateMask:
currentMode === "plan"
? ["options.planModeLiteLlmModelInfo"]
: ["options.actModeLiteLlmModelInfo"],
}),
UpdateApiConfigurationRequestNew.create(
currentMode === "plan"
? {
updates: { options: { planModeLiteLlmModelInfo: modelInfo } },
updateMask: ["options.planModeLiteLlmModelInfo"],
}
: {
updates: { options: { actModeLiteLlmModelInfo: modelInfo } },
updateMask: ["options.actModeLiteLlmModelInfo"],
},
),
)
}}>
Supports Images
@@ -198,18 +200,17 @@ export const LiteLlmProvider = ({ showModelOptions, isPopup, currentMode }: Lite
modelInfo.contextWindow = Number(value)
await ModelsServiceClient.updateApiConfiguration(
UpdateApiConfigurationRequestNew.create({
apiConfiguration: {
options:
currentMode === "plan"
? { planModeLiteLlmModelInfo: modelInfo }
: { actModeLiteLlmModelInfo: modelInfo },
},
updateMask:
currentMode === "plan"
? ["options.planModeLiteLlmModelInfo"]
: ["options.actModeLiteLlmModelInfo"],
}),
UpdateApiConfigurationRequestNew.create(
currentMode === "plan"
? {
updates: { options: { planModeLiteLlmModelInfo: modelInfo } },
updateMask: ["options.planModeLiteLlmModelInfo"],
}
: {
updates: { options: { actModeLiteLlmModelInfo: modelInfo } },
updateMask: ["options.actModeLiteLlmModelInfo"],
},
),
)
}}
style={{ flex: 1 }}>
@@ -226,18 +227,17 @@ export const LiteLlmProvider = ({ showModelOptions, isPopup, currentMode }: Lite
modelInfo.maxTokens = Number(value)
await ModelsServiceClient.updateApiConfiguration(
UpdateApiConfigurationRequestNew.create({
apiConfiguration: {
options:
currentMode === "plan"
? { planModeLiteLlmModelInfo: modelInfo }
: { actModeLiteLlmModelInfo: modelInfo },
},
updateMask:
currentMode === "plan"
? ["options.planModeLiteLlmModelInfo"]
: ["options.actModeLiteLlmModelInfo"],
}),
UpdateApiConfigurationRequestNew.create(
currentMode === "plan"
? {
updates: { options: { planModeLiteLlmModelInfo: modelInfo } },
updateMask: ["options.planModeLiteLlmModelInfo"],
}
: {
updates: { options: { actModeLiteLlmModelInfo: modelInfo } },
updateMask: ["options.actModeLiteLlmModelInfo"],
},
),
)
}}
style={{ flex: 1 }}>
@@ -261,18 +261,17 @@ export const LiteLlmProvider = ({ showModelOptions, isPopup, currentMode }: Lite
value === "" ? liteLlmModelInfoSaneDefaults.temperature : parseFloat(value)
await ModelsServiceClient.updateApiConfiguration(
UpdateApiConfigurationRequestNew.create({
apiConfiguration: {
options:
currentMode === "plan"
? { planModeLiteLlmModelInfo: modelInfo }
: { actModeLiteLlmModelInfo: modelInfo },
},
updateMask:
currentMode === "plan"
? ["options.planModeLiteLlmModelInfo"]
: ["options.actModeLiteLlmModelInfo"],
}),
UpdateApiConfigurationRequestNew.create(
currentMode === "plan"
? {
updates: { options: { planModeLiteLlmModelInfo: modelInfo } },
updateMask: ["options.planModeLiteLlmModelInfo"],
}
: {
updates: { options: { actModeLiteLlmModelInfo: modelInfo } },
updateMask: ["options.actModeLiteLlmModelInfo"],
},
),
)
}}>
<span style={{ fontWeight: 500 }}>Temperature</span>
@@ -1,12 +1,13 @@
import { moonshotModels } from "@shared/api"
import { UpdateApiConfigurationRequestNew } from "@shared/proto/index.cline"
import { Mode } from "@shared/storage/types"
import { VSCodeDropdown, VSCodeOption } from "@vscode/webview-ui-toolkit/react"
import { useExtensionState } from "@/context/ExtensionStateContext"
import { ModelsServiceClient } from "@/services/grpc-client"
import { ApiKeyField } from "../common/ApiKeyField"
import { ModelInfoView } from "../common/ModelInfoView"
import { DropdownContainer, ModelSelector } from "../common/ModelSelector"
import { normalizeApiConfiguration } from "../utils/providerUtils"
import { useApiConfigurationHandlers } from "../utils/useApiConfigurationHandlers"
/**
* Props for the MoonshotProvider component
@@ -22,7 +23,6 @@ interface MoonshotProviderProps {
*/
export const MoonshotProvider = ({ showModelOptions, isPopup, currentMode }: MoonshotProviderProps) => {
const { apiConfiguration } = useExtensionState()
const { handleFieldChange, handleModeFieldChange } = useApiConfigurationHandlers()
// Get the normalized configuration
const { selectedModelId, selectedModelInfo } = normalizeApiConfiguration(apiConfiguration, currentMode)
@@ -35,7 +35,19 @@ export const MoonshotProvider = ({ showModelOptions, isPopup, currentMode }: Moo
</label>
<VSCodeDropdown
id="moonshot-entrypoint"
onChange={(e) => handleFieldChange("moonshotApiLine", (e.target as any).value)}
onChange={async (e) => {
const value = (e.target as any).value
await ModelsServiceClient.updateApiConfiguration(
UpdateApiConfigurationRequestNew.create({
updates: {
options: {
moonshotApiLine: value,
},
},
updateMask: ["options.moonshotApiLine"],
}),
)
}}
style={{
minWidth: 130,
position: "relative",
@@ -48,7 +60,18 @@ export const MoonshotProvider = ({ showModelOptions, isPopup, currentMode }: Moo
<ApiKeyField
helpText="This key is stored locally and only used to make API requests from this extension."
initialValue={apiConfiguration?.moonshotApiKey || ""}
onChange={(value) => handleFieldChange("moonshotApiKey", value)}
onChange={async (value) => {
await ModelsServiceClient.updateApiConfiguration(
UpdateApiConfigurationRequestNew.create({
updates: {
secrets: {
moonshotApiKey: value,
},
},
updateMask: ["secrets.moonshotApiKey"],
}),
)
}}
providerName="Moonshot"
signupUrl={
apiConfiguration?.moonshotApiLine === "china"
@@ -62,13 +85,23 @@ export const MoonshotProvider = ({ showModelOptions, isPopup, currentMode }: Moo
<ModelSelector
label="Model"
models={moonshotModels}
onChange={(e: any) =>
handleModeFieldChange(
{ plan: "planModeApiModelId", act: "actModeApiModelId" },
e.target.value,
currentMode,
onChange={async (e: any) => {
const value = e.target.value
await ModelsServiceClient.updateApiConfiguration(
UpdateApiConfigurationRequestNew.create(
currentMode === "plan"
? {
updates: { options: { planModeApiModelId: value } },
updateMask: ["options.planModeApiModelId"],
}
: {
updates: { options: { actModeApiModelId: value } },
updateMask: ["options.actModeApiModelId"],
},
),
)
}
}}
selectedModelId={selectedModelId}
/>
@@ -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,4 +1,6 @@
import { VSCodeButton } from "@vscode/webview-ui-toolkit/react"
import { Button } from "@/components/ui/button"
import { useExtensionState } from "@/context/ExtensionStateContext"
import { StateServiceClient } from "@/services/grpc-client"
import Section from "../Section"
interface DebugSectionProps {
@@ -7,26 +9,32 @@ interface DebugSectionProps {
}
const DebugSection = ({ onResetState, renderSectionHeader }: DebugSectionProps) => {
const { setShowWelcome } = useExtensionState()
return (
<div>
{renderSectionHeader("debug")}
<Section>
<VSCodeButton
className="mt-[5px] w-auto"
onClick={() => onResetState()}
style={{ backgroundColor: "var(--vscode-errorForeground)", color: "black" }}>
<Button onClick={() => onResetState()} variant="danger">
Reset Workspace State
</VSCodeButton>
<VSCodeButton
className="mt-[5px] w-auto"
onClick={() => onResetState(true)}
style={{ backgroundColor: "var(--vscode-errorForeground)", color: "black" }}>
</Button>
<Button onClick={() => onResetState(true)} variant="danger">
Reset Global State
</VSCodeButton>
</Button>
<p className="text-xs mt-[5px] text-(--vscode-descriptionForeground)">
This will reset all global state and secret storage in the extension.
</p>
</Section>
<Section>
<Button
onClick={async () =>
await StateServiceClient.setWelcomeViewCompleted({ value: false })
.catch(() => {})
.finally(() => setShowWelcome(true))
}
variant="secondary">
Reset Onboarding State
</Button>
</Section>
</div>
)
}
+34
View File
@@ -0,0 +1,34 @@
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-xs 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 }
+13 -12
View File
@@ -4,25 +4,26 @@ import * as React from "react"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"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",
"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",
{
variants: {
variant: {
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",
default:
"bg-button-background text-primary-foreground hover:bg-button-hover shadow-sm shadow-button-background/50",
secondary:
"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",
"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",
text: "text-foreground",
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",
icon: "hover:opacity-80 p-0 m-0 border-0 cursor-pointer hover:shadow-none focus:ring-0 focus:ring-offset-0",
},
size: {
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",
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",
icon: "px-0.5 m-0 [&_svg]:size-2",
},
},
+20
View File
@@ -0,0 +1,20 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(({ className, type, ...props }, ref) => {
return (
<input
className={cn(
"flex w-full rounded-sm border border-input-foreground/20 bg-input-background px-3 py-2 text-base text-input-foreground shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-input-placeholder focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-input-border disabled:cursor-not-allowed disabled:opacity-50 md:text-sm text-pretty text-ellipsis",
className,
)}
ref={ref}
type={type}
{...props}
/>
)
})
Input.displayName = "Input"
export { Input }
+137
View File
@@ -0,0 +1,137 @@
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 }
@@ -0,0 +1,26 @@
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 }
@@ -77,6 +77,7 @@ 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
@@ -313,8 +314,12 @@ export const ExtensionStateContextProvider: React.FC<{
: prevState.autoApprovalSettings,
}
// Update welcome screen state based on API configuration
setShowWelcome(!newState.welcomeViewCompleted)
// 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)
}
setDidHydrateState(true)
console.log("[DEBUG] returning new state in ESC")
@@ -685,6 +690,7 @@ export const ExtensionStateContextProvider: React.FC<{
hideAnnouncement,
setShowAnnouncement,
hideChatModelSelector,
setShowWelcome,
setShowChatModelSelector,
setShouldShowAnnouncement: (value) =>
setState((prevState) => ({
+3 -2
View File
@@ -26,7 +26,7 @@
--color-button-secondary-background: var(--vscode-button-secondaryBackground);
--color-button-secondary-background-hover: var(--vscode-button-secondaryHoverBackground);
--color-button-secondary-foreground: var(--vscode-button-secondaryForeground);
--color-muted: var(--vscode-editor-foldBackground);
--color-muted: color-mix(in srgb, var(--vscode-toolbar-hoverBackground) 65%, transparent);
--color-muted-foreground: var(--vscode-editor-foldPlaceholderForeground);
--color-menu: var(--vscode-menu-background);
--color-menu-foreground: var(--vscode-menu-foreground);
@@ -58,6 +58,7 @@
--text-xs: calc(0.85 * var(--vscode-font-size));
--text-xxs: calc(0.5 * var(--vscode-font-size));
--breakpoint-xs: 400px;
--radius-xs: 4px;
--breakpoint-xxs: 180px;
}
@@ -74,7 +75,7 @@
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted: color-mix(in srgb, var(--vscode-toolbar-hoverBackground) 65%, transparent);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);