mirror of
https://github.com/cline/cline.git
synced 2026-09-15 13:02:17 +08:00
Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
97777689ac | ||
|
|
3409fa7442 | ||
|
|
a20685d289 | ||
|
|
926c5e189e | ||
|
|
2a5ca9d312 | ||
|
|
d06717342b | ||
|
|
c904cfe376 | ||
|
|
924ca1278c | ||
|
|
2b0c0a659d | ||
|
|
f4477e229d | ||
|
|
5207d5c68e | ||
|
|
51b535e2d6 | ||
|
|
4db61b1b36 | ||
|
|
6baf611322 | ||
|
|
97d635d606 | ||
|
|
8ce476ccaa |
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
fix a11y for auto approve checkbox
|
||||
+17
-1
@@ -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
|
||||
|
||||
Generated
+2
-2
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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(/^.*[/\\]/, "")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,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,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"
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
+1
-1
@@ -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.
|
||||
+1
-1
@@ -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.
|
||||
+7
-2
@@ -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
|
||||
|
||||
+1
-1
@@ -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.
|
||||
@@ -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[]
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
]
|
||||
@@ -29,6 +29,7 @@ export interface RemoteConfigExtraFields {
|
||||
allowedMCPServers: Array<{ id: string }>
|
||||
remoteGlobalRules?: GlobalInstructionsFile[]
|
||||
remoteGlobalWorkflows?: GlobalInstructionsFile[]
|
||||
blockPersonalRemoteMCPServers?: boolean
|
||||
}
|
||||
|
||||
export type RemoteConfigFields = GlobalStateAndSettings & RemoteConfigExtraFields
|
||||
|
||||
@@ -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
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,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
|
||||
|
||||
Reference in New Issue
Block a user