Compare commits

...

3 Commits

Author SHA1 Message Date
abeatrix 9fcdf12f23 Use classname 2025-09-22 15:34:19 -07:00
Arafatkatze 7a3185af3d Adding voice mode fixes 2025-09-21 11:42:28 -07:00
Arafatkatze 726ea432b9 Adding Voice Mode 2025-09-21 10:03:22 -07:00
35 changed files with 15758 additions and 14533 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
Add speech-to-text dictation feature for Cline account users
+1
View File
@@ -91,6 +91,7 @@
"features/focus-chain",
"features/auto-compact",
"features/editing-messages",
"features/dictation",
{
"group": "@ Mentions",
"pages": [
+60
View File
@@ -0,0 +1,60 @@
---
title: Dictation
description:
---
Cline lets you transcribe speech to text in an easy, built-in service
## Get Started
1. **Enable Dictation** in Feature Settings.
2. **Click the microphone** in the chat input area.
3. **Speak** - the button turns red while recording.
4. **Click Stop Recording** when done.
5. **Wait for transcription** - "[Transcribing...]" will appear and then the finished transcription will appear.
## Settings
Enable or disable dictation in Feature Settings by toggling "Enable Dictation." You can also change the transcription language to one of the supported languages.
## Requirements
Cline uses FFmpeg to capture your voice across all platforms:
- **macOS**: FFmpeg (via Homebrew: `brew install ffmpeg`)
- **Linux**: FFmpeg (via apt: `sudo apt-get install ffmpeg`)
- **Windows**: FFmpeg (via winget: `winget install Gyan.FFmpeg`)
If you don't have FFmpeg installed, Cline will automatically detect this and prompt you to install it with a single click.
## Technical Details
### Independent from Chat Provider
The voice transcription feature works completely independently from whatever chat provider you're using. You can use Claude, GPT-4, or any other model for your conversations, and voice transcription will always use Cline's own transcription service. As long as you have a valid Cline account with enough credits, dictation will work regardless of your chat model choice.
### Audio Format
Voice recordings are captured in WebM format using the Opus codec for optimal compression. The system records in mono (single channel) at a 16kHz sample rate, which is specifically optimized for voice recognition. The bitrate is set to 32kbps to keep file sizes efficient while maintaining good audio quality.
### Privacy & Security
Your audio is recorded locally on your machine and only the audio file itself is sent to Cline's transcription service for processing. No audio is stored anywhere after transcription is complete, and all temporary files are automatically cleaned up to protect your privacy.
## Troubleshooting
`Failed to start recording` - Audio recording tools aren't installed. Cline will prompt you to install FFmpeg - just follow the chat instructions.
`Invalid audio format or request data` - Usually an audio recording issue. Make sure FFmpeg is properly installed and working.
`Authentication failed` - You need to reauthenticate your Cline account. Sign out and back in, then check your internet connection.
`Insufficient credits for transcription service` - Your Cline account doesn't have enough credits. Check your balance and purchase more if needed.
`Cannot connect to transcription service` - Connection issue. Check your internet connection and firewall settings aren't blocking Cline's servers.
## API Usage
Voice transcription uses Cline's transcription service, which requires credits from your Cline account. Currently, voice transcription is billed at $0.006 per minute of audio.
**Note:** We are still experimenting with this feature and pricing may change in the future.
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "claude-dev",
"version": "3.29.2",
"version": "3.30.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "claude-dev",
"version": "3.29.2",
"version": "3.30.3",
"license": "Apache-2.0",
"dependencies": {
"@anthropic-ai/sdk": "^0.37.0",
+41
View File
@@ -0,0 +1,41 @@
syntax = "proto3";
package cline;
import "cline/common.proto";
option java_package = "bot.cline.proto";
option java_multiple_files = true;
service DictationService {
rpc startRecording(EmptyRequest) returns (RecordingResult);
rpc stopRecording(EmptyRequest) returns (RecordedAudio);
rpc cancelRecording(EmptyRequest) returns (RecordingResult);
rpc getRecordingStatus(EmptyRequest) returns (RecordingStatus);
rpc transcribeAudio(TranscribeAudioRequest) returns (Transcription);
}
message TranscribeAudioRequest {
string audio_base64 = 2;
string language = 3;
}
message RecordingResult {
bool success = 1;
string error = 2;
}
message RecordedAudio {
bool success = 1;
string audio_base64 = 2;
string error = 3;
}
message RecordingStatus {
bool is_recording = 1;
double duration_seconds = 2;
string error = 3;
}
message Transcription {
string text = 1;
string error = 2;
}
+5 -1
View File
@@ -19,7 +19,10 @@ service StateService {
rpc updateTelemetrySetting(TelemetrySettingRequest) returns (Empty);
rpc setWelcomeViewCompleted(BooleanRequest) returns (Empty);
}
message DictationSettings {
bool dictation_enabled = 1;
string dictation_language = 2;
}
message State {
string state_json = 1;
}
@@ -140,6 +143,7 @@ message UpdateSettingsRequest {
optional BrowserSettingsUpdate browser_settings = 20;
optional string default_terminal_profile = 21;
optional bool yolo_mode_toggled = 22;
optional DictationSettings dictation_settings = 23;
}
// Complete API Configuration message
+4
View File
@@ -13,6 +13,7 @@ import "./utils/path" // necessary to have access to String.prototype.toPosix
import { HostProvider } from "@/hosts/host-provider"
import { FileContextTracker } from "./core/context/context-tracking/FileContextTracker"
import { ExtensionRegistryInfo } from "./registry"
import { audioRecordingService } from "./services/dictation/AudioRecordingService"
import { ErrorService } from "./services/error"
import { featureFlagsService } from "./services/feature-flags"
import { initializeDistinctId } from "./services/logging/distinctId"
@@ -99,6 +100,9 @@ async function showVersionUpdateAnnouncement(context: vscode.ExtensionContext) {
* Performs cleanup when Cline is deactivated that is common to all platforms.
*/
export async function tearDown(): Promise<void> {
// Clean up audio recording service to ensure no orphaned processes
audioRecordingService.cleanup()
PostHogClientProvider.getInstance().dispose()
telemetryService.dispose()
ErrorService.get().dispose()
@@ -0,0 +1,32 @@
import { RecordingResult } from "@shared/proto/cline/dictation"
import { audioRecordingService } from "@/services/dictation/AudioRecordingService"
import { telemetryService } from "@/services/telemetry"
import { Controller } from ".."
/**
* Cancels audio recording without saving or transcribing the audio
* @param controller The controller instance
* @returns RecordingResult indicating success or failure
*/
export const cancelRecording = async (controller: Controller): Promise<RecordingResult> => {
const taskId = controller.task?.taskId
const recordingStatus = audioRecordingService.getRecordingStatus()
const recordingDuration = recordingStatus.durationSeconds * 1000 // Convert to milliseconds
let errorMessage = ""
let isSuccess = true
try {
const result = await audioRecordingService.cancelRecording()
isSuccess = !!result?.success
errorMessage = result?.error ?? ""
} catch (error) {
console.error("Error canceling recording:", error)
isSuccess = false
errorMessage = error instanceof Error ? error.message : "Unknown error occurred"
}
telemetryService.captureVoiceRecordingStopped(taskId, recordingDuration, false, process.platform)
return RecordingResult.create({
success: isSuccess,
error: errorMessage ?? "",
})
}
@@ -0,0 +1,25 @@
import { RecordingStatus } from "@shared/proto/cline/dictation"
import { audioRecordingService } from "@/services/dictation/AudioRecordingService"
/**
* Gets the current recording status
* @returns RecordingStatus with current status
*/
export const getRecordingStatus = async (): Promise<RecordingStatus> => {
try {
const status = audioRecordingService.getRecordingStatus()
return RecordingStatus.create({
isRecording: status.isRecording,
durationSeconds: status.durationSeconds,
error: status.error ?? "",
})
} catch (error) {
console.error("Error getting recording status:", error)
return RecordingStatus.create({
isRecording: false,
durationSeconds: 0,
error: error instanceof Error ? error.message : "Unknown error occurred",
})
}
}
@@ -0,0 +1,165 @@
import { RecordingResult } from "@shared/proto/cline/dictation"
import * as os from "os"
import { HostProvider } from "@/hosts/host-provider"
import { audioRecordingService } from "@/services/dictation/AudioRecordingService"
import { telemetryService } from "@/services/telemetry"
import { AUDIO_PROGRAM_CONFIG } from "@/shared/audioProgramConstants"
import { ShowMessageType } from "@/shared/proto/host/window"
import { Controller } from ".."
/**
* Handles the installation of missing dependencies with Cline
*/
async function handleInstallWithCline(
controller: Controller,
dependencyName: string,
installCommand: string,
platform: string,
): Promise<void> {
const platformName = platform === "darwin" ? "macOS" : platform === "win32" ? "Windows" : "Linux"
const installTask = `Please install ${dependencyName} for voice recording on ${platformName}.\n\nRun this command:\n\`\`\`bash\n${installCommand}\n\`\`\`\n\nThis will enable voice recording functionality in Cline.`
// Clear any existing task and start the installation task
await controller.clearTask()
await controller.postStateToWebview()
await controller.initTask(installTask)
HostProvider.get().logToChannel(`Started task to install ${dependencyName}`)
}
/**
* Handles copying the installation command to clipboard
*/
async function handleCopyCommand(installCommand: string): Promise<void> {
const vscode = await import("vscode")
await vscode.env.clipboard.writeText(installCommand)
await HostProvider.window.showMessage({
type: ShowMessageType.INFORMATION,
message: `Installation command copied to clipboard: ${installCommand}`,
options: { items: [] },
})
}
/**
* Handles missing dependency notification and user action
*/
async function handleMissingDependency(
controller: Controller,
platform: string,
config: (typeof AUDIO_PROGRAM_CONFIG)[keyof typeof AUDIO_PROGRAM_CONFIG],
): Promise<void> {
const installWithCline = "Install with Cline"
const installManually = "Copy Command"
const dismiss = "Dismiss"
const action = await HostProvider.window.showMessage({
type: ShowMessageType.INFORMATION,
message: `${config.dependencyName} is required for voice recording. ${config.installDescription}`,
options: { items: [installWithCline, installManually, dismiss] },
})
if (action.selectedOption === installWithCline) {
await handleInstallWithCline(controller, config.dependencyName, config.installCommand, platform)
} else if (action.selectedOption === installManually) {
await handleCopyCommand(config.installCommand)
}
// If dismiss, do nothing
}
/**
* Handles sign-in errors for dictation
*/
async function handleSignInError(controller: Controller, errorMessage: string): Promise<void> {
const signInAction = "Sign in to Cline"
const action = await HostProvider.window.showMessage({
type: ShowMessageType.ERROR,
message: `Voice recording error: ${errorMessage}`,
options: { items: [signInAction] },
})
if (action.selectedOption === signInAction) {
await controller.authService.createAuthRequest()
}
}
/**
* Shows a generic error message
*/
async function showGenericError(errorMessage: string): Promise<void> {
await HostProvider.window.showMessage({
type: ShowMessageType.ERROR,
message: `Voice recording error: ${errorMessage}`,
options: { items: [] },
})
}
/**
* Checks if the recording error is due to missing dependencies
*/
function isMissingDependencyError(
error: string | undefined,
config: (typeof AUDIO_PROGRAM_CONFIG)[keyof typeof AUDIO_PROGRAM_CONFIG] | undefined,
): boolean {
return !!(error && config && error.includes(config.error))
}
/**
* Starts audio recording using the Extension Host
* @param controller The controller instance
* @returns RecordingResult with success status
*/
export const startRecording = async (controller: Controller): Promise<RecordingResult> => {
const taskId = controller.task?.taskId
try {
// Verify user authentication
const userInfo = controller.authService.getInfo()
if (!userInfo?.user?.uid) {
throw new Error("Please sign in to your Cline Account to use Dictation.")
}
// Attempt to start recording
const result = await audioRecordingService.startRecording()
// Handle successful recording start
if (result.success) {
telemetryService.captureVoiceRecordingStarted(taskId, process.platform)
return RecordingResult.create({
success: true,
error: "",
})
}
// Check if the error is due to missing dependencies
const platform = os.platform() as keyof typeof AUDIO_PROGRAM_CONFIG
const config = AUDIO_PROGRAM_CONFIG[platform]
if (isMissingDependencyError(result.error, config)) {
// Don't await - show dialog asynchronously so frontend gets immediate response
handleMissingDependency(controller, platform, config)
}
return RecordingResult.create({
success: false,
error: result.error || "",
})
} catch (error) {
console.error("Error starting recording:", error)
const errorMessage = error instanceof Error ? error.message : "Unknown error occurred"
// Handle different error types
if (errorMessage.includes("sign in")) {
// Don't await - show dialog asynchronously so frontend gets immediate response
handleSignInError(controller, errorMessage)
} else {
// Don't await - show dialog asynchronously so frontend gets immediate response
showGenericError(errorMessage)
}
return RecordingResult.create({
success: false,
error: errorMessage,
})
}
}
@@ -0,0 +1,37 @@
import { RecordedAudio } from "@shared/proto/cline/dictation"
import { audioRecordingService } from "@/services/dictation/AudioRecordingService"
import { telemetryService } from "@/services/telemetry"
import { Controller } from ".."
/**
* Stops audio recording and returns the recorded audio
* @param controller The controller instance
* @returns RecordedAudio with audio data
*/
export const stopRecording = async (controller: Controller): Promise<RecordedAudio> => {
const taskId = controller.task?.taskId
const recordingStatus = audioRecordingService.getRecordingStatus()
const recordingDuration = recordingStatus.durationSeconds * 1000 // Convert to milliseconds
try {
const result = await audioRecordingService.stopRecording()
telemetryService.captureVoiceRecordingStopped(taskId, recordingDuration, result.success, process.platform)
return RecordedAudio.create({
success: result.success,
audioBase64: result.audioBase64 ?? "",
error: result.error ?? "",
})
} catch (error) {
console.error("Error stopping recording:", error)
telemetryService.captureVoiceRecordingStopped(taskId, recordingDuration, false, process.platform)
return RecordedAudio.create({
success: false,
audioBase64: "",
error: error instanceof Error ? error.message : "Unknown error occurred",
})
}
}
@@ -0,0 +1,73 @@
import { TranscribeAudioRequest, Transcription } from "@shared/proto/cline/dictation"
import { HostProvider } from "@/hosts/host-provider"
import { getVoiceTranscriptionService } from "@/services/dictation/VoiceTranscriptionService"
import { telemetryService } from "@/services/telemetry"
import { ShowMessageType } from "@/shared/proto/host/window"
import { Controller } from ".."
/**
* Transcribes audio using Cline transcription service
* @param controller The controller instance
* @param request TranscribeAudioRequest containing base64 audio data
* @returns Transcription with transcribed text or error
*/
export const transcribeAudio = async (controller: Controller, request: TranscribeAudioRequest): Promise<Transcription> => {
const taskId = controller.task?.taskId
const startTime = Date.now()
// Capture telemetry for transcription start
telemetryService.captureVoiceTranscriptionStarted(taskId, request.language ?? "en")
try {
// Transcribe the audio
const result = await getVoiceTranscriptionService().transcribeAudio(request.audioBase64, request.language ?? "en")
const durationMs = Date.now() - startTime
if (result.error) {
let errorType = "api_error"
if (result.error.includes("Authentication failed")) {
errorType = "invalid_jwt_token"
} else if (result.error.includes("Insufficient credits")) {
errorType = "insufficient_credits"
} else if (result.error.includes("Invalid audio format")) {
errorType = "invalid_audio_format"
} else if (result.error.includes("No internet connection")) {
errorType = "no_internet"
} else if (result.error.includes("Cannot connect")) {
errorType = "connection_error"
} else if (result.error.includes("Connection timed out")) {
errorType = "timeout_error"
} else if (result.error.includes("Network error")) {
errorType = "network_error"
}
telemetryService.captureVoiceTranscriptionError(taskId, errorType, result.error, durationMs)
// Use the error message directly from the service as it's already user-friendly
const errorMessage = result.error
HostProvider.window.showMessage({
type: ShowMessageType.ERROR,
message: errorMessage,
})
} else if (result.text) {
telemetryService.captureVoiceTranscriptionCompleted(taskId, result.text.length, durationMs, request.language ?? "en")
}
return Transcription.create({
text: result.text ?? "",
error: result.error ?? "",
})
} catch (error) {
console.error("Error transcribing audio:", error)
const durationMs = Date.now() - startTime
const errorMessage = error instanceof Error ? error.message : "Unknown error occurred"
telemetryService.captureVoiceTranscriptionError(taskId, "unexpected_error", errorMessage, durationMs)
return Transcription.create({
text: "",
error: errorMessage,
})
}
}
+7
View File
@@ -719,6 +719,7 @@ export class Controller {
const autoApprovalSettings = this.stateManager.getGlobalSettingsKey("autoApprovalSettings")
const browserSettings = this.stateManager.getGlobalSettingsKey("browserSettings")
const focusChainSettings = this.stateManager.getGlobalSettingsKey("focusChainSettings")
const dictationSettings = this.stateManager.getGlobalSettingsKey("dictationSettings")
const preferredLanguage = this.stateManager.getGlobalSettingsKey("preferredLanguage")
const openaiReasoningEffort = this.stateManager.getGlobalSettingsKey("openaiReasoningEffort")
const mode = this.stateManager.getGlobalSettingsKey("mode")
@@ -765,6 +766,10 @@ export class Controller {
const distinctId = getDistinctId()
const version = ExtensionRegistryInfo.version
// Check if dictation feature flag is enabled
const isDictationFeatureEnabled = true
// featureFlagsService.getBooleanFlagEnabled(FeatureFlag.DICTATION, true)
return {
version,
apiConfiguration,
@@ -774,7 +779,9 @@ export class Controller {
checkpointManagerErrorMessage,
autoApprovalSettings,
browserSettings,
isDictationFeatureEnabled,
focusChainSettings,
dictationSettings,
preferredLanguage,
openaiReasoningEffort,
mode,
+4 -1
View File
@@ -1,4 +1,5 @@
import { buildApiHandler } from "@core/api"
import { Empty } from "@shared/proto/cline/common"
import {
PlanActMode,
@@ -160,7 +161,6 @@ export async function updateSettings(controller: Controller, request: UpdateSett
}
controller.stateManager.setGlobalState("strictPlanModeEnabled", request.strictPlanModeEnabled)
}
// Update yolo mode setting
if (request.yoloModeToggled !== undefined) {
if (controller.task) {
@@ -170,6 +170,9 @@ export async function updateSettings(controller: Controller, request: UpdateSett
controller.stateManager.setGlobalState("yoloModeToggled", request.yoloModeToggled)
}
if (request.dictationSettings !== undefined) {
controller.stateManager.setGlobalState("dictationSettings", request.dictationSettings)
}
// Update auto-condense setting
if (request.useAutoCondense !== undefined) {
if (controller.task) {
+2 -4
View File
@@ -24,7 +24,6 @@ import {
SettingsKey,
} from "./state-keys"
import { readGlobalStateFromDisk, readSecretsFromDisk, readWorkspaceStateFromDisk } from "./utils/state-helpers"
export interface PersistenceErrorEvent {
error: Error
}
@@ -992,10 +991,9 @@ export class StateManager {
planModeVercelAiGatewayModelInfo:
this.taskStateCache["planModeVercelAiGatewayModelInfo"] ||
this.globalStateCache["planModeVercelAiGatewayModelInfo"],
planModeOcaModelId: this.globalStateCache["planModeOcaModelId"],
planModeOcaModelId: this.globalStateCache["planModeOcaModelId"],
planModeOcaModelInfo: this.globalStateCache["planModeOcaModelInfo"],
// Act mode configurations
actModeApiProvider: this.taskStateCache["actModeApiProvider"] || this.globalStateCache["actModeApiProvider"],
actModeApiModelId: this.taskStateCache["actModeApiModelId"] || this.globalStateCache["actModeApiModelId"],
@@ -1055,7 +1053,7 @@ export class StateManager {
actModeVercelAiGatewayModelInfo:
this.taskStateCache["actModeVercelAiGatewayModelInfo"] ||
this.globalStateCache["actModeVercelAiGatewayModelInfo"],
actModeOcaModelId: this.globalStateCache["actModeOcaModelId"],
actModeOcaModelId: this.globalStateCache["actModeOcaModelId"],
actModeOcaModelInfo: this.globalStateCache["actModeOcaModelInfo"],
}
}
+2 -1
View File
@@ -5,13 +5,13 @@ import { WorkspaceRoot } from "@/core/workspace/WorkspaceRoot"
import { AutoApprovalSettings } from "@/shared/AutoApprovalSettings"
import { BrowserSettings } from "@/shared/BrowserSettings"
import { ClineRulesToggles } from "@/shared/cline-rules"
import { DictationSettings } from "@/shared/DictationSettings"
import { HistoryItem } from "@/shared/HistoryItem"
import { McpDisplayMode } from "@/shared/McpDisplayMode"
import { McpMarketplaceCatalog } from "@/shared/mcp"
import { Mode, OpenaiReasoningEffort } from "@/shared/storage/types"
import { TelemetrySetting } from "@/shared/TelemetrySetting"
import { UserInfo } from "@/shared/UserInfo"
export type SecretKey = keyof Secrets
export type GlobalStateKey = keyof GlobalState
@@ -94,6 +94,7 @@ export interface Settings {
preferredLanguage: string
openaiReasoningEffort: OpenaiReasoningEffort
mode: Mode
dictationSettings: DictationSettings
focusChainSettings: FocusChainSettings
customPrompt: "compact" | undefined
difyBaseUrl: string | undefined
+5 -1
View File
@@ -4,12 +4,12 @@ import { Controller } from "@/core/controller"
import { DEFAULT_AUTO_APPROVAL_SETTINGS } from "@/shared/AutoApprovalSettings"
import { DEFAULT_BROWSER_SETTINGS } from "@/shared/BrowserSettings"
import { ClineRulesToggles } from "@/shared/cline-rules"
import { DEFAULT_DICTATION_SETTINGS, DictationSettings } from "@/shared/DictationSettings"
import { DEFAULT_FOCUS_CHAIN_SETTINGS } from "@/shared/FocusChainSettings"
import { DEFAULT_MCP_DISPLAY_MODE } from "@/shared/McpDisplayMode"
import { OpenaiReasoningEffort } from "@/shared/storage/types"
import { readTaskHistoryFromState } from "../disk"
import { GlobalStateAndSettings, LocalState, SecretKey, Secrets } from "../state-keys"
export async function readSecretsFromDisk(context: ExtensionContext): Promise<Secrets> {
const [
apiKey,
@@ -230,6 +230,9 @@ export async function readGlobalStateFromDisk(context: ExtensionContext): Promis
context.globalState.get<GlobalStateAndSettings["openaiReasoningEffort"]>("openaiReasoningEffort")
const preferredLanguage = context.globalState.get<GlobalStateAndSettings["preferredLanguage"]>("preferredLanguage")
const focusChainSettings = context.globalState.get<GlobalStateAndSettings["focusChainSettings"]>("focusChainSettings")
const dictationSettings = context.globalState.get<GlobalStateAndSettings["dictationSettings"]>("dictationSettings") as
| DictationSettings
| undefined
const mcpMarketplaceCatalog =
context.globalState.get<GlobalStateAndSettings["mcpMarketplaceCatalog"]>("mcpMarketplaceCatalog")
@@ -523,6 +526,7 @@ export async function readGlobalStateFromDisk(context: ExtensionContext): Promis
// Other global fields
focusChainSettings: focusChainSettings || DEFAULT_FOCUS_CHAIN_SETTINGS,
dictationSettings: { ...DEFAULT_DICTATION_SETTINGS, ...dictationSettings },
strictPlanModeEnabled: strictPlanModeEnabled ?? true,
yoloModeToggled: yoloModeToggled ?? false,
useAutoCondense: useAutoCondense ?? false,
@@ -240,4 +240,22 @@ export class ClineAccountService {
await this._authService.restoreRefreshTokenAndRetrieveAuthInfo()
}
}
/**
* Transcribes audio using the Cline transcription service
* @param audioBase64 - Base64 encoded audio data
* @param language - Optional language hint for transcription
* @returns Promise with transcribed text or error
*/
async transcribeAudio(audioBase64: string, language = "en"): Promise<{ text: string }> {
const response = await this.authenticatedRequest<{ text: string }>(`/api/v1/chat/transcriptions`, {
method: "POST",
data: {
audioData: audioBase64,
language: language,
},
})
return response
}
}
@@ -0,0 +1,283 @@
import { ChildProcess, spawn } from "node:child_process"
import * as fs from "node:fs"
import * as os from "node:os"
import * as path from "node:path"
import { Logger } from "@services/logging/Logger"
import { AUDIO_PROGRAM_CONFIG } from "@/shared/audioProgramConstants"
function isExecutable(filePath: string): boolean {
try {
fs.accessSync(filePath, fs.constants.X_OK)
return true
} catch {
return false
}
}
export class AudioRecordingService {
private recordingProcess: ChildProcess | null = null
private startTime: number = 0
private outputFile: string = ""
constructor() {}
/**
* Determines if recording is currently active by checking process state
*/
private get isRecording(): boolean {
return this.recordingProcess !== null && !this.recordingProcess.killed && this.recordingProcess.exitCode === null
}
/**
* Resets the recording state variables
*/
private resetRecordingState(): void {
this.recordingProcess = null
this.startTime = 0
}
/**
* Cleans up the temporary audio file
*/
private async cleanupTempFile(): Promise<void> {
if (this.outputFile && fs.existsSync(this.outputFile)) {
try {
fs.unlinkSync(this.outputFile)
Logger.info("Temporary audio file cleaned up")
} catch (error) {
Logger.warn("Failed to cleanup temporary audio file: " + (error instanceof Error ? error.message : String(error)))
} finally {
this.outputFile = ""
}
}
}
/**
* Terminates the recording process gracefully
*/
private async terminateProcess(): Promise<void> {
if (!this.recordingProcess) {
return
}
Logger.info("Terminating recording process...")
this.recordingProcess.kill("SIGINT")
// Wait for the process to finish with timeout
await new Promise<void>((resolve) => {
const timeoutId = setTimeout(() => {
Logger.warn("Process termination timed out after 5 seconds")
resolve()
}, 5000)
this.recordingProcess?.on("exit", (code) => {
clearTimeout(timeoutId)
Logger.info(`Recording process exited with code: ${code}`)
resolve()
})
})
}
/**
* Performs comprehensive cleanup of recording resources
* @param options - Cleanup options
* @param options.keepFile - If true, preserves the temporary file
*/
private async performCleanup(options?: { keepFile?: boolean }): Promise<void> {
await this.terminateProcess()
this.resetRecordingState()
if (!options?.keepFile) {
await this.cleanupTempFile()
}
}
async startRecording(): Promise<{ success: boolean; error?: string }> {
try {
// Defensive cleanup before starting - ensures clean state
if (this.recordingProcess || this.outputFile) {
Logger.info("Performing pre-recording cleanup of stale resources...")
await this.performCleanup()
}
if (this.isRecording) {
return { success: false, error: "Already recording" }
}
// Check if recording software is available
const checkResult = this.checkRecordingDependencies()
if (!checkResult.available) {
return { success: false, error: checkResult.error }
}
// Create temporary file for audio output
const tempDir = os.tmpdir()
this.outputFile = path.join(tempDir, `cline_recording_${Date.now()}.webm`)
Logger.info("Starting audio recording...")
// Get the recording program path
const recordProgram = this.getRecordProgram()
if (!recordProgram) {
return { success: false, error: "Recording program not found" }
}
Logger.info(`Using recording program: ${recordProgram.path}`)
// Set up recording arguments
const args = recordProgram.getArgs(this.outputFile)
// Spawn the recording process
this.recordingProcess = spawn(recordProgram.path, args)
this.startTime = Date.now()
// Handle process errors
this.recordingProcess.on("error", (error) => {
Logger.error(`Recording process error: ${error.message}`)
this.resetRecordingState()
})
// Handle process exit
this.recordingProcess.on("exit", (code) => {
if (code !== 0 && code !== null) {
Logger.warn(`Recording process exited with code: ${code}`)
}
})
this.recordingProcess.stderr?.on("data", (data) => {
const message = data.toString().trim()
if (message && !message.includes("In:") && !message.includes("Out:")) {
Logger.info(`Recording stderr: ${message}`)
}
})
Logger.info("Audio recording started successfully")
return { success: true }
} catch (error) {
await this.performCleanup()
const errorMessage = error instanceof Error ? error.message : String(error)
Logger.error("Failed to start audio recording: " + errorMessage)
return { success: false, error: `Failed to start recording: ${errorMessage}` }
}
}
async stopRecording(): Promise<{ success: boolean; audioBase64?: string; error?: string }> {
try {
if (!this.isRecording) {
return { success: false, error: "Not currently recording" }
}
Logger.info("Stopping audio recording...")
// Terminate the process but keep the file for reading
await this.terminateProcess()
this.resetRecordingState()
// Wait a moment for file to be fully written
await new Promise((resolve) => setTimeout(resolve, 500))
// Read the audio file and convert to base64
if (!fs.existsSync(this.outputFile)) {
return { success: false, error: "Recording file not found" }
}
const audioBuffer = fs.readFileSync(this.outputFile)
const audioBase64 = audioBuffer.toString("base64")
// Clean up temporary file after reading
await this.cleanupTempFile()
Logger.info("Audio recording stopped and converted to base64")
return { success: true, audioBase64 }
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error)
Logger.error("Failed to stop audio recording: " + errorMessage)
// Ensure cleanup happens even on error
await this.performCleanup()
return { success: false, error: `Failed to stop recording: ${errorMessage}` }
}
}
async cancelRecording(): Promise<{ success: boolean; error?: string }> {
try {
if (!this.isRecording) {
return { success: false, error: "Not currently recording" }
}
Logger.info("Canceling audio recording...")
// Perform full cleanup including file deletion
await this.performCleanup()
Logger.info("Audio recording canceled successfully")
return { success: true }
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error)
Logger.error("Failed to cancel audio recording: " + errorMessage)
// Ensure cleanup happens even on error
await this.performCleanup()
return { success: false, error: `Failed to cancel recording: ${errorMessage}` }
}
}
getRecordingStatus(): { isRecording: boolean; durationSeconds: number; error?: string } {
const durationSeconds = this.isRecording ? (Date.now() - this.startTime) / 1000 : 0
return {
isRecording: this.isRecording,
durationSeconds,
}
}
private checkRecordingDependencies(): { available: boolean; error?: string } {
const program = this.getRecordProgram()
if (!program) {
const platform = os.platform() as keyof typeof AUDIO_PROGRAM_CONFIG
const config = AUDIO_PROGRAM_CONFIG[platform]
const error = config ? config.error : `Audio recording is not supported on platform: ${platform}`
return { available: false, error }
}
return { available: true }
}
private getRecordProgram(): { path: string; getArgs: (outputFile: string) => string[] } | undefined {
const platform = os.platform() as keyof typeof AUDIO_PROGRAM_CONFIG
const config = AUDIO_PROGRAM_CONFIG[platform]
if (!config) {
return undefined
}
// 1. Check if the command is in the system's PATH
const pathDirs = (process.env.PATH || "").split(path.delimiter)
for (const dir of pathDirs) {
const fullPath = path.join(dir, config.command)
if (fs.existsSync(fullPath) && isExecutable(fullPath)) {
return { path: fullPath, getArgs: config.getArgs }
}
}
// 2. Check fallback paths if not in PATH
for (const p of config.fallbackPaths) {
if (fs.existsSync(p) && isExecutable(p)) {
return { path: p, getArgs: config.getArgs }
}
}
return undefined
}
/**
* Public cleanup method for service shutdown
*/
cleanup(): void {
// Use async cleanup but don't await since this is often called in sync contexts
this.performCleanup().catch((error) => {
Logger.error("Error during cleanup: " + (error instanceof Error ? error.message : String(error)))
})
}
}
export const audioRecordingService = new AudioRecordingService()
@@ -0,0 +1,141 @@
import { Logger } from "@services/logging/Logger"
import axios from "axios"
import { ClineAccountService } from "@/services/account/ClineAccountService"
// Network error matchers using Map for O(1) lookup
const NETWORK_ERROR_MAP = new Map<string, string>([
["enotfound", "No internet connection. Please check your network and try again."],
["econnrefused", "Cannot connect to transcription service. Please check your internet connection."],
["etimedout", "Connection timed out. Please check your internet connection and try again."],
["econnreset", "Connection timed out. Please check your internet connection and try again."],
["network error", "Network error. Please check your internet connection."],
])
// HTTP status code error messages using Map for O(1) lookup
const STATUS_ERROR_MAP = new Map<number, string>([
[401, "Authentication failed. Please reauthenticate your Cline account"],
[402, "Insufficient credits for transcription service."],
[500, "Transcription server error. Please try again later."],
])
// Special 400 error patterns that need custom handling
const BAD_REQUEST_ERROR_PATTERNS = [
{
patterns: ["insufficient balance", "insufficient credits"],
message: "Insufficient credits for transcription service.",
},
{
patterns: ["invalid audio", "invalid format"],
message: "Invalid audio format. Please try recording again.",
},
]
export class VoiceTranscriptionService {
private readonly clineAccountService: ClineAccountService
constructor() {
this.clineAccountService = ClineAccountService.getInstance()
}
/**
* Parses transcription errors and returns user-friendly error messages
* @param error The error object from the transcription attempt
* @returns An object with the error message
*/
private parseTranscriptionError(error: unknown): { error: string } {
// Handle axios errors with proper status code mapping
if (axios.isAxiosError(error)) {
const status = error.response?.status
// Extract error message from server response - check both 'error' and 'message' fields
const rawMessage = error.response?.data?.error || error.response?.data?.message || error.message
const lowerMessage = rawMessage.toLowerCase()
// Check for network errors using the Map (these don't have status codes)
for (const [keyword, response] of NETWORK_ERROR_MAP) {
if (lowerMessage.includes(keyword)) {
return { error: response }
}
}
// Check if we have a simple status code mapping
if (status && STATUS_ERROR_MAP.has(status)) {
return { error: STATUS_ERROR_MAP.get(status)! }
}
// Handle special 400 errors with pattern matching
if (status === 400) {
// Check for specific error patterns
for (const { patterns, message } of BAD_REQUEST_ERROR_PATTERNS) {
if (patterns.some((pattern) => lowerMessage.includes(pattern))) {
return { error: message }
}
}
// Check for limit exceeded messages (preserve original message)
if (lowerMessage.includes("exceeds") && lowerMessage.includes("limit")) {
return { error: rawMessage }
}
// For other 400 errors, show the server's message if available, otherwise use generic
return { error: rawMessage || "Invalid audio format or request data." }
}
// Default case for unhandled status codes
return {
error: "Transcription failed. Please try again later or raise an issue on https://github.com/cline/cline/issues",
}
}
// Handle non-axios errors (general network errors)
const errorMessage = error instanceof Error ? error.message : String(error)
const lowerErrorMessage = errorMessage.toLowerCase()
// Check network errors using the Map
for (const [keyword, response] of NETWORK_ERROR_MAP) {
if (lowerErrorMessage.includes(keyword)) {
return { error: response }
}
}
return { error: `Network error: ${errorMessage}` }
}
async transcribeAudio(audioBase64: string, language?: string): Promise<{ text?: string; error?: string }> {
try {
Logger.info("Transcribing audio with Cline transcription service...")
// Check if using organization account for telemetry
const userInfo = await this.clineAccountService.fetchMe()
const activeOrg = userInfo?.organizations?.find((org) => org.active)
const isOrgAccount = !!activeOrg
const result = await this.clineAccountService.transcribeAudio(audioBase64, language)
Logger.info("Transcription successful")
// Capture telemetry with account type - use dynamic import to avoid circular dependency
const { telemetryService } = await import("@/services/telemetry")
telemetryService.captureVoiceTranscriptionCompleted(
undefined, // taskId
result.text?.length,
undefined, // duration
language,
isOrgAccount,
)
return { text: result.text }
} catch (error) {
Logger.error("Voice transcription error:", error)
return this.parseTranscriptionError(error)
}
}
}
// Lazily construct the service to avoid circular import initialization issues
let _voiceTranscriptionServiceInstance: VoiceTranscriptionService | null = null
export function getVoiceTranscriptionService(): VoiceTranscriptionService {
if (!_voiceTranscriptionServiceInstance) {
_voiceTranscriptionServiceInstance = new VoiceTranscriptionService()
}
return _voiceTranscriptionServiceInstance
}
+134 -1
View File
@@ -16,7 +16,7 @@ import { TelemetryProviderFactory } from "./TelemetryProviderFactory"
* When adding a new category, add it both here and to the initial values in telemetryCategoryEnabled
* Ensure `if (!this.isCategoryEnabled('<category_name>')` is added to the capture method
*/
type TelemetryCategory = "checkpoints" | "browser" | "focus_chain"
type TelemetryCategory = "checkpoints" | "browser" | "focus_chain" | "dictation"
/**
* Enum for terminal output failure reasons
@@ -76,6 +76,7 @@ export class TelemetryService {
private telemetryCategoryEnabled: Map<TelemetryCategory, boolean> = new Map([
["checkpoints", true], // Checkpoints telemetry enabled
["browser", true], // Browser telemetry enabled
["dictation", true], // Dictation telemetry enabled
["focus_chain", true], // Focus Chain telemetry enabled
])
@@ -88,6 +89,19 @@ export class TelemetryService {
TELEMETRY_ENABLED: "user.telemetry_enabled",
EXTENSION_ACTIVATED: "user.extension_activated",
},
DICTATION: {
// Tracks when voice recording is started
RECORDING_STARTED: "voice.recording_started",
// Tracks when voice recording is stopped
RECORDING_STOPPED: "voice.recording_stopped",
// Tracks when voice transcription is started
TRANSCRIPTION_STARTED: "voice.transcription_started",
// Tracks when voice transcription is completed successfully
TRANSCRIPTION_COMPLETED: "voice.transcription_completed",
// Tracks when voice transcription fails
TRANSCRIPTION_ERROR: "voice.transcription_error",
// Tracks when voice feature is enabled or disabled in settings
},
// Workspace-related events for multi-root support
WORKSPACE: {
// Track workspace initialization
@@ -285,7 +299,126 @@ export class TelemetryService {
setDistinctId(userInfo.id)
}
}
// Dictation events
/**
* Records when voice recording is started
* @param taskId Optional task identifier if recording was started during a task
* @param platform The platform where recording is happening (macOS, Windows, Linux)
*/
public captureVoiceRecordingStarted(taskId?: string, platform?: string) {
if (!this.isCategoryEnabled("dictation")) {
return
}
this.capture({
event: TelemetryService.EVENTS.DICTATION.RECORDING_STARTED,
properties: {
taskId,
platform: platform ?? process.platform,
timestamp: new Date().toISOString(),
},
})
}
/**
* Records when voice recording is stopped
* @param taskId Optional task identifier if recording was stopped during a task
* @param durationMs Duration of the recording in milliseconds
* @param success Whether the recording was successful
* @param platform The platform where recording happened
*/
public captureVoiceRecordingStopped(taskId?: string, durationMs?: number, success?: boolean, platform?: string) {
if (!this.isCategoryEnabled("dictation")) {
return
}
this.capture({
event: TelemetryService.EVENTS.DICTATION.RECORDING_STOPPED,
properties: {
taskId,
durationMs,
success,
platform: platform ?? process.platform,
timestamp: new Date().toISOString(),
},
})
}
/**
* Records when voice transcription is started
* @param taskId Optional task identifier if transcription was started during a task
* @param language Language hint provided for transcription
*/
public captureVoiceTranscriptionStarted(taskId?: string, language?: string) {
if (!this.isCategoryEnabled("dictation")) {
return
}
this.capture({
event: TelemetryService.EVENTS.DICTATION.TRANSCRIPTION_STARTED,
properties: {
taskId,
language,
timestamp: new Date().toISOString(),
},
})
}
/**
* Records when voice transcription is completed successfully
* @param taskId Optional task identifier if transcription was completed during a task
* @param transcriptionLength Length of the transcribed text
* @param durationMs Time taken for transcription in milliseconds
* @param language Language used for transcription
* @param isOrgAccount Whether the transcription was done using an organization account
*/
public captureVoiceTranscriptionCompleted(
taskId?: string,
transcriptionLength?: number,
durationMs?: number,
language?: string,
isOrgAccount?: boolean,
) {
if (!this.isCategoryEnabled("dictation")) {
return
}
this.capture({
event: TelemetryService.EVENTS.DICTATION.TRANSCRIPTION_COMPLETED,
properties: {
taskId,
transcriptionLength,
durationMs,
language,
accountType: isOrgAccount ? "organization" : "personal",
timestamp: new Date().toISOString(),
},
})
}
/**
* Records when voice transcription fails
* @param taskId Optional task identifier if transcription failed during a task
* @param errorType Type of error that occurred (e.g., "no_openai_key", "api_error", "network_error")
* @param errorMessage The error message
* @param durationMs Time taken before failure in milliseconds
*/
public captureVoiceTranscriptionError(taskId?: string, errorType?: string, errorMessage?: string, durationMs?: number) {
if (!this.isCategoryEnabled("dictation")) {
return
}
this.capture({
event: TelemetryService.EVENTS.DICTATION.TRANSCRIPTION_ERROR,
properties: {
taskId,
errorType,
errorMessage,
durationMs,
timestamp: new Date().toISOString(),
},
})
}
// Task events
/**
* Records when a new task/conversation is started
+74
View File
@@ -0,0 +1,74 @@
export interface DictationSettings {
dictationEnabled: boolean
dictationLanguage: string
}
export const DEFAULT_DICTATION_SETTINGS: DictationSettings = {
dictationEnabled: true, // Default is true while this service is in Experimental status
dictationLanguage: "en",
}
export interface LanguageItem {
name: string
code: string
}
export const SUPPORTED_DICTATION_LANGUAGES: LanguageItem[] = [
{ name: "English", code: "en" },
{ name: "Spanish (Español)", code: "es" },
{ name: "Chinese (中文)", code: "zh" },
{ name: "Japanese (日本語)", code: "ja" },
{ name: "Afrikaans", code: "af" },
{ name: "Arabic (العربية)", code: "ar" },
{ name: "Armenian (Հայերեն)", code: "hy" },
{ name: "Azerbaijani (Azərbaycan)", code: "az" },
{ name: "Belarusian (Беларуская)", code: "be" },
{ name: "Bosnian (Bosanski)", code: "bs" },
{ name: "Bulgarian (Български)", code: "bg" },
{ name: "Catalan (Català)", code: "ca" },
{ name: "Croatian (Hrvatski)", code: "hr" },
{ name: "Czech (Čeština)", code: "cs" },
{ name: "Danish (Dansk)", code: "da" },
{ name: "Dutch (Nederlands)", code: "nl" },
{ name: "Estonian (Eesti)", code: "et" },
{ name: "Finnish (Suomi)", code: "fi" },
{ name: "French (Français)", code: "fr" },
{ name: "Galician (Galego)", code: "gl" },
{ name: "German (Deutsch)", code: "de" },
{ name: "Greek (Ελληνικά)", code: "el" },
{ name: "Hebrew (עברית)", code: "he" },
{ name: "Hindi (हिन्दी)", code: "hi" },
{ name: "Hungarian (Magyar)", code: "hu" },
{ name: "Icelandic (Íslenska)", code: "is" },
{ name: "Indonesian (Bahasa Indonesia)", code: "id" },
{ name: "Italian (Italiano)", code: "it" },
{ name: "Kannada (ಕನ್ನಡ)", code: "kn" },
{ name: "Kazakh (Қазақша)", code: "kk" },
{ name: "Korean (한국어)", code: "ko" },
{ name: "Latvian (Latviešu)", code: "lv" },
{ name: "Lithuanian (Lietuvių)", code: "lt" },
{ name: "Macedonian (Македонски)", code: "mk" },
{ name: "Malay (Bahasa Melayu)", code: "ms" },
{ name: "Marathi (मराठी)", code: "mr" },
{ name: "Maori (Te Reo Māori)", code: "mi" },
{ name: "Nepali (नेपाली)", code: "ne" },
{ name: "Norwegian (Norsk)", code: "no" },
{ name: "Persian (فارسی)", code: "fa" },
{ name: "Polish (Polski)", code: "pl" },
{ name: "Portuguese (Português)", code: "pt" },
{ name: "Romanian (Română)", code: "ro" },
{ name: "Russian (Русский)", code: "ru" },
{ name: "Serbian (Српски)", code: "sr" },
{ name: "Slovak (Slovenčina)", code: "sk" },
{ name: "Slovenian (Slovenščina)", code: "sl" },
{ name: "Swahili (Kiswahili)", code: "sw" },
{ name: "Swedish (Svenska)", code: "sv" },
{ name: "Tagalog", code: "tl" },
{ name: "Tamil (தமிழ்)", code: "ta" },
{ name: "Thai (ไทย)", code: "th" },
{ name: "Turkish (Türkçe)", code: "tr" },
{ name: "Ukrainian (Українська)", code: "uk" },
{ name: "Urdu (اردو)", code: "ur" },
{ name: "Vietnamese (Tiếng Việt)", code: "vi" },
{ name: "Welsh (Cymraeg)", code: "cy" },
]
+3 -1
View File
@@ -5,13 +5,13 @@ import { AutoApprovalSettings } from "./AutoApprovalSettings"
import { ApiConfiguration } from "./api"
import { BrowserSettings } from "./BrowserSettings"
import { ClineRulesToggles } from "./cline-rules"
import { DictationSettings } from "./DictationSettings"
import { FocusChainSettings } from "./FocusChainSettings"
import { HistoryItem } from "./HistoryItem"
import { McpDisplayMode } from "./McpDisplayMode"
import { Mode, OpenaiReasoningEffort } from "./storage/types"
import { TelemetrySetting } from "./TelemetrySetting"
import { UserInfo } from "./UserInfo"
// webview will hold state
export interface ExtensionMessage {
type: "grpc_response" // New type for gRPC responses
@@ -36,6 +36,7 @@ export interface ExtensionState {
apiConfiguration?: ApiConfiguration
autoApprovalSettings: AutoApprovalSettings
browserSettings: BrowserSettings
isDictationFeatureEnabled?: boolean // Feature flag for dictation
remoteBrowserHost?: string
preferredLanguage?: string
openaiReasoningEffort?: OpenaiReasoningEffort
@@ -70,6 +71,7 @@ export interface ExtensionState {
yoloModeToggled?: boolean
useAutoCondense?: boolean
focusChainSettings: FocusChainSettings
dictationSettings: DictationSettings
customPrompt?: string
favoritedModelIds: string[]
// NEW: Add workspace information
+81
View File
@@ -0,0 +1,81 @@
export const AUDIO_PROGRAM_CONFIG = {
darwin: {
command: "ffmpeg",
fallbackPaths: ["/usr/local/bin/ffmpeg", "/opt/homebrew/bin/ffmpeg"],
getArgs: (outputFile: string) => [
"-f",
"avfoundation",
"-i",
":default",
"-c:a",
"libopus",
"-b:a",
"32k",
"-application",
"voip",
"-ar",
"16000",
"-ac",
"1",
outputFile,
],
dependencyName: "FFmpeg",
installCommand: "brew install ffmpeg",
error: "FFmpeg is required for voice recording but is not installed on your system.",
installDescription: "FFmpeg is a multimedia framework that Cline uses to record audio from your microphone.",
},
linux: {
command: "ffmpeg",
fallbackPaths: ["/usr/bin/ffmpeg", "/usr/local/bin/ffmpeg", "/snap/bin/ffmpeg"],
getArgs: (outputFile: string) => [
"-f",
"alsa",
"-i",
"default",
"-c:a",
"libopus",
"-b:a",
"32k",
"-application",
"voip",
"-ar",
"16000",
"-ac",
"1",
outputFile,
],
dependencyName: "FFmpeg",
installCommand: "sudo apt-get update && sudo apt-get install -y ffmpeg",
error: "FFmpeg is required for voice recording but is not installed on your system.",
installDescription: "FFmpeg is a multimedia framework that Cline uses to record audio from your microphone.",
},
win32: {
command: "ffmpeg",
fallbackPaths: [
"C:\\ffmpeg\\bin\\ffmpeg.exe",
"C:\\Program Files\\ffmpeg\\bin\\ffmpeg.exe",
"C:\\Program Files (x86)\\ffmpeg\\bin\\ffmpeg.exe",
],
getArgs: (outputFile: string) => [
"-f",
"wasapi",
"-i",
"audio=default",
"-c:a",
"libopus",
"-b:a",
"32k",
"-application",
"voip",
"-ar",
"16000",
"-ac",
"1",
outputFile,
],
dependencyName: "FFmpeg",
installCommand: "winget install Gyan.FFmpeg",
error: "FFmpeg is required for voice recording but is not installed on your system.",
installDescription: "FFmpeg is a multimedia framework that Cline uses to record audio from your microphone.",
},
}
@@ -1,6 +1,7 @@
export enum FeatureFlag {
CUSTOM_INSTRUCTIONS = "custom-instructions",
DEV_ENV_POSTHOG = "dev-env-posthog",
DICTATION = "dictation",
FOCUS_CHAIN_CHECKLIST = "focus_chain_checklist",
MULTI_ROOT_WORKSPACE = "multi_root_workspace",
}
+14065 -14372
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -20,6 +20,7 @@
"@floating-ui/react": "^0.27.4",
"@fontsource/azeret-mono": "^5.2.9",
"@heroui/react": "^2.8.0-beta.2",
"@paper-design/shaders-react": "^0.0.46",
"@vscode/webview-ui-toolkit": "^1.4.0",
"debounce": "^2.1.1",
"dompurify": "^3.2.4",
+114 -133
View File
@@ -1,3 +1,5 @@
import { cn } from "@heroui/react"
import { PulsingBorder } from "@paper-design/shaders-react"
import { mentionRegex, mentionRegexGlobal } from "@shared/context-mentions"
import { EmptyRequest, StringRequest } from "@shared/proto/cline/common"
import { FileSearchRequest, FileSearchType, RelativePathsRequest } from "@shared/proto/cline/file"
@@ -6,6 +8,7 @@ import { PlanActMode, TogglePlanActModeRequest } from "@shared/proto/cline/state
import { convertApiConfigurationToProto } from "@shared/proto-conversions/models/api-configuration-conversion"
import { Mode } from "@shared/storage/types"
import { VSCodeButton } from "@vscode/webview-ui-toolkit/react"
import { AtSignIcon, PlusIcon } from "lucide-react"
import type React from "react"
import { forwardRef, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"
import DynamicTextArea from "react-textarea-autosize"
@@ -46,6 +49,7 @@ import {
import { validateApiConfiguration, validateModelId } from "@/utils/validate"
import ClineRulesToggleModal from "../cline-rules/ClineRulesToggleModal"
import ServersToggleModal from "./ServersToggleModal"
import VoiceRecorder from "./VoiceRecorder"
const { MAX_IMAGES_AND_FILES_PER_MESSAGE } = CHAT_CONSTANTS
@@ -289,11 +293,13 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
globalWorkflowToggles,
showChatModelSelector: showModelSelector,
setShowChatModelSelector: setShowModelSelector,
dictationSettings,
isDictationFeatureEnabled,
} = useExtensionState()
const [isTextAreaFocused, setIsTextAreaFocused] = useState(false)
const [isDraggingOver, setIsDraggingOver] = useState(false)
const [gitCommits, setGitCommits] = useState<GitCommit[]>([])
const [isVoiceRecording, setIsVoiceRecording] = useState(false)
const [showSlashCommandsMenu, setShowSlashCommandsMenu] = useState(false)
const [selectedSlashCommandsIndex, setSelectedSlashCommandsIndex] = useState(0)
const [slashCommandsQuery, setSlashCommandsQuery] = useState("")
@@ -517,7 +523,6 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
},
[setInputValue, slashCommandsQuery],
)
const handleKeyDown = useCallback(
(event: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (showSlashCommandsMenu) {
@@ -1431,65 +1436,49 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
return (
<div>
<div
className="relative flex transition-colors ease-in-out duration-100 p-2.5"
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={onDragOver}
onDrop={onDrop}
style={{
padding: "10px 15px",
opacity: 1,
position: "relative",
display: "flex",
// Drag-over styles moved to DynamicTextArea
transition: "background-color 0.1s ease-in-out, border 0.1s ease-in-out",
}}>
onDrop={onDrop}>
<div
className={cn(
// transition: "opacity 1s ease-in-out",
"absolute pointer-events-none z-10 overflow-hidden rounded-xs transition-colors ease-in-out duration-1000",
{
"opacity-0": !isVoiceRecording,
},
)}>
<PulsingBorder
bloom={1}
colorBack={"rgba(0,0,0,0)"}
colors={[
"#ffffff", // white
"#ffffff",
"#9d57fa",
"#ffffff",
]} // Match textarea border radius
intensity={0.97}
pulse={0}
roundness={0}
scale={1.0}
smoke={0.18}
smokeSize={0.76}
softness={1}
speed={1}
spotSize={0.4}
spots={3}
thickness={0}
/>
</div>
{showDimensionError && (
<div
style={{
position: "absolute",
inset: "10px 15px",
backgroundColor: "rgba(var(--vscode-errorForeground-rgb), 0.1)",
border: "2px solid var(--vscode-errorForeground)",
borderRadius: 2,
display: "flex",
alignItems: "center",
justifyContent: "center",
zIndex: 10, // Ensure it's above other elements
pointerEvents: "none",
}}>
<span
style={{
color: "var(--vscode-errorForeground)",
fontWeight: "bold",
fontSize: "12px",
textAlign: "center",
}}>
Image dimensions exceed 7500px
</span>
<div className="absolute inset-2.5 bg-[rgba(var(--vscode-errorForeground-rgb),0.1)] border-2 border-error rounded-xs flex items-center justify-center z-10 pointer-events-none">
<span className="text-error font-bold text-xs text-center">Image dimensions exceed 7500px</span>
</div>
)}
{showUnsupportedFileError && (
<div
style={{
position: "absolute",
inset: "10px 15px",
backgroundColor: "rgba(var(--vscode-errorForeground-rgb), 0.1)",
border: "2px solid var(--vscode-errorForeground)",
borderRadius: 2,
display: "flex",
alignItems: "center",
justifyContent: "center",
zIndex: 10,
pointerEvents: "none",
}}>
<span
style={{
color: "var(--vscode-errorForeground)",
fontWeight: "bold",
fontSize: "12px",
}}>
Files other than images are currently disabled
</span>
<div className="absolute inset-2.5 bg-[rgba(var(--vscode-errorForeground-rgb),0.1)] border-2 border-error rounded-xs flex items-center justify-center z-10 pointer-events-none">
<span className="text-error font-bold text-xs">Files other than images are currently disabled</span>
</div>
)}
{showSlashCommandsMenu && (
@@ -1521,32 +1510,21 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
/>
</div>
)}
{!isTextAreaFocused && !activeQuote && (
<div
style={{
position: "absolute",
inset: "10px 15px",
border: "1px solid var(--vscode-input-border)",
borderRadius: 2,
pointerEvents: "none",
zIndex: 5,
}}
/>
)}
<div
className={cn(
"absolute left-2.5 right-2.5 bottom-2.5 top-2.5 whitespace-pre-wrap break-words rounded-xs px-9 py-9 overflow-hidden bg-input-background",
{
"border-input-border": isTextAreaFocused,
},
)}
ref={highlightLayerRef}
style={{
position: "absolute",
top: 10,
left: 15,
right: 15,
bottom: 10,
pointerEvents: "none",
whiteSpace: "pre-wrap",
wordWrap: "break-word",
color: "transparent",
overflow: "hidden",
backgroundColor: "var(--vscode-input-background)",
fontFamily: "var(--vscode-font-family)",
fontSize: "var(--vscode-editor-font-size)",
lineHeight: "var(--vscode-editor-line-height)",
@@ -1619,7 +1597,7 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
// borderLeft: "9px solid transparent", // NOTE: react-textarea-autosize doesn't calculate correct height when using borderLeft/borderRight so we need to use horizontal padding instead
// Instead of using boxShadow, we use a div with a border to better replicate the behavior when the textarea is focused
// boxShadow: "0px 0px 0px 1px var(--vscode-input-border)",
padding: "9px 28px 9px 9px",
padding: `9px ${dictationSettings?.dictationEnabled ? "48" : "28"}px 9px 9px`,
cursor: "text",
flex: 1,
zIndex: 1,
@@ -1634,7 +1612,7 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
value={inputValue}
/>
{!inputValue && selectedImages.length === 0 && selectedFiles.length === 0 && (
<div className="absolute bottom-4 left-[25px] right-[60px] text-[10px] text-[var(--vscode-input-placeholderForeground)] opacity-70 whitespace-nowrap overflow-hidden text-ellipsis pointer-events-none z-[1]">
<div className="text-[10px] absolute bottom-5 left-5 right-16 text-[var(--vscode-input-placeholderForeground)]/50 whitespace-nowrap overflow-hidden text-ellipsis pointer-events-none z-1">
Type @ for context, / for slash commands & workflows, hold shift to drag in files/images
</div>
)}
@@ -1656,44 +1634,62 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
/>
)}
<div
style={{
position: "absolute",
right: 23,
display: "flex",
alignItems: "flex-end",
height: textAreaBaseHeight || 31,
bottom: 9.5, // should be 10 but doesn't look good on mac
paddingBottom: "8px",
zIndex: 2,
}}>
<div
style={{
display: "flex",
flexDirection: "row",
alignItems: "center",
}}>
{/* <div
className={`input-icon-button ${shouldDisableImages ? "disabled" : ""} codicon codicon-device-camera`}
onClick={() => {
if (!shouldDisableImages) {
onSelectImages()
}
}}
style={{
marginRight: 5.5,
fontSize: 16.5,
}}
/> */}
<div
className={`input-icon-button ${sendingDisabled ? "disabled" : ""} codicon codicon-send`}
data-testid="send-button"
onClick={() => {
if (!sendingDisabled) {
setIsTextAreaFocused(false)
onSend()
}
}}
style={{ fontSize: 15 }}></div>
className="absolute flex items-end bottom-3.5 right-4 z-10 h-8 text-xs"
style={{ height: textAreaBaseHeight }}>
<div className="flex flex-row items-center">
{dictationSettings?.dictationEnabled === true && isDictationFeatureEnabled && (
<VoiceRecorder
disabled={sendingDisabled}
language={dictationSettings?.dictationLanguage || "en"}
onProcessingStateChange={(isProcessing, message) => {
if (isProcessing && message) {
// Show processing message in input
const processingText = inputValue + (inputValue ? " " : "") + `[${message}]`
setInputValue(processingText)
}
// When processing is done, the onTranscription callback will handle the final text
}}
onRecordingStateChange={setIsVoiceRecording}
onTranscription={(text) => {
// Remove any processing text first
const processingPattern = /\s*\[Transcribing\.\.\.\]$/
const cleanedValue = inputValue.replace(processingPattern, "")
if (!text) {
setInputValue(cleanedValue)
return
}
// Append the transcribed text to the cleaned input
const newValue = cleanedValue + (cleanedValue ? " " : "") + text
setInputValue(newValue)
// Focus the textarea and move cursor to end
setTimeout(() => {
if (textAreaRef.current) {
textAreaRef.current.focus()
const length = newValue.length
textAreaRef.current.setSelectionRange(length, length)
}
}, 0)
}}
/>
)}
{!isVoiceRecording && (
<div
className={cn(
"input-icon-button",
{ disabled: sendingDisabled },
"codicon codicon-send text-sm",
)}
data-testid="send-button"
onClick={() => {
if (!sendingDisabled) {
setIsTextAreaFocused(false)
onSend()
}
}}
/>
)}
</div>
</div>
</div>
@@ -1708,28 +1704,16 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
height: "28px", // Fixed height to prevent container shrinking
}}>
{/* ButtonGroup - always in DOM but visibility controlled */}
<ButtonGroup
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
transition: "opacity 0.3s ease-in-out",
width: "100%",
height: "100%",
zIndex: 6,
}}>
<ButtonGroup className="absolute top-0 left-0 right-0 transition-opacity duration-300 ease-in-out w-full h-5 z-10 flex items-center">
<Tooltip style={{ left: 0 }} tipText="Add Context">
<VSCodeButton
appearance="icon"
aria-label="Add Context"
className="p-0 m-0 flex items-center mt-0.5"
data-testid="context-button"
onClick={handleContextButtonClick}
style={{ padding: "0px 0px", height: "20px" }}>
onClick={handleContextButtonClick}>
<ButtonContainer>
<span className="flex items-center" style={{ fontSize: "13px", marginBottom: 1 }}>
@
</span>
<AtSignIcon size={12} />
</ButtonContainer>
</VSCodeButton>
</Tooltip>
@@ -1738,19 +1722,16 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
<VSCodeButton
appearance="icon"
aria-label="Add Files & Images"
className="p-0 m-0 flex items-center mt-0.5"
data-testid="files-button"
disabled={shouldDisableFilesAndImages}
onClick={() => {
if (!shouldDisableFilesAndImages) {
onSelectFilesAndImages()
}
}}
style={{ padding: "0px 0px", height: "20px" }}>
}}>
<ButtonContainer>
<span
className="codicon codicon-add flex items-center"
style={{ fontSize: "14px", marginBottom: -3 }}
/>
<PlusIcon size={13} />
</ButtonContainer>
</VSCodeButton>
</Tooltip>
@@ -0,0 +1,271 @@
import { cn } from "@heroui/react"
import { TranscribeAudioRequest } from "@shared/proto/cline/dictation"
import { EmptyRequest } from "@shared/proto/index.cline"
import React, { useCallback, useEffect, useRef, useState } from "react"
import { DictationServiceClient } from "@/services/grpc-client"
import { formatSeconds } from "@/utils/format"
import HeroTooltip from "../common/HeroTooltip"
interface VoiceRecorderProps {
onTranscription: (text: string) => void
onProcessingStateChange?: (isProcessing: boolean, message?: string) => void
onRecordingStateChange?: (isRecording: boolean) => void
disabled?: boolean
language?: string
}
const MAX_DURATION = 5 * 60 // 5 minutes in seconds
const VoiceRecorder: React.FC<VoiceRecorderProps> = ({
onTranscription,
onProcessingStateChange,
onRecordingStateChange,
disabled = false,
language = "en",
}) => {
const [isRecording, setIsRecording] = useState(false)
const [isProcessing, setIsProcessing] = useState(false)
const [isStarting, setIsStarting] = useState(false) // New state for loading
const [recordingDuration, setRecordingDuration] = useState(0)
const [error, setError] = useState<string | null>(null)
const pollingIntervalRef = useRef<NodeJS.Timeout | null>(null)
// Notify parent when recording state changes
useEffect(() => {
onRecordingStateChange?.(isRecording)
}, [isRecording, onRecordingStateChange])
const startRecording = useCallback(async () => {
try {
// Show loading state instead of immediately setting recording
setIsStarting(true)
setError(null) // Clear any previous errors
onProcessingStateChange?.(false) // Clear any previous processing state
setRecordingDuration(0) // Reset recording duration
// Call Extension Host to start recording
const response = await DictationServiceClient.startRecording(EmptyRequest.create({}))
if (!response.success) {
console.error("Failed to start recording:", response.error)
setError(response.error || "Failed to start recording")
return
}
// Only set recording state after backend confirms success
setIsRecording(true)
console.log("Recording started successfully")
} catch (error) {
console.error("Error starting recording:", error)
const errorMessage = error instanceof Error ? error.message : "Failed to start recording"
setError(errorMessage)
} finally {
// Always clear the starting state
setIsStarting(false)
}
}, [onProcessingStateChange])
const stopRecording = useCallback(async () => {
try {
setIsRecording(false)
setIsProcessing(true)
onProcessingStateChange?.(true, "Processing...")
// Call Extension Host to stop recording and get audio
const response = await DictationServiceClient.stopRecording(EmptyRequest.create({}))
if (!response.success) {
console.error("Failed to stop recording:", response.error)
setIsProcessing(false)
const errorMessage = response.error || "Failed to stop recording"
setError(errorMessage)
onTranscription("")
return
}
if (!response.audioBase64) {
console.error("No audio data received")
setIsProcessing(false)
const errorMessage = "No audio data received"
setError(errorMessage)
onTranscription("")
return
}
// Update processing state for transcription
onProcessingStateChange?.(true, "Transcribing...")
// Transcribe the audio using OpenAI Whisper
const transcriptionResponse = await DictationServiceClient.transcribeAudio(
TranscribeAudioRequest.create({
audioBase64: response.audioBase64,
language: language,
}),
)
if (transcriptionResponse.error) {
console.error("Transcription error:", transcriptionResponse.error)
setError(transcriptionResponse.error)
onTranscription("")
// Clear the error after a delay
setTimeout(() => {
setError(null)
onProcessingStateChange?.(false)
}, 5000)
} else if (transcriptionResponse.text) {
setError(null)
onTranscription(transcriptionResponse.text)
onProcessingStateChange?.(false)
}
} catch (error) {
console.error("Error stopping recording:", error)
const errorMessage = error instanceof Error ? error.message : "An error occurred"
setError(errorMessage)
onTranscription("")
} finally {
setIsProcessing(false)
}
}, [onTranscription, onProcessingStateChange])
// Poll recording status while recording to update duration
useEffect(() => {
const pollRecordingStatus = async () => {
try {
const statusResponse = await DictationServiceClient.getRecordingStatus(EmptyRequest.create({}))
if (statusResponse.isRecording) {
setRecordingDuration(Math.floor(statusResponse.durationSeconds))
// Auto-stop if max duration reached
if (statusResponse.durationSeconds >= MAX_DURATION) {
stopRecording()
}
}
} catch (error) {
console.error("Error polling recording status:", error)
}
}
if (isRecording && !isProcessing) {
pollingIntervalRef.current = setInterval(pollRecordingStatus, 1000)
} else {
// Clear polling when not recording
if (pollingIntervalRef.current) {
clearInterval(pollingIntervalRef.current)
pollingIntervalRef.current = null
}
}
// Cleanup on unmount
return () => {
if (pollingIntervalRef.current) {
clearInterval(pollingIntervalRef.current)
pollingIntervalRef.current = null
}
}
}, [isRecording, isProcessing, stopRecording])
const cancelRecording = useCallback(async () => {
try {
setIsRecording(false)
setError(null)
onProcessingStateChange?.(false)
onTranscription("")
// Call Extension Host to cancel recording
const response = await DictationServiceClient.cancelRecording(EmptyRequest.create({}))
if (!response.success) {
console.error("Failed to cancel recording:", response.error)
setError(response.error || "Failed to cancel recording")
return
}
console.log("Recording canceled successfully")
} catch (error) {
console.error("Error canceling recording:", error)
const errorMessage = error instanceof Error ? error.message : "Failed to cancel recording"
setError(errorMessage)
}
}, [onProcessingStateChange, onTranscription])
const handleStartClick = useCallback(() => {
if (disabled || isProcessing || isStarting) {
return
}
if (error) {
return setError(null)
}
startRecording()
}, [startRecording, disabled, isProcessing, isStarting, error])
const handleCancelClick = useCallback(() => {
if (disabled || isProcessing) {
return
}
cancelRecording()
}, [cancelRecording, disabled, isProcessing])
const handleStopClick = useCallback(() => {
if (disabled || isProcessing) {
return
}
stopRecording()
}, [stopRecording, disabled, isProcessing])
const iconAnimation = isProcessing || isStarting ? "animate-spin" : ""
const iconAdjustment = isProcessing || isStarting ? "mt-0" : error ? "mt-1" : "mt-0.5"
// When not recording, show single mic button
if (!isRecording) {
const iconClass = isProcessing
? "codicon-loading"
: isStarting
? "codicon-loading"
: error
? "codicon-error"
: "codicon-mic"
const iconColor = error ? "text-error" : ""
const tooltipContent = isProcessing
? "Transcribing..."
: isStarting
? "Starting recording..."
: error
? `Error: ${error}`
: null
return (
<HeroTooltip content={tooltipContent} placement="top">
<div
className={`input-icon-button mr-1.5 text-base ${iconAdjustment} ${iconAnimation} ${disabled || isProcessing || isStarting ? "disabled" : ""}`}
onClick={handleStartClick}
style={{ color: iconColor }}>
<span className={`codicon ${iconClass}`} />
</div>
</HeroTooltip>
)
}
return (
<div className={`flex items-center ${isRecording ? "mr-0.5" : "mr-1.5"}`}>
<HeroTooltip
content={`Stop Recording (${formatSeconds(recordingDuration)}/${formatSeconds(MAX_DURATION)})`}
placement="top">
<div
className={cn("input-icon-button mr-1.5 text-base", iconAdjustment, iconAnimation, {
disabled: disabled || isProcessing,
})}
onClick={handleStopClick}>
<span className="codicon codicon-stop-circle" />
</div>
</HeroTooltip>
<HeroTooltip content="Cancel Recording" placement="top">
<div
className={`input-icon-button text-base mt-1 text-[var(--vscode-textForeground)] ${disabled || isProcessing ? "disabled" : ""}`}
onClick={handleCancelClick}>
<span className="codicon codicon-close" />
</div>
</HeroTooltip>
</div>
)
}
export default VoiceRecorder
@@ -1,3 +1,4 @@
import { cn } from "@heroui/react"
import { StringRequest } from "@shared/proto/cline/common"
import React, { memo, useLayoutEffect, useRef, useState } from "react"
import { useWindowSize } from "react-use"
@@ -10,9 +11,10 @@ interface ThumbnailsProps {
setImages?: React.Dispatch<React.SetStateAction<string[]>>
setFiles?: React.Dispatch<React.SetStateAction<string[]>>
onHeightChange?: (height: number) => void
className?: string
}
const Thumbnails = ({ images, files, style, setImages, setFiles, onHeightChange }: ThumbnailsProps) => {
const Thumbnails = ({ images, files, style, setImages, setFiles, onHeightChange, className }: ThumbnailsProps) => {
const [hoveredIndex, setHoveredIndex] = useState<string | null>(null)
const containerRef = useRef<HTMLDivElement>(null)
const { width } = useWindowSize()
@@ -54,10 +56,9 @@ const Thumbnails = ({ images, files, style, setImages, setFiles, onHeightChange
return (
<div
className={cn("flex flex-wrap", className)}
ref={containerRef}
style={{
display: "flex",
flexWrap: "wrap",
gap: 5,
rowGap: 3,
...style,
@@ -0,0 +1,16 @@
import styled from "styled-components"
const CollapsibleContent = styled.div<{ isOpen: boolean }>`
overflow: hidden;
transition:
max-height 0.3s ease-in-out,
opacity 0.3s ease-in-out,
margin-top 0.3s ease-in-out,
visibility 0.3s ease-in-out;
max-height: ${({ isOpen }) => (isOpen ? "1000px" : "0")};
opacity: ${({ isOpen }) => (isOpen ? 1 : 0)};
margin-top: ${({ isOpen }) => (isOpen ? "15px" : "0")};
visibility: ${({ isOpen }) => (isOpen ? "visible" : "hidden")};
`
export default CollapsibleContent
@@ -5,6 +5,7 @@ import styled from "styled-components"
import { BROWSER_VIEWPORT_PRESETS } from "../../../../../src/shared/BrowserSettings"
import { useExtensionState } from "../../../context/ExtensionStateContext"
import { BrowserServiceClient } from "../../../services/grpc-client"
import CollapsibleContent from "../CollapsibleContent"
import { DebouncedTextField } from "../common/DebouncedTextField"
import Section from "../Section"
import { updateSetting } from "../utils/settingsHandlers"
@@ -45,19 +46,6 @@ const ConnectionStatusIndicator = ({
)
}
const CollapsibleContent = styled.div<{ isOpen: boolean }>`
overflow: hidden;
transition:
max-height 0.3s ease-in-out,
opacity 0.3s ease-in-out,
margin-top 0.3s ease-in-out,
visibility 0.3s ease-in-out;
max-height: ${({ isOpen }) => (isOpen ? "1000px" : "0")}; // Sufficiently large height
opacity: ${({ isOpen }) => (isOpen ? 1 : 0)};
margin-top: ${({ isOpen }) => (isOpen ? "15px" : "0")};
visibility: ${({ isOpen }) => (isOpen ? "visible" : "hidden")};
`
export const BrowserSettingsSection: React.FC<BrowserSettingsSectionProps> = ({ renderSectionHeader }) => {
const { browserSettings } = useExtensionState()
const [isCheckingConnection, setIsCheckingConnection] = useState(false)
@@ -1,3 +1,4 @@
import { SUPPORTED_DICTATION_LANGUAGES } from "@shared/DictationSettings"
import { McpDisplayMode } from "@shared/McpDisplayMode"
import { OpenaiReasoningEffort } from "@shared/storage/types"
import { VSCodeCheckbox, VSCodeDropdown, VSCodeOption, VSCodeTextField } from "@vscode/webview-ui-toolkit/react"
@@ -20,6 +21,8 @@ const FeatureSettingsSection = ({ renderSectionHeader }: FeatureSettingsSectionP
openaiReasoningEffort,
strictPlanModeEnabled,
yoloModeToggled,
dictationSettings,
isDictationFeatureEnabled,
useAutoCondense,
focusChainSettings,
} = useExtensionState()
@@ -169,6 +172,63 @@ const FeatureSettingsSection = ({ renderSectionHeader }: FeatureSettingsSectionP
</p>
</div>
)}
{isDictationFeatureEnabled && (
<>
<div style={{ marginTop: 10 }}>
<VSCodeCheckbox
checked={dictationSettings?.dictationEnabled}
onChange={(e: any) => {
const checked = e.target.checked === true
const updatedDictationSettings = {
...dictationSettings,
dictationEnabled: checked,
}
updateSetting("dictationSettings", updatedDictationSettings)
}}>
Enable Dictation
</VSCodeCheckbox>
<p className="text-xs text-[var(--vscode-descriptionForeground)] mt-1">
Enables speech-to-text transcription using your Cline account. Uses the Whisper model, at
$0.006 credits per minute of audio processed. 5 minutes max per message.
</p>
</div>
{/* TODO: Fix and use CollapsibleContent, the animation is good but it breaks the dropdown
<CollapsibleContent isOpen={dictationSettings?.dictationEnabled}> */}
{dictationSettings?.dictationEnabled && (
<div style={{ marginTop: 10, marginLeft: 20 }}>
<label
className="block text-sm font-medium text-[var(--vscode-foreground)] mb-1"
htmlFor="dictation-language-dropdown">
Dictation Language
</label>
<VSCodeDropdown
className="w-full"
currentValue={dictationSettings?.dictationLanguage || "en"}
id="dictation-language-dropdown"
onChange={(e: any) => {
const newValue = e.target.value
const updatedDictationSettings = {
...dictationSettings,
dictationLanguage: newValue,
}
updateSetting("dictationSettings", updatedDictationSettings)
}}>
{SUPPORTED_DICTATION_LANGUAGES.map((language) => (
<VSCodeOption className="py-0.5" key={language.code} value={language.code}>
{language.name}
</VSCodeOption>
))}
</VSCodeDropdown>
<p className="text-xs mt-1 text-[var(--vscode-descriptionForeground)]">
The language you want to speak to the Dictation service in. This is separate from your
preferred UI language.
</p>
</div>
)}
{/* </CollapsibleContent> */}
</>
)}
<div style={{ marginTop: 10 }}>
<VSCodeCheckbox
checked={useAutoCondense}
@@ -4,6 +4,7 @@ import "../../../src/shared/webview/types"
import { DEFAULT_AUTO_APPROVAL_SETTINGS } from "@shared/AutoApprovalSettings"
import { findLastIndex } from "@shared/array"
import { DEFAULT_BROWSER_SETTINGS } from "@shared/BrowserSettings"
import { DEFAULT_DICTATION_SETTINGS, DictationSettings } from "@shared/DictationSettings"
import { DEFAULT_PLATFORM, type ExtensionState } from "@shared/ExtensionMessage"
import { DEFAULT_FOCUS_CHAIN_SETTINGS } from "@shared/FocusChainSettings"
import { DEFAULT_MCP_DISPLAY_MODE } from "@shared/McpDisplayMode"
@@ -43,6 +44,7 @@ export interface ExtensionStateContextType extends ExtensionState {
mcpServers: McpServer[]
mcpMarketplaceCatalog: McpMarketplaceCatalog
totalTasksSize: number | null
availableTerminalProfiles: TerminalProfile[]
// View state
@@ -55,6 +57,7 @@ export interface ExtensionStateContextType extends ExtensionState {
showChatModelSelector: boolean
// Setters
setDictationSettings: (value: DictationSettings) => void
setShowAnnouncement: (value: boolean) => void
setShowChatModelSelector: (value: boolean) => void
setShouldShowAnnouncement: (value: boolean) => void
@@ -179,6 +182,7 @@ export const ExtensionStateContextProvider: React.FC<{
shouldShowAnnouncement: false,
autoApprovalSettings: DEFAULT_AUTO_APPROVAL_SETTINGS,
browserSettings: DEFAULT_BROWSER_SETTINGS,
dictationSettings: DEFAULT_DICTATION_SETTINGS,
focusChainSettings: DEFAULT_FOCUS_CHAIN_SETTINGS,
preferredLanguage: "English",
openaiReasoningEffort: "medium",
@@ -717,6 +721,11 @@ export const ExtensionStateContextProvider: React.FC<{
refreshOpenRouterModels,
onRelinquishControl,
setUserInfo: (userInfo?: UserInfo) => setState((prevState) => ({ ...prevState, userInfo })),
setDictationSettings: (value: DictationSettings) =>
setState((prevState) => ({
...prevState,
dictationSettings: value,
})),
}
return <ExtensionStateContext.Provider value={contextValue}>{children}</ExtensionStateContext.Provider>
+12
View File
@@ -62,3 +62,15 @@ export function formatSize(bytes?: number) {
return prettyBytes(bytes)
}
export function formatSeconds(seconds?: number): string {
if (seconds === undefined) {
return "--:--"
}
const mins = Math.floor(seconds / 60)
const secs = Math.floor(seconds % 60)
.toString()
.padStart(2, "0")
return `${mins}:${secs}`
}