diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 23d766412b..fded4897bd 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -41,7 +41,7 @@ import { BrowserSession } from "../../services/browser/BrowserSession" import { discoverChromeInstances } from "../../services/browser/browserDiscovery" import { fileExistsAtPath } from "../../utils/fs" import { playSound, setSoundEnabled, setSoundVolume } from "../../utils/sound" -import { playTts, setTtsEnabled, setTtsSpeed } from "../../utils/tts" +import { playTts, setTtsEnabled, setTtsSpeed, stopTts } from "../../utils/tts" import { singleCompletionHandler } from "../../utils/single-completion-handler" import { searchCommits } from "../../utils/git" import { getDiffStrategy } from "../diff/DiffStrategy" @@ -1281,9 +1281,15 @@ export class ClineProvider extends EventEmitter implements break case "playTts": if (message.text) { - playTts(message.text) + playTts(message.text, { + onStart: () => this.postMessageToWebview({ type: "ttsStart", text: message.text }), + onStop: () => this.postMessageToWebview({ type: "ttsStop", text: message.text }), + }) } break + case "stopTts": + stopTts() + break case "diffEnabled": const diffEnabled = message.bool ?? true await this.updateGlobalState("diffEnabled", diffEnabled) diff --git a/src/shared/ExtensionMessage.ts b/src/shared/ExtensionMessage.ts index 17b5fc4c68..f40eadb84c 100644 --- a/src/shared/ExtensionMessage.ts +++ b/src/shared/ExtensionMessage.ts @@ -54,6 +54,8 @@ export interface ExtensionMessage { | "browserToolEnabled" | "browserConnectionResult" | "remoteBrowserEnabled" + | "ttsStart" + | "ttsStop" text?: string action?: | "chatButtonClicked" diff --git a/src/shared/WebviewMessage.ts b/src/shared/WebviewMessage.ts index 76965f77fe..67272adc0f 100644 --- a/src/shared/WebviewMessage.ts +++ b/src/shared/WebviewMessage.ts @@ -52,6 +52,7 @@ export interface WebviewMessage { | "alwaysAllowSubtasks" | "playSound" | "playTts" + | "stopTts" | "soundEnabled" | "ttsEnabled" | "ttsSpeed" diff --git a/src/utils/tts.ts b/src/utils/tts.ts index cc567b1b1a..b544960571 100644 --- a/src/utils/tts.ts +++ b/src/utils/tts.ts @@ -1,75 +1,81 @@ -import * as vscode from "vscode" +interface Say { + speak: (text: string, voice?: string, speed?: number, callback?: (err?: string) => void) => void + stop: () => void +} + +type PlayTtsOptions = { + onStart?: () => void + onStop?: () => void +} + +type QueueItem = { + message: string + options: PlayTtsOptions +} let isTtsEnabled = false + +export const setTtsEnabled = (enabled: boolean) => (isTtsEnabled = enabled) + let speed = 1.0 -let isSpeaking = false -const utteranceQueue: string[] = [] -/** - * Set tts configuration - * @param enabled boolean - */ -export const setTtsEnabled = (enabled: boolean): void => { - isTtsEnabled = enabled -} +export const setTtsSpeed = (newSpeed: number) => (speed = newSpeed) -/** - * Set tts speed - * @param speed number - */ -export const setTtsSpeed = (newSpeed: number): void => { - speed = newSpeed -} +let sayInstance: Say | undefined = undefined +let queue: QueueItem[] = [] -/** - * Process the next item in the utterance queue - */ -const processQueue = async (): Promise => { - if (!isTtsEnabled || isSpeaking || utteranceQueue.length === 0) { - return - } - - try { - isSpeaking = true - const nextUtterance = utteranceQueue.shift()! - const say = require("say") - - // Wrap say.speak in a promise to handle completion - await new Promise((resolve, reject) => { - say.speak(nextUtterance, null, speed, (err: Error) => { - if (err) { - reject(err) - } else { - resolve() - } - }) - }) - - isSpeaking = false - // Process next item in queue if any - await processQueue() - } catch (error: any) { - isSpeaking = false - //vscode.window.showErrorMessage(error.message) - // Try to continue with next item despite error - await processQueue() - } -} - -/** - * Queue a tts message to be spoken - * @param message string - * @return void - */ -export const playTts = async (message: string): Promise => { +export const playTts = async (message: string, options: PlayTtsOptions = {}) => { if (!isTtsEnabled) { return } try { - utteranceQueue.push(message) + queue.push({ message, options }) + await processQueue() + } catch (error) {} +} + +export const stopTts = () => { + sayInstance?.stop() + sayInstance = undefined + queue = [] +} + +const processQueue = async (): Promise => { + if (!isTtsEnabled || sayInstance) { + return + } + + const item = queue.shift() + + if (!item) { + return + } + + try { + const { message: nextUtterance, options } = item + + await new Promise((resolve, reject) => { + const say: Say = require("say") + sayInstance = say + options.onStart?.() + + say.speak(nextUtterance, undefined, speed, (err) => { + options.onStop?.() + + if (err) { + reject(new Error(err)) + } else { + resolve() + } + + sayInstance = undefined + }) + }) + await processQueue() } catch (error: any) { - //vscode.window.showErrorMessage(error.message) + sayInstance = undefined + await processQueue() } } diff --git a/webview-ui/src/components/chat/ChatTextArea.tsx b/webview-ui/src/components/chat/ChatTextArea.tsx index be0ddd0b0e..137d16cb00 100644 --- a/webview-ui/src/components/chat/ChatTextArea.tsx +++ b/webview-ui/src/components/chat/ChatTextArea.tsx @@ -1,11 +1,15 @@ import React, { forwardRef, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react" +import { useEvent } from "react-use" import DynamicTextArea from "react-textarea-autosize" import { mentionRegex, mentionRegexGlobal } from "../../../../src/shared/context-mentions" import { WebviewMessage } from "../../../../src/shared/WebviewMessage" import { Mode, getAllModes } from "../../../../src/shared/modes" +import { ExtensionMessage } from "../../../../src/shared/ExtensionMessage" import { vscode } from "@/utils/vscode" +import { useExtensionState } from "@/context/ExtensionStateContext" +import { useAppTranslation } from "@/i18n/TranslationContext" import { ContextMenuOptionType, getContextMenuOptions, @@ -13,14 +17,13 @@ import { removeMention, shouldShowContextMenu, } from "@/utils/context-mentions" -import { SelectDropdown, DropdownOptionType } from "@/components/ui" +import { convertToMentionPath } from "@/utils/path-mentions" +import { SelectDropdown, DropdownOptionType, Button } from "@/components/ui" -import { useExtensionState } from "../../context/ExtensionStateContext" import Thumbnails from "../common/Thumbnails" -import { convertToMentionPath } from "../../utils/path-mentions" import { MAX_IMAGES_PER_MESSAGE } from "./ChatView" import ContextMenu from "./ContextMenu" -import { useAppTranslation } from "../../i18n/TranslationContext" +import { VolumeX } from "lucide-react" interface ChatTextAreaProps { inputValue: string @@ -62,7 +65,7 @@ const ChatTextArea = forwardRef( const [gitCommits, setGitCommits] = useState([]) const [showDropdown, setShowDropdown] = useState(false) - // Close dropdown when clicking outside + // Close dropdown when clicking outside. useEffect(() => { const handleClickOutside = (event: MouseEvent) => { if (showDropdown) { @@ -73,14 +76,16 @@ const ChatTextArea = forwardRef( return () => document.removeEventListener("mousedown", handleClickOutside) }, [showDropdown]) - // Handle enhanced prompt response + // Handle enhanced prompt response. useEffect(() => { const messageHandler = (event: MessageEvent) => { const message = event.data + if (message.type === "enhancedPrompt") { if (message.text) { setInputValue(message.text) } + setIsEnhancingPrompt(false) } else if (message.type === "commitSearchResults") { const commits = message.commits.map((commit: any) => ({ @@ -90,9 +95,11 @@ const ChatTextArea = forwardRef( description: `${commit.shortHash} by ${commit.author} on ${commit.date}`, icon: "$(git-commit)", })) + setGitCommits(commits) } } + window.addEventListener("message", messageHandler) return () => window.removeEventListener("message", messageHandler) }, [setInputValue]) @@ -113,7 +120,7 @@ const ChatTextArea = forwardRef( const [isEnhancingPrompt, setIsEnhancingPrompt] = useState(false) const [isFocused, setIsFocused] = useState(false) - // Fetch git commits when Git is selected or when typing a hash + // Fetch git commits when Git is selected or when typing a hash. useEffect(() => { if (selectedType === ContextMenuOptionType.Git || /^[a-f0-9]+$/i.test(searchQuery)) { const message: WebviewMessage = { @@ -188,14 +195,11 @@ const ChatTextArea = forwardRef( } if (type === ContextMenuOptionType.Mode && value) { - // Handle mode selection + // Handle mode selection. setMode(value) setInputValue("") setShowContextMenu(false) - vscode.postMessage({ - type: "mode", - text: value, - }) + vscode.postMessage({ type: "mode", text: value }) return } @@ -214,8 +218,10 @@ const ChatTextArea = forwardRef( setShowContextMenu(false) setSelectedType(null) + if (textAreaRef.current) { let insertValue = value || "" + if (type === ContextMenuOptionType.URL) { insertValue = value || "" } else if (type === ContextMenuOptionType.File || type === ContextMenuOptionType.Folder) { @@ -239,7 +245,7 @@ const ChatTextArea = forwardRef( setCursorPosition(newCursorPosition) setIntendedCursorPosition(newCursorPosition) - // scroll to cursor + // Scroll to cursor. setTimeout(() => { if (textAreaRef.current) { textAreaRef.current.blur() @@ -378,7 +384,7 @@ const ChatTextArea = forwardRef( useLayoutEffect(() => { if (intendedCursorPosition !== null && textAreaRef.current) { textAreaRef.current.setSelectionRange(intendedCursorPosition, intendedCursorPosition) - setIntendedCursorPosition(null) // Reset the state + setIntendedCursorPosition(null) // Reset the state. } }, [inputValue, intendedCursorPosition]) @@ -423,10 +429,11 @@ const ChatTextArea = forwardRef( }, [showContextMenu]) const handleBlur = useCallback(() => { - // Only hide the context menu if the user didn't click on it + // Only hide the context menu if the user didn't click on it. if (!isMouseDownOnMenu) { setShowContextMenu(false) } + setIsFocused(false) }, [isMouseDownOnMenu]) @@ -435,7 +442,8 @@ const ChatTextArea = forwardRef( const items = e.clipboardData.items const pastedText = e.clipboardData.getData("text") - // Check if the pasted content is a URL, add space after so user can easily delete if they don't want it + // Check if the pasted content is a URL, add space after so user + // can easily delete if they don't want it. const urlRegex = /^\S+:\/\/\S+$/ if (urlRegex.test(pastedText.trim())) { e.preventDefault() @@ -448,7 +456,7 @@ const ChatTextArea = forwardRef( setIntendedCursorPosition(newCursorPosition) setShowContextMenu(false) - // Scroll to new cursor position + // Scroll to new cursor position. setTimeout(() => { if (textAreaRef.current) { textAreaRef.current.blur() @@ -460,10 +468,12 @@ const ChatTextArea = forwardRef( } const acceptedTypes = ["png", "jpeg", "webp"] + const imageItems = Array.from(items).filter((item) => { const [type, subtype] = item.type.split("/") return type === "image" && acceptedTypes.includes(subtype) }) + if (!shouldDisableImages && imageItems.length > 0) { e.preventDefault() const imagePromises = imageItems.map((item) => { @@ -498,9 +508,7 @@ const ChatTextArea = forwardRef( [shouldDisableImages, setSelectedImages, cursorPosition, setInputValue, inputValue, t], ) - const handleThumbnailsHeightChange = useCallback((height: number) => { - setThumbnailsHeight(height) - }, []) + const handleThumbnailsHeightChange = useCallback((height: number) => setThumbnailsHeight(height), []) useEffect(() => { if (selectedImages.length === 0) { @@ -545,6 +553,18 @@ const ChatTextArea = forwardRef( [updateCursorPosition], ) + const [isTtsPlaying, setIsTtsPlaying] = useState(false) + + useEvent("message", (event: MessageEvent) => { + const message: ExtensionMessage = event.data + + if (message.type === "ttsStart") { + setIsTtsPlaying(true) + } else if (message.type === "ttsStop") { + setIsTtsPlaying(false) + } + }) + return (
( e.preventDefault() const files = Array.from(e.dataTransfer.files) const text = e.dataTransfer.getData("text") + if (text) { // Split text on newlines to handle multiple files const lines = text.split(/\r?\n/).filter((line) => line.trim() !== "") @@ -597,6 +618,7 @@ const ChatTextArea = forwardRef( setCursorPosition(newCursorPosition) setIntendedCursorPosition(newCursorPosition) } + return } @@ -741,6 +763,15 @@ const ChatTextArea = forwardRef( }} onScroll={() => updateHighlights()} /> + {isTtsPlaying && ( + + )}
{selectedImages.length > 0 && ( @@ -782,26 +813,22 @@ const ChatTextArea = forwardRef( disabled={textAreaDisabled} title={t("chat:selectMode")} options={[ - // Add the shortcut text as a disabled option at the top { value: "shortcut", label: modeShortcutText, disabled: true, type: DropdownOptionType.SHORTCUT, }, - // Add all modes ...getAllModes(customModes).map((mode) => ({ value: mode.slug, label: mode.name, type: DropdownOptionType.ITEM, })), - // Add separator { value: "sep-1", label: t("chat:separator"), type: DropdownOptionType.SEPARATOR, }, - // Add Edit option { value: "promptsButtonClicked", label: t("chat:edit"), @@ -810,10 +837,7 @@ const ChatTextArea = forwardRef( ]} onChange={(value) => { setMode(value as Mode) - vscode.postMessage({ - type: "mode", - text: value, - }) + vscode.postMessage({ type: "mode", text: value }) }} shortcutText={modeShortcutText} triggerClassName="w-full" @@ -832,31 +856,23 @@ const ChatTextArea = forwardRef( disabled={textAreaDisabled} title={t("chat:selectApiConfig")} options={[ - // Add all API configurations ...(listApiConfigMeta || []).map((config) => ({ value: config.name, label: config.name, type: DropdownOptionType.ITEM, })), - // Add separator { value: "sep-2", label: t("chat:separator"), type: DropdownOptionType.SEPARATOR, }, - // Add Edit option { value: "settingsButtonClicked", label: t("chat:edit"), type: DropdownOptionType.ACTION, }, ]} - onChange={(value) => { - vscode.postMessage({ - type: "loadApiConfiguration", - text: value, - }) - }} + onChange={(value) => vscode.postMessage({ type: "loadApiConfiguration", text: value })} contentClassName="max-h-[300px] overflow-y-auto" triggerClassName="w-full text-ellipsis overflow-hidden" />