Merge pull request #129 from RooVetGit/jq/sound-setting-improvements

Sound setting improvements
This commit is contained in:
jquanton
2024-12-16 14:30:02 -08:00
committed by GitHub
12 changed files with 486 additions and 126 deletions

View File

@@ -0,0 +1,5 @@
---
"roo-cline": patch
---
Add volume slider in settings and change sound effects to only trigger when user intervention is required, an error occurs, or a task is completed.

24
package-lock.json generated
View File

@@ -34,10 +34,10 @@
"os-name": "^6.0.0",
"p-wait-for": "^5.0.2",
"pdf-parse": "^1.1.1",
"play-sound": "^1.1.6",
"puppeteer-chromium-resolver": "^23.0.0",
"puppeteer-core": "^23.4.0",
"serialize-error": "^11.0.3",
"sound-play": "^1.1.0",
"strip-ansi": "^7.1.0",
"tree-sitter-wasms": "^0.1.11",
"turndown": "^7.2.0",
@@ -8850,14 +8850,6 @@
"node": ">=8"
}
},
"node_modules/find-exec": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/find-exec/-/find-exec-1.0.3.tgz",
"integrity": "sha512-gnG38zW90mS8hm5smNcrBnakPEt+cGJoiMkJwCU0IYnEb0H2NQk0NIljhNW+48oniCriFek/PH6QXbwsJo/qug==",
"dependencies": {
"shell-quote": "^1.8.1"
}
},
"node_modules/find-up": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
@@ -13102,14 +13094,6 @@
"node": ">=8"
}
},
"node_modules/play-sound": {
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/play-sound/-/play-sound-1.1.6.tgz",
"integrity": "sha512-09eO4QiXNFXJffJaOW5P6x6F5RLihpLUkXttvUZeWml0fU6x6Zp7AjG9zaeMpgH2ZNvq4GR1ytB22ddYcqJIZA==",
"dependencies": {
"find-exec": "1.0.3"
}
},
"node_modules/possible-typed-array-names": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.0.0.tgz",
@@ -13873,6 +13857,7 @@
"version": "1.8.2",
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.2.tgz",
"integrity": "sha512-AzqKpGKjrj7EM6rKVQEPpB288oCfnrEIuyoT9cyF4nmGa7V8Zk6f7RRqYisX8X9m+Q7bd632aZW4ky7EhbQztA==",
"dev": true,
"engines": {
"node": ">= 0.4"
},
@@ -14001,6 +13986,11 @@
"node": ">= 14"
}
},
"node_modules/sound-play": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/sound-play/-/sound-play-1.1.0.tgz",
"integrity": "sha512-Bd/L0AoCwITFeOnpNLMsfPXrV5GG5NhrC/T6odveahYbhPZkdTnrFXRia9FCC5WBWdUTw1d+yvLBvi4wnD1xOA=="
},
"node_modules/source-map": {
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",

View File

@@ -216,10 +216,10 @@
"os-name": "^6.0.0",
"p-wait-for": "^5.0.2",
"pdf-parse": "^1.1.1",
"play-sound": "^1.1.6",
"puppeteer-chromium-resolver": "^23.0.0",
"puppeteer-core": "^23.4.0",
"serialize-error": "^11.0.3",
"sound-play": "^1.1.0",
"strip-ansi": "^7.1.0",
"tree-sitter-wasms": "^0.1.11",
"turndown": "^7.2.0",

View File

@@ -22,7 +22,7 @@ import { Cline } from "../Cline"
import { openMention } from "../mentions"
import { getNonce } from "./getNonce"
import { getUri } from "./getUri"
import { playSound, setSoundEnabled } from "../../utils/sound"
import { playSound, setSoundEnabled, setSoundVolume } from "../../utils/sound"
/*
https://github.com/microsoft/vscode-webview-ui-toolkit-samples/blob/main/default/weather-webview/src/providers/WeatherViewProvider.ts
@@ -66,6 +66,7 @@ type GlobalStateKey =
| "openRouterUseMiddleOutTransform"
| "allowedCommands"
| "soundEnabled"
| "soundVolume"
| "diffEnabled"
| "debugDiffEnabled"
| "alwaysAllowMcp"
@@ -137,6 +138,11 @@ export class ClineProvider implements vscode.WebviewViewProvider {
this.outputChannel.appendLine("Resolving webview view")
this.view = webviewView
// Initialize sound enabled state
this.getState().then(({ soundEnabled }) => {
setSoundEnabled(soundEnabled ?? false)
})
webviewView.webview.options = {
// Allow scripts in the webview
enableScripts: true,
@@ -597,6 +603,12 @@ export class ClineProvider implements vscode.WebviewViewProvider {
setSoundEnabled(soundEnabled) // Add this line to update the sound utility
await this.postStateToWebview()
break
case "soundVolume":
const soundVolume = message.value ?? 0.5
await this.updateGlobalState("soundVolume", soundVolume)
setSoundVolume(soundVolume)
await this.postStateToWebview()
break
case "diffEnabled":
const diffEnabled = message.bool ?? true
await this.updateGlobalState("diffEnabled", diffEnabled)
@@ -935,6 +947,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
diffEnabled,
debugDiffEnabled,
taskHistory,
soundVolume,
} = await this.getState()
const allowedCommands = vscode.workspace
@@ -960,6 +973,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
debugDiffEnabled: debugDiffEnabled ?? false,
shouldShowAnnouncement: lastShownAnnouncementId !== this.latestAnnouncementId,
allowedCommands,
soundVolume: soundVolume ?? 0.5,
}
}
@@ -1053,6 +1067,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
soundEnabled,
diffEnabled,
debugDiffEnabled,
soundVolume,
] = await Promise.all([
this.getGlobalState("apiProvider") as Promise<ApiProvider | undefined>,
this.getGlobalState("apiModelId") as Promise<string | undefined>,
@@ -1091,6 +1106,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
this.getGlobalState("soundEnabled") as Promise<boolean | undefined>,
this.getGlobalState("diffEnabled") as Promise<boolean | undefined>,
this.getGlobalState("debugDiffEnabled") as Promise<boolean | undefined>,
this.getGlobalState("soundVolume") as Promise<number | undefined>,
])
let apiProvider: ApiProvider
@@ -1147,6 +1163,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
soundEnabled: soundEnabled ?? false,
diffEnabled: diffEnabled ?? false,
debugDiffEnabled: debugDiffEnabled ?? false,
soundVolume,
}
}

View File

@@ -51,6 +51,7 @@ export interface ExtensionState {
uriScheme?: string
allowedCommands?: string[]
soundEnabled?: boolean
soundVolume?: number
diffEnabled?: boolean
debugDiffEnabled?: boolean
}

View File

@@ -32,6 +32,7 @@ export interface WebviewMessage {
| "alwaysAllowMcp"
| "playSound"
| "soundEnabled"
| "soundVolume"
| "diffEnabled"
| "debugDiffEnabled"
| "openMcpSettings"
@@ -44,6 +45,7 @@ export interface WebviewMessage {
apiConfiguration?: ApiConfiguration
images?: string[]
bool?: boolean
value?: number
commands?: string[]
audioType?: AudioType
// For toggleToolAutoApprove

View File

@@ -21,6 +21,7 @@ export const isWAV = (filepath: string): boolean => {
}
let isSoundEnabled = false
let volume = .5
/**
* Set sound configuration
@@ -30,6 +31,14 @@ export const setSoundEnabled = (enabled: boolean): void => {
isSoundEnabled = enabled
}
/**
* Set sound volume
* @param volume number
*/
export const setSoundVolume = (newVolume: number): void => {
volume = newVolume
}
/**
* Play a sound file
* @param filepath string
@@ -54,11 +63,9 @@ export const playSound = (filepath: string): void => {
return // Skip playback within minimum interval to prevent continuous playback
}
const player = require("play-sound")()
player.play(filepath, function (err: any) {
if (err) {
throw new Error("Failed to play sound effect")
}
const sound = require("sound-play")
sound.play(filepath, volume).catch(() => {
throw new Error("Failed to play sound effect")
})
lastPlayedTime = currentTime

View File

@@ -64,7 +64,6 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
const [isAtBottom, setIsAtBottom] = useState(false)
const [wasStreaming, setWasStreaming] = useState<boolean>(false)
const [hasStarted, setHasStarted] = useState(false)
// UI layout depends on the last 2 messages
// (since it relies on the content of these messages, we are deep comparing. i.e. the button state after hitting button sets enableButtons to false, and this effect otherwise would have to true again even if messages didn't change
@@ -75,12 +74,6 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
vscode.postMessage({ type: "playSound", audioType })
}
function playSoundOnMessage(audioType: AudioType) {
if (hasStarted && !isStreaming) {
playSound(audioType)
}
}
useDeepCompareEffect(() => {
// if last message is an ask, show user ask UI
// if user finished a task, then start a new task with a new conversation history since in this moment that the extension is waiting for user response, the user could close the extension and the conversation history would be lost.
@@ -91,7 +84,7 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
const isPartial = lastMessage.partial === true
switch (lastMessage.ask) {
case "api_req_failed":
playSoundOnMessage("progress_loop")
playSound("progress_loop")
setTextAreaDisabled(true)
setClineAsk("api_req_failed")
setEnableButtons(true)
@@ -99,7 +92,7 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
setSecondaryButtonText("Start New Task")
break
case "mistake_limit_reached":
playSoundOnMessage("progress_loop")
playSound("progress_loop")
setTextAreaDisabled(false)
setClineAsk("mistake_limit_reached")
setEnableButtons(true)
@@ -107,7 +100,6 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
setSecondaryButtonText("Start New Task")
break
case "followup":
playSoundOnMessage("notification")
setTextAreaDisabled(isPartial)
setClineAsk("followup")
setEnableButtons(isPartial)
@@ -115,7 +107,9 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
// setSecondaryButtonText(undefined)
break
case "tool":
playSoundOnMessage("notification")
if (!isAutoApproved(lastMessage)) {
playSound("notification")
}
setTextAreaDisabled(isPartial)
setClineAsk("tool")
setEnableButtons(!isPartial)
@@ -134,7 +128,9 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
}
break
case "browser_action_launch":
playSoundOnMessage("notification")
if (!isAutoApproved(lastMessage)) {
playSound("notification")
}
setTextAreaDisabled(isPartial)
setClineAsk("browser_action_launch")
setEnableButtons(!isPartial)
@@ -142,7 +138,9 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
setSecondaryButtonText("Reject")
break
case "command":
playSoundOnMessage("notification")
if (!isAutoApproved(lastMessage)) {
playSound("notification")
}
setTextAreaDisabled(isPartial)
setClineAsk("command")
setEnableButtons(!isPartial)
@@ -150,7 +148,6 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
setSecondaryButtonText("Reject")
break
case "command_output":
playSoundOnMessage("notification")
setTextAreaDisabled(false)
setClineAsk("command_output")
setEnableButtons(true)
@@ -166,7 +163,7 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
break
case "completion_result":
// extension waiting for feedback. but we can just present a new task button
playSoundOnMessage("celebration")
playSound("celebration")
setTextAreaDisabled(isPartial)
setClineAsk("completion_result")
setEnableButtons(!isPartial)
@@ -174,7 +171,6 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
setSecondaryButtonText(undefined)
break
case "resume_task":
playSoundOnMessage("notification")
setTextAreaDisabled(false)
setClineAsk("resume_task")
setEnableButtons(true)
@@ -183,7 +179,7 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
setDidClickCancel(false) // special case where we reset the cancel button state
break
case "resume_completed_task":
playSoundOnMessage("celebration")
playSound("celebration")
setTextAreaDisabled(false)
setClineAsk("resume_completed_task")
setEnableButtons(true)
@@ -482,30 +478,103 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
return true
})
}, [modifiedMessages])
useEffect(() => {
if (isStreaming) {
// Set to true once any request has started
setHasStarted(true)
const isReadOnlyToolAction = (message: ClineMessage | undefined) => {
if (message?.type === "ask") {
if (!message.text) {
return true
}
const tool = JSON.parse(message.text)
return ["readFile", "listFiles", "listFilesTopLevel", "listFilesRecursive", "listCodeDefinitionNames", "searchFiles"].includes(tool.tool)
}
return false
}
const isWriteToolAction = (message: ClineMessage | undefined) => {
if (message?.type === "ask") {
if (!message.text) {
return true
}
const tool = JSON.parse(message.text)
return ["editedExistingFile", "appliedDiff", "newFileCreated"].includes(tool.tool)
}
return false
}
const isMcpToolAlwaysAllowed = (message: ClineMessage | undefined) => {
if (message?.type === "ask" && message.ask === "use_mcp_server") {
if (!message.text) {
return true
}
const mcpServerUse = JSON.parse(message.text) as { type: string; serverName: string; toolName: string }
if (mcpServerUse.type === "use_mcp_tool") {
const server = mcpServers?.find((s: McpServer) => s.name === mcpServerUse.serverName)
const tool = server?.tools?.find((t: McpTool) => t.name === mcpServerUse.toolName)
return tool?.alwaysAllow || false
}
}
return false
}
const isAllowedCommand = (message: ClineMessage | undefined) => {
if (message?.type === "ask") {
const command = message.text
if (!command) {
return true
}
// Split command by chaining operators
const commands = command.split(/&&|\|\||;|\||\$\(|`/).map(cmd => cmd.trim())
// Check if all individual commands are allowed
return commands.every((cmd) => {
const trimmedCommand = cmd.toLowerCase()
return allowedCommands?.some((prefix) => trimmedCommand.startsWith(prefix.toLowerCase()))
})
}
return false
}
const isAutoApproved = (message: ClineMessage | undefined) => {
if (!message || message.type !== "ask") return false
return (
(alwaysAllowBrowser && message.ask === "browser_action_launch") ||
(alwaysAllowReadOnly && message.ask === "tool" && isReadOnlyToolAction(message)) ||
(alwaysAllowWrite && message.ask === "tool" && isWriteToolAction(message)) ||
(alwaysAllowExecute && message.ask === "command" && isAllowedCommand(message)) ||
(alwaysAllowMcp && message.ask === "use_mcp_server" && isMcpToolAlwaysAllowed(message))
)
}
useEffect(() => {
// Only execute when isStreaming changes from true to false
if (wasStreaming && !isStreaming && lastMessage) {
// Play appropriate sound based on lastMessage content
if (lastMessage.type === "ask") {
switch (lastMessage.ask) {
case "api_req_failed":
case "mistake_limit_reached":
playSound("progress_loop")
break
case "tool":
case "followup":
case "browser_action_launch":
case "resume_task":
playSound("notification")
break
case "completion_result":
case "resume_completed_task":
playSound("celebration")
break
// Don't play sounds for auto-approved actions
if (!isAutoApproved(lastMessage)) {
switch (lastMessage.ask) {
case "api_req_failed":
case "mistake_limit_reached":
playSound("progress_loop")
break
case "followup":
if (!lastMessage.partial) {
playSound("notification")
}
break
case "tool":
case "browser_action_launch":
case "resume_task":
case "use_mcp_server":
playSound("notification")
break
case "completion_result":
case "resume_completed_task":
playSound("celebration")
break
}
}
}
}
@@ -750,61 +819,7 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
// Only proceed if we have an ask and buttons are enabled
if (!clineAsk || !enableButtons) return
const isReadOnlyToolAction = () => {
const lastMessage = messages.at(-1)
if (lastMessage?.type === "ask" && lastMessage.text) {
const tool = JSON.parse(lastMessage.text)
return ["readFile", "listFiles", "listFilesTopLevel", "listFilesRecursive", "listCodeDefinitionNames", "searchFiles"].includes(tool.tool)
}
return false
}
const isWriteToolAction = () => {
const lastMessage = messages.at(-1)
if (lastMessage?.type === "ask" && lastMessage.text) {
const tool = JSON.parse(lastMessage.text)
return ["editedExistingFile", "appliedDiff", "newFileCreated"].includes(tool.tool)
}
return false
}
const isMcpToolAlwaysAllowed = () => {
const lastMessage = messages.at(-1)
if (lastMessage?.type === "ask" && lastMessage.ask === "use_mcp_server" && lastMessage.text) {
const mcpServerUse = JSON.parse(lastMessage.text) as { type: string; serverName: string; toolName: string }
if (mcpServerUse.type === "use_mcp_tool") {
const server = mcpServers?.find((s: McpServer) => s.name === mcpServerUse.serverName)
const tool = server?.tools?.find((t: McpTool) => t.name === mcpServerUse.toolName)
return tool?.alwaysAllow || false
}
}
return false
}
const isAllowedCommand = () => {
const lastMessage = messages.at(-1)
if (lastMessage?.type === "ask" && lastMessage.text) {
const command = lastMessage.text
// Split command by chaining operators
const commands = command.split(/&&|\|\||;|\||\$\(|`/).map(cmd => cmd.trim())
// Check if all individual commands are allowed
return commands.every((cmd) => {
const trimmedCommand = cmd.toLowerCase()
return allowedCommands?.some((prefix) => trimmedCommand.startsWith(prefix.toLowerCase()))
})
}
return false
}
if (
(alwaysAllowBrowser && clineAsk === "browser_action_launch") ||
(alwaysAllowReadOnly && clineAsk === "tool" && isReadOnlyToolAction()) ||
(alwaysAllowWrite && clineAsk === "tool" && isWriteToolAction()) ||
(alwaysAllowExecute && clineAsk === "command" && isAllowedCommand()) ||
(alwaysAllowMcp && clineAsk === "use_mcp_server" && isMcpToolAlwaysAllowed())
) {
if (isAutoApproved(lastMessage)) {
handlePrimaryButtonClick()
}
}, [clineAsk, enableButtons, handlePrimaryButtonClick, alwaysAllowBrowser, alwaysAllowReadOnly, alwaysAllowWrite, alwaysAllowExecute, alwaysAllowMcp, messages, allowedCommands, mcpServers])

View File

@@ -547,3 +547,247 @@ describe('ChatView - Auto Approval Tests', () => {
})
})
})
describe('ChatView - Sound Playing Tests', () => {
beforeEach(() => {
jest.clearAllMocks()
})
it('does not play sound for auto-approved browser actions', async () => {
render(
<ExtensionStateContextProvider>
<ChatView
isHidden={false}
showAnnouncement={false}
hideAnnouncement={() => {}}
showHistoryView={() => {}}
/>
</ExtensionStateContextProvider>
)
// First hydrate state with initial task and streaming
mockPostMessage({
alwaysAllowBrowser: true,
clineMessages: [
{
type: 'say',
say: 'task',
ts: Date.now() - 2000,
text: 'Initial task'
},
{
type: 'say',
say: 'api_req_started',
ts: Date.now() - 1000,
text: JSON.stringify({}),
partial: true
}
]
})
// Then send the browser action ask message (streaming finished)
mockPostMessage({
alwaysAllowBrowser: true,
clineMessages: [
{
type: 'say',
say: 'task',
ts: Date.now() - 2000,
text: 'Initial task'
},
{
type: 'ask',
ask: 'browser_action_launch',
ts: Date.now(),
text: JSON.stringify({ action: 'launch', url: 'http://example.com' }),
partial: false
}
]
})
// Verify no sound was played
expect(vscode.postMessage).not.toHaveBeenCalledWith({
type: 'playSound',
audioType: expect.any(String)
})
})
it('plays notification sound for non-auto-approved browser actions', async () => {
render(
<ExtensionStateContextProvider>
<ChatView
isHidden={false}
showAnnouncement={false}
hideAnnouncement={() => {}}
showHistoryView={() => {}}
/>
</ExtensionStateContextProvider>
)
// First hydrate state with initial task and streaming
mockPostMessage({
alwaysAllowBrowser: false,
clineMessages: [
{
type: 'say',
say: 'task',
ts: Date.now() - 2000,
text: 'Initial task'
},
{
type: 'say',
say: 'api_req_started',
ts: Date.now() - 1000,
text: JSON.stringify({}),
partial: true
}
]
})
// Then send the browser action ask message (streaming finished)
mockPostMessage({
alwaysAllowBrowser: false,
clineMessages: [
{
type: 'say',
say: 'task',
ts: Date.now() - 2000,
text: 'Initial task'
},
{
type: 'ask',
ask: 'browser_action_launch',
ts: Date.now(),
text: JSON.stringify({ action: 'launch', url: 'http://example.com' }),
partial: false
}
]
})
// Verify notification sound was played
await waitFor(() => {
expect(vscode.postMessage).toHaveBeenCalledWith({
type: 'playSound',
audioType: 'notification'
})
})
})
it('plays celebration sound for completion results', async () => {
render(
<ExtensionStateContextProvider>
<ChatView
isHidden={false}
showAnnouncement={false}
hideAnnouncement={() => {}}
showHistoryView={() => {}}
/>
</ExtensionStateContextProvider>
)
// First hydrate state with initial task and streaming
mockPostMessage({
clineMessages: [
{
type: 'say',
say: 'task',
ts: Date.now() - 2000,
text: 'Initial task'
},
{
type: 'say',
say: 'api_req_started',
ts: Date.now() - 1000,
text: JSON.stringify({}),
partial: true
}
]
})
// Then send the completion result message (streaming finished)
mockPostMessage({
clineMessages: [
{
type: 'say',
say: 'task',
ts: Date.now() - 2000,
text: 'Initial task'
},
{
type: 'ask',
ask: 'completion_result',
ts: Date.now(),
text: 'Task completed successfully',
partial: false
}
]
})
// Verify celebration sound was played
await waitFor(() => {
expect(vscode.postMessage).toHaveBeenCalledWith({
type: 'playSound',
audioType: 'celebration'
})
})
})
it('plays progress_loop sound for api failures', async () => {
render(
<ExtensionStateContextProvider>
<ChatView
isHidden={false}
showAnnouncement={false}
hideAnnouncement={() => {}}
showHistoryView={() => {}}
/>
</ExtensionStateContextProvider>
)
// First hydrate state with initial task and streaming
mockPostMessage({
clineMessages: [
{
type: 'say',
say: 'task',
ts: Date.now() - 2000,
text: 'Initial task'
},
{
type: 'say',
say: 'api_req_started',
ts: Date.now() - 1000,
text: JSON.stringify({}),
partial: true
}
]
})
// Then send the api failure message (streaming finished)
mockPostMessage({
clineMessages: [
{
type: 'say',
say: 'task',
ts: Date.now() - 2000,
text: 'Initial task'
},
{
type: 'ask',
ask: 'api_req_failed',
ts: Date.now(),
text: 'API request failed',
partial: false
}
]
})
// Verify progress_loop sound was played
await waitFor(() => {
expect(vscode.postMessage).toHaveBeenCalledWith({
type: 'playSound',
audioType: 'progress_loop'
})
})
})
})

View File

@@ -29,6 +29,8 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
setAlwaysAllowMcp,
soundEnabled,
setSoundEnabled,
soundVolume,
setSoundVolume,
diffEnabled,
setDiffEnabled,
debugDiffEnabled,
@@ -60,6 +62,7 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
vscode.postMessage({ type: "alwaysAllowMcp", bool: alwaysAllowMcp })
vscode.postMessage({ type: "allowedCommands", commands: allowedCommands ?? [] })
vscode.postMessage({ type: "soundEnabled", bool: soundEnabled })
vscode.postMessage({ type: "soundVolume", value: soundVolume })
vscode.postMessage({ type: "diffEnabled", bool: diffEnabled })
vscode.postMessage({ type: "debugDiffEnabled", bool: debugDiffEnabled })
onDone()
@@ -318,17 +321,42 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
<h4 style={{ fontWeight: 500, marginBottom: 10 }}>Experimental Features</h4>
<div style={{ marginBottom: 5 }}>
<VSCodeCheckbox checked={soundEnabled} onChange={(e: any) => setSoundEnabled(e.target.checked)}>
<span style={{ fontWeight: "500" }}>Enable sound effects</span>
</VSCodeCheckbox>
<p
style={{
fontSize: "12px",
marginTop: "5px",
color: "var(--vscode-descriptionForeground)",
}}>
When enabled, Cline will play sound effects for notifications and events.
</p>
<div style={{ marginBottom: 10 }}>
<VSCodeCheckbox checked={soundEnabled} onChange={(e: any) => setSoundEnabled(e.target.checked)}>
<span style={{ fontWeight: "500" }}>Enable sound effects</span>
</VSCodeCheckbox>
<p
style={{
fontSize: "12px",
marginTop: "5px",
color: "var(--vscode-descriptionForeground)",
}}>
When enabled, Cline will play sound effects for notifications and events.
</p>
</div>
{soundEnabled && (
<div style={{ marginLeft: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '5px' }}>
<span style={{ fontWeight: "500", minWidth: '50px' }}>Volume</span>
<input
type="range"
min="0"
max="1"
step="0.01"
value={soundVolume ?? 0.5}
onChange={(e) => setSoundVolume(parseFloat(e.target.value))}
style={{
flexGrow: 1,
accentColor: 'var(--vscode-button-background)',
height: '2px'
}}
/>
<span style={{ minWidth: '35px', textAlign: 'left' }}>
{Math.round((soundVolume ?? 0.5) * 100)}%
</span>
</div>
</div>
)}
</div>
<div style={{ marginBottom: 5 }}>

View File

@@ -104,6 +104,9 @@ describe('SettingsView - Sound Settings', () => {
name: /Enable sound effects/i
})
expect(soundCheckbox).not.toBeChecked()
// Volume slider should not be visible when sound is disabled
expect(screen.queryByRole('slider')).not.toBeInTheDocument()
})
it('toggles sound setting and sends message to VSCode', () => {
@@ -128,6 +131,50 @@ describe('SettingsView - Sound Settings', () => {
})
)
})
it('shows volume slider when sound is enabled', () => {
renderSettingsView()
// Enable sound
const soundCheckbox = screen.getByRole('checkbox', {
name: /Enable sound effects/i
})
fireEvent.click(soundCheckbox)
// Volume slider should be visible
const volumeSlider = screen.getByRole('slider')
expect(volumeSlider).toBeInTheDocument()
expect(volumeSlider).toHaveValue('0.5') // Default value
})
it('updates volume and sends message to VSCode when slider changes', () => {
renderSettingsView()
// Enable sound
const soundCheckbox = screen.getByRole('checkbox', {
name: /Enable sound effects/i
})
fireEvent.click(soundCheckbox)
// Change volume
const volumeSlider = screen.getByRole('slider')
fireEvent.change(volumeSlider, { target: { value: '0.75' } })
// Verify volume display updates
expect(screen.getByText('75%')).toBeInTheDocument()
// Click Done to save settings
const doneButton = screen.getByText('Done')
fireEvent.click(doneButton)
// Verify message sent to VSCode
expect(vscode.postMessage).toHaveBeenCalledWith(
expect.objectContaining({
type: 'soundVolume',
value: 0.75
})
)
})
})
describe('SettingsView - Allowed Commands', () => {

View File

@@ -29,6 +29,7 @@ export interface ExtensionStateContextType extends ExtensionState {
setShowAnnouncement: (value: boolean) => void
setAllowedCommands: (value: string[]) => void
setSoundEnabled: (value: boolean) => void
setSoundVolume: (value: number) => void
setDiffEnabled: (value: boolean) => void
setDebugDiffEnabled: (value: boolean) => void
}
@@ -43,6 +44,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
shouldShowAnnouncement: false,
allowedCommands: [],
soundEnabled: false,
soundVolume: 0.5,
diffEnabled: false,
debugDiffEnabled: false,
})
@@ -131,6 +133,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
openRouterModels,
mcpServers,
filePaths,
soundVolume: state.soundVolume,
setApiConfiguration: (value) => setState((prevState) => ({
...prevState,
apiConfiguration: value
@@ -144,6 +147,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
setShowAnnouncement: (value) => setState((prevState) => ({ ...prevState, shouldShowAnnouncement: value })),
setAllowedCommands: (value) => setState((prevState) => ({ ...prevState, allowedCommands: value })),
setSoundEnabled: (value) => setState((prevState) => ({ ...prevState, soundEnabled: value })),
setSoundVolume: (value) => setState((prevState) => ({ ...prevState, soundVolume: value })),
setDiffEnabled: (value) => setState((prevState) => ({ ...prevState, diffEnabled: value })),
setDebugDiffEnabled: (value) => setState((prevState) => ({
...prevState,