Compare commits

..
Author SHA1 Message Date
Zhongying Qiao 97777689ac Revert "feat: check pre-configured MCP server urls and do not parse unless on…"
This reverts commit 3409fa7442.
2025-12-16 00:07:02 -08:00
Zhongying QiaoandSarah Fortune 3409fa7442 feat: check pre-configured MCP server urls and do not parse unless on the allowed list (#8055)
* feat: add ability for enterprise to disable user from adding MCP servers via remote config

* use a proper type check instead of an as any assertion

* feat: check remote mcp server url against user configured mcp server and do not parse servers not on the allow list if no personal server allowed

* feat: Enforce remote config's local MCP market place settings and filter by allowlist and source (#8068)

* feat: add parse/load enforcement for local mcp market place servers

---------

Co-authored-by: Sarah Fortune <sarah.fortune@gmail.com>
2025-12-16 00:04:46 -08:00
Zhongying Qiao a20685d289 feat: Enable extensions to send os types as query param for banners (#8114)
* feat: enable extensions to send os types as query param for banners

* add tests

* check os type with semantically correct method
2025-12-15 22:34:53 -08:00
Bee 926c5e189e fix: use cursor pointer to model description expand button (#8106)
Added cursor-pointer class to the expand/collapse button in ModelDescriptionMarkdown to provide proper visual feedback on hover.
2025-12-15 12:40:55 -08:00
CandiedUniverse 2a5ca9d312 feat(hooks): Add telemetry for hooks [ENG-999] (#7993)
* feat(hooks): Add telemetry for hooks

feat(hooks): Simplify hooks telemetry implementation and improve safety

feat(hooks): Changes as per Cline's code review

* feat(hooks): Changes as per PR feedback.
2025-12-15 10:06:18 -08:00
celestial-vault d06717342b add the parsing of env variable patterns to the mcpconfig.json (#8079)
* add the parsing of env variable patterns to the mcpconfig.json

* make sure to expand env variables in the config before validation
2025-12-13 21:50:25 -08:00
Zhongying Qiao c904cfe376 feat: Add ability for enterprise to disable user from adding MCP servers via remote config (#8029)
* feat: add ability for enterprise to disable user from adding MCP servers via remote config

* use a proper type check instead of an as any assertion
2025-12-13 21:34:28 -08:00
lahernandezbandCandiedUniverse 924ca1278c fix: auto approve screen reader a11y (#7901)
* fix: auto approve screen reader a11y

prevoius impl contained 2 tab stops per checkbox and read a generic
"Checkbox" label when focues on the checkbox input. This can be
confusing for a visually impaired person using a screen reader

* chore: changeset

* chore: remove unused imports

---------

Co-authored-by: CandiedUniverse <132302818+candieduniverse@users.noreply.github.com>
2025-12-12 16:39:08 -08:00
github-actions[bot] 2b0c0a659d v3.42.0 Release Notes (#8052)
- Expose `getAvailableSlashCommands` rpc endpoint to UI clients
- Made slash command menu and context menu accessible and screenreader-friendly
- Made expanding/collapsing UI components accessible

- Model identity and routing for devstral-2512 free model
- Extension pricing/UI bug where extension incorrectly shows zero price for devstral-2512

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-12-12 15:16:52 -08:00
Ara f4477e229d fix: update OpenRouter model ID and filter logic for devstral-2512 (#8065)
* fix: update OpenRouter model ID and filter logic for devstral-2512

- Update the model ID for devstral-2512 to include the ':free' suffix
- Modify the filter logic in providerUtils to handle devstral-2512 models
  and ensure they are not excluded when using the Cline provider
- Also ensure the default OpenRouter model is preserved in the filter

* Mistral change

* Mistral change

* Mistral change
2025-12-12 14:25:58 -08:00
Ara 5207d5c68e refactor: extract OpenRouter model filtering into reusable utility (#8064)
- Add filterOpenRouterModelIds function to providerUtils.ts
- Apply consistent filtering logic in ModelPickerModal and OpenRouterModelPicker
- For Cline provider: exclude :free models except Minimax
- For OpenRouter/Vercel: exclude cline/ prefixed models
2025-12-12 12:38:09 -08:00
Mingxuan Zhang 51b535e2d6 accessibility: screen reader support for slash and context menus (#7832)
* accessibility: screen reader support for slash and context menus

* remove unnecessary selection announcement

* changeset run

* chore: clear announcement to avoid interfering with dom queries

* chore: resolve conflict
2025-12-12 11:46:32 -08:00
jgellin-sfandCandiedUniverse 4db61b1b36 chore: make expanding/collapsing ui components accessible (#7828)
* chore: make expanding/collapsing ui components accessible

* chore: changeset

* chore: check isLoading on CodeAccordian key handler

---------

Co-authored-by: CandiedUniverse <132302818+candieduniverse@users.noreply.github.com>
2025-12-12 10:41:32 -08:00
Zhongying Qiao 6baf611322 feat: Make extension use new banner api, keep providers for extension side rules eval (#8022)
* feat: make extension use new banner api, keep providers for extension side banner rules eval
2025-12-12 09:00:27 -08:00
Max 97d635d606 expose a getAvailableSlashCommands rpc endpoint in cline core (#8024) 2025-12-11 16:05:35 -08:00
Ara 8ce476ccaa Refactor Terminal Module (#7966)
* refactor: move terminal integration from core to vscode host

- Relocate terminal-related code from core/integrations to hosts/vscode/terminal
- Move TerminalManager, TerminalProcess, TerminalRegistry, and related utilities
- Update import paths across the codebase to reference new locations
- Remove unused shellIntegrationWarningTracker and shouldShowBackgroundTerminalSuggestion from Controller
- This change better separates VSCode-specific terminal handling from core logic

* Mistral change

* refactor: consolidate terminal types into types.ts

- Move ActiveBackgroundCommand, AskResponse, CommandExecutorCallbacks, CommandExecutorConfig from ICommandExecutor.ts to types.ts
- Move OrchestrationOptions, OrchestrationResult from CommandOrchestrator.ts to types.ts
- Delete ICommandExecutor.ts (all types now in types.ts)
- Update imports in CommandExecutor.ts, CommandOrchestrator.ts, index.ts, and src/core/task/index.ts
- types.ts is now the single source of truth for all terminal-related types

* refactor: consolidate ITerminalProcess into types.ts

- Move ITerminalProcess, TerminalProcessEvents from ITerminalProcess.ts to types.ts
- Delete ITerminalProcess.ts (all types now in types.ts)
- Update imports in VscodeTerminalProcess.ts, StandaloneTerminalProcess.ts
- Update exports in index.ts
- types.ts is now the single source of truth for ALL terminal-related types
2025-12-11 12:11:32 -08:00
54 changed files with 3044 additions and 1313 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
fix a11y for auto approve checkbox
+17 -1
View File
@@ -1,24 +1,40 @@
# Changelog
## [3.42.0]
### Added
- Expose `getAvailableSlashCommands` rpc endpoint to UI clients
- Made slash command menu and context menu accessible and screenreader-friendly
- Made expanding/collapsing UI components accessible
### Fixed
- Devstral OpenRouter model ID and routing issues
- Incorrect pricing display for Devstral model in the extension
## [3.41.0]
### Added
- OpenAI GPT-5.2
- Devstral-2512 (formerly stealth model "Microwave")
- Improvements to chat modal model picker
- Amazon Nova 2 Lite
- Amazon Nova 2 Lite
- DeepSeek 3.2 to native tool calling allow list
- Responses API support for Codex models in OpenAI provider (requires native tool calling)
- Xmas Special Santa Cline
- Welcome screen UI enhancements
### Fixed
- Initial checkpoint commit now non-blocking for improved responsiveness in large repositories
- Gemini Vertex models erroring when thinking parameters are not supported
- Restrictive file permissions for secrets.json
- Ollama streaming requests not aborting when task is cancelled
### Refactored
- OpenAI provider to centralize temperature configuration and include missing GPT-5 model settings
- OpenAI native handler to use metadata for model capabilities
- Vertex provider to use metadata for model capabilities
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "claude-dev",
"version": "3.41.0",
"version": "3.42.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "claude-dev",
"version": "3.41.0",
"version": "3.42.0",
"license": "Apache-2.0",
"dependencies": {
"@anthropic-ai/sdk": "^0.37.0",
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "claude-dev",
"displayName": "Cline",
"description": "Autonomous coding agent right in your IDE, capable of creating/editing files, running commands, using the browser, and more with your permission every step of the way.",
"version": "3.41.0",
"version": "3.42.0",
"icon": "assets/icons/icon.png",
"engines": {
"vscode": "^1.84.0"
+17 -1
View File
@@ -8,9 +8,25 @@ option go_package = "github.com/cline/grpc-go/cline";
option java_multiple_files = true;
option java_package = "bot.cline.proto";
// SlashService provides methods for managing slash
// SlashService provides methods for managing slash commands
service SlashService {
// Sends button click message
rpc reportBug(StringRequest) returns (Empty);
rpc condense(StringRequest) returns (Empty);
// Get available slash commands for autocomplete (used by CLI)
rpc getAvailableSlashCommands(EmptyRequest) returns (SlashCommandsResponse);
}
// Slash command definition for autocomplete
message SlashCommandInfo {
string name = 1; // Command name without slash, e.g., "newtask", "smol"
string description = 2; // Human-readable description
string section = 3; // "default", "custom", or "cli"
bool cli_compatible = 4; // false for VS Code-only commands like explain-changes
}
// Response containing all available slash commands
message SlashCommandsResponse {
repeated SlashCommandInfo commands = 1;
}
+1 -1
View File
@@ -199,7 +199,7 @@ export class ClineHandler implements ApiHandler {
// @ts-ignore-next-line
let totalCost = (chunk.usage.cost || 0) + (chunk.usage.cost_details?.upstream_inference_cost || 0)
if (["x-ai/grok-code-fast-1", "minimax/minimax-m2", "mistralai/devstral-2512"].includes(this.getModel().id)) {
if (["x-ai/grok-code-fast-1", "minimax/minimax-m2"].includes(this.getModel().id)) {
totalCost = 0
}
-38
View File
@@ -81,12 +81,6 @@ export class Controller {
// Flag to prevent duplicate cancellations from spam clicking
private cancelInProgress = false
// Shell integration warning tracker
private shellIntegrationWarningTracker: {
timestamps: number[]
lastSuggestionShown?: number
} = { timestamps: [] }
// Timer for periodic remote config fetching
private remoteConfigTimer?: NodeJS.Timeout
@@ -515,38 +509,6 @@ export class Controller {
}
}
/**
* Check if we should show the background terminal suggestion based on shell integration warning frequency
* @returns true if we should show the suggestion, false otherwise
*/
shouldShowBackgroundTerminalSuggestion(): boolean {
const oneHourAgo = Date.now() - 60 * 60 * 1000
// Clean old timestamps (older than 1 hour)
this.shellIntegrationWarningTracker.timestamps = this.shellIntegrationWarningTracker.timestamps.filter(
(ts) => ts > oneHourAgo,
)
// Add current warning
this.shellIntegrationWarningTracker.timestamps.push(Date.now())
// Check if we've shown suggestion recently (within last hour)
if (
this.shellIntegrationWarningTracker.lastSuggestionShown &&
Date.now() - this.shellIntegrationWarningTracker.lastSuggestionShown < 60 * 60 * 1000
) {
return false
}
// Show suggestion if 3+ warnings in last hour
if (this.shellIntegrationWarningTracker.timestamps.length >= 3) {
this.shellIntegrationWarningTracker.lastSuggestionShown = Date.now()
return true
}
return false
}
async handleAuthCallback(customToken: string, provider: string | null = null) {
try {
await this.authService.handleAuthCallback(customToken, provider ? provider : "google")
@@ -0,0 +1,88 @@
import { EmptyRequest } from "@shared/proto/cline/common"
import { SlashCommandInfo, SlashCommandsResponse } from "@shared/proto/cline/slash"
import { BASE_SLASH_COMMANDS } from "@/shared/slashCommands"
import { Controller } from ".."
/**
* Returns all available slash commands for autocomplete.
*/
export async function getAvailableSlashCommands(controller: Controller, _request: EmptyRequest): Promise<SlashCommandsResponse> {
const commands: SlashCommandInfo[] = []
// Add built-in commands
for (const cmd of [...BASE_SLASH_COMMANDS]) {
commands.push(
SlashCommandInfo.create({
name: cmd.name,
description: cmd.description,
section: "default",
cliCompatible: cmd.cliCompatible,
}),
)
}
// Get workflow toggles from state
const localWorkflowToggles = controller.stateManager.getWorkspaceStateKey("workflowToggles") ?? {}
const globalWorkflowToggles = controller.stateManager.getGlobalSettingsKey("globalWorkflowToggles") ?? {}
const remoteWorkflowToggles = controller.stateManager.getGlobalStateKey("remoteWorkflowToggles") ?? {}
const remoteConfigSettings = controller.stateManager.getRemoteConfigSettings()
const remoteWorkflows = remoteConfigSettings?.remoteGlobalWorkflows ?? []
// Track local workflow names to avoid duplicates from global
const localNames = new Set<string>()
// Add local workflows (enabled only)
for (const [path, enabled] of Object.entries(localWorkflowToggles)) {
if (enabled) {
const fileName = fullPathToFileName(path)
localNames.add(fileName)
commands.push(
SlashCommandInfo.create({
name: fileName,
description: `Custom workflow: ${fileName}`,
section: "custom",
cliCompatible: true,
}),
)
}
}
// Add global workflows (enabled only, skip if local exists with same name)
for (const [path, enabled] of Object.entries(globalWorkflowToggles)) {
if (enabled) {
const fileName = fullPathToFileName(path)
if (!localNames.has(fileName)) {
commands.push(
SlashCommandInfo.create({
name: fileName,
description: `Custom workflow: ${fileName}`,
section: "custom",
cliCompatible: true,
}),
)
}
}
}
// Add remote workflows that are enabled
for (const workflow of remoteWorkflows) {
const enabled = workflow.alwaysEnabled || remoteWorkflowToggles[workflow.name] !== false
if (enabled) {
commands.push(
SlashCommandInfo.create({
name: workflow.name,
description: `Remote workflow: ${workflow.name}`,
section: "custom",
cliCompatible: true,
}),
)
}
}
return SlashCommandsResponse.create({ commands })
}
function fullPathToFileName(path: string): string {
// e.g. replace /path/to/workflow.md with workflow.md
return path.replace(/^.*[/\\]/, "")
}
+76 -40
View File
@@ -1,3 +1,4 @@
import { telemetryService } from "../../services/telemetry"
import { getAllHooksDirs } from "../storage/disk"
import { HookFactory, Hooks } from "./hook-factory"
@@ -56,8 +57,8 @@ export class HookDiscoveryCache {
// Directories we've tried to watch (even if watcher creation failed)
private watchedDirs = new Set<string>()
// Currently scanning (to prevent concurrent scans)
private scanning = new Set<HookName>()
// Currently scanning promises (to prevent concurrent scans)
private scanningPromises = new Map<HookName, Promise<string[]>>()
// For disposal
private context: ExtensionContext | null = null
@@ -105,60 +106,95 @@ export class HookDiscoveryCache {
this.log(`Getting hooks for ${hookName}`)
const cached = this.cache.get(hookName)
if (cached) {
const cacheHit = cached !== undefined
let scripts: string[]
let initiatedScan = false // Track if this caller initiated the scan
if (cacheHit) {
this.log(`Cache hit for ${hookName}: ${cached.scriptPaths.length} scripts`)
return cached.scriptPaths
scripts = cached.scriptPaths
} else {
this.log(`Cache miss for ${hookName}, scanning...`)
// Check if scan is already in progress
const existingPromise = this.scanningPromises.get(hookName)
if (existingPromise) {
// Another caller is already scanning, reuse their promise
this.log(`Reusing existing scan for ${hookName}`)
scripts = await existingPromise
} else {
// This caller initiates the scan
initiatedScan = true
scripts = await this.scan(hookName)
}
}
this.log(`Cache miss for ${hookName}, scanning...`)
return this.scan(hookName)
// Only report telemetry if:
// 1. It was a cache hit, OR
// 2. This caller initiated the scan (not reusing another caller's promise)
if (cacheHit || initiatedScan) {
telemetryService.safeCapture(
() => telemetryService.captureHookCacheAccess(hookName, cacheHit),
"HookDiscoveryCache.get",
)
}
return scripts
}
/**
* Scan for hook scripts and cache the result
*/
private async scan(hookName: HookName): Promise<string[]> {
// Prevent concurrent scans of the same hook
if (this.scanning.has(hookName)) {
this.log(`Already scanning ${hookName}, waiting...`)
await new Promise((resolve) => setTimeout(resolve, 50))
return this.get(hookName)
// Check if a scan is already in progress for this hook
const existingPromise = this.scanningPromises.get(hookName)
if (existingPromise) {
this.log(`Already scanning ${hookName}, waiting for existing scan...`)
return existingPromise
}
this.scanning.add(hookName)
// Create a new scan promise
const scanPromise = (async () => {
try {
// Get all current hooks directories
const hooksDirs = await getAllHooksDirs()
this.log(`Scanning ${hooksDirs.length} directories for ${hookName}`)
try {
// Get all current hooks directories
const hooksDirs = await getAllHooksDirs()
this.log(`Scanning ${hooksDirs.length} directories for ${hookName}`)
// Ensure watchers are set up for each directory (lazy initialization)
for (const dir of hooksDirs) {
this.ensureWatcher(dir)
}
// Ensure watchers are set up for each directory (lazy initialization)
for (const dir of hooksDirs) {
this.ensureWatcher(dir)
// Scan each directory for this hook
const scriptPromises = hooksDirs.map((dir) => HookFactory.findHookInHooksDir(hookName, dir))
const results = await Promise.all(scriptPromises)
const scripts = results.filter((path): path is string => path !== undefined)
this.log(`Found ${scripts.length} scripts for ${hookName}`)
// Cache the result
this.cache.set(hookName, {
scriptPaths: scripts,
timestamp: Date.now(),
})
return scripts
} catch (error) {
console.error(`Error scanning for ${hookName} hooks:`, error)
// Return empty array on error - don't break the whole system
return []
} finally {
// Remove from scanning promises map
this.scanningPromises.delete(hookName)
}
})()
// Scan each directory for this hook
const scriptPromises = hooksDirs.map((dir) => HookFactory.findHookInHooksDir(hookName, dir))
// Store the promise so concurrent calls can await it
this.scanningPromises.set(hookName, scanPromise)
const results = await Promise.all(scriptPromises)
const scripts = results.filter((path): path is string => path !== undefined)
this.log(`Found ${scripts.length} scripts for ${hookName}`)
// Cache the result
this.cache.set(hookName, {
scriptPaths: scripts,
timestamp: Date.now(),
})
return scripts
} catch (error) {
console.error(`Error scanning for ${hookName} hooks:`, error)
// Return empty array on error - don't break the whole system
return []
} finally {
this.scanning.delete(hookName)
}
return scanPromise
}
/**
+2
View File
@@ -105,6 +105,8 @@ export async function executeHook<Name extends keyof Hooks>(options: HookExecuti
hookName,
streamCallback,
isCancellable ? abortController.signal : undefined,
taskId,
options.toolName,
)
const result = await hook.run({
+226 -3
View File
@@ -2,6 +2,7 @@ import fs from "fs/promises"
import path from "path"
import { version as clineVersion } from "../../../package.json"
import { getDistinctId } from "../../services/logging/distinctId"
import { telemetryService } from "../../services/telemetry"
import {
HookInput,
HookOutput,
@@ -25,6 +26,9 @@ const HOOK_EXECUTION_TIMEOUT_MS = 30000
// Maximum size for context modification (to prevent prompt overflow)
const MAX_CONTEXT_MODIFICATION_SIZE = 50000 // ~50KB
// Exit code indicating cancellation/interruption (Unix SIGINT convention: 128 + signal 2)
const EXIT_CODE_SIGINT = 130
/**
* Validates hook output JSON structure.
* Ensures required fields are present and have correct types.
@@ -233,6 +237,7 @@ export type HookStreamCallback = (line: string, stream: "stdout" | "stderr") =>
* - Parses JSON output from stdout, attempting to extract it even if mixed with debug output
* - Truncates context modifications that exceed 50KB to prevent prompt overflow
* - Handles both successful and failed executions gracefully
* - Emits per-hook telemetry with source attribution (global or workspace)
*
* Error handling:
* - Treats hooks as "fail-open": only shouldContinue:false blocks tool execution
@@ -245,13 +250,31 @@ class StdioHookRunner<Name extends HookName> extends HookRunner<Name> {
constructor(
hookName: Name,
public readonly scriptPath: string,
private readonly source: "global" | "workspace",
private readonly streamCallback?: HookStreamCallback,
private readonly abortSignal?: AbortSignal,
private readonly taskId?: string,
private readonly toolName?: string,
) {
super(hookName)
}
override async [exec](input: HookInput): Promise<HookOutput> {
const startTime = performance.now()
const taskId = this.taskId // Local const for type narrowing in closures
// Capture telemetry at the start of individual hook execution
if (taskId) {
telemetryService.safeCapture(
() =>
telemetryService.captureHookExecution(taskId, this.hookName, "started", {
source: this.source,
toolName: this.toolName,
}),
"HookFactory.exec.started",
)
}
// Check if already aborted before starting
if (this.abortSignal?.aborted) {
throw HookExecutionError.cancellation(this.scriptPath)
@@ -398,6 +421,8 @@ class StdioHookRunner<Name extends HookName> extends HookRunner<Name> {
// If we have valid JSON, honor it regardless of exit code
if (parsedOutput) {
const durationMs = performance.now() - startTime
// Log warning if non-zero exit but valid JSON (for developers)
if (exitCode !== 0) {
console.warn(`[Hook ${this.hookName}] Exited with code ${exitCode} but provided valid JSON response`)
@@ -406,6 +431,39 @@ class StdioHookRunner<Name extends HookName> extends HookRunner<Name> {
}
}
// Capture success/cancellation telemetry
if (taskId) {
if (parsedOutput.cancel) {
telemetryService.safeCapture(
() =>
telemetryService.captureHookExecution(taskId, this.hookName, "completed", {
source: this.source,
toolName: this.toolName,
durationMs,
exitCode: exitCode ?? EXIT_CODE_SIGINT,
cancelRequested: true,
contextModified: !!parsedOutput.contextModification,
contextSize: parsedOutput.contextModification?.length,
}),
"HookFactory.exec.completed.cancel",
)
} else {
telemetryService.safeCapture(
() =>
telemetryService.captureHookExecution(taskId, this.hookName, "completed", {
source: this.source,
toolName: this.toolName,
durationMs,
exitCode: exitCode ?? 0,
cancelRequested: false,
contextModified: !!parsedOutput.contextModification,
contextSize: parsedOutput.contextModification?.length,
}),
"HookFactory.exec.completed.success",
)
}
}
return parsedOutput
}
@@ -413,6 +471,24 @@ class StdioHookRunner<Name extends HookName> extends HookRunner<Name> {
if (exitCode === 0) {
// Hook succeeded but didn't provide JSON - allow execution (no cancellation)
console.warn(`[Hook ${this.hookName}] Completed successfully but no JSON response found`)
const durationMs = performance.now() - startTime
// Capture success telemetry even without JSON
if (taskId) {
telemetryService.safeCapture(
() =>
telemetryService.captureHookExecution(taskId, this.hookName, "completed", {
source: this.source,
toolName: this.toolName,
durationMs,
exitCode: 0,
cancelRequested: false,
contextModified: false,
}),
"HookFactory.exec.completed.noJson",
)
}
return HookOutput.create({
cancel: false,
})
@@ -421,8 +497,48 @@ class StdioHookRunner<Name extends HookName> extends HookRunner<Name> {
throw HookExecutionError.execution(this.scriptPath, exitCode ?? 1, stderr, this.hookName)
}
} catch (error) {
const durationMs = performance.now() - startTime
// If it's already a HookExecutionError, re-throw it
if (HookExecutionError.isHookError(error)) {
// Capture failure telemetry based on error type
if (taskId) {
if (error.errorInfo.type === "cancellation") {
telemetryService.safeCapture(
() =>
telemetryService.captureHookExecution(taskId, this.hookName, "cancelled", {
source: this.source,
toolName: this.toolName,
}),
"HookFactory.exec.error.cancellation",
)
} else if (error.errorInfo.type === "timeout") {
telemetryService.safeCapture(
() =>
telemetryService.captureHookExecution(taskId, this.hookName, "failed", {
source: this.source,
toolName: this.toolName,
durationMs,
errorType: "timeout",
errorMessage: error.message,
}),
"HookFactory.exec.error.timeout",
)
} else {
telemetryService.safeCapture(
() =>
telemetryService.captureHookExecution(taskId, this.hookName, "failed", {
source: this.source,
toolName: this.toolName,
durationMs,
exitCode: error.errorInfo.exitCode ?? 1,
errorType: error.errorInfo.type as "execution" | "timeout" | "validation",
errorMessage: error.message,
}),
"HookFactory.exec.error.failed",
)
}
}
throw error
}
@@ -432,15 +548,52 @@ class StdioHookRunner<Name extends HookName> extends HookRunner<Name> {
// Check for timeout
if (error instanceof Error && error.message.includes("timed out")) {
if (taskId) {
telemetryService.safeCapture(
() =>
telemetryService.captureHookExecution(taskId, this.hookName, "failed", {
source: this.source,
toolName: this.toolName,
durationMs,
errorType: "timeout",
errorMessage: error.message,
}),
"HookFactory.exec.catch.timeout",
)
}
throw HookExecutionError.timeout(this.scriptPath, HOOK_EXECUTION_TIMEOUT_MS, stderr, this.hookName)
}
// Check for cancellation
if (error instanceof Error && error.message.includes("cancelled")) {
if (taskId) {
telemetryService.safeCapture(
() =>
telemetryService.captureHookExecution(taskId, this.hookName, "cancelled", {
source: this.source,
toolName: this.toolName,
}),
"HookFactory.exec.catch.cancelled",
)
}
throw HookExecutionError.cancellation(this.scriptPath, this.hookName)
}
// Generic execution error - include hook name
if (taskId) {
telemetryService.safeCapture(
() =>
telemetryService.captureHookExecution(taskId, this.hookName, "failed", {
source: this.source,
toolName: this.toolName,
durationMs,
exitCode: exitCode ?? 1,
errorType: "execution",
errorMessage: error instanceof Error ? error.message : String(error),
}),
"HookFactory.exec.catch.execution",
)
}
throw HookExecutionError.execution(this.scriptPath, exitCode ?? 1, stderr, this.hookName)
}
}
@@ -546,8 +699,8 @@ export class HookFactory {
/**
* Create a hook runner without streaming support (backwards compatible)
*/
async create<Name extends HookName>(hookName: Name): Promise<HookRunner<Name>> {
return this.createWithStreaming(hookName)
async create<Name extends HookName>(hookName: Name, taskId?: string, toolName?: string): Promise<HookRunner<Name>> {
return this.createWithStreaming(hookName, undefined, undefined, taskId, toolName)
}
/**
@@ -566,24 +719,94 @@ export class HookFactory {
* @param hookName The type of hook to create (e.g., "PreToolUse", "PostToolUse")
* @param streamCallback Optional callback for real-time output streaming
* @param abortSignal Optional signal to cancel hook execution
* @param taskId Optional task ID for telemetry context
* @param toolName Optional tool name for telemetry context
* @returns A HookRunner that executes the hook(s), or NoOpRunner if none found
*/
async createWithStreaming<Name extends HookName>(
hookName: Name,
streamCallback?: HookStreamCallback,
abortSignal?: AbortSignal,
taskId?: string,
toolName?: string,
): Promise<HookRunner<Name>> {
// Use cache for hook discovery instead of direct file system scan
const { HookDiscoveryCache } = await import("./HookDiscoveryCache")
const scripts = await HookDiscoveryCache.getInstance().get(hookName)
const runners = scripts.map((script) => new StdioHookRunner(hookName, script, streamCallback, abortSignal))
// Fetch hooks dirs once for source determination and telemetry
const hooksDirs = await getAllHooksDirs()
// Capture hook discovery telemetry
// Categorize scripts by location (global vs workspace)
const { globalCount, workspaceCount } = this.categorizeHookScripts(scripts, hooksDirs)
if (scripts.length > 0) {
telemetryService.safeCapture(
() => telemetryService.captureHookDiscovery(hookName, globalCount, workspaceCount),
"HookFactory.createWithStreaming.discovery",
)
}
// Create runners with source determination for each script
const runners = scripts.map((script) => {
const source = this.determineScriptSource(script, hooksDirs)
return new StdioHookRunner(hookName, script, source, streamCallback, abortSignal, taskId, toolName)
})
if (runners.length === 0) {
return new NoOpRunner(hookName)
}
return runners.length === 1 ? runners[0] : new CombinedHookRunner(hookName, runners)
}
/**
* Checks if a hooks directory is a global hooks directory.
* Global hooks are located in paths containing "Cline/Hooks" or "cline/hooks".
*/
private static isGlobalHooksDir(dir: string): boolean {
return /[/\\][Cc]line[/\\][Hh]ooks/i.test(dir)
}
/**
* Determines if a single script is from global or workspace location
*/
private determineScriptSource(scriptPath: string, hooksDirs: string[]): "global" | "workspace" {
const containingDir = hooksDirs.find((dir) => scriptPath.startsWith(dir))
if (containingDir && HookFactory.isGlobalHooksDir(containingDir)) {
return "global"
}
return "workspace" // Default to workspace if uncertain
}
/**
* Categorizes hook scripts by their location (global vs workspace).
* Global hooks are located in ~/Documents/Cline/Hooks/
* Workspace hooks are located in workspace .clinerules/hooks/ directories
*
* @param scripts Array of hook script paths
* @param hooksDirs Array of hooks directories (passed to avoid redundant fetches)
* @returns Object with globalCount and workspaceCount
*/
private categorizeHookScripts(scripts: string[], hooksDirs: string[]): { globalCount: number; workspaceCount: number } {
if (scripts.length === 0) {
return { globalCount: 0, workspaceCount: 0 }
}
let globalCount = 0
let workspaceCount = 0
for (const script of scripts) {
const containingDir = hooksDirs.find((dir) => script.startsWith(dir))
if (containingDir && HookFactory.isGlobalHooksDir(containingDir)) {
globalCount++
} else {
workspaceCount++
}
}
return { globalCount, workspaceCount }
}
/**
* @returns A list of paths to scripts for the given hook name.
* Includes both global hooks (from ~/Documents/Cline/Hooks/) and workspace hooks
+1 -1
View File
@@ -1,6 +1,5 @@
import { FileContextTracker } from "@core/context/context-tracking/FileContextTracker"
import * as extractTextModule from "@integrations/misc/extract-text"
import * as terminalModule from "@integrations/terminal/get-latest-output"
import { UrlContentFetcher } from "@services/browser/UrlContentFetcher"
import * as gitModule from "@utils/git"
import { expect } from "chai"
@@ -9,6 +8,7 @@ import * as isBinaryFileModule from "isbinaryfile"
import * as path from "path"
import * as sinon from "sinon"
import { HostProvider } from "@/hosts/host-provider"
import * as terminalModule from "@/hosts/vscode/terminal/get-latest-output"
import { setVscodeHostProviderMock } from "@/test/host-provider-test-utils"
import { parseMentions } from "."
+1 -1
View File
@@ -1,7 +1,6 @@
import { diagnosticsToProblemsString } from "@integrations/diagnostics"
import { extractTextFromFile } from "@integrations/misc/extract-text"
import { openFile } from "@integrations/misc/open-file"
import { getLatestTerminalOutput } from "@integrations/terminal/get-latest-output"
import { UrlContentFetcher } from "@services/browser/UrlContentFetcher"
import { telemetryService } from "@services/telemetry"
import { mentionRegexGlobal } from "@shared/context-mentions"
@@ -12,6 +11,7 @@ import fs from "fs/promises"
import { isBinaryFile } from "isbinaryfile"
import * as path from "path"
import { HostProvider } from "@/hosts/host-provider"
import { getLatestTerminalOutput } from "@/hosts/vscode/terminal/get-latest-output"
import { ShowMessageType } from "@/shared/proto/host/window"
import { DiagnosticSeverity } from "@/shared/proto/index.cline"
import { isDirectory } from "@/utils/fs"
+3
View File
@@ -21,6 +21,9 @@ export function transformRemoteConfigToStateShape(remoteConfig: RemoteConfig): P
if (remoteConfig.allowedMCPServers !== undefined) {
transformed.allowedMCPServers = remoteConfig.allowedMCPServers
}
if (remoteConfig.blockPersonalRemoteMCPServers !== undefined) {
transformed.blockPersonalRemoteMCPServers = remoteConfig.blockPersonalRemoteMCPServers
}
if (remoteConfig.yoloModeAllowed !== undefined) {
// only set the yoloModeToggled field if yolo mode is not allowed. Otherwise, we let the user toggle it.
if (remoteConfig.yoloModeAllowed === false) {
+51 -426
View File
@@ -46,6 +46,7 @@ import { showSystemNotification } from "@integrations/notifications"
import { ITerminalManager } from "@integrations/terminal/types"
import { BrowserSession } from "@services/browser/BrowserSession"
import { UrlContentFetcher } from "@services/browser/UrlContentFetcher"
import { featureFlagsService } from "@services/feature-flags"
import { listFiles } from "@services/glob/list-files"
import { Logger } from "@services/logging/Logger"
import { McpHub } from "@services/mcp/McpHub"
@@ -53,14 +54,7 @@ import { ApiConfiguration } from "@shared/api"
import { findLast, findLastIndex } from "@shared/array"
import { combineApiRequests } from "@shared/combineApiRequests"
import { combineCommandSequences } from "@shared/combineCommandSequences"
import {
ClineApiReqCancelReason,
ClineApiReqInfo,
ClineAsk,
ClineMessage,
ClineSay,
COMMAND_CANCEL_TOKEN,
} from "@shared/ExtensionMessage"
import { ClineApiReqCancelReason, ClineApiReqInfo, ClineAsk, ClineMessage, ClineSay } from "@shared/ExtensionMessage"
import { HistoryItem } from "@shared/HistoryItem"
import { DEFAULT_LANGUAGE_SETTINGS, getLanguageKey, LanguageDisplay } from "@shared/Languages"
import { USER_CONTENT_TAGS } from "@shared/messages/constants"
@@ -79,12 +73,10 @@ import * as vscode from "vscode"
import type { SystemPromptContext } from "@/core/prompts/system-prompt"
import { getSystemPrompt } from "@/core/prompts/system-prompt"
import { HostProvider } from "@/hosts/host-provider"
import { TerminalProcessResultPromise } from "@/hosts/vscode/terminal/VscodeTerminalProcess"
import { isSubagentCommand, transformClineCommand } from "@/integrations/cli-subagents/subagent_command"
import { StandaloneTerminalManager } from "@/integrations/terminal"
import { CommandExecutorCallbacks, StandaloneTerminalManager } from "@/integrations/terminal"
import { CommandExecutor, FullCommandExecutorConfig } from "@/integrations/terminal/CommandExecutor"
import { ClineError, ClineErrorType, ErrorService } from "@/services/error"
import { featureFlagsService } from "@/services/feature-flags"
import { TerminalHangStage, TerminalUserInterventionAction, telemetryService } from "@/services/telemetry"
import { telemetryService } from "@/services/telemetry"
import {
ClineAssistantContent,
ClineContent,
@@ -97,7 +89,6 @@ import {
} from "@/shared/messages"
import { ShowMessageType } from "@/shared/proto/index.host"
import { isClineCliInstalled, isCliSubagentContext } from "@/utils/cli-detector"
import { isInTestMode } from "../../services/test/TestMode"
import { ensureLocalClineDirExists } from "../context/instructions/user-instructions/rule-helpers"
import { refreshWorkflowToggles } from "../context/instructions/user-instructions/workflows"
import { Controller } from "../controller"
@@ -216,13 +207,6 @@ export class Task {
private streamHandler: StreamResponseHandler
private terminalExecutionMode: "vscodeTerminal" | "backgroundExec"
private activeBackgroundCommand?: {
process: TerminalProcessResultPromise & {
terminate?: () => void
}
command: string
outputLines: string[]
}
// Metadata tracking
private fileContextTracker: FileContextTracker
@@ -250,6 +234,9 @@ export class Task {
// Task Locking (Sqlite)
private taskLockAcquired: boolean
// Command executor for running shell commands (extracted from executeCommandTool)
private commandExecutor!: CommandExecutor
constructor(params: TaskParams) {
const {
controller,
@@ -497,6 +484,40 @@ export class Task {
telemetryService.captureTaskCreated(this.ulid, currentProvider, openAiCompatibleDomain)
}
// Initialize command executor with config and callbacks
const commandExecutorConfig: FullCommandExecutorConfig = {
cwd: this.cwd,
terminalExecutionMode: this.terminalExecutionMode,
terminalManager: this.terminalManager,
taskId: this.taskId,
ulid: this.ulid,
}
const commandExecutorCallbacks: CommandExecutorCallbacks = {
say: this.say.bind(this) as CommandExecutorCallbacks["say"],
ask: async (type: string, text?: string, partial?: boolean) => {
const result = await this.ask(type as ClineAsk, text, partial)
return {
response: result.response,
text: result.text,
images: result.images,
files: result.files,
}
},
updateBackgroundCommandState: (isRunning: boolean) =>
this.controller.updateBackgroundCommandState(isRunning, this.taskId),
updateClineMessage: async (index: number, updates: { commandCompleted?: boolean }) => {
await this.messageStateHandler.updateClineMessage(index, updates)
},
getClineMessages: () => this.messageStateHandler.getClineMessages() as Array<{ ask?: string; say?: string }>,
addToUserMessageContent: (content: { type: string; text: string }) => {
// Cast to ClineTextContentBlock which is compatible with ClineContent
this.taskState.userMessageContent.push({ type: "text", text: content.text } as ClineTextContentBlock)
},
}
this.commandExecutor = new CommandExecutor(commandExecutorConfig, commandExecutorCallbacks)
this.toolExecutor = new ToolExecutor(
this.controller.context,
this.taskState,
@@ -1352,7 +1373,7 @@ export class Task {
}
// Run if there's active background command (work happening now)
if (this.activeBackgroundCommand) {
if (this.commandExecutor.hasActiveBackgroundCommand()) {
return true
}
@@ -1406,9 +1427,9 @@ export class Task {
}
}
if (this.activeBackgroundCommand) {
if (this.commandExecutor.hasActiveBackgroundCommand()) {
try {
await this.cancelBackgroundCommand()
await this.commandExecutor.cancelBackgroundCommand()
} catch (error) {
Logger.error("Failed to cancel background command during task abort", error)
}
@@ -1525,410 +1546,15 @@ export class Task {
// Tools
async executeCommandTool(command: string, timeoutSeconds: number | undefined): Promise<[boolean, ClineToolResponseContent]> {
// For Cline CLI subagents, we want to parse and process the command to ensure flags are correct
const isSubagent = isSubagentCommand(command)
if (transformClineCommand(command) !== command && isSubagent) {
command = transformClineCommand(command)
}
// Strip leading `cd` to workspace from command
// TODO - feed this back to the model to discourage redundant `cd` usage in subsequent commands. For now we re just stripping it for better UX
const workspaceCdPrefix = `cd ${this.cwd} && `
if (command.startsWith(workspaceCdPrefix)) {
command = command.substring(workspaceCdPrefix.length)
}
const subAgentStartTime = isSubagent ? performance.now() : 0
Logger.info("IS_TEST: " + isInTestMode())
// Force subagents to use background terminal (hidden execution)
Logger.info("Executing command in terminal: " + command)
let terminalManager: ITerminalManager
if (isSubagent || this.terminalExecutionMode === "backgroundExec") {
// Use StandaloneTerminalManager for hidden background execution (subagents and backgroundExec mode)
terminalManager = new StandaloneTerminalManager()
Logger.info(
`[Task ${this.taskId}] Using StandaloneTerminalManager for ${isSubagent ? "subagent" : "backgroundExec"} command: ${command}`,
)
} else {
// Use the configured terminal manager for regular commands (VSCode terminal)
terminalManager = this.terminalManager
}
const terminalInfo = await terminalManager.getOrCreateTerminal(this.cwd)
terminalInfo.terminal.show() // weird visual bug when creating new terminals (even manually) where there's an empty space at the top.
// Use `as any` to handle type incompatibility between VSCode's Thenable and Promise
// Both TerminalInfo types have the same runtime structure, the difference is purely TypeScript
const process = terminalManager.runCommand(terminalInfo as any, command)
// Track command execution for both terminal modes
this.controller.updateBackgroundCommandState(true, this.taskId)
if (this.terminalExecutionMode === "backgroundExec") {
this.activeBackgroundCommand = { process: process as any, command, outputLines: [] }
}
const clearCommandState = async () => {
if (this.terminalExecutionMode === "backgroundExec") {
if (this.activeBackgroundCommand?.process !== process) {
return
}
this.activeBackgroundCommand = undefined
}
this.controller.updateBackgroundCommandState(false, this.taskId)
// Mark the command message as completed
const clineMessages = this.messageStateHandler.getClineMessages()
const lastCommandIndex = findLastIndex(clineMessages, (m) => m.ask === "command" || m.say === "command")
if (lastCommandIndex !== -1) {
await this.messageStateHandler.updateClineMessage(lastCommandIndex, {
commandCompleted: true,
})
}
}
process.once("completed", clearCommandState)
process.once("error", clearCommandState)
process
// process.continue() will complete the process promise, letting exeuction continue. therefore the command should not be considered 'completed', since it could still be running in the background
// .finally(() => {
// clearCommandState()
// })
.catch(() => {
clearCommandState()
})
let userFeedback: { text?: string; images?: string[]; files?: string[] } | undefined
let didContinue = false
let didCancelViaUi = false
// Chunked terminal output buffering
const CHUNK_LINE_COUNT = 20
const CHUNK_BYTE_SIZE = 2048 // 2KB
const CHUNK_DEBOUNCE_MS = 100
let outputBuffer: string[] = []
let outputBufferSize: number = 0
let chunkTimer: NodeJS.Timeout | null = null
// Track if buffer gets stuck (correlated with PROCESS_WHILE_RUNNING to indicate genuine technical issues)
let bufferStuckTimer: NodeJS.Timeout | null = null
const BUFFER_STUCK_TIMEOUT_MS = 6000 // 6 seconds
const flushBuffer = async (force = false) => {
if (outputBuffer.length === 0) {
if (force) {
// If force is true, flush anyway
} else {
return
}
}
const chunk = outputBuffer.join("\n")
outputBuffer = []
outputBufferSize = 0
// Start timer to detect if buffer gets stuck
bufferStuckTimer = setTimeout(() => {
telemetryService.captureTerminalHang(TerminalHangStage.BUFFER_STUCK)
bufferStuckTimer = null
}, BUFFER_STUCK_TIMEOUT_MS)
try {
const { response, text, images, files } = await this.ask("command_output", chunk)
if (response === "yesButtonClicked") {
// Track when user clicks "Process while Running"
telemetryService.captureTerminalUserIntervention(TerminalUserInterventionAction.PROCESS_WHILE_RUNNING)
// proceed while running - but still capture user feedback if provided
if (text || (images && images.length > 0) || (files && files.length > 0)) {
userFeedback = { text, images, files }
}
} else if (response === "noButtonClicked" && text === COMMAND_CANCEL_TOKEN) {
telemetryService.captureTerminalUserIntervention(TerminalUserInterventionAction.CANCELLED)
didCancelViaUi = true
userFeedback = undefined
} else {
userFeedback = { text, images, files }
}
didContinue = true
process.continue()
if (didCancelViaUi) {
outputBuffer = []
outputBufferSize = 0
await this.say("command_output", "Command cancelled")
}
// If more output accumulated, flush again
if (!didCancelViaUi && outputBuffer.length > 0) {
await flushBuffer()
}
} catch {
Logger.error("Error while asking for command output")
} finally {
// If the command finishes execution before the 'command_output' ask promise resolves (in other words before the user responded to the ask, which is expected when the command finishes execution first), this block is reached. This is expected and safe to ignore, as no further handling is required.
// Clear the stuck timer
if (bufferStuckTimer) {
clearTimeout(bufferStuckTimer)
bufferStuckTimer = null
}
}
}
const scheduleFlush = () => {
if (chunkTimer) {
clearTimeout(chunkTimer)
}
chunkTimer = setTimeout(async () => await flushBuffer(), CHUNK_DEBOUNCE_MS)
}
const outputLines: string[] = []
process.on("line", async (line) => {
if (didCancelViaUi) {
return
}
outputLines.push(line)
// Track output in activeBackgroundCommand for cancellation
if (this.terminalExecutionMode === "backgroundExec" && this.activeBackgroundCommand) {
this.activeBackgroundCommand.outputLines.push(line)
}
// Apply buffered streaming for both vscodeTerminal and backgroundExec modes
if (!didContinue) {
outputBuffer.push(line)
outputBufferSize += Buffer.byteLength(line, "utf8")
// Flush if buffer is large enough
if (outputBuffer.length >= CHUNK_LINE_COUNT || outputBufferSize >= CHUNK_BYTE_SIZE) {
await flushBuffer()
} else {
scheduleFlush()
}
} else {
// For backgroundExec mode, stream output directly to UI after user continues
// For vscodeTerminal mode, this maintains existing behavior
this.say("command_output", line)
}
})
let completed = false
let completionTimer: NodeJS.Timeout | null = null
const COMPLETION_TIMEOUT_MS = 6000 // 6 seconds
// Start timer to detect if waiting for completion takes too long
completionTimer = setTimeout(() => {
if (!completed) {
telemetryService.captureTerminalHang(TerminalHangStage.WAITING_FOR_COMPLETION)
completionTimer = null
}
}, COMPLETION_TIMEOUT_MS)
process.once("completed", async () => {
completed = true
//await this.say("shell_integration_warning_with_suggestion")
// Clear the completion timer
if (completionTimer) {
clearTimeout(completionTimer)
completionTimer = null
}
// Flush any remaining buffered output
if (!didContinue && outputBuffer.length > 0) {
if (chunkTimer) {
clearTimeout(chunkTimer)
chunkTimer = null
}
await flushBuffer(true)
}
})
process.once("no_shell_integration", async () => {
const shouldShowSuggestion = this.controller.shouldShowBackgroundTerminalSuggestion()
if (shouldShowSuggestion) {
await this.say("shell_integration_warning_with_suggestion")
} else {
await this.say("shell_integration_warning")
}
})
//await process
if (!didCancelViaUi) {
if (timeoutSeconds) {
const timeoutPromise = new Promise<never>((_, reject) => {
setTimeout(() => {
reject(new Error("COMMAND_TIMEOUT"))
}, timeoutSeconds * 1000)
})
try {
await Promise.race([process, timeoutPromise])
} catch (error) {
// This will continue running the command in the background
didContinue = true
process.continue()
// Clear all our timers
if (chunkTimer) {
clearTimeout(chunkTimer)
chunkTimer = null
}
if (completionTimer) {
clearTimeout(completionTimer)
completionTimer = null
}
// Process any output we captured before timeout
await setTimeoutPromise(50)
const result = terminalManager.processOutput(outputLines, undefined, isSubagent)
if (error.message === "COMMAND_TIMEOUT") {
return [
false,
`Command execution timed out after ${timeoutSeconds} seconds. ${result.length > 0 ? `\nOutput so far:\n${result}` : ""}`,
]
}
// Re-throw other errors
throw error
}
} else {
await process
}
}
// Clear timer if process completes normally
if (completionTimer) {
clearTimeout(completionTimer)
completionTimer = null
}
// Wait for a short delay to ensure all messages are sent to the webview
// This delay allows time for non-awaited promises to be created and
// for their associated messages to be sent to the webview, maintaining
// the correct order of messages (although the webview is smart about
// grouping command_output messages despite any gaps anyways)
if (!didCancelViaUi) {
await setTimeoutPromise(50)
}
const result = terminalManager.processOutput(outputLines, undefined, isSubagent)
if (didCancelViaUi) {
return [
true,
formatResponse.toolResult(
`Command cancelled. ${result.length > 0 ? `\nOutput captured before cancellation:\n${result}` : ""}`,
),
]
}
// Capture subagent telemetry if this was a subagent command
if (isSubagent && subAgentStartTime > 0) {
const durationMs = Math.round(performance.now() - subAgentStartTime)
telemetryService.captureSubagentExecution(this.ulid, durationMs, outputLines.length, completed)
}
if (userFeedback) {
await this.say("user_feedback", userFeedback.text, userFeedback.images, userFeedback.files)
let fileContentString = ""
if (userFeedback.files && userFeedback.files.length > 0) {
fileContentString = await processFilesIntoText(userFeedback.files)
}
return [
true,
formatResponse.toolResult(
`Command is still running in the user's terminal.${
result.length > 0 ? `\nHere's the output so far:\n${result}` : ""
}\n\nThe user provided the following feedback:\n<feedback>\n${userFeedback.text}\n</feedback>`,
userFeedback.images,
fileContentString,
),
]
}
if (completed) {
return [false, `Command executed.${result.length > 0 ? `\nOutput:\n${result}` : ""}`]
} else {
return [
false,
`Command is still running in the user's terminal.${
result.length > 0 ? `\nHere's the output so far:\n${result}` : ""
}\n\nYou will be updated on the terminal status and new output in the future.`,
]
}
return this.commandExecutor.execute(command, timeoutSeconds)
}
/**
* Cancel a background command that is running in the background
* @returns true if a command was cancelled, false if no command was running
*/
public async cancelBackgroundCommand(): Promise<boolean> {
if (this.terminalExecutionMode !== "backgroundExec" || !this.activeBackgroundCommand) {
return false
}
const { process, command, outputLines } = this.activeBackgroundCommand
this.activeBackgroundCommand = undefined
this.controller.updateBackgroundCommandState(false, this.taskId)
try {
// Try to terminate the process if the method exists
if (typeof process.terminate === "function") {
try {
await process.terminate()
Logger.info(`Terminated background command: ${command}`)
} catch (error) {
Logger.error(`Error terminating background command: ${command}`, error)
}
}
// Ensure any pending operations complete
if (typeof process.continue === "function") {
try {
process.continue()
} catch (error) {
Logger.error(`Error continuing background command: ${command}`, error)
}
}
// Mark the command message as completed in the UI
const clineMessages = this.messageStateHandler.getClineMessages()
const lastCommandIndex = findLastIndex(clineMessages, (m) => m.ask === "command" || m.say === "command")
if (lastCommandIndex !== -1) {
await this.messageStateHandler.updateClineMessage(lastCommandIndex, {
commandCompleted: true,
})
}
// Process the captured output to include in the cancellation message
const processedOutput = this.terminalManager.processOutput(outputLines, undefined, isSubagentCommand(command))
// Add cancellation information to the API conversation history
// This ensures the agent knows the command was cancelled in the next request
let cancellationMessage = `Command "${command}" was cancelled by the user.`
if (processedOutput.length > 0) {
cancellationMessage += `\n\nOutput captured before cancellation:\n${processedOutput}`
}
this.taskState.userMessageContent.push({
type: "text",
text: cancellationMessage,
})
return true
} catch (error) {
Logger.error("Error in cancelBackgroundCommand", error)
return false
} finally {
try {
await this.say("command_output", "Command execution has been cancelled.")
} catch (error) {
Logger.error("Failed to send cancellation notification", error)
}
}
return this.commandExecutor.cancelBackgroundCommand()
}
/**
@@ -3563,7 +3189,6 @@ export class Task {
// || this.didEditFile
await setTimeoutPromise(300) // delay after saving file to let terminals catch up
}
// let terminalWasBusy = false
if (busyTerminals.length > 0) {
// wait for terminals to cool down
@@ -1,22 +1,30 @@
import { TerminalOutputFailureReason, telemetryService } from "@services/telemetry"
import { EventEmitter } from "events"
import * as vscode from "vscode"
import { getLatestTerminalOutput } from "../../../integrations/terminal/get-latest-output"
import { stripAnsi } from "./ansiUtils"
export interface TerminalProcessEvents {
line: [line: string]
continue: []
completed: []
error: [error: Error]
no_shell_integration: []
}
import { stripAnsi } from "@/hosts/vscode/terminal/ansiUtils"
import { getLatestTerminalOutput } from "@/hosts/vscode/terminal/get-latest-output"
import type { ITerminalProcess, TerminalProcessEvents } from "@/integrations/terminal/types"
// how long to wait after a process outputs anything before we consider it "cool" again
const PROCESS_HOT_TIMEOUT_NORMAL = 2_000
const PROCESS_HOT_TIMEOUT_COMPILING = 15_000
export class VscodeTerminalProcess extends EventEmitter<TerminalProcessEvents> {
/**
* VscodeTerminalProcess - Manages command execution in VSCode's integrated terminal.
*
* This class handles command execution using VSCode's shell integration API.
* It processes VSCode-specific escape sequences and streams output through events.
*
* Implements ITerminalProcess interface for polymorphic usage with CommandExecutor.
*
* Events:
* - 'line': Emitted for each line of output
* - 'completed': Emitted when the process completes
* - 'continue': Emitted when continue() is called
* - 'error': Emitted on process errors
* - 'no_shell_integration': Emitted when shell integration is not available
*/
export class VscodeTerminalProcess extends EventEmitter<TerminalProcessEvents> implements ITerminalProcess {
waitForShellIntegration: boolean = true
private isListening: boolean = true
private buffer: string = ""
@@ -0,0 +1,319 @@
/**
* CommandExecutor - Unified command execution for all terminal modes.
*
* This class handles command execution for both VSCode terminal mode and
* standalone/CLI mode. It uses the shared CommandOrchestrator for the
* common orchestration logic (buffering, user interaction, result formatting).
*
* The differentiation between modes happens at the TerminalManager level:
* - VscodeTerminalManager → VscodeTerminalProcess (shell integration)
* - StandaloneTerminalManager → StandaloneTerminalProcess (child_process)
*
* IMPORTANT: Subagent commands (cline CLI) are ALWAYS routed to use
* StandaloneTerminalManager regardless of the configured mode. This ensures
* subagents run in hidden/background terminals rather than cluttering the
* user's visible VSCode terminal.
*/
import { isSubagentCommand, transformClineCommand } from "@integrations/cli-subagents/subagent_command"
import { Logger } from "@services/logging/Logger"
import { telemetryService } from "@services/telemetry"
import { ClineToolResponseContent } from "@shared/messages"
import { orchestrateCommandExecution } from "./CommandOrchestrator"
import { StandaloneTerminalManager } from "./standalone/StandaloneTerminalManager"
import {
ActiveBackgroundCommand,
CommandExecutorCallbacks,
CommandExecutorConfig,
ITerminalManager,
TerminalProcessResultPromise,
} from "./types"
// Re-export types for convenience
export type { CommandExecutorCallbacks, CommandExecutorConfig, FullCommandExecutorConfig } from "./types"
/**
* Tracker for shell integration warnings to determine when to show background terminal suggestion
*/
interface ShellIntegrationWarningTracker {
timestamps: number[]
lastSuggestionShown?: number
}
/**
* CommandExecutor - Unified command executor for all terminal modes.
*
* Uses the shared CommandOrchestrator for common logic and delegates
* process management to the appropriate TerminalManager.
*/
export class CommandExecutor {
private cwd: string
private taskId: string
private ulid: string
private terminalExecutionMode: "vscodeTerminal" | "backgroundExec"
private terminalManager: ITerminalManager
private standaloneManager: StandaloneTerminalManager
private callbacks: CommandExecutorCallbacks
// Track shell integration warnings to determine when to show background terminal suggestion
private shellIntegrationWarningTracker: ShellIntegrationWarningTracker = {
timestamps: [],
lastSuggestionShown: undefined,
}
// Track active background command for cancellation (standalone mode only)
private activeBackgroundCommand?: {
process: TerminalProcessResultPromise & { terminate?: () => void }
command: string
outputLines: string[]
}
constructor(config: CommandExecutorConfig, callbacks: CommandExecutorCallbacks) {
this.cwd = config.cwd
this.taskId = config.taskId
this.ulid = config.ulid
this.terminalExecutionMode = config.terminalExecutionMode
this.terminalManager = config.terminalManager
this.callbacks = callbacks
// Always create StandaloneTerminalManager for subagents (even in VSCode mode)
this.standaloneManager = new StandaloneTerminalManager()
// Copy settings from the provided terminalManager to ensure consistency
if ("shellIntegrationTimeout" in config.terminalManager) {
const tm = config.terminalManager as any
this.standaloneManager.setShellIntegrationTimeout(tm.shellIntegrationTimeout || 4000)
this.standaloneManager.setTerminalReuseEnabled(tm.terminalReuseEnabled ?? true)
this.standaloneManager.setTerminalOutputLineLimit(tm.terminalOutputLineLimit || 500)
this.standaloneManager.setSubagentTerminalOutputLineLimit(tm.subagentTerminalOutputLineLimit || 2000)
}
}
/**
* Execute a command in the terminal.
*
* Routing logic:
* 1. Subagent commands (cline CLI) → Always use StandaloneTerminalManager
* This ensures subagents run in hidden terminals, not cluttering the user's VSCode terminal
* 2. Regular commands → Use the configured terminal manager based on terminalExecutionMode
*
* @param command The command to execute
* @param timeoutSeconds Optional timeout in seconds
* @returns [userRejected, result] tuple
*/
async execute(command: string, timeoutSeconds: number | undefined): Promise<[boolean, ClineToolResponseContent]> {
// Transform subagent commands to ensure flags are correct
const isSubagent = isSubagentCommand(command)
if (isSubagent) {
command = transformClineCommand(command)
}
// Strip leading `cd` to workspace from command
const workspaceCdPrefix = `cd ${this.cwd} && `
if (command.startsWith(workspaceCdPrefix)) {
command = command.substring(workspaceCdPrefix.length)
}
const subAgentStartTime = isSubagent ? performance.now() : 0
// Select the appropriate terminal manager
// Subagents always use standalone manager (hidden terminal)
const useStandalone = isSubagent || this.terminalExecutionMode === "backgroundExec"
const manager = useStandalone ? this.standaloneManager : this.terminalManager
Logger.info(`Executing command in ${useStandalone ? "standalone" : "VSCode"} terminal: ${command}`)
// Get terminal and run command
const terminalInfo = await manager.getOrCreateTerminal(this.cwd)
terminalInfo.terminal.show()
const process = manager.runCommand(terminalInfo, command)
// Track background command for standalone mode (enables cancellation)
if (useStandalone) {
this.activeBackgroundCommand = {
process: process as any,
command,
outputLines: [],
}
}
// Use shared orchestration logic
const result = await orchestrateCommandExecution(process, manager, this.callbacks, {
command,
timeoutSeconds,
onOutputLine: useStandalone
? (line) => {
if (this.activeBackgroundCommand) {
this.activeBackgroundCommand.outputLines.push(line)
}
}
: undefined,
showShellIntegrationSuggestion: this.shouldShowBackgroundTerminalSuggestion(),
})
// Clear background command tracking if completed
if (result.completed && useStandalone) {
this.activeBackgroundCommand = undefined
}
// Capture subagent telemetry
if (isSubagent && subAgentStartTime > 0) {
const durationMs = Math.round(performance.now() - subAgentStartTime)
telemetryService.captureSubagentExecution(this.ulid, durationMs, result.outputLines.length, result.completed)
}
return [result.userRejected, result.result]
}
/**
* Cancel the currently running background command.
* Only works in standalone/backgroundExec mode.
*
* @returns true if a command was cancelled, false otherwise
*/
async cancelBackgroundCommand(): Promise<boolean> {
if (!this.activeBackgroundCommand) {
return false
}
const { process, command, outputLines } = this.activeBackgroundCommand
this.activeBackgroundCommand = undefined
this.callbacks.updateBackgroundCommandState(false)
try {
// Try to terminate the process if the method exists
if (typeof process.terminate === "function") {
try {
await process.terminate()
Logger.info(`Terminated background command: ${command}`)
} catch (error) {
Logger.error(`Error terminating background command: ${command}`, error)
}
}
// Ensure any pending operations complete
if (typeof process.continue === "function") {
try {
process.continue()
} catch (error) {
Logger.error(`Error continuing background command: ${command}`, error)
}
}
// Mark the command message as completed in the UI
const clineMessages = this.callbacks.getClineMessages()
const lastCommandIndex = this.findLastIndex(clineMessages, (m) => m.ask === "command" || m.say === "command")
if (lastCommandIndex !== -1) {
await this.callbacks.updateClineMessage(lastCommandIndex, {
commandCompleted: true,
})
}
// Process the captured output to include in the cancellation message
const processedOutput = this.standaloneManager.processOutput(outputLines, undefined, false)
// Add cancellation information to the API conversation history
let cancellationMessage = `Command "${command}" was cancelled by the user.`
if (processedOutput.length > 0) {
cancellationMessage += `\n\nOutput captured before cancellation:\n${processedOutput}`
}
this.callbacks.addToUserMessageContent({
type: "text",
text: cancellationMessage,
})
return true
} catch (error) {
Logger.error("Error in cancelBackgroundCommand", error)
return false
} finally {
try {
await this.callbacks.say("command_output", "Command execution has been cancelled.")
} catch (error) {
Logger.error("Failed to send cancellation notification", error)
}
}
}
/**
* Check if there's an active background command
*/
hasActiveBackgroundCommand(): boolean {
return !!this.activeBackgroundCommand
}
/**
* Get the active background command info (for external access)
*/
getActiveBackgroundCommand(): ActiveBackgroundCommand | undefined {
return this.activeBackgroundCommand
}
/**
* Get a summary of background commands for environment details
*/
getBackgroundCommandSummary(): string | undefined {
if (!this.activeBackgroundCommand) {
return undefined
}
const { command, outputLines } = this.activeBackgroundCommand
const recentOutput = outputLines.slice(-10).join("\n")
let summary = "# Background Commands\n"
summary += `## Running: \`${command}\`\n`
if (recentOutput) {
summary += `### Recent Output\n${recentOutput}`
}
return summary
}
/**
* Determines whether to show the background terminal suggestion.
* Shows suggestion if there have been 3+ shell integration warnings in the last hour,
* and we haven't shown the suggestion in the last hour.
*
* @returns true if the suggestion should be shown, false otherwise
*/
private shouldShowBackgroundTerminalSuggestion(): boolean {
const oneHourAgo = Date.now() - 60 * 60 * 1000
// Clean old timestamps (older than 1 hour)
this.shellIntegrationWarningTracker.timestamps = this.shellIntegrationWarningTracker.timestamps.filter(
(ts) => ts > oneHourAgo,
)
// Add current warning
this.shellIntegrationWarningTracker.timestamps.push(Date.now())
// Check if we've shown suggestion recently (within last hour)
if (
this.shellIntegrationWarningTracker.lastSuggestionShown &&
Date.now() - this.shellIntegrationWarningTracker.lastSuggestionShown < 60 * 60 * 1000
) {
return false
}
// Show suggestion if 3+ warnings in last hour
if (this.shellIntegrationWarningTracker.timestamps.length >= 3) {
this.shellIntegrationWarningTracker.lastSuggestionShown = Date.now()
return true
}
return false
}
/**
* Helper to find last index matching a predicate
*/
private findLastIndex<T>(array: T[], predicate: (item: T) => boolean): number {
for (let i = array.length - 1; i >= 0; i--) {
if (predicate(array[i])) {
return i
}
}
return -1
}
}
@@ -0,0 +1,350 @@
/**
* CommandOrchestrator - Shared command execution orchestration logic.
*
* This module contains the common orchestration logic for command execution
* that is shared between VSCode and Standalone terminal modes. It handles:
* - Output buffering and chunking
* - User interaction (ask/say callbacks)
* - "Proceed While Running" behavior
* - Timeout handling
* - Result formatting
*
* The actual process spawning/management is handled by the TerminalProcess
* implementations (VscodeTerminalProcess, StandaloneTerminalProcess).
*/
import { setTimeout as setTimeoutPromise } from "node:timers/promises"
import { formatResponse } from "@core/prompts/responses"
import { processFilesIntoText } from "@integrations/misc/extract-text"
import { Logger } from "@services/logging/Logger"
import { TerminalHangStage, TerminalUserInterventionAction, telemetryService } from "@services/telemetry"
import { COMMAND_CANCEL_TOKEN } from "@shared/ExtensionMessage"
import type {
CommandExecutorCallbacks,
ITerminalManager,
OrchestrationOptions,
OrchestrationResult,
TerminalProcessResultPromise,
} from "./types"
// Chunked terminal output buffering constants
export const CHUNK_LINE_COUNT = 20
export const CHUNK_BYTE_SIZE = 2048 // 2KB
export const CHUNK_DEBOUNCE_MS = 100
export const BUFFER_STUCK_TIMEOUT_MS = 6000 // 6 seconds
export const COMPLETION_TIMEOUT_MS = 6000 // 6 seconds
// Re-export types for convenience
export type { OrchestrationOptions, OrchestrationResult } from "./types"
/**
* Orchestrate command execution with shared logic for buffering, user interaction, and result formatting.
*
* @param process The terminal process (implements ITerminalProcess)
* @param terminalManager The terminal manager (for processOutput)
* @param callbacks The executor callbacks for UI interaction
* @param options Orchestration options
* @returns The orchestration result
*/
export async function orchestrateCommandExecution(
process: TerminalProcessResultPromise,
terminalManager: ITerminalManager,
callbacks: CommandExecutorCallbacks,
options: OrchestrationOptions,
): Promise<OrchestrationResult> {
const { command, timeoutSeconds, onOutputLine, showShellIntegrationSuggestion } = options
// Track command execution state
callbacks.updateBackgroundCommandState(true)
const clearCommandState = async () => {
callbacks.updateBackgroundCommandState(false)
// Mark the command message as completed
const clineMessages = callbacks.getClineMessages()
const lastCommandIndex = findLastIndex(clineMessages, (m) => m.ask === "command" || m.say === "command")
if (lastCommandIndex !== -1) {
await callbacks.updateClineMessage(lastCommandIndex, {
commandCompleted: true,
})
}
}
process.once("completed", clearCommandState)
process.once("error", clearCommandState)
process.catch(() => {
clearCommandState()
})
let userFeedback: { text?: string; images?: string[]; files?: string[] } | undefined
let didContinue = false
let didCancelViaUi = false
// Chunked terminal output buffering
let outputBuffer: string[] = []
let outputBufferSize: number = 0
let chunkTimer: NodeJS.Timeout | null = null
// Track if buffer gets stuck
let bufferStuckTimer: NodeJS.Timeout | null = null
/**
* Flush buffered output to the UI using ask() which waits for user response.
* This is the key mechanism for "Proceed While Running" - when user clicks the button,
* the ask() returns with response "yesButtonClicked".
*/
const flushBuffer = async (force = false) => {
if (outputBuffer.length === 0 && !force) {
return
}
const chunk = outputBuffer.join("\n")
outputBuffer = []
outputBufferSize = 0
if (!didContinue) {
// Start timer to detect if buffer gets stuck
bufferStuckTimer = setTimeout(() => {
telemetryService.captureTerminalHang(TerminalHangStage.BUFFER_STUCK)
bufferStuckTimer = null
}, BUFFER_STUCK_TIMEOUT_MS)
try {
// Use ask() to present output and wait for user response
// This enables "Proceed While Running" button functionality
const { response, text, images, files } = await callbacks.ask("command_output", chunk)
if (response === "yesButtonClicked") {
// Track when user clicks "Proceed While Running"
telemetryService.captureTerminalUserIntervention(TerminalUserInterventionAction.PROCESS_WHILE_RUNNING)
// Proceed while running - but still capture user feedback if provided
if (text || (images && images.length > 0) || (files && files.length > 0)) {
userFeedback = { text, images, files }
}
didContinue = true
process.continue()
} else if (response === "noButtonClicked" && text === COMMAND_CANCEL_TOKEN) {
telemetryService.captureTerminalUserIntervention(TerminalUserInterventionAction.CANCELLED)
didCancelViaUi = true
userFeedback = undefined
didContinue = true
process.continue()
outputBuffer = []
outputBufferSize = 0
await callbacks.say("command_output", "Command cancelled")
} else {
userFeedback = { text, images, files }
didContinue = true
process.continue()
// If more output accumulated, flush again
if (outputBuffer.length > 0) {
await flushBuffer()
}
}
} catch {
Logger.error("Error while asking for command output")
} finally {
// Clear the stuck timer
if (bufferStuckTimer) {
clearTimeout(bufferStuckTimer)
bufferStuckTimer = null
}
}
} else {
// After "Proceed While Running": stream output directly to UI
await callbacks.say("command_output", chunk)
}
}
const scheduleFlush = () => {
if (chunkTimer) {
clearTimeout(chunkTimer)
}
chunkTimer = setTimeout(async () => await flushBuffer(), CHUNK_DEBOUNCE_MS)
}
const outputLines: string[] = []
process.on("line", async (line: string) => {
if (didCancelViaUi) {
return
}
outputLines.push(line)
// Notify caller about output line (for background command tracking)
if (onOutputLine) {
onOutputLine(line)
}
// Apply buffered streaming
if (!didContinue) {
outputBuffer.push(line)
outputBufferSize += Buffer.byteLength(line, "utf8")
// Flush if buffer is large enough
if (outputBuffer.length >= CHUNK_LINE_COUNT || outputBufferSize >= CHUNK_BYTE_SIZE) {
await flushBuffer()
} else {
scheduleFlush()
}
} else {
// After "Proceed While Running": stream output directly to UI
await callbacks.say("command_output", line)
}
})
let completed = false
let completionTimer: NodeJS.Timeout | null = null
// Start timer to detect if waiting for completion takes too long
completionTimer = setTimeout(() => {
if (!completed) {
telemetryService.captureTerminalHang(TerminalHangStage.WAITING_FOR_COMPLETION)
completionTimer = null
}
}, COMPLETION_TIMEOUT_MS)
process.once("completed", async () => {
completed = true
// Clear the completion timer
if (completionTimer) {
clearTimeout(completionTimer)
completionTimer = null
}
// Flush any remaining buffered output
if (!didContinue && outputBuffer.length > 0) {
if (chunkTimer) {
clearTimeout(chunkTimer)
chunkTimer = null
}
await flushBuffer(true)
}
})
process.once("no_shell_integration", async () => {
if (showShellIntegrationSuggestion) {
await callbacks.say("shell_integration_warning_with_suggestion")
} else {
await callbacks.say("shell_integration_warning")
}
})
// Handle timeout if specified, or wait for process to complete
if (!didCancelViaUi) {
if (timeoutSeconds) {
const timeoutPromise = new Promise<never>((_, reject) => {
setTimeout(() => {
reject(new Error("COMMAND_TIMEOUT"))
}, timeoutSeconds * 1000)
})
try {
await Promise.race([process, timeoutPromise])
} catch (error: any) {
if (error.message === "COMMAND_TIMEOUT") {
// Timeout triggers "Proceed While Running" behavior
didContinue = true
process.continue()
// Clear all our timers
if (chunkTimer) {
clearTimeout(chunkTimer)
chunkTimer = null
}
if (completionTimer) {
clearTimeout(completionTimer)
completionTimer = null
}
// Process any output we captured before timeout
await setTimeoutPromise(50)
const result = terminalManager.processOutput(outputLines)
return {
userRejected: false,
result: `Command execution timed out after ${timeoutSeconds} seconds. ${result.length > 0 ? `\nOutput so far:\n${result}` : ""}`,
completed: false,
outputLines,
}
}
// Re-throw other errors
throw error
}
} else {
// No timeout - wait for process to complete
await process
}
}
// Clear timer if process completes normally
if (completionTimer) {
clearTimeout(completionTimer)
completionTimer = null
}
// Wait for a short delay to ensure all messages are sent to the webview
await setTimeoutPromise(50)
const result = terminalManager.processOutput(outputLines)
if (didCancelViaUi) {
return {
userRejected: true,
result: formatResponse.toolResult(
`Command cancelled. ${result.length > 0 ? `\nOutput captured before cancellation:\n${result}` : ""}`,
),
completed: false,
outputLines,
}
}
if (userFeedback) {
await callbacks.say("user_feedback", userFeedback.text, userFeedback.images, userFeedback.files)
let fileContentString = ""
if (userFeedback.files && userFeedback.files.length > 0) {
fileContentString = await processFilesIntoText(userFeedback.files)
}
return {
userRejected: true,
result: formatResponse.toolResult(
`Command is still running in the user's terminal.${
result.length > 0 ? `\nHere's the output so far:\n${result}` : ""
}\n\nThe user provided the following feedback:\n<feedback>\n${userFeedback.text}\n</feedback>`,
userFeedback.images,
fileContentString,
),
completed: false,
outputLines,
}
}
if (completed) {
return {
userRejected: false,
result: `Command executed.${result.length > 0 ? `\nOutput:\n${result}` : ""}`,
completed: true,
outputLines,
}
} else {
return {
userRejected: false,
result: `Command is still running in the user's terminal.${
result.length > 0 ? `\nHere's the output so far:\n${result}` : ""
}\n\nYou will be updated on the terminal status and new output in the future.`,
completed: false,
outputLines,
}
}
}
/**
* Helper to find last index matching a predicate
*/
export function findLastIndex<T>(array: T[], predicate: (item: T) => boolean): number {
for (let i = array.length - 1; i >= 0; i--) {
if (predicate(array[i])) {
return i
}
}
return -1
}
+36 -7
View File
@@ -8,7 +8,7 @@
*
* @example
* ```typescript
* import { StandaloneTerminalManager, ITerminalManager } from "@shared/terminal"
* import { StandaloneTerminalManager, ITerminalManager } from "@integrations/terminal"
*
* const manager: ITerminalManager = new StandaloneTerminalManager()
* const terminalInfo = await manager.getOrCreateTerminal("/path/to/cwd")
@@ -19,17 +19,46 @@
* ```
*/
export { StandaloneTerminal } from "./StandaloneTerminal"
export { StandaloneTerminalManager } from "./StandaloneTerminalManager"
// Export standalone implementations
export { StandaloneTerminalProcess } from "./StandaloneTerminalProcess"
export { StandaloneTerminalRegistry } from "./StandaloneTerminalRegistry"
// Export all types
// Export unified command executor
export { CommandExecutor } from "./CommandExecutor"
// Export command orchestrator (shared logic)
export {
BUFFER_STUCK_TIMEOUT_MS,
CHUNK_BYTE_SIZE,
CHUNK_DEBOUNCE_MS,
CHUNK_LINE_COUNT,
COMPLETION_TIMEOUT_MS,
findLastIndex,
orchestrateCommandExecution,
} from "./CommandOrchestrator"
// Export terminal process interface
// Export standalone terminal implementations
export { StandaloneTerminal } from "./standalone/StandaloneTerminal"
export { StandaloneTerminalManager } from "./standalone/StandaloneTerminalManager"
export { StandaloneTerminalProcess } from "./standalone/StandaloneTerminalProcess"
export { StandaloneTerminalRegistry } from "./standalone/StandaloneTerminalRegistry"
// Export all types from types.ts
export type {
// Command Executor types
ActiveBackgroundCommand,
AskResponse,
CommandExecutorCallbacks,
CommandExecutorConfig,
FullCommandExecutorConfig,
// Terminal types
ITerminal,
ITerminalManager,
ITerminalProcess,
ITerminalProcessResult,
// Command Orchestrator types
OrchestrationOptions,
OrchestrationResult,
StandaloneTerminalOptions,
TerminalInfo,
TerminalProcessEvents,
TerminalProcessResultPromise,
} from "./types"
@@ -7,7 +7,7 @@
import type { ChildProcess } from "child_process"
import type { ITerminal, StandaloneTerminalOptions } from "./types"
import type { ITerminal, StandaloneTerminalOptions } from "../types"
/**
* A standalone terminal implementation that doesn't depend on VSCode.
@@ -6,9 +6,9 @@
* VSCode's terminal API.
*/
import type { ITerminalManager, TerminalInfo, TerminalProcessResultPromise } from "../types"
import { StandaloneTerminalProcess } from "./StandaloneTerminalProcess"
import { StandaloneTerminalRegistry } from "./StandaloneTerminalRegistry"
import type { ITerminalManager, TerminalInfo, TerminalProcessResultPromise } from "./types"
/**
* Helper function to merge a process with a promise for the TerminalProcessResultPromise type.
@@ -4,24 +4,29 @@
* This class handles subprocess management for terminal commands when running
* outside of VSCode (CLI, JetBrains). It spawns child processes and streams
* their output through events.
*
* Implements ITerminalProcess interface for polymorphic usage with CommandExecutor.
*/
import { ChildProcess, spawn } from "child_process"
import { EventEmitter } from "events"
import type { ITerminal, ITerminalProcessResult } from "./types"
import type { ITerminal, ITerminalProcess, TerminalProcessEvents } from "../types"
/**
* Manages the execution of a command in a standalone terminal environment.
* Extends EventEmitter to provide real-time output streaming.
*
* Implements ITerminalProcess for polymorphic usage with CommandExecutor.
*
* Events:
* - 'line': Emitted for each line of output
* - 'completed': Emitted when the process completes
* - 'continue': Emitted when continue() is called
* - 'error': Emitted on process errors
* - 'no_shell_integration': Emitted for compatibility (never actually emitted in standalone)
*/
export class StandaloneTerminalProcess extends EventEmitter implements ITerminalProcessResult {
export class StandaloneTerminalProcess extends EventEmitter<TerminalProcessEvents> implements ITerminalProcess {
/** We don't need to wait since we control the process directly */
waitForShellIntegration: boolean = false
@@ -5,8 +5,8 @@
* functionality to create, retrieve, update, and remove terminals.
*/
import type { ITerminal, StandaloneTerminalOptions, TerminalInfo } from "../types"
import { StandaloneTerminal } from "./StandaloneTerminal"
import type { ITerminal, StandaloneTerminalOptions, TerminalInfo } from "./types"
/**
* Registry for tracking standalone terminal instances.
+172 -17
View File
@@ -4,7 +4,71 @@
* the StandaloneTerminalManager used in CLI/JetBrains environments.
*/
import { EventEmitter } from "events"
import type { ClineToolResponseContent } from "@shared/messages"
import type { EventEmitter } from "events"
// =============================================================================
// Terminal Process Types
// =============================================================================
/**
* Event types for terminal process
*/
export interface TerminalProcessEvents {
line: [line: string]
continue: []
completed: []
error: [error: Error]
no_shell_integration: []
}
/**
* Interface for terminal process implementations.
* Both VscodeTerminalProcess and StandaloneTerminalProcess implement this interface.
*
* Events emitted:
* - 'line': Emitted for each line of output
* - 'completed': Emitted when the process completes
* - 'continue': Emitted when continue() is called
* - 'error': Emitted on process errors
* - 'no_shell_integration': Emitted when shell integration is not available (VSCode only)
*/
export interface ITerminalProcess extends EventEmitter<TerminalProcessEvents> {
/**
* Whether the process is actively outputting (used to stall API requests)
*/
isHot: boolean
/**
* Whether to wait for shell integration before running commands.
* VSCode processes may need to wait, standalone processes don't.
*/
waitForShellIntegration: boolean
/**
* Continue execution without waiting for completion.
* Stops event emission and resolves the promise.
* This is called when user clicks "Proceed While Running".
*/
continue(): void
/**
* Get output that hasn't been retrieved yet.
* @returns The unretrieved output
*/
getUnretrievedOutput(): string
/**
* Terminate the process if it's still running.
* Only available for standalone processes (child_process).
* VSCode terminal processes cannot be terminated via this interface.
*/
terminate?(): void
}
// =============================================================================
// Terminal Types
// =============================================================================
/**
* Represents a terminal instance with its metadata and state.
@@ -54,28 +118,19 @@ export interface ITerminal {
}
/**
* Terminal process result that combines Promise functionality with event emission.
* Allows for both awaiting completion and listening to real-time output.
* Terminal process result interface.
* @deprecated Use ITerminalProcess instead.
* This is kept for backwards compatibility.
*/
export interface ITerminalProcessResult extends EventEmitter {
/** Whether the process is actively outputting (hot) */
isHot: boolean
/** Whether we're waiting for shell integration to activate */
waitForShellIntegration: boolean
/** Continue execution without waiting for completion */
continue(): void
/** Terminate the process (if supported) */
terminate?(): void
/** Get output that hasn't been retrieved yet */
getUnretrievedOutput(): string
}
export type ITerminalProcessResult = ITerminalProcess
/**
* Promise-like interface for terminal process results.
* Combines Promise<void> with ITerminalProcessResult for flexible usage.
* Combines Promise<void> with ITerminalProcess for flexible usage.
* This allows the process to be awaited while also providing access to events.
*/
export type TerminalProcessResultPromise = Promise<void> &
ITerminalProcessResult & {
ITerminalProcess & {
/** Listen for line output events */
on(event: "line", listener: (line: string) => void): TerminalProcessResultPromise
/** Listen for completion event */
@@ -187,3 +242,103 @@ export interface StandaloneTerminalOptions {
/** Shell path to use */
shellPath?: string
}
// =============================================================================
// Command Executor Types
// =============================================================================
/**
* Represents an active background command that can be cancelled
*/
export interface ActiveBackgroundCommand {
process: {
terminate?: () => void
continue?: () => void
}
command: string
outputLines: string[]
}
/**
* Response from an ask() call
*/
export interface AskResponse {
response: string // "yesButtonClicked" | "noButtonClicked" | "messageResponse"
text?: string
images?: string[]
files?: string[]
}
/**
* Callbacks for CommandExecutor to interact with Task state
* These are bound methods from the Task class that allow CommandExecutor
* to update UI and state without owning that state directly.
*/
export interface CommandExecutorCallbacks {
/** Display a message in the chat UI (non-blocking) */
say: (type: string, text?: string, images?: string[], files?: string[], partial?: boolean) => Promise<number | undefined>
/**
* Ask the user a question and wait for response (blocking)
* This is used for "Proceed While Running" flow where we need to wait for user input
*/
ask: (type: string, text?: string, partial?: boolean) => Promise<AskResponse>
/** Update the background command running state in the controller */
updateBackgroundCommandState: (running: boolean) => void
/** Update a cline message by index */
updateClineMessage: (index: number, updates: { commandCompleted?: boolean }) => Promise<void>
/** Get cline messages array */
getClineMessages: () => Array<{ ask?: string; say?: string }>
/** Add content to user message for next API request */
addToUserMessageContent: (content: { type: string; text: string }) => void
}
/**
* Configuration for CommandExecutor
*/
export interface CommandExecutorConfig {
/** Working directory for command execution */
cwd: string
/** Task ID for tracking */
taskId: string
/** Unique task identifier */
ulid: string
/** Terminal execution mode */
terminalExecutionMode: "vscodeTerminal" | "backgroundExec"
/** The primary terminal manager (VSCode or Standalone) */
terminalManager: ITerminalManager
}
/** Alias for backwards compatibility */
export type FullCommandExecutorConfig = CommandExecutorConfig
// =============================================================================
// Command Orchestrator Types
// =============================================================================
/**
* Options for command orchestration
*/
export interface OrchestrationOptions {
/** The command being executed */
command: string
/** Optional timeout in seconds */
timeoutSeconds?: number
/** Callback to track output lines for background command tracking */
onOutputLine?: (line: string) => void
/** Whether to show shell integration warning with suggestion */
showShellIntegrationSuggestion?: boolean
}
/**
* Result of command orchestration
*/
export interface OrchestrationResult {
/** Whether the user rejected/cancelled the command */
userRejected: boolean
/** The result content to return */
result: ClineToolResponseContent
/** Whether the command completed */
completed: boolean
/** All output lines captured */
outputLines: string[]
}
+126 -330
View File
@@ -1,6 +1,6 @@
/**
* Tests for BannerService
* Tests API fetching, caching, and rule evaluation logic
* Tests API fetching, caching, and client-side provider filtering
*/
import type { BannerRules } from "@shared/ClineBanner"
@@ -59,8 +59,6 @@ describe("BannerService", () => {
severity: "info" as const,
placement: "top" as const,
rulesJson: "{}",
activeFrom: new Date(Date.now() - 86400000).toISOString(),
activeTo: new Date(Date.now() + 86400000).toISOString(),
},
],
},
@@ -127,85 +125,7 @@ describe("BannerService", () => {
})
})
describe("Date Range Filtering", () => {
it("should filter out expired banners", async () => {
const mockResponse = {
data: {
data: {
items: [
{
id: "bnr_expired",
titleMd: "Expired",
bodyMd: "Test",
severity: "info" as const,
placement: "top" as const,
rulesJson: "{}",
activeFrom: new Date(Date.now() - 172800000).toISOString(),
activeTo: new Date(Date.now() - 86400000).toISOString(), // activeTo is in the Past
},
],
},
},
}
axiosGetStub.resolves(mockResponse)
const banners = await bannerService.fetchActiveBanners()
expect(banners).to.have.lengthOf(0)
})
it("should filter out future banners", async () => {
const mockResponse = {
data: {
data: {
items: [
{
id: "bnr_future",
titleMd: "Future",
bodyMd: "Test",
severity: "info" as const,
placement: "top" as const,
rulesJson: "{}",
activeFrom: new Date(Date.now() + 86400000).toISOString(), // activeFrom is in the Future
activeTo: new Date(Date.now() + 172800000).toISOString(),
},
],
},
},
}
axiosGetStub.resolves(mockResponse)
const banners = await bannerService.fetchActiveBanners()
expect(banners).to.have.lengthOf(0)
})
it("should include currently active banners", async () => {
const mockResponse = {
data: {
data: {
items: [
{
id: "bnr_active",
titleMd: "Active",
bodyMd: "Test",
severity: "info" as const,
placement: "top" as const,
rulesJson: "{}",
activeFrom: new Date(Date.now() - 86400000).toISOString(),
activeTo: new Date(Date.now() + 86400000).toISOString(),
},
],
},
},
}
axiosGetStub.resolves(mockResponse)
const banners = await bannerService.fetchActiveBanners()
expect(banners).to.have.lengthOf(1)
expect(banners[0].id).to.equal("bnr_active")
})
})
describe("API Provider Rule Evaluation", () => {
describe("API Provider Rule Evaluation (Client-Side)", () => {
it("should show banner when user has the required API provider configured", async () => {
const controllerWithOpenAI: Partial<Controller> = {
stateManager: {
@@ -320,254 +240,6 @@ describe("BannerService", () => {
})
})
describe("Audience Targeting", () => {
it("should show banner targeting all users", async () => {
const mockResponse = {
data: {
data: {
items: [
{
id: "bnr_all",
titleMd: "All Users",
bodyMd: "For everyone",
severity: "info" as const,
placement: "top" as const,
rulesJson: JSON.stringify({ audience: ["all"] } as BannerRules),
},
],
},
},
}
axiosGetStub.resolves(mockResponse)
const banners = await bannerService.fetchActiveBanners()
expect(banners).to.have.lengthOf(1)
expect(banners[0].id).to.equal("bnr_all")
})
it("should show team admin banner to admin users", async () => {
const mockAuthService = {
getUserOrganizations: () => [{ id: "org1", name: "Test Org", roles: ["admin"] }],
getInfo: () => ({ user: { email: "test@example.com" } }),
} as any
bannerService.setAuthService(mockAuthService)
const mockResponse = {
data: {
data: {
items: [
{
id: "bnr_admin",
titleMd: "Team Admins",
bodyMd: "For team admins only",
severity: "info" as const,
placement: "top" as const,
rulesJson: JSON.stringify({ audience: ["team_admin_only"] } as BannerRules),
},
],
},
},
}
axiosGetStub.resolves(mockResponse)
const banners = await bannerService.fetchActiveBanners()
expect(banners).to.have.lengthOf(1)
expect(banners[0].id).to.equal("bnr_admin")
})
it("should show team admin banner to owner users", async () => {
const mockAuthService = {
getUserOrganizations: () => [{ id: "org1", name: "Test Org", roles: ["owner"] }],
getInfo: () => ({ user: { email: "test@example.com" } }),
} as any
bannerService.setAuthService(mockAuthService)
const mockResponse = {
data: {
data: {
items: [
{
id: "bnr_admin",
titleMd: "Team Admins",
bodyMd: "For team admins only",
severity: "info" as const,
placement: "top" as const,
rulesJson: JSON.stringify({ audience: ["team_admin_only"] } as BannerRules),
},
],
},
},
}
axiosGetStub.resolves(mockResponse)
const banners = await bannerService.fetchActiveBanners()
expect(banners).to.have.lengthOf(1)
expect(banners[0].id).to.equal("bnr_admin")
})
it("should NOT show team admin banner to non-admin users", async () => {
const mockAuthService = {
getUserOrganizations: () => [{ id: "org1", name: "Test Org", roles: ["member"] }],
getInfo: () => ({ user: { email: "test@example.com" } }),
} as any
bannerService.setAuthService(mockAuthService)
const mockResponse = {
data: {
data: {
items: [
{
id: "bnr_admin",
titleMd: "Team Admins",
bodyMd: "For team admins only",
severity: "info" as const,
placement: "top" as const,
rulesJson: JSON.stringify({ audience: ["team_admin_only"] } as BannerRules),
},
],
},
},
}
axiosGetStub.resolves(mockResponse)
const banners = await bannerService.fetchActiveBanners()
expect(banners).to.have.lengthOf(0)
})
it("should show team members banner to users in organizations", async () => {
const mockAuthService = {
getUserOrganizations: () => [{ id: "org1", name: "Test Org", roles: ["member"] }],
getInfo: () => ({ user: { email: "test@example.com" } }),
} as any
bannerService.setAuthService(mockAuthService)
const mockResponse = {
data: {
data: {
items: [
{
id: "bnr_team",
titleMd: "Team Members",
bodyMd: "For team members",
severity: "info" as const,
placement: "top" as const,
rulesJson: JSON.stringify({ audience: ["team_members"] } as BannerRules),
},
],
},
},
}
axiosGetStub.resolves(mockResponse)
const banners = await bannerService.fetchActiveBanners()
expect(banners).to.have.lengthOf(1)
expect(banners[0].id).to.equal("bnr_team")
})
it("should NOT show team members banner to users without organizations", async () => {
const mockAuthService = {
getUserOrganizations: () => [],
getInfo: () => ({ user: { email: "test@example.com" } }),
} as any
bannerService.setAuthService(mockAuthService)
const mockResponse = {
data: {
data: {
items: [
{
id: "bnr_team",
titleMd: "Team Members",
bodyMd: "For team members",
severity: "info" as const,
placement: "top" as const,
rulesJson: JSON.stringify({ audience: ["team_members"] } as BannerRules),
},
],
},
},
}
axiosGetStub.resolves(mockResponse)
const banners = await bannerService.fetchActiveBanners()
expect(banners).to.have.lengthOf(0)
})
it("should show personal banner to users without organizations", async () => {
const mockAuthService = {
getUserOrganizations: () => [],
getInfo: () => ({ user: { email: "test@example.com" } }),
} as any
bannerService.setAuthService(mockAuthService)
const mockResponse = {
data: {
data: {
items: [
{
id: "bnr_personal",
titleMd: "Personal Users",
bodyMd: "For personal users",
severity: "info" as const,
placement: "top" as const,
rulesJson: JSON.stringify({ audience: ["personal_only"] } as BannerRules),
},
],
},
},
}
axiosGetStub.resolves(mockResponse)
const banners = await bannerService.fetchActiveBanners()
expect(banners).to.have.lengthOf(1)
expect(banners[0].id).to.equal("bnr_personal")
})
it("should NOT show personal banner to users with organizations", async () => {
const mockAuthService = {
getUserOrganizations: () => [{ id: "org1", name: "Test Org", roles: ["member"] }],
getInfo: () => ({ user: { email: "test@example.com" } }),
} as any
bannerService.setAuthService(mockAuthService)
const mockResponse = {
data: {
data: {
items: [
{
id: "bnr_personal",
titleMd: "Personal Users",
bodyMd: "For personal users",
severity: "info" as const,
placement: "top" as const,
rulesJson: JSON.stringify({ audience: ["personal_only"] } as BannerRules),
},
],
},
},
}
axiosGetStub.resolves(mockResponse)
const banners = await bannerService.fetchActiveBanners()
expect(banners).to.have.lengthOf(0)
})
})
describe("Invalid or No Banner Rules", () => {
it("should handle malformed rules gracefully (fail open)", async () => {
const mockResponse = {
@@ -649,4 +321,128 @@ describe("BannerService", () => {
expect(axiosGetStub.calledTwice).to.be.true
})
})
describe("OS Parameter Integration", () => {
it("should send OS parameter in API request", async () => {
const mockResponse = {
data: {
data: {
items: [
{
id: "bnr_test",
titleMd: "Test Banner",
bodyMd: "This is a test",
severity: "info" as const,
placement: "top" as const,
rulesJson: "{}",
},
],
},
},
}
axiosGetStub.resolves(mockResponse)
await bannerService.fetchActiveBanners()
expect(axiosGetStub.calledOnce).to.be.true
const call = axiosGetStub.getCall(0)
const url = call.args[0]
expect(url).to.include("os=")
})
it("should handle OS detection errors gracefully", async () => {
const originalPlatform = process.platform
Object.defineProperty(process, "platform", {
get: () => {
throw new Error("Platform access denied")
},
configurable: true,
})
const mockResponse = {
data: {
data: {
items: [
{
id: "bnr_test",
titleMd: "Test Banner",
bodyMd: "This is a test",
severity: "info" as const,
placement: "top" as const,
rulesJson: "{}",
},
],
},
},
}
axiosGetStub.resolves(mockResponse)
const banners = await bannerService.fetchActiveBanners()
Object.defineProperty(process, "platform", {
value: originalPlatform,
configurable: true,
})
expect(banners).to.have.lengthOf(1)
expect(axiosGetStub.calledOnce).to.be.true
const call = axiosGetStub.getCall(0)
const url = call.args[0]
expect(url).to.include("os=unknown")
})
it("should detect different OS types correctly", async () => {
const testCases = [
{ platform: "win32", expected: "windows" },
{ platform: "darwin", expected: "macos" },
{ platform: "linux", expected: "linux" },
{ platform: "freebsd", expected: "unknown" },
]
for (const { platform, expected } of testCases) {
const originalPlatform = process.platform
Object.defineProperty(process, "platform", {
value: platform,
configurable: true,
})
const mockResponse = {
data: {
data: {
items: [
{
id: "bnr_test",
titleMd: "Test Banner",
bodyMd: "This is a test",
severity: "info" as const,
placement: "top" as const,
rulesJson: "{}",
},
],
},
},
}
axiosGetStub.resolves(mockResponse)
// Clear cache to ensure fresh API call for each platform test
bannerService.clearCache()
await bannerService.fetchActiveBanners()
expect(axiosGetStub.called).to.be.true
const call = axiosGetStub.lastCall
expect(call).to.not.be.null
const url = call.args[0]
expect(url).to.include(`os=${expected}`)
Object.defineProperty(process, "platform", {
value: originalPlatform,
configurable: true,
})
axiosGetStub.resetHistory()
}
})
})
})
+114 -262
View File
@@ -1,5 +1,4 @@
import type { Banner, BannerRules, BannersResponse } from "@shared/ClineBanner"
import { isClineInternalTester } from "@shared/internal/account"
import axios from "axios"
import { ClineEnv } from "@/config"
import type { Controller } from "@/core/controller"
@@ -74,6 +73,8 @@ export class BannerService {
/**
* Fetches active banners from the API
* Backend handles all filtering based on ide and user context
* Extension only filters by providers (API provider configuration)
* @param forceRefresh If true, bypasses cache and fetches fresh data
* @returns Array of banners that match current environment
*/
@@ -86,21 +87,36 @@ export class BannerService {
return this._cachedBanners
}
// Fetch from API
let url: string
try {
url = new URL("/banners/v1/messages", this._baseUrl).toString()
Logger.log(`BannerService: Fetching banners from ${url}`)
} catch (urlError) {
console.error("Error constructing URL:", urlError)
throw urlError
const ideType = await this.getIdeType()
const extensionVersion = await this.getExtensionVersion()
const osType = await this.getOSType()
const urlObj = new URL("/banners/v1/messages", this._baseUrl)
urlObj.searchParams.set("ide", ideType)
if (extensionVersion) {
urlObj.searchParams.set("extension_version", extensionVersion)
}
urlObj.searchParams.set("os", osType)
const url = urlObj.toString()
Logger.log(`BannerService: Fetching banners from ${url}`)
const authService = this.getAuthServiceInstance()
let token: string | null = null
if (authService) {
token = await authService.getAuthToken()
}
const headers: Record<string, string> = {
"Content-Type": "application/json",
}
if (token) {
headers["Authorization"] = `Bearer ${token}`
}
const response = await axios.get<BannersResponse>(url, {
timeout: 10000, // 10 second timeout
headers: {
"Content-Type": "application/json",
},
timeout: 10000,
headers,
...getAxiosSettings(),
})
@@ -109,17 +125,12 @@ export class BannerService {
return []
}
const allBanners = response.data.data.items
Logger.log(`BannerService: Received ${allBanners.length} banners from API`)
const backendFilteredBanners = response.data.data.items
Logger.log(`BannerService: Received ${backendFilteredBanners.length} banners from backend (already filtered)`)
// Filter banners based on rules evaluation
const matchingBanners = []
for (const banner of allBanners) {
if (await this.evaluateBannerRules(banner)) {
matchingBanners.push(banner)
}
}
Logger.log(`BannerService: ${matchingBanners.length} banners match current environment`)
// Client-side filtering: Only filter by providers
const matchingBanners = backendFilteredBanners.filter((banner) => this.matchesProviderRule(banner))
Logger.log(`BannerService: ${matchingBanners.length} banners match provider requirements`)
// Update cache
this._cachedBanners = matchingBanners
@@ -134,164 +145,107 @@ export class BannerService {
}
/**
* Evaluates banner rules against the current environment
* @param banner Banner to evaluate
* @returns true if banner should be displayed
* Gets the current extension version
* @returns Extension version string (e.g., "3.39.2")
*/
private async evaluateBannerRules(banner: Banner): Promise<boolean> {
private async getExtensionVersion(): Promise<string> {
try {
// Check date range first (active_from and active_to)
if (!this.isWithinActiveDateRange(banner)) {
Logger.log(`BannerService: Banner ${banner.id} filtered out - outside active date range`)
return false
}
const hostVersion = await HostProvider.env.getHostVersion({})
return hostVersion.clineVersion || ""
} catch (error) {
Logger.error("BannerService: Error getting extension version", error)
return ""
}
}
// Parse rules JSON
/**
* Client-side filtering by providers rule only
* Backend handles all other filtering (ide, employee_only, audience, org_type, version)
* @param banner Banner to check
* @returns true if banner matches provider requirements or has no provider restrictions
*/
private matchesProviderRule(banner: Banner): boolean {
try {
const rules: BannerRules = JSON.parse(banner.rulesJson || "{}")
// Check IDE rule
if (rules.ide && rules.ide.length > 0) {
const currentIde = await this.getIdeType()
if (currentIde && !rules.ide.includes(currentIde)) {
Logger.log(
`BannerService: Banner ${banner.id} filtered out by IDE rule (requires: ${rules.ide.join(", ")}, current: ${currentIde})`,
)
return false
}
if (!rules.providers || rules.providers.length === 0) {
return true
}
// Check auth provider rule
if (rules.auth && rules.auth.length > 0 && this._controller) {
const authProvider = this.getAuthProvider()
if (authProvider && !rules.auth.includes(authProvider)) {
Logger.log(
`BannerService: Banner ${banner.id} filtered out by auth rule (requires: ${rules.auth.join(", ")}, current: ${authProvider})`,
)
return false
const apiConfiguration = this._controller.stateManager.getApiConfiguration()
const hasAnyProvider = rules.providers.some((provider) => {
switch (provider) {
case "anthropic":
case "claude-code":
return !!apiConfiguration?.apiKey
case "openai":
case "openai-native":
return !!apiConfiguration?.openAiApiKey || !!apiConfiguration?.openAiNativeApiKey
case "openrouter":
return !!apiConfiguration?.openRouterApiKey
case "bedrock":
return !!apiConfiguration?.awsAccessKey || !!apiConfiguration?.awsBedrockApiKey
case "gemini":
return !!apiConfiguration?.geminiApiKey
case "deepseek":
return !!apiConfiguration?.deepSeekApiKey
case "qwen":
case "qwen-code":
return !!apiConfiguration?.qwenApiKey
case "mistral":
return !!apiConfiguration?.mistralApiKey
case "ollama":
return !!apiConfiguration?.ollamaApiKey
case "xai":
return !!apiConfiguration?.xaiApiKey
case "cerebras":
return !!apiConfiguration?.cerebrasApiKey
case "groq":
return !!apiConfiguration?.groqApiKey
case "cline":
return (
apiConfiguration?.planModeApiProvider === "cline" || apiConfiguration?.actModeApiProvider === "cline"
)
default:
return false
}
})
if (!hasAnyProvider) {
Logger.log(
`BannerService: Banner ${banner.id} filtered by client - user doesn't have any of these providers configured: ${rules.providers.join(", ")}`,
)
}
// Check API providers rule - show banner if user has ANY of the specified providers configured
if (rules.providers && rules.providers.length > 0 && this._controller) {
const apiConfiguration = this._controller.stateManager.getApiConfiguration()
const hasAnyProvider = rules.providers.some((provider) => {
switch (provider) {
case "anthropic":
case "claude-code":
return !!apiConfiguration?.apiKey
case "openai":
case "openai-native":
return !!apiConfiguration?.openAiApiKey || !!apiConfiguration?.openAiNativeApiKey
case "openrouter":
return !!apiConfiguration?.openRouterApiKey
case "bedrock":
return !!apiConfiguration?.awsAccessKey || !!apiConfiguration?.awsBedrockApiKey
case "gemini":
return !!apiConfiguration?.geminiApiKey
case "deepseek":
return !!apiConfiguration?.deepSeekApiKey
case "qwen":
case "qwen-code":
return !!apiConfiguration?.qwenApiKey
case "mistral":
return !!apiConfiguration?.mistralApiKey
case "ollama":
return !!apiConfiguration?.ollamaApiKey
case "xai":
return !!apiConfiguration?.xaiApiKey
case "cerebras":
return !!apiConfiguration?.cerebrasApiKey
case "groq":
return !!apiConfiguration?.groqApiKey
case "cline":
return (
apiConfiguration?.planModeApiProvider === "cline" ||
apiConfiguration?.actModeApiProvider === "cline"
)
default:
return false
}
})
if (!hasAnyProvider) {
Logger.log(
`BannerService: Banner ${banner.id} filtered out - user doesn't have any of these providers configured: ${rules.providers.join(", ")}`,
)
return false
}
}
// Check employee only rule
if (rules.employee_only && this._controller) {
const isEmployee = this.isEmployee()
if (!isEmployee) {
Logger.log(`BannerService: Banner ${banner.id} filtered out - employee only`)
return false
}
}
if (rules.audience && rules.audience.length > 0 && this._controller) {
const matchesAnyAudience = rules.audience.some((audienceType) => {
switch (audienceType) {
case "all":
return true
case "team_admin_only":
const isTeamAdmin = this.isUserTeamAdmin()
return isTeamAdmin
case "team_members":
const hasOrganizations = this.hasOrganizations()
return hasOrganizations
case "personal_only":
const hasOrgs = this.hasOrganizations()
return !hasOrgs
default:
return false
}
})
if (!matchesAnyAudience) {
return false
}
}
Logger.log(`BannerService: Banner ${banner.id} passed all rules checks`)
return true
return hasAnyProvider
} catch (error) {
// If rules can't be parsed or evaluated, show the banner (fail open)
Logger.log(
`BannerService: Error evaluating rules for banner ${banner.id}: ${error instanceof Error ? error.message : String(error)}`,
`BannerService: Error parsing provider rules for banner ${banner.id}: ${error instanceof Error ? error.message : String(error)}`,
)
return true
}
}
/**
* Checks if the banner is within its active date range
* @param banner Banner to check
* @returns true if current date is within activeFrom and activeTo range
* Gets the current Operating System
* @returns OS type (windows, linux, macos or unknown)
*/
private isWithinActiveDateRange(banner: Banner): boolean {
const now = new Date()
if (banner.activeFrom) {
const activeFrom = new Date(banner.activeFrom)
if (now < activeFrom) {
return false
private async getOSType(): Promise<string> {
try {
switch (process.platform) {
case "win32":
return "windows"
case "linux":
return "linux"
case "darwin":
return "macos"
default:
return "unknown"
}
} catch (error) {
Logger.error("BannerService: Error getting OS type", error)
return "unknown"
}
if (banner.activeTo) {
const activeTo = new Date(banner.activeTo)
if (now > activeTo) {
return false
}
}
return true
}
/**
@@ -322,108 +276,6 @@ export class BannerService {
}
}
/**
* Gets the current auth provider name
* @returns Auth provider name (firebase, workos, or unknown)
*/
private getAuthProvider(): string {
try {
// Get auth provider from AuthService
const authService = this.getAuthServiceInstance()
if (!authService) {
return "unknown"
}
const authInfo = authService.getInfo()
// Check if user is authenticated
if (!authInfo.user) {
return "other"
}
// Get provider name using public method
const providerName = authService.getProviderName()
if (providerName) {
// Map provider names to expected values
if (providerName === "cline") {
return "workos"
}
return providerName
}
return "unknown"
} catch (error) {
Logger.error("BannerService: Error getting auth provider", error)
return "unknown"
}
}
/**
* Checks if the current user is a Cline employee
* @returns true if user has a @cline.bot email or is a trusted tester
*/
private isEmployee(): boolean {
try {
const authService = this.getAuthServiceInstance()
if (!authService) {
return false
}
const authInfo = authService.getInfo()
if (!authInfo.user || !authInfo.user.email) {
return false
}
return isClineInternalTester(authInfo.user.email)
} catch (error) {
Logger.error("BannerService: Error checking employee status", error)
return false
}
}
/**
* Checks if the current user is a team admin
* @returns true if user is an admin or owner of any organization
*/
private isUserTeamAdmin(): boolean {
try {
const authService = this.getAuthServiceInstance()
if (!authService) {
return false
}
const organizations = authService.getUserOrganizations()
if (!organizations || organizations.length === 0) {
return false
}
// Check if user has admin or owner role in any organization
// Admin and owner roles have the same permissions
return organizations.some((org: any) => org.roles && (org.roles.includes("admin") || org.roles.includes("owner")))
} catch (error) {
Logger.error("BannerService: Error checking team admin status", error)
return false
}
}
/**
* Checks if the current user is part of any organization
* @returns true if user has one or more organizations
*/
private hasOrganizations(): boolean {
try {
const authService = this.getAuthServiceInstance()
if (!authService) {
return false
}
const organizations = authService.getUserOrganizations()
return !!(organizations && organizations.length > 0)
} catch (error) {
Logger.error("BannerService: Error checking organizations", error)
return false
}
}
/**
* Gets the AuthService instance
* @returns AuthService instance or undefined if not available
+33 -20
View File
@@ -36,6 +36,7 @@ import { z } from "zod"
import { HostProvider } from "@/hosts/host-provider"
import { fetch } from "@/shared/net"
import { ShowMessageType } from "@/shared/proto/host/window"
import { expandEnvironmentVariables } from "@/utils/envExpansion"
import { getServerAuthHash } from "@/utils/mcpAuth"
import { TelemetryService } from "../telemetry/TelemetryService"
import { DEFAULT_REQUEST_TIMEOUT_MS } from "./constants"
@@ -152,6 +153,10 @@ export class McpHub {
return undefined
}
// Expand environment variables before validation
// This allows ${env:VAR_NAME} syntax in URLs, headers, env vars, etc.
config = expandEnvironmentVariables(config)
// Validate against schema
const result = McpSettingsSchema.safeParse(config)
if (!result.success) {
@@ -236,6 +241,12 @@ export class McpHub {
}
try {
// Store unexpanded config for display/comparison (keeps credentials out of stored config)
const configForStorage = JSON.stringify(config)
// Expand environment variables in config before using it
const expandedConfig = expandEnvironmentVariables(config)
// Each MCP server requires its own transport connection and has unique capabilities, configurations, and error handling. Having separate clients also allows proper scoping of resources/tools and independent server management like reconnection.
const client = new Client(
{
@@ -251,20 +262,19 @@ export class McpHub {
// Create OAuth provider for remote transports (SSE and HTTP)
const authProvider =
config.type === "sse" || config.type === "streamableHttp"
? await this.mcpOAuthManager.getOrCreateProvider(name, config.url)
expandedConfig.type === "sse" || expandedConfig.type === "streamableHttp"
? await this.mcpOAuthManager.getOrCreateProvider(name, expandedConfig.url)
: undefined
switch (config.type) {
switch (expandedConfig.type) {
case "stdio": {
transport = new StdioClientTransport({
command: config.command,
args: config.args,
cwd: config.cwd,
command: expandedConfig.command,
args: expandedConfig.args,
cwd: expandedConfig.cwd,
env: {
// ...(config.env ? await injectEnv(config.env) : {}), // Commented out as injectEnv is not found
...getDefaultEnvironment(),
...(config.env || {}), // Use config.env directly or an empty object
...(expandedConfig.env || {}), // Now has expanded environment variables
},
stderr: "pipe",
})
@@ -319,12 +329,12 @@ export class McpHub {
const sseOptions = {
authProvider,
requestInit: {
headers: config.headers,
headers: expandedConfig.headers,
},
}
const reconnectingEventSourceOptions = {
max_retry_time: 5000,
withCredentials: !!config.headers?.["Authorization"],
withCredentials: !!expandedConfig.headers?.["Authorization"],
// IMPORTANT: Custom fetch function is required for SSE with OAuth
// When we provide eventSourceInit, we override the SDK's default fetch
// The SDK's default would call _commonHeaders() for auth, but since we're
@@ -346,7 +356,7 @@ export class McpHub {
}
// Use ReconnectingEventSource for auto-reconnection on connection drops
global.EventSource = ReconnectingEventSource
transport = new SSEClientTransport(new URL(config.url), {
transport = new SSEClientTransport(new URL(expandedConfig.url), {
...sseOptions,
eventSourceInit: reconnectingEventSourceOptions,
})
@@ -364,10 +374,10 @@ export class McpHub {
break
}
case "streamableHttp": {
transport = new StreamableHTTPClientTransport(new URL(config.url), {
transport = new StreamableHTTPClientTransport(new URL(expandedConfig.url), {
authProvider,
requestInit: {
headers: config.headers ?? undefined,
headers: expandedConfig.headers ?? undefined,
},
})
transport.onerror = async (error) => {
@@ -389,7 +399,7 @@ export class McpHub {
const connection: McpConnection = {
server: {
name,
config: JSON.stringify(config),
config: configForStorage,
status: "connecting",
disabled: config.disabled,
uid: this.getMcpServerKey(name),
@@ -1118,11 +1128,6 @@ export class McpHub {
throw new Error(`An MCP server with the name "${serverName}" already exists`)
}
const urlValidation = z.string().url().safeParse(serverUrl)
if (!urlValidation.success) {
throw new Error(`Invalid server URL: ${serverUrl}. Please provide a valid URL.`)
}
const serverConfig = {
url: serverUrl,
type: transportType,
@@ -1130,7 +1135,15 @@ export class McpHub {
autoApprove: [],
}
const parsedConfig = ServerConfigSchema.parse(serverConfig)
// Expand environment variables for validation
const expandedConfig = expandEnvironmentVariables(serverConfig)
const urlValidation = z.string().url().safeParse(expandedConfig.url)
if (!urlValidation.success) {
throw new Error(`Invalid server URL: ${expandedConfig.url}. Please provide a valid URL.`)
}
const parsedConfig = ServerConfigSchema.parse(expandedConfig)
settings.mcpServers[serverName] = parsedConfig
const settingsPath = await this.getMcpSettingsFilePath()
+188 -1
View File
@@ -16,7 +16,7 @@ import { TelemetryProviderFactory } from "./TelemetryProviderFactory"
* When adding a new category, add it both here and to the initial values in telemetryCategoryEnabled
* Ensure `if (!this.isCategoryEnabled('<category_name>')` is added to the capture method
*/
type TelemetryCategory = "checkpoints" | "browser" | "focus_chain" | "dictation" | "subagents"
type TelemetryCategory = "checkpoints" | "browser" | "focus_chain" | "dictation" | "subagents" | "hooks"
/**
* Enum for terminal output failure reasons
@@ -89,6 +89,7 @@ export class TelemetryService {
["dictation", true], // Dictation telemetry enabled
["focus_chain", true], // Focus Chain telemetry enabled
["subagents", true], // CLI Subagents telemetry enabled
["hooks", true], // Hooks telemetry enabled
])
private userId?: string
@@ -126,6 +127,14 @@ export class TelemetryService {
DURATION_SECONDS: "cline.api.duration.seconds",
THROUGHPUT_TOKENS_PER_SECOND: "cline.api.throughput.tokens_per_second",
},
HOOKS: {
EXECUTIONS_TOTAL: "cline.hooks.executions.total",
DURATION_SECONDS: "cline.hooks.duration.seconds",
FAILURES_TOTAL: "cline.hooks.failures.total",
CANCELLATIONS_TOTAL: "cline.hooks.cancellations.total",
CONTEXT_MODIFICATIONS_TOTAL: "cline.hooks.context_modifications.total",
CACHE_ACCESSES_TOTAL: "cline.hooks.cache.accesses.total",
},
}
// Event constants for tracking user interactions and system events
private static readonly EVENTS = {
@@ -267,6 +276,19 @@ export class TelemetryService {
// Tracks when the rules menu button is clicked
RULES_MENU_OPENED: "ui.rules_menu_opened",
},
// Hooks-related events for tracking hook execution
HOOKS: {
// Tracks when hooks feature is enabled
ENABLED: "hooks.enabled",
// Tracks when hooks feature is disabled
DISABLED: "hooks.disabled",
// Tracks when a hook requests task cancellation
CANCEL_REQUESTED: "hooks.cancel_requested",
// Tracks when a hook modifies context
CONTEXT_MODIFIED: "hooks.context_modified",
// Tracks when hook discovery completes
DISCOVERY_COMPLETED: "hooks.discovery_completed",
},
}
public static async create(): Promise<TelemetryService> {
@@ -1926,6 +1948,171 @@ export class TelemetryService {
})
}
// Hooks telemetry methods
/**
* Records hook discovery cache access (hit or miss)
* @param hookName The type of hook being accessed
* @param cacheHit Whether the cache had the result (true) or miss (false)
*/
public captureHookCacheAccess(hookName: string, cacheHit: boolean) {
if (!this.isCategoryEnabled("hooks")) {
return
}
// Record cache access counter with hit/miss attribute
// This allows deriving hit rate: hits / (hits + misses)
this.recordCounter(TelemetryService.METRICS.HOOKS.CACHE_ACCESSES_TOTAL, 1, {
hookName,
cacheHit: cacheHit.toString(),
})
}
// Simplified Hook Telemetry API (following MCP pattern)
/**
* Records hook execution events with a unified status-based approach.
* This is the simplified API that consolidates multiple hook execution methods.
*
* @param ulid Task identifier
* @param hookName Type of hook (PreToolUse, PostToolUse, etc.)
* @param status Current execution status
* @param metadata Optional execution metadata
*/
public captureHookExecution(
ulid: string,
hookName: string,
status: "started" | "completed" | "failed" | "cancelled",
metadata?: {
source?: "global" | "workspace"
toolName?: string
durationMs?: number
exitCode?: number
errorType?: "timeout" | "execution" | "validation"
errorMessage?: string
cancelRequested?: boolean
contextModified?: boolean
contextSize?: number
},
) {
if (!this.isCategoryEnabled("hooks")) {
return
}
const properties: TelemetryProperties = {
ulid,
hookName,
status,
timestamp: new Date().toISOString(),
...(metadata?.source && { source: metadata.source }),
...(metadata?.toolName && { toolName: metadata.toolName }),
...(metadata?.durationMs !== undefined && { durationMs: metadata.durationMs }),
...(metadata?.exitCode !== undefined && { exitCode: metadata.exitCode }),
...(metadata?.errorType && { errorType: metadata.errorType }),
...(metadata?.errorMessage && {
errorMessage: metadata.errorMessage.substring(0, MAX_ERROR_MESSAGE_LENGTH),
}),
...(metadata?.cancelRequested !== undefined && { cancelRequested: metadata.cancelRequested }),
...(metadata?.contextModified !== undefined && { contextModified: metadata.contextModified }),
...(metadata?.contextSize !== undefined && { contextSize: metadata.contextSize }),
}
// Single event for all statuses
this.capture({
event: "hooks.execution",
properties,
})
// Record metrics based on status
const hookAttributes = {
ulid,
hookName,
status,
...(metadata?.source && { source: metadata.source }),
...(metadata?.toolName && { toolName: metadata.toolName }),
}
if (status === "started") {
this.recordCounter(TelemetryService.METRICS.HOOKS.EXECUTIONS_TOTAL, 1, hookAttributes)
} else if (status === "completed") {
if (metadata?.durationMs !== undefined) {
this.recordHistogram(TelemetryService.METRICS.HOOKS.DURATION_SECONDS, metadata.durationMs / 1000, hookAttributes)
}
if (metadata?.cancelRequested) {
this.recordCounter(TelemetryService.METRICS.HOOKS.CANCELLATIONS_TOTAL, 1, hookAttributes)
}
if (metadata?.contextModified) {
this.recordCounter(TelemetryService.METRICS.HOOKS.CONTEXT_MODIFICATIONS_TOTAL, 1, hookAttributes)
}
} else if (status === "failed") {
this.recordCounter(TelemetryService.METRICS.HOOKS.FAILURES_TOTAL, 1, {
...hookAttributes,
errorType: metadata?.errorType || "unknown",
})
} else if (status === "cancelled") {
this.recordCounter(TelemetryService.METRICS.HOOKS.CANCELLATIONS_TOTAL, 1, hookAttributes)
}
}
/**
* Records hook discovery results (simplified version).
*
* @param hookName The type of hook being discovered
* @param globalCount Number of global hooks found
* @param workspaceCount Number of workspace-specific hooks found
*/
public captureHookDiscovery(hookName: string, globalCount: number, workspaceCount: number) {
if (!this.isCategoryEnabled("hooks")) {
return
}
this.capture({
event: TelemetryService.EVENTS.HOOKS.DISCOVERY_COMPLETED,
properties: {
hookName,
globalCount,
workspaceCount,
totalCount: globalCount + workspaceCount,
timestamp: new Date().toISOString(),
},
})
}
/**
* Safely executes a telemetry call with error protection.
*
* Use for critical execution paths where telemetry errors could break functionality:
* - Hook execution (during tool execution)
* - Browser automation (during active sessions)
* - Auth flows, task initialization
* - MCP server operations
*
* Not needed for non-critical, fire-and-forget events:
* - UI events (clicks, navigation)
* - Post-completion events
* - Background operations
*
* This wrapper protects against both pre-provider errors (parameter construction,
* property access, calculations) and provider-level errors (network, API failures).
*
* @param telemetryFn The telemetry function to execute
* @param context Optional context string for debugging (e.g., "HookFactory.exec")
*
* @example
* telemetryService.safeCapture(
* () => telemetryService.captureHookExecution(taskId, hookName, "started", {...}),
* 'HookFactory.exec.started'
* )
*/
public safeCapture(telemetryFn: () => void, context?: string): void {
try {
telemetryFn()
} catch (error) {
const contextStr = context ? ` [Context: ${context}]` : ""
console.error(`[Telemetry] Failed to capture telemetry${contextStr}:`, error)
}
}
/**
* Clean up resources when the service is disposed
*/
+4
View File
@@ -43,6 +43,10 @@ export interface BannerRules {
providers?: string[]
/** Target specific audience segment */
audience?: BannerAudience[]
/** Target team vs enterprise organizations */
org_type?: "all" | "team_only" | "enterprise_only" | ""
/** Minimum extension version required (e.g., "3.39.2") */
min_extension_version?: string
}
/**
+54
View File
@@ -0,0 +1,54 @@
export interface SlashCommand {
name: string
description?: string
section?: "default" | "custom"
cliCompatible?: boolean
}
export const BASE_SLASH_COMMANDS: SlashCommand[] = [
{
name: "newtask",
description: "Create a new task with context from the current task",
section: "default",
cliCompatible: true,
},
{
name: "smol",
description: "Condenses your current context window",
section: "default",
cliCompatible: true,
},
{
name: "newrule",
description: "Create a new Cline rule based on your conversation",
section: "default",
cliCompatible: true,
},
{
name: "reportbug",
description: "Create a Github issue with Cline",
section: "default",
cliCompatible: true,
},
{
name: "deep-planning",
description: "Create a comprehensive implementation plan before coding",
section: "default",
cliCompatible: true,
},
{
name: "subagent",
description: "Invoke a Cline CLI subagent for focused research tasks",
section: "default",
cliCompatible: true,
},
]
// VS Code-only slash commands
export const VSCODE_ONLY_COMMANDS: SlashCommand[] = [
{
name: "explain-changes",
description: "Explain code changes between git refs (PRs, commits, branches, etc.)",
section: "default",
},
]
+1
View File
@@ -29,6 +29,7 @@ export interface RemoteConfigExtraFields {
allowedMCPServers: Array<{ id: string }>
remoteGlobalRules?: GlobalInstructionsFile[]
remoteGlobalWorkflows?: GlobalInstructionsFile[]
blockPersonalRemoteMCPServers?: boolean
}
export type RemoteConfigFields = GlobalStateAndSettings & RemoteConfigExtraFields
+407
View File
@@ -0,0 +1,407 @@
import { afterEach, beforeEach, describe, it } from "mocha";
import "should";
import * as sinon from "sinon";
import { Controller } from "../core/controller";
import { getAvailableSlashCommands } from "../core/controller/slash/getAvailableSlashCommands";
import { EmptyRequest } from "../shared/proto/cline/common";
import { BASE_SLASH_COMMANDS } from "../shared/slashCommands";
/**
* Unit tests for getAvailableSlashCommands RPC endpoint
* Tests the slash command discovery and filtering functionality
*/
describe("getAvailableSlashCommands", () => {
let mockController: Partial<Controller>;
let mockStateManager: {
getWorkspaceStateKey: sinon.SinonStub;
getGlobalSettingsKey: sinon.SinonStub;
getGlobalStateKey: sinon.SinonStub;
getRemoteConfigSettings: sinon.SinonStub;
};
beforeEach(() => {
mockStateManager = {
getWorkspaceStateKey: sinon.stub(),
getGlobalSettingsKey: sinon.stub(),
getGlobalStateKey: sinon.stub(),
getRemoteConfigSettings: sinon.stub(),
};
// Default stubs return empty/null values
mockStateManager.getWorkspaceStateKey.returns(null);
mockStateManager.getGlobalSettingsKey.returns(null);
mockStateManager.getGlobalStateKey.returns(null);
mockStateManager.getRemoteConfigSettings.returns(null);
mockController = {
stateManager: mockStateManager as any,
};
});
afterEach(() => {
sinon.restore();
});
describe("Base Slash Commands", () => {
it("should return all base slash commands", async () => {
const response = await getAvailableSlashCommands(
mockController as Controller,
EmptyRequest.create()
);
// Should have at least all base commands
response.commands.length.should.be.greaterThanOrEqual(
BASE_SLASH_COMMANDS.length
);
// Verify each base command is present
for (const baseCmd of BASE_SLASH_COMMANDS) {
const found = response.commands.find(
(cmd) => cmd.name === baseCmd.name
);
found!.should.not.be.undefined();
found!.description.should.equal(baseCmd.description);
found!.section.should.equal("default");
found!.cliCompatible.should.equal(baseCmd.cliCompatible ?? false);
}
});
it("should mark base commands with section 'default'", async () => {
const response = await getAvailableSlashCommands(
mockController as Controller,
EmptyRequest.create()
);
const baseCommandNames = BASE_SLASH_COMMANDS.map((cmd) => cmd.name);
for (const cmd of response.commands) {
if (baseCommandNames.includes(cmd.name)) {
cmd.section.should.equal("default");
}
}
});
});
describe("Local Workflow Toggles", () => {
it("should include enabled local workflows", async () => {
mockStateManager.getWorkspaceStateKey
.withArgs("workflowToggles")
.returns({
"/path/to/my-workflow.md": true,
"/path/to/another-workflow.md": true,
});
const response = await getAvailableSlashCommands(
mockController as Controller,
EmptyRequest.create()
);
const myWorkflow = response.commands.find(
(cmd) => cmd.name === "my-workflow.md"
);
myWorkflow!.should.not.be.undefined();
myWorkflow!.section.should.equal("custom");
myWorkflow!.cliCompatible.should.equal(true);
const anotherWorkflow = response.commands.find(
(cmd) => cmd.name === "another-workflow.md"
);
anotherWorkflow!.should.not.be.undefined();
});
it("should exclude disabled local workflows", async () => {
mockStateManager.getWorkspaceStateKey
.withArgs("workflowToggles")
.returns({
"/path/to/enabled-workflow.md": true,
"/path/to/disabled-workflow.md": false,
});
const response = await getAvailableSlashCommands(
mockController as Controller,
EmptyRequest.create()
);
const enabled = response.commands.find(
(cmd) => cmd.name === "enabled-workflow.md"
);
enabled!.should.not.be.undefined();
const disabled = response.commands.find(
(cmd) => cmd.name === "disabled-workflow.md"
);
(disabled === undefined).should.be.true();
});
it("should extract filename from full path", async () => {
mockStateManager.getWorkspaceStateKey
.withArgs("workflowToggles")
.returns({
"/Users/test/project/.clinerules/workflows/deep-analysis.md": true,
});
const response = await getAvailableSlashCommands(
mockController as Controller,
EmptyRequest.create()
);
const workflow = response.commands.find(
(cmd) => cmd.name === "deep-analysis.md"
);
workflow!.should.not.be.undefined();
});
it("should handle Windows-style paths", async () => {
mockStateManager.getWorkspaceStateKey
.withArgs("workflowToggles")
.returns({
"C:\\Users\\test\\project\\.clinerules\\workflows\\windows-workflow.md":
true,
});
const response = await getAvailableSlashCommands(
mockController as Controller,
EmptyRequest.create()
);
const workflow = response.commands.find(
(cmd) => cmd.name === "windows-workflow.md"
);
workflow!.should.not.be.undefined();
});
});
describe("Global Workflow Toggles", () => {
it("should include enabled global workflows", async () => {
mockStateManager.getGlobalSettingsKey
.withArgs("globalWorkflowToggles")
.returns({
"/global/path/global-workflow.md": true,
});
const response = await getAvailableSlashCommands(
mockController as Controller,
EmptyRequest.create()
);
const workflow = response.commands.find(
(cmd) => cmd.name === "global-workflow.md"
);
workflow!.should.not.be.undefined();
workflow!.section.should.equal("custom");
});
it("should exclude disabled global workflows", async () => {
mockStateManager.getGlobalSettingsKey
.withArgs("globalWorkflowToggles")
.returns({
"/global/path/disabled-global.md": false,
});
const response = await getAvailableSlashCommands(
mockController as Controller,
EmptyRequest.create()
);
const workflow = response.commands.find(
(cmd) => cmd.name === "disabled-global.md"
);
(workflow === undefined).should.be.true();
});
});
describe("Workflow Deduplication", () => {
it("should prefer local workflows over global workflows with same name", async () => {
// Same filename in both local and global
mockStateManager.getWorkspaceStateKey
.withArgs("workflowToggles")
.returns({
"/local/path/shared-workflow.md": true,
});
mockStateManager.getGlobalSettingsKey
.withArgs("globalWorkflowToggles")
.returns({
"/global/path/shared-workflow.md": true,
});
const response = await getAvailableSlashCommands(
mockController as Controller,
EmptyRequest.create()
);
// Should only appear once
const matches = response.commands.filter(
(cmd) => cmd.name === "shared-workflow.md"
);
matches.length.should.equal(1);
});
it("should include global workflow if local with same name is disabled", async () => {
mockStateManager.getWorkspaceStateKey
.withArgs("workflowToggles")
.returns({
"/local/path/shared-workflow.md": false, // disabled locally
});
mockStateManager.getGlobalSettingsKey
.withArgs("globalWorkflowToggles")
.returns({
"/global/path/shared-workflow.md": true, // enabled globally
});
const response = await getAvailableSlashCommands(
mockController as Controller,
EmptyRequest.create()
);
// Global should appear since local is disabled
const workflow = response.commands.find(
(cmd) => cmd.name === "shared-workflow.md"
);
workflow!.should.not.be.undefined();
});
});
describe("Remote Workflows", () => {
it("should include alwaysEnabled remote workflows", async () => {
mockStateManager.getRemoteConfigSettings.returns({
remoteGlobalWorkflows: [
{ name: "always-on-workflow", alwaysEnabled: true },
],
});
const response = await getAvailableSlashCommands(
mockController as Controller,
EmptyRequest.create()
);
const workflow = response.commands.find(
(cmd) => cmd.name === "always-on-workflow"
);
workflow!.should.not.be.undefined();
workflow!.section.should.equal("custom");
});
it("should include remote workflows enabled by toggle", async () => {
mockStateManager.getRemoteConfigSettings.returns({
remoteGlobalWorkflows: [
{ name: "toggle-workflow", alwaysEnabled: false },
],
});
mockStateManager.getGlobalStateKey
.withArgs("remoteWorkflowToggles")
.returns({
"toggle-workflow": true, // not explicitly disabled
});
const response = await getAvailableSlashCommands(
mockController as Controller,
EmptyRequest.create()
);
const workflow = response.commands.find(
(cmd) => cmd.name === "toggle-workflow"
);
workflow!.should.not.be.undefined();
});
it("should exclude remote workflows explicitly disabled by toggle", async () => {
mockStateManager.getRemoteConfigSettings.returns({
remoteGlobalWorkflows: [
{ name: "disabled-remote", alwaysEnabled: false },
],
});
mockStateManager.getGlobalStateKey
.withArgs("remoteWorkflowToggles")
.returns({
"disabled-remote": false,
});
const response = await getAvailableSlashCommands(
mockController as Controller,
EmptyRequest.create()
);
const workflow = response.commands.find(
(cmd) => cmd.name === "disabled-remote"
);
(workflow === undefined).should.be.true();
});
it("should include remote workflows by default if not explicitly disabled", async () => {
mockStateManager.getRemoteConfigSettings.returns({
remoteGlobalWorkflows: [
{ name: "default-enabled", alwaysEnabled: false },
],
});
// No toggle entry for this workflow
mockStateManager.getGlobalStateKey
.withArgs("remoteWorkflowToggles")
.returns({});
const response = await getAvailableSlashCommands(
mockController as Controller,
EmptyRequest.create()
);
const workflow = response.commands.find(
(cmd) => cmd.name === "default-enabled"
);
workflow!.should.not.be.undefined();
});
});
describe("Edge Cases", () => {
it("should handle null/undefined state values gracefully", async () => {
mockStateManager.getWorkspaceStateKey.returns(null);
mockStateManager.getGlobalSettingsKey.returns(undefined);
mockStateManager.getGlobalStateKey.returns(null);
mockStateManager.getRemoteConfigSettings.returns(null);
const response = await getAvailableSlashCommands(
mockController as Controller,
EmptyRequest.create()
);
// Should still return base commands
response.commands.length.should.be.greaterThanOrEqual(
BASE_SLASH_COMMANDS.length
);
});
it("should handle empty workflow toggle objects", async () => {
mockStateManager.getWorkspaceStateKey
.withArgs("workflowToggles")
.returns({});
mockStateManager.getGlobalSettingsKey
.withArgs("globalWorkflowToggles")
.returns({});
mockStateManager.getGlobalStateKey
.withArgs("remoteWorkflowToggles")
.returns({});
mockStateManager.getRemoteConfigSettings.returns({
remoteGlobalWorkflows: [],
});
const response = await getAvailableSlashCommands(
mockController as Controller,
EmptyRequest.create()
);
// Should only have base commands
response.commands.length.should.equal(BASE_SLASH_COMMANDS.length);
});
it("should handle remote config with no remoteGlobalWorkflows property", async () => {
mockStateManager.getRemoteConfigSettings.returns({});
const response = await getAvailableSlashCommands(
mockController as Controller,
EmptyRequest.create()
);
// Should not throw, just return base commands
response.commands.length.should.be.greaterThanOrEqual(
BASE_SLASH_COMMANDS.length
);
});
});
});
+259
View File
@@ -0,0 +1,259 @@
import { afterEach, beforeEach, describe, it } from "mocha"
import "should"
import { expandEnvironmentVariables } from "../envExpansion"
describe("expandEnvironmentVariables", () => {
// Store original environment
let originalEnv: NodeJS.ProcessEnv
beforeEach(() => {
originalEnv = { ...process.env }
})
afterEach(() => {
// Restore original environment
process.env = originalEnv
})
describe("string expansion", () => {
it("should expand a single environment variable", () => {
process.env.TEST_VAR = "test_value"
const result = expandEnvironmentVariables("${env:TEST_VAR}")
result.should.equal("test_value")
})
it("should expand multiple environment variables in one string", () => {
process.env.VAR1 = "value1"
process.env.VAR2 = "value2"
const result = expandEnvironmentVariables("${env:VAR1} and ${env:VAR2}")
result.should.equal("value1 and value2")
})
it("should expand environment variables with surrounding text", () => {
process.env.API_KEY = "secret123"
const result = expandEnvironmentVariables("Bearer ${env:API_KEY}")
result.should.equal("Bearer secret123")
})
it("should leave unexpanded when variable is missing", () => {
const result = expandEnvironmentVariables("${env:MISSING_VAR}")
result.should.equal("${env:MISSING_VAR}")
})
it("should handle empty string values", () => {
process.env.EMPTY_VAR = ""
const result = expandEnvironmentVariables("${env:EMPTY_VAR}")
result.should.equal("")
})
it("should trim whitespace from variable names", () => {
process.env.SPACED_VAR = "value"
const result = expandEnvironmentVariables("${env: SPACED_VAR }")
result.should.equal("value")
})
it("should handle variable names with hyphens", () => {
process.env["VAR-NAME"] = "hyphenated"
const result = expandEnvironmentVariables("${env:VAR-NAME}")
result.should.equal("hyphenated")
})
it("should handle variable names with underscores", () => {
process.env.VAR_NAME = "underscored"
const result = expandEnvironmentVariables("${env:VAR_NAME}")
result.should.equal("underscored")
})
it("should not expand malformed syntax", () => {
process.env.TEST_VAR = "value"
const result = expandEnvironmentVariables("${env:TEST_VAR")
result.should.equal("${env:TEST_VAR")
})
it("should return string unchanged when no variables present", () => {
const result = expandEnvironmentVariables("plain string")
result.should.equal("plain string")
})
})
describe("object expansion", () => {
it("should expand variables in object values", () => {
process.env.API_KEY = "secret"
const result = expandEnvironmentVariables({
key: "${env:API_KEY}",
})
result.should.deepEqual({
key: "secret",
})
})
it("should expand variables in nested objects", () => {
process.env.TOKEN = "token123"
process.env.KEY = "key456"
const result = expandEnvironmentVariables({
outer: {
inner: {
token: "${env:TOKEN}",
key: "${env:KEY}",
},
},
})
result.should.deepEqual({
outer: {
inner: {
token: "token123",
key: "key456",
},
},
})
})
it("should preserve non-string values in objects", () => {
process.env.VAR = "value"
const result = expandEnvironmentVariables({
string: "${env:VAR}",
number: 42,
boolean: true,
nullValue: null,
})
result.should.deepEqual({
string: "value",
number: 42,
boolean: true,
nullValue: null,
})
})
})
describe("array expansion", () => {
it("should expand variables in array elements", () => {
process.env.VAR1 = "first"
process.env.VAR2 = "second"
const result = expandEnvironmentVariables(["${env:VAR1}", "${env:VAR2}"])
result.should.deepEqual(["first", "second"])
})
it("should expand variables in arrays within objects", () => {
process.env.ARG1 = "arg1"
process.env.ARG2 = "arg2"
const result = expandEnvironmentVariables({
args: ["${env:ARG1}", "${env:ARG2}"],
})
result.should.deepEqual({
args: ["arg1", "arg2"],
})
})
it("should preserve non-string values in arrays", () => {
process.env.VAR = "value"
const result = expandEnvironmentVariables(["${env:VAR}", 123, true, null])
result.should.deepEqual(["value", 123, true, null])
})
})
describe("complex nested structures", () => {
it("should expand variables in deeply nested structures", () => {
process.env.API_KEY = "key123"
process.env.TOKEN = "token456"
const result = expandEnvironmentVariables({
server: {
auth: {
headers: {
Authorization: "Bearer ${env:TOKEN}",
"X-API-Key": "${env:API_KEY}",
},
},
config: {
args: ["--key", "${env:API_KEY}"],
},
},
})
result.should.deepEqual({
server: {
auth: {
headers: {
Authorization: "Bearer token456",
"X-API-Key": "key123",
},
},
config: {
args: ["--key", "key123"],
},
},
})
})
})
describe("MCP config realistic scenarios", () => {
it("should expand env variables in stdio server config", () => {
process.env.MCP_API_KEY = "mykey"
const result = expandEnvironmentVariables({
type: "stdio",
command: "node",
args: ["server.js"],
env: {
API_KEY: "${env:MCP_API_KEY}",
},
})
result.should.deepEqual({
type: "stdio",
command: "node",
args: ["server.js"],
env: {
API_KEY: "mykey",
},
})
})
it("should expand env variables in HTTP server headers", () => {
process.env.AUTH_TOKEN = "bearer_token_123"
const result = expandEnvironmentVariables({
type: "streamableHttp",
url: "http://localhost:3001/mcp",
headers: {
Authorization: "Bearer ${env:AUTH_TOKEN}",
},
})
result.should.deepEqual({
type: "streamableHttp",
url: "http://localhost:3001/mcp",
headers: {
Authorization: "Bearer bearer_token_123",
},
})
})
it("should expand env variables in URLs", () => {
process.env.MCP_HOST = "api.example.com"
process.env.MCP_PORT = "8080"
const result = expandEnvironmentVariables({
url: "https://${env:MCP_HOST}:${env:MCP_PORT}/mcp",
})
result.should.deepEqual({
url: "https://api.example.com:8080/mcp",
})
})
})
describe("primitive values", () => {
it("should return numbers unchanged", () => {
const result = expandEnvironmentVariables(42)
result.should.equal(42)
})
it("should return booleans unchanged", () => {
const result = expandEnvironmentVariables(true)
result.should.equal(true)
})
it("should return null unchanged", () => {
const result = expandEnvironmentVariables(null)
;(result === null).should.be.true()
})
it("should return undefined unchanged", () => {
const result = expandEnvironmentVariables(undefined)
;(result === undefined).should.be.true()
})
})
})
+72
View File
@@ -0,0 +1,72 @@
/**
* Utility for expanding environment variables in configuration values.
* Supports ${env:VAR_NAME} syntax for referencing environment variables.
*/
/**
* Expands environment variables in a string value.
* Supports ${env:VAR_NAME} syntax.
*
* @param value - String that may contain variable references
* @returns String with environment variables expanded
*
* @example
* // If process.env.API_KEY = "secret123"
* expandString("Bearer ${env:API_KEY}") // Returns: "Bearer secret123"
* expandString("${env:MISSING}") // Returns: "${env:MISSING}" (unchanged)
*/
function expandString(value: string): string {
return value.replace(/\$\{env:([^}]+)\}/g, (match, varName) => {
// Trim whitespace from variable name to be forgiving of formatting
const trimmedVarName = varName.trim()
const envValue = process.env[trimmedVarName]
if (envValue === undefined) {
console.warn(`[MCP Config] Environment variable not found: ${trimmedVarName}`)
return match // Leave unexpanded to show what's missing
}
// Empty string is a valid value, return it
return envValue
})
}
/**
* Recursively expands environment variables in any value (string, object, array).
* Only processes string values, leaving other types unchanged.
*
* @param value - Value to process (can be string, object, array, or primitive)
* @returns Value with all environment variables expanded
*
* @example
* expandEnvironmentVariables({
* api_key: "${env:API_KEY}",
* nested: {
* token: "${env:TOKEN}"
* }
* })
* // Returns object with all ${env:*} references expanded
*/
export function expandEnvironmentVariables<T>(value: T): T {
// Handle string values
if (typeof value === "string") {
return expandString(value) as T
}
// Handle arrays
if (Array.isArray(value)) {
return value.map((item) => expandEnvironmentVariables(item)) as T
}
// Handle objects (but not null)
if (value && typeof value === "object") {
const result: any = {}
for (const [key, val] of Object.entries(value)) {
result[key] = expandEnvironmentVariables(val)
}
return result
}
// Return primitives unchanged (numbers, booleans, null, undefined)
return value
}
+30 -3
View File
@@ -767,7 +767,15 @@ export const ChatRowContent = memo(
border: "1px solid var(--vscode-editorGroup-border)",
}}>
<div
aria-label={isExpanded ? "Collapse summary" : "Expand summary"}
onClick={handleToggle}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault()
e.stopPropagation()
handleToggle()
}
}}
style={{
color: "var(--vscode-descriptionForeground)",
padding: "9px 10px",
@@ -776,7 +784,8 @@ export const ChatRowContent = memo(
WebkitUserSelect: "none",
MozUserSelect: "none",
msUserSelect: "none",
}}>
}}
tabIndex={0}>
{isExpanded ? (
<div>
<div style={{ display: "flex", alignItems: "center", marginBottom: "8px" }}>
@@ -1291,7 +1300,15 @@ export const ChatRowContent = memo(
return (
<>
<div
aria-label={isExpanded ? "Collapse API request" : "Expand API request"}
onClick={handleToggle}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault()
e.stopPropagation()
handleToggle()
}
}}
style={{
...headerStyle,
marginBottom:
@@ -1302,7 +1319,8 @@ export const ChatRowContent = memo(
WebkitUserSelect: "none",
MozUserSelect: "none",
msUserSelect: "none",
}}>
}}
tabIndex={0}>
<div
style={{
display: "flex",
@@ -1404,7 +1422,15 @@ export const ChatRowContent = memo(
<>
{message.text && (
<div
aria-label={isExpanded ? "Collapse thinking" : "Expand thinking"}
onClick={handleToggle}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault()
e.stopPropagation()
handleToggle()
}
}}
style={{
// marginBottom: 15,
cursor: "pointer",
@@ -1412,7 +1438,8 @@ export const ChatRowContent = memo(
fontStyle: "italic",
overflow: "hidden",
}}>
}}
tabIndex={0}>
{isExpanded ? (
<div style={{ marginTop: -3 }}>
<span style={{ fontWeight: "bold", display: "block", marginBottom: "4px" }}>
@@ -5,6 +5,7 @@ import { FileSearchRequest, FileSearchType, RelativePathsRequest } from "@shared
import { UpdateApiConfigurationRequest } from "@shared/proto/cline/models"
import { PlanActMode, TogglePlanActModeRequest } from "@shared/proto/cline/state"
import { convertApiConfigurationToProto } from "@shared/proto-conversions/models/api-configuration-conversion"
import { type SlashCommand } from "@shared/slashCommands"
import { Mode } from "@shared/storage/types"
import { VSCodeButton } from "@vscode/webview-ui-toolkit/react"
import { AtSignIcon, PlusIcon } from "lucide-react"
@@ -42,7 +43,6 @@ import {
getMatchingSlashCommands,
insertSlashCommand,
removeSlashCommand,
type SlashCommand,
shouldShowSlashCommandsMenu,
slashCommandDeleteRegex,
slashCommandRegexGlobal,
+78 -20
View File
@@ -1,5 +1,7 @@
import React, { useEffect, useMemo, useRef, useState } from "react"
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"
import { cleanPathPrefix } from "@/components/common/CodeAccordian"
import ScreenReaderAnnounce from "@/components/common/ScreenReaderAnnounce"
import { useMenuAnnouncement } from "@/hooks/useMenuAnnouncement"
import { ContextMenuOptionType, ContextMenuQueryItem, getContextMenuOptions, SearchResult } from "@/utils/context-mentions"
interface ContextMenuProps {
@@ -79,16 +81,47 @@ const ContextMenu: React.FC<ContextMenuProps> = ({
}
}, [selectedIndex])
// Shared label definitions for simple option types
const SIMPLE_OPTION_LABELS: Partial<Record<ContextMenuOptionType, string>> = {
[ContextMenuOptionType.Problems]: "Problems",
[ContextMenuOptionType.Terminal]: "Terminal",
[ContextMenuOptionType.URL]: "Paste URL to fetch contents",
[ContextMenuOptionType.NoResults]: "No results found",
}
// Get accessible label for an option (used for screen readers and aria-label)
const getOptionLabel = useCallback((option: ContextMenuQueryItem): string => {
// Check simple labels first
const simpleLabel = SIMPLE_OPTION_LABELS[option.type]
if (simpleLabel) {
return simpleLabel
}
switch (option.type) {
case ContextMenuOptionType.Git:
if (option.value) {
return `${option.label}${option.description ? `, ${option.description}` : ""}`
}
return "Git Commits"
case ContextMenuOptionType.File:
case ContextMenuOptionType.Folder:
if (option.value) {
return option.label || option.value
}
return `Add ${option.type === ContextMenuOptionType.File ? "File" : "Folder"}`
default:
return option.label || option.value || ""
}
}, [])
const renderOptionContent = (option: ContextMenuQueryItem) => {
// Handle simple label types
const simpleLabel = SIMPLE_OPTION_LABELS[option.type]
if (simpleLabel) {
return <span>{simpleLabel}</span>
}
switch (option.type) {
case ContextMenuOptionType.Problems:
return <span>Problems</span>
case ContextMenuOptionType.Terminal:
return <span>Terminal</span>
case ContextMenuOptionType.URL:
return <span>Paste URL to fetch contents</span>
case ContextMenuOptionType.NoResults:
return <span>No results found</span>
case ContextMenuOptionType.Git:
if (option.value) {
return (
@@ -110,9 +143,8 @@ const ContextMenu: React.FC<ContextMenuProps> = ({
</span>
</div>
)
} else {
return <span>Git Commits</span>
}
return <span>Git Commits</span>
case ContextMenuOptionType.File:
case ContextMenuOptionType.Folder:
if (option.value) {
@@ -137,9 +169,10 @@ const ContextMenu: React.FC<ContextMenuProps> = ({
</span>
</>
)
} else {
return <span>Add {option.type === ContextMenuOptionType.File ? "File" : "Folder"}</span>
}
return <span>Add {option.type === ContextMenuOptionType.File ? "File" : "Folder"}</span>
default:
return null
}
}
@@ -168,6 +201,25 @@ const ContextMenu: React.FC<ContextMenuProps> = ({
return option.type !== ContextMenuOptionType.NoResults && option.type !== ContextMenuOptionType.URL
}
// Screen reader announcements
const { announcement } = useMenuAnnouncement({
items: filteredOptions,
selectedIndex,
getItemLabel: getOptionLabel,
isItemSelectable: isOptionSelectable,
})
// Handle selection with announcement
const handleSelect = useCallback(
(option: ContextMenuQueryItem) => {
if (isOptionSelectable(option)) {
const mentionValue = option.label?.includes(":") ? option.label : option.value
onSelect(option.type, mentionValue)
}
},
[onSelect],
)
return (
<div
onMouseDown={onMouseDown}
@@ -178,8 +230,16 @@ const ContextMenu: React.FC<ContextMenuProps> = ({
right: 15,
overflowX: "hidden",
}}>
<ScreenReaderAnnounce message={announcement} />
<div
aria-activedescendant={
filteredOptions.length > 0 && isOptionSelectable(filteredOptions[selectedIndex])
? `context-menu-item-${selectedIndex}`
: undefined
}
aria-label="Context mentions"
ref={menuRef}
role="listbox"
style={{
backgroundColor: "var(--vscode-dropdown-background)",
border: "1px solid var(--vscode-editorGroup-border)",
@@ -212,15 +272,13 @@ const ContextMenu: React.FC<ContextMenuProps> = ({
return (
<div
aria-label={getOptionLabel(option)}
aria-selected={index === selectedIndex && isOptionSelectable(option)}
id={`context-menu-item-${index}`}
key={generatedKey}
onClick={() => {
if (isOptionSelectable(option)) {
// Use label if it contains workspace prefix, otherwise use value
const mentionValue = option.label?.includes(":") ? option.label : option.value
onSelect(option.type, mentionValue)
}
}}
onClick={() => handleSelect(option)}
onMouseEnter={() => isOptionSelectable(option) && setSelectedIndex(index)}
role="option"
style={{
padding: "8px 12px",
cursor: isOptionSelectable(option) ? "pointer" : "default",
@@ -32,6 +32,7 @@ import { freeModels, recommendedModels } from "@/components/settings/OpenRouterM
import { SUPPORTED_ANTHROPIC_THINKING_MODELS } from "@/components/settings/providers/AnthropicProvider"
import { SUPPORTED_BEDROCK_THINKING_MODELS } from "@/components/settings/providers/BedrockProvider"
import {
filterOpenRouterModelIds,
getModelsForProvider,
getModeSpecificFields,
normalizeApiConfiguration,
@@ -128,11 +129,14 @@ const ModelPickerModal: React.FC<ModelPickerModalProps> = ({ isOpen, onOpenChang
// Get models for current provider
const allModels = useMemo((): ModelItem[] => {
if (OPENROUTER_MODEL_PROVIDERS.includes(selectedProvider)) {
return Object.entries(openRouterModels || {}).map(([id, info]) => ({
const modelIds = Object.keys(openRouterModels || {})
const filteredIds = filterOpenRouterModelIds(modelIds, selectedProvider)
return filteredIds.map((id) => ({
id,
name: id.split("/").pop() || id,
provider: id.split("/")[0],
info,
info: openRouterModels[id],
}))
}
@@ -89,7 +89,15 @@ const SearchResultsDisplay: React.FC<SearchResultsDisplayProps> = ({
border: "1px solid var(--vscode-editorGroup-border)",
}}>
<div
aria-label={isExpanded ? "Collapse search results" : "Expand search results"}
onClick={onToggleExpand}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault()
e.stopPropagation()
onToggleExpand()
}
}}
style={{
color: "var(--vscode-descriptionForeground)",
display: "flex",
@@ -100,7 +108,8 @@ const SearchResultsDisplay: React.FC<SearchResultsDisplayProps> = ({
WebkitUserSelect: "none",
MozUserSelect: "none",
msUserSelect: "none",
}}>
}}
tabIndex={0}>
<span>/</span>
<span
style={{
@@ -1,5 +1,8 @@
import { type SlashCommand } from "@shared/slashCommands"
import React, { useCallback, useEffect, useRef } from "react"
import { getMatchingSlashCommands, SlashCommand } from "@/utils/slash-commands"
import ScreenReaderAnnounce from "@/components/common/ScreenReaderAnnounce"
import { useMenuAnnouncement } from "@/hooks/useMenuAnnouncement"
import { getMatchingSlashCommands } from "@/utils/slash-commands"
interface SlashCommandMenuProps {
onSelect: (command: SlashCommand) => void
@@ -26,6 +29,29 @@ const SlashCommandMenu: React.FC<SlashCommandMenuProps> = ({
}) => {
const menuRef = useRef<HTMLDivElement>(null)
// Filter commands based on query
const filteredCommands = getMatchingSlashCommands(
query,
localWorkflowToggles,
globalWorkflowToggles,
remoteWorkflowToggles,
remoteWorkflows,
)
const defaultCommands = filteredCommands.filter((cmd) => cmd.section === "default" || !cmd.section)
const workflowCommands = filteredCommands.filter((cmd) => cmd.section === "custom")
// Screen reader announcements
const getCommandLabel = useCallback((command: SlashCommand) => {
const description = command.description ? `, ${command.description}` : ""
return `${command.name}${description}`
}, [])
const { announcement } = useMenuAnnouncement({
items: filteredCommands,
selectedIndex,
getItemLabel: getCommandLabel,
})
const handleClick = useCallback(
(command: SlashCommand) => {
onSelect(command)
@@ -49,17 +75,6 @@ const SlashCommandMenu: React.FC<SlashCommandMenuProps> = ({
}
}, [selectedIndex])
// Filter commands based on query
const filteredCommands = getMatchingSlashCommands(
query,
localWorkflowToggles,
globalWorkflowToggles,
remoteWorkflowToggles,
remoteWorkflows,
)
const defaultCommands = filteredCommands.filter((cmd) => cmd.section === "default" || !cmd.section)
const workflowCommands = filteredCommands.filter((cmd) => cmd.section === "custom")
// Create a reusable function for rendering a command section
const renderCommandSection = (commands: SlashCommand[], title: string, indexOffset: number, showDescriptions: boolean) => {
if (commands.length === 0) {
@@ -68,13 +83,16 @@ const SlashCommandMenu: React.FC<SlashCommandMenuProps> = ({
return (
<>
<div className="text-xs text-(--vscode-descriptionForeground) px-3 py-1 font-bold border-b border-(--vscode-editorGroup-border)">
<div
className="text-xs text-(--vscode-descriptionForeground) px-3 py-1 font-bold border-b border-(--vscode-editorGroup-border)"
role="presentation">
{title}
</div>
{commands.map((command, index) => {
const itemIndex = index + indexOffset
return (
<div
aria-selected={itemIndex === selectedIndex}
className={`slash-command-menu-item py-2 px-3 cursor-pointer flex flex-col border-b border-(--vscode-editorGroup-border) ${
itemIndex === selectedIndex
? "bg-(--vscode-quickInputList-focusBackground) text-(--vscode-quickInputList-focusForeground)"
@@ -83,7 +101,8 @@ const SlashCommandMenu: React.FC<SlashCommandMenuProps> = ({
id={`slash-command-menu-item-${itemIndex}`}
key={command.name}
onClick={() => handleClick(command)}
onMouseEnter={() => setSelectedIndex(itemIndex)}>
onMouseEnter={() => setSelectedIndex(itemIndex)}
role="option">
<div className="font-bold whitespace-nowrap overflow-hidden text-ellipsis">
<span className="ph-no-capture">/{command.name}</span>
</div>
@@ -104,9 +123,13 @@ const SlashCommandMenu: React.FC<SlashCommandMenuProps> = ({
className="absolute bottom-[calc(100%-10px)] left-[15px] right-[15px] overflow-x-hidden z-1000"
data-testid="slash-commands-menu"
onMouseDown={onMouseDown}>
<ScreenReaderAnnounce message={announcement} />
<div
aria-activedescendant={filteredCommands.length > 0 ? `slash-command-menu-item-${selectedIndex}` : undefined}
aria-label="Slash commands"
className="bg-(--vscode-dropdown-background) border border-(--vscode-editorGroup-border) rounded-[3px] shadow-[0_4px_10px_rgba(0,0,0,0.25)] flex flex-col overflow-y-auto"
ref={menuRef}
role="listbox"
style={{ maxHeight: "min(200px, calc(50vh))", overscrollBehavior: "contain" }}>
{filteredCommands.length > 0 ? (
<>
@@ -114,7 +137,7 @@ const SlashCommandMenu: React.FC<SlashCommandMenuProps> = ({
{renderCommandSection(workflowCommands, "Workflow Commands", defaultCommands.length, false)}
</>
) : (
<div className="py-2 px-3 cursor-default flex flex-col">
<div aria-selected="false" className="py-2 px-3 cursor-default flex flex-col" role="option">
<div className="text-[0.85em] text-(--vscode-descriptionForeground)">No matching commands found</div>
</div>
)}
@@ -143,11 +143,20 @@ const AutoApproveBar = ({ style }: AutoApproveBarProps) => {
/>
<div
aria-label={isModalVisible ? "Close auto-approve settings" : "Open auto-approve settings"}
className="group cursor-pointer pt-3 pb-3.5 pr-2 px-3.5 flex items-center justify-between gap-0"
onClick={() => {
setIsModalVisible((prev) => !prev)
}}
ref={buttonRef}>
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault()
e.stopPropagation()
setIsModalVisible((prev) => !prev)
}
}}
ref={buttonRef}
tabIndex={0}>
<div className="flex flex-nowrap items-center gap-1 min-w-0 flex-1">
<span className="whitespace-nowrap">Auto-approve:</span>
{getEnabledActionsText()}
@@ -1,7 +1,5 @@
import { VSCodeCheckbox } from "@vscode/webview-ui-toolkit/react"
import styled from "styled-components"
import { Button } from "@/components/ui/button"
import { cn } from "@/lib/utils"
import { ActionMetadata } from "./types"
interface AutoApproveMenuItemProps {
@@ -12,25 +10,28 @@ interface AutoApproveMenuItemProps {
disabled?: boolean
}
const SubOptionAnimateIn = styled.div<{ show: boolean }>`
position: relative;
transform: ${(props) => (props.show ? "scaleY(1)" : "scaleY(0)")};
transform-origin: top;
padding-left: 24px;
opacity: ${(props) => (props.show ? "1" : "0")};
height: ${(props) => (props.show ? "auto" : "0")}; /* Manage height for layout */
overflow: visible; /* Allow tooltips to escape */
transition: transform 0.2s ease-in-out;
const SubOptionAnimateIn = styled.div<{ show: boolean; inert?: string }>`
position: relative;
transform: ${(props) => (props.show ? "scaleY(1)" : "scaleY(0)")};
transform-origin: top;
padding-left: 24px;
opacity: ${(props) => (props.show ? "1" : "0")};
height: ${(props) => (props.show ? "auto" : "0")}; /* Manage height for layout */
overflow: visible; /* Allow tooltips to escape */
transition: transform 0.2s ease-in-out;
`
const ActionButtonContainer = styled.div`
padding: 2px;
const CheckboxWrapper = styled.div<{ $disabled: boolean }>`
padding: 2px 0.125rem;
margin: 0;
width: 100%;
cursor: ${(props) => (props.$disabled ? "not-allowed" : "pointer")};
`
const AutoApproveMenuItem = ({ action, isChecked, onToggle, showIcon = true, disabled = false }: AutoApproveMenuItemProps) => {
const checked = isChecked(action)
const onChange = async (e: Event) => {
const onChange = async (e: React.MouseEvent) => {
if (disabled) {
return
}
@@ -40,21 +41,16 @@ const AutoApproveMenuItem = ({ action, isChecked, onToggle, showIcon = true, dis
const content = (
<div className="w-full" style={{ opacity: disabled ? 0.5 : 1 }}>
<ActionButtonContainer className="w-full">
<Button
className={cn("w-full flex text-sm items-center justify-start text-foreground gap-2")}
disabled={disabled}
onClick={(e) => onChange(e as unknown as Event)}
size="icon"
style={{ cursor: disabled ? "not-allowed" : "pointer" }}
variant="icon">
<VSCodeCheckbox checked={checked} disabled={disabled} />
{showIcon && <span className={`codicon ${action.icon} icon`}></span>}
<span className="label">{action.label}</span>
</Button>
</ActionButtonContainer>
<CheckboxWrapper $disabled={disabled} className="w-full" onClick={onChange}>
<VSCodeCheckbox checked={checked} disabled={disabled}>
<div className="w-full flex text-sm items-center justify-start text-foreground gap-2">
{showIcon && <span className={`codicon ${action.icon} icon`}></span>}
<span className="label">{action.label}</span>
</div>
</VSCodeCheckbox>
</CheckboxWrapper>
{action.subAction && (
<SubOptionAnimateIn show={checked}>
<SubOptionAnimateIn inert={!checked ? "" : undefined} show={checked}>
<AutoApproveMenuItem action={action.subAction} isChecked={isChecked} onToggle={onToggle} />
</SubOptionAnimateIn>
)}
@@ -189,8 +189,17 @@ export const FocusChain: React.FC<FocusChainProps> = memo(
return (
<div
aria-label={isExpanded ? "Collapse focus chain" : "Expand focus chain"}
className="relative rounded-sm bg-toolbar-hover/65 flex flex-col gap-1.5 select-none hover:bg-toolbar-hover overflow-hidden opacity-80 hover:opacity-100 transition-[transform,box-shadow] duration-200 cursor-pointer"
onClick={handleToggle}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault()
e.stopPropagation()
handleToggle()
}
}}
tabIndex={0}
title={CLICK_TO_EDIT_TITLE}>
<ToDoListHeader isExpanded={isExpanded} todoInfo={todoInfo} />
{isExpanded && (
@@ -126,7 +126,18 @@ const TaskHeader: React.FC<TaskHeaderProps> = ({
borderColor: environmentBorderColor,
}}>
{/* Task Title */}
<div className="flex justify-between items-center cursor-pointer" onClick={toggleTaskExpanded}>
<div
aria-label={isTaskExpanded ? "Collapse task header" : "Expand task header"}
className="flex justify-between items-center cursor-pointer"
onClick={toggleTaskExpanded}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault()
e.stopPropagation()
toggleTaskExpanded()
}
}}
tabIndex={0}>
<div className="flex justify-between items-center">
{isTaskExpanded ? <ChevronDownIcon size="16" /> : <ChevronRightIcon size="16" />}
{isTaskExpanded && (
@@ -55,7 +55,16 @@ const CodeAccordian = ({
}}>
{(path || isFeedback || isConsoleLogs) && (
<div
aria-label={isExpanded ? "Collapse code block" : "Expand code block"}
onClick={isLoading ? undefined : onToggleExpand}
onKeyDown={(e) => {
if (isLoading) return
if (e.key === "Enter" || e.key === " ") {
e.preventDefault()
e.stopPropagation()
onToggleExpand()
}
}}
style={{
color: "var(--vscode-descriptionForeground)",
display: "flex",
@@ -68,7 +77,8 @@ const CodeAccordian = ({
WebkitUserSelect: "none",
MozUserSelect: "none",
msUserSelect: "none",
}}>
}}
tabIndex={0}>
{isFeedback || isConsoleLogs ? (
<div style={{ display: "flex", alignItems: "center" }}>
<span
@@ -0,0 +1,35 @@
import React from "react"
interface ScreenReaderAnnounceProps {
/** The message to announce to screen readers */
message: string
/** The politeness level of the announcement (default: "assertive") */
politeness?: "polite" | "assertive"
}
/**
* Visually hidden component that announces messages to screen readers.
* Uses an aria-live region to communicate dynamic content changes.
*/
const ScreenReaderAnnounce: React.FC<ScreenReaderAnnounceProps> = ({ message, politeness = "assertive" }) => {
return (
<div
aria-atomic="true"
aria-live={politeness}
style={{
position: "absolute",
width: "1px",
height: "1px",
padding: 0,
margin: "-1px",
overflow: "hidden",
clip: "rect(0, 0, 0, 0)",
whiteSpace: "nowrap",
border: 0,
}}>
{message}
</div>
)
}
export default ScreenReaderAnnounce
@@ -51,7 +51,7 @@ export const WhatsNewModal: React.FC<WhatsNewModalProps> = ({ open, onClose, ver
}, [open, onClose])
const setDevstral = () => {
const modelId = "mistralai/devstral-2512"
const modelId = "mistralai/devstral-2512:free"
handleFieldsChange({
planModeOpenRouterModelId: modelId,
actModeOpenRouterModelId: modelId,
@@ -193,7 +193,7 @@ export const WhatsNewModal: React.FC<WhatsNewModalProps> = ({ open, onClose, ver
)}
</li>
<li className="mb-2">
Mistral's <strong>Devstral-2512</strong> (formerly stealth model "Microwave"), free for a limited
Mistral's <strong>Devstral-2512:free</strong> (formerly stealth model "Microwave"), free for a limited
time!
<br />
{user ? (
@@ -21,6 +21,7 @@ const McpConfigurationView = ({ onDone, initialTab }: McpViewProps) => {
const { remoteConfigSettings, setMcpServers, environment } = useExtensionState()
// Show marketplace by default unless remote config explicitly disables it
const showMarketplace = remoteConfigSettings?.mcpMarketplaceEnabled !== false
const showRemoteServers = remoteConfigSettings?.blockPersonalRemoteMCPServers !== true
const [activeTab, setActiveTab] = useState<McpViewTab>(initialTab || (showMarketplace ? "marketplace" : "configure"))
const handleTabChange = (tab: McpViewTab) => {
@@ -32,7 +33,10 @@ const McpConfigurationView = ({ onDone, initialTab }: McpViewProps) => {
// If marketplace is disabled by remote config and we're on marketplace tab, switch to configure
setActiveTab("configure")
}
}, [showMarketplace, activeTab])
if (!showRemoteServers && activeTab === "addRemote") {
setActiveTab("configure")
}
}, [showMarketplace, showRemoteServers, activeTab])
// Get setter for MCP marketplace catalog from context
const { setMcpMarketplaceCatalog } = useExtensionState()
@@ -102,9 +106,11 @@ const McpConfigurationView = ({ onDone, initialTab }: McpViewProps) => {
Marketplace
</TabButton>
)}
<TabButton isActive={activeTab === "addRemote"} onClick={() => handleTabChange("addRemote")}>
Remote Servers
</TabButton>
{showRemoteServers && (
<TabButton isActive={activeTab === "addRemote"} onClick={() => handleTabChange("addRemote")}>
Remote Servers
</TabButton>
)}
<TabButton isActive={activeTab === "configure"} onClick={() => handleTabChange("configure")}>
Configure
</TabButton>
@@ -113,7 +119,9 @@ const McpConfigurationView = ({ onDone, initialTab }: McpViewProps) => {
{/* Content container */}
<div style={{ width: "100%" }}>
{showMarketplace && activeTab === "marketplace" && <McpMarketplaceView />}
{activeTab === "addRemote" && <AddRemoteServerForm onServerAdded={() => handleTabChange("configure")} />}
{showRemoteServers && activeTab === "addRemote" && (
<AddRemoteServerForm onServerAdded={() => handleTabChange("configure")} />
)}
{activeTab === "configure" && <ConfigureServersView />}
</div>
</div>
@@ -46,7 +46,7 @@ export const ModelDescriptionMarkdown = memo(({ markdown, key, isPopup }: ModelD
<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", {
className={cn("bg-sidebar-background p-0 m-0 text-sm cursor-pointer", {
"bg-code-block-background": isPopup,
})}
onClick={() => setIsExpanded(!isExpanded)}
@@ -15,7 +15,7 @@ import { ModelInfoView } from "./common/ModelInfoView"
import { DropdownContainer } from "./common/ModelSelector"
import FeaturedModelCard from "./FeaturedModelCard"
import ThinkingBudgetSlider from "./ThinkingBudgetSlider"
import { getModeSpecificFields, normalizeApiConfiguration } from "./utils/providerUtils"
import { filterOpenRouterModelIds, getModeSpecificFields, normalizeApiConfiguration } from "./utils/providerUtils"
import { useApiConfigurationHandlers } from "./utils/useApiConfigurationHandlers"
// Star icon for favorites
@@ -81,7 +81,7 @@ export const freeModels = [
label: "FREE",
},
{
id: "mistralai/devstral-2512",
id: "mistralai/devstral-2512:free",
description: "Mistral's latest model with strong coding abilities",
label: "FREE",
},
@@ -164,20 +164,7 @@ const OpenRouterModelPicker: React.FC<OpenRouterModelPickerProps> = ({ isPopup,
const modelIds = useMemo(() => {
const unfilteredModelIds = Object.keys(openRouterModels).sort((a, b) => a.localeCompare(b))
if (modeFields.apiProvider === "cline") {
// For Cline provider: exclude :free models, but keep Minimax models
return unfilteredModelIds.filter((id) => {
// Keep all Minimax models regardless of :free suffix
if (id.toLowerCase().includes("minimax-m2")) {
return true
}
// Filter out other :free models
return !id.includes(":free")
})
}
// For OpenRouter and Vercel AI Gateway providers: exclude Cline-specific models
return unfilteredModelIds.filter((id) => !id.startsWith("cline/"))
return filterOpenRouterModelIds(unfilteredModelIds, modeFields.apiProvider || "openrouter")
}, [openRouterModels, modeFields.apiProvider])
const searchableItems = useMemo(() => {
@@ -791,3 +791,28 @@ export async function syncModeConfigurations(
// Make the atomic update
await handleFieldsChange(updates)
}
/**
* Filters OpenRouter model IDs based on provider-specific rules.
* For Cline provider: excludes :free models (except Minimax models)
* For OpenRouter/Vercel: excludes cline/ prefixed models
* @param modelIds Array of model IDs to filter
* @param provider The current API provider
* @returns Filtered array of model IDs
*/
export function filterOpenRouterModelIds(modelIds: string[], provider: ApiProvider): string[] {
if (provider === "cline") {
// For Cline provider: exclude :free models, but keep Minimax models
return modelIds.filter((id) => {
// Keep all Minimax and devstral models regardless of :free suffix
if (id.toLowerCase().includes("minimax-m2") || id.toLowerCase().includes("devstral-2512")) {
return true
}
// Filter out other :free models
return !id.includes(":free")
})
}
// For OpenRouter and Vercel AI Gateway providers: exclude Cline-specific models
return modelIds.filter((id) => !id.startsWith("cline/"))
}
@@ -0,0 +1,80 @@
import { useEffect, useRef, useState } from "react"
interface UseMenuAnnouncementOptions<T> {
/** The list of items in the menu */
items: T[]
/** The currently selected index */
selectedIndex: number
/** Function to get the label for an item */
getItemLabel: (item: T) => string
/** Optional function to check if an item is selectable (default: all items are selectable) */
isItemSelectable?: (item: T) => boolean
}
interface UseMenuAnnouncementResult {
/** The current announcement text for screen readers */
announcement: string
}
/**
* Hook to manage screen reader announcements for menu components.
* Automatically announces the currently selected item when the selection changes.
* The announcement is cleared after a short delay to avoid interfering with DOM queries.
*/
export function useMenuAnnouncement<T>({
items,
selectedIndex,
getItemLabel,
isItemSelectable = () => true,
}: UseMenuAnnouncementOptions<T>): UseMenuAnnouncementResult {
const [announcement, setAnnouncement] = useState("")
const clearTimeoutRef = useRef<NodeJS.Timeout | null>(null)
const hasNavigatedRef = useRef(false)
const previousIndexRef = useRef(selectedIndex)
// Announce selected item when user navigates (not on initial render)
useEffect(() => {
// Clear any pending timeout
if (clearTimeoutRef.current) {
clearTimeout(clearTimeoutRef.current)
clearTimeoutRef.current = null
}
// Only announce if user has navigated (index changed from previous value)
const hasIndexChanged = previousIndexRef.current !== selectedIndex
previousIndexRef.current = selectedIndex
if (hasIndexChanged) {
hasNavigatedRef.current = true
}
// Skip announcement if user hasn't navigated yet (menu just opened)
if (!hasNavigatedRef.current) {
return
}
if (items.length > 0 && selectedIndex >= 0 && selectedIndex < items.length) {
const selectedItem = items[selectedIndex]
if (isItemSelectable(selectedItem)) {
const label = getItemLabel(selectedItem)
setAnnouncement(`${label}, ${selectedIndex + 1} of ${items.length}`)
// Clear announcement after screen reader has time to read it
clearTimeoutRef.current = setTimeout(() => {
setAnnouncement("")
}, 1000)
}
}
return () => {
if (clearTimeoutRef.current) {
clearTimeout(clearTimeoutRef.current)
clearTimeoutRef.current = null
}
}
}, [selectedIndex, items, getItemLabel, isItemSelectable])
return {
announcement,
}
}
+1 -48
View File
@@ -1,52 +1,5 @@
import { PLATFORM_CONFIG, PlatformType } from "@/config/platform.config"
export interface SlashCommand {
name: string
description?: string
section?: "default" | "custom"
}
const BASE_SLASH_COMMANDS: SlashCommand[] = [
{
name: "newtask",
description: "Create a new task with context from the current task",
section: "default",
},
{
name: "smol",
description: "Condenses your current context window",
section: "default",
},
{
name: "newrule",
description: "Create a new Cline rule based on your conversation",
section: "default",
},
{
name: "reportbug",
description: "Create a Github issue with Cline",
section: "default",
},
{
name: "deep-planning",
description: "Create a comprehensive implementation plan before coding",
section: "default",
},
{
name: "subagent",
description: "Invoke a Cline CLI subagent for focused research tasks",
section: "default",
},
]
// VS Code-only slash commands
const VSCODE_ONLY_COMMANDS: SlashCommand[] = [
{
name: "explain-changes",
description: "Explain code changes between git refs (PRs, commits, branches, etc.)",
section: "default",
},
]
import { BASE_SLASH_COMMANDS, type SlashCommand, VSCODE_ONLY_COMMANDS } from "../../../src/shared/slashCommands.ts"
export const DEFAULT_SLASH_COMMANDS: SlashCommand[] =
PLATFORM_CONFIG.type === PlatformType.VSCODE ? [...BASE_SLASH_COMMANDS, ...VSCODE_ONLY_COMMANDS] : BASE_SLASH_COMMANDS