Merge branch 'main' of https://github.com/RooVetGit/Roo-Cline into chores/remove-ai-releases

This commit is contained in:
a8trejo
2024-12-16 15:28:47 -08:00
19 changed files with 825 additions and 156 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.

View File

@@ -0,0 +1,5 @@
---
"roo-cline": patch
---
Fix lint errors and change npm run lint to also run on webview-ui

View File

@@ -1,5 +1,9 @@
# Roo Cline Changelog # Roo Cline Changelog
## [2.2.12]
- Better support for pure deletion and insertion diffs
## [2.2.11] ## [2.2.11]
- Added settings checkbox for verbose diff debugging - Added settings checkbox for verbose diff debugging

28
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "roo-cline", "name": "roo-cline",
"version": "2.2.11", "version": "2.2.12",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "roo-cline", "name": "roo-cline",
"version": "2.2.11", "version": "2.2.12",
"dependencies": { "dependencies": {
"@anthropic-ai/bedrock-sdk": "^0.10.2", "@anthropic-ai/bedrock-sdk": "^0.10.2",
"@anthropic-ai/sdk": "^0.26.0", "@anthropic-ai/sdk": "^0.26.0",
@@ -34,10 +34,10 @@
"os-name": "^6.0.0", "os-name": "^6.0.0",
"p-wait-for": "^5.0.2", "p-wait-for": "^5.0.2",
"pdf-parse": "^1.1.1", "pdf-parse": "^1.1.1",
"play-sound": "^1.1.6",
"puppeteer-chromium-resolver": "^23.0.0", "puppeteer-chromium-resolver": "^23.0.0",
"puppeteer-core": "^23.4.0", "puppeteer-core": "^23.4.0",
"serialize-error": "^11.0.3", "serialize-error": "^11.0.3",
"sound-play": "^1.1.0",
"strip-ansi": "^7.1.0", "strip-ansi": "^7.1.0",
"tree-sitter-wasms": "^0.1.11", "tree-sitter-wasms": "^0.1.11",
"turndown": "^7.2.0", "turndown": "^7.2.0",
@@ -8851,14 +8851,6 @@
"node": ">=8" "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": { "node_modules/find-up": {
"version": "5.0.0", "version": "5.0.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
@@ -13103,14 +13095,6 @@
"node": ">=8" "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": { "node_modules/possible-typed-array-names": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.0.0.tgz", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.0.0.tgz",
@@ -13874,6 +13858,7 @@
"version": "1.8.2", "version": "1.8.2",
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.2.tgz", "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.2.tgz",
"integrity": "sha512-AzqKpGKjrj7EM6rKVQEPpB288oCfnrEIuyoT9cyF4nmGa7V8Zk6f7RRqYisX8X9m+Q7bd632aZW4ky7EhbQztA==", "integrity": "sha512-AzqKpGKjrj7EM6rKVQEPpB288oCfnrEIuyoT9cyF4nmGa7V8Zk6f7RRqYisX8X9m+Q7bd632aZW4ky7EhbQztA==",
"dev": true,
"engines": { "engines": {
"node": ">= 0.4" "node": ">= 0.4"
}, },
@@ -14002,6 +13987,11 @@
"node": ">= 14" "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": { "node_modules/source-map": {
"version": "0.6.1", "version": "0.6.1",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",

View File

@@ -3,7 +3,7 @@
"displayName": "Roo Cline", "displayName": "Roo Cline",
"description": "A fork of Cline, an autonomous coding agent, with some added experimental configuration and automation features.", "description": "A fork of Cline, an autonomous coding agent, with some added experimental configuration and automation features.",
"publisher": "RooVeterinaryInc", "publisher": "RooVeterinaryInc",
"version": "2.2.11", "version": "2.2.12",
"icon": "assets/icons/rocket.png", "icon": "assets/icons/rocket.png",
"galleryBanner": { "galleryBanner": {
"color": "#617A91", "color": "#617A91",
@@ -153,7 +153,7 @@
"compile": "npm run check-types && npm run lint && node esbuild.js", "compile": "npm run check-types && npm run lint && node esbuild.js",
"compile-tests": "tsc -p . --outDir out", "compile-tests": "tsc -p . --outDir out",
"install:all": "npm install && cd webview-ui && npm install", "install:all": "npm install && cd webview-ui && npm install",
"lint": "eslint src --ext ts", "lint": "eslint src --ext ts && npm run lint --prefix webview-ui",
"package": "npm run build:webview && npm run check-types && npm run lint && node esbuild.js --production", "package": "npm run build:webview && npm run check-types && npm run lint && node esbuild.js --production",
"pretest": "npm run compile-tests && npm run compile && npm run lint", "pretest": "npm run compile-tests && npm run compile && npm run lint",
"start:webview": "cd webview-ui && npm run start", "start:webview": "cd webview-ui && npm run start",
@@ -217,10 +217,10 @@
"os-name": "^6.0.0", "os-name": "^6.0.0",
"p-wait-for": "^5.0.2", "p-wait-for": "^5.0.2",
"pdf-parse": "^1.1.1", "pdf-parse": "^1.1.1",
"play-sound": "^1.1.6",
"puppeteer-chromium-resolver": "^23.0.0", "puppeteer-chromium-resolver": "^23.0.0",
"puppeteer-core": "^23.4.0", "puppeteer-core": "^23.4.0",
"serialize-error": "^11.0.3", "serialize-error": "^11.0.3",
"sound-play": "^1.1.0",
"strip-ansi": "^7.1.0", "strip-ansi": "^7.1.0",
"tree-sitter-wasms": "^0.1.11", "tree-sitter-wasms": "^0.1.11",
"turndown": "^7.2.0", "turndown": "^7.2.0",

View File

@@ -592,6 +592,26 @@ this.init();
} }
}) })
it('should strip line numbers with leading spaces', () => {
const originalContent = 'function test() {\n return true;\n}\n'
const diffContent = `test.ts
<<<<<<< SEARCH
1 | function test() {
2 | return true;
3 | }
=======
1 | function test() {
2 | return false;
3 | }
>>>>>>> REPLACE`
const result = strategy.applyDiff(originalContent, diffContent)
expect(result.success).toBe(true)
if (result.success) {
expect(result.content).toBe('function test() {\n return false;\n}\n')
}
})
it('should not strip when not all lines have numbers in either section', () => { it('should not strip when not all lines have numbers in either section', () => {
const originalContent = 'function test() {\n return true;\n}\n' const originalContent = 'function test() {\n return true;\n}\n'
const diffContent = `test.ts const diffContent = `test.ts
@@ -711,6 +731,212 @@ this.init();
}) })
}); });
describe('insertion/deletion', () => {
let strategy: SearchReplaceDiffStrategy
beforeEach(() => {
strategy = new SearchReplaceDiffStrategy()
})
describe('deletion', () => {
it('should delete code when replace block is empty', () => {
const originalContent = `function test() {
console.log("hello");
// Comment to remove
console.log("world");
}`
const diffContent = `test.ts
<<<<<<< SEARCH
// Comment to remove
=======
>>>>>>> REPLACE`
const result = strategy.applyDiff(originalContent, diffContent)
expect(result.success).toBe(true)
if (result.success) {
expect(result.content).toBe(`function test() {
console.log("hello");
console.log("world");
}`)
}
})
it('should delete multiple lines when replace block is empty', () => {
const originalContent = `class Example {
constructor() {
// Initialize
this.value = 0;
// Set defaults
this.name = "";
// End init
}
}`
const diffContent = `test.ts
<<<<<<< SEARCH
// Initialize
this.value = 0;
// Set defaults
this.name = "";
// End init
=======
>>>>>>> REPLACE`
const result = strategy.applyDiff(originalContent, diffContent)
expect(result.success).toBe(true)
if (result.success) {
expect(result.content).toBe(`class Example {
constructor() {
}
}`)
}
})
it('should preserve indentation when deleting nested code', () => {
const originalContent = `function outer() {
if (true) {
// Remove this
console.log("test");
// And this
}
return true;
}`
const diffContent = `test.ts
<<<<<<< SEARCH
// Remove this
console.log("test");
// And this
=======
>>>>>>> REPLACE`
const result = strategy.applyDiff(originalContent, diffContent)
expect(result.success).toBe(true)
if (result.success) {
expect(result.content).toBe(`function outer() {
if (true) {
}
return true;
}`)
}
})
})
describe('insertion', () => {
it('should insert code at specified line when search block is empty', () => {
const originalContent = `function test() {
const x = 1;
return x;
}`
const diffContent = `test.ts
<<<<<<< SEARCH
=======
console.log("Adding log");
>>>>>>> REPLACE`
const result = strategy.applyDiff(originalContent, diffContent, 2, 2)
expect(result.success).toBe(true)
if (result.success) {
expect(result.content).toBe(`function test() {
console.log("Adding log");
const x = 1;
return x;
}`)
}
})
it('should preserve indentation when inserting at nested location', () => {
const originalContent = `function test() {
if (true) {
const x = 1;
}
}`
const diffContent = `test.ts
<<<<<<< SEARCH
=======
console.log("Before");
console.log("After");
>>>>>>> REPLACE`
const result = strategy.applyDiff(originalContent, diffContent, 3, 3)
expect(result.success).toBe(true)
if (result.success) {
expect(result.content).toBe(`function test() {
if (true) {
console.log("Before");
console.log("After");
const x = 1;
}
}`)
}
})
it('should handle insertion at start of file', () => {
const originalContent = `function test() {
return true;
}`
const diffContent = `test.ts
<<<<<<< SEARCH
=======
// Copyright 2024
// License: MIT
>>>>>>> REPLACE`
const result = strategy.applyDiff(originalContent, diffContent, 1, 1)
expect(result.success).toBe(true)
if (result.success) {
expect(result.content).toBe(`// Copyright 2024
// License: MIT
function test() {
return true;
}`)
}
})
it('should handle insertion at end of file', () => {
const originalContent = `function test() {
return true;
}`
const diffContent = `test.ts
<<<<<<< SEARCH
=======
// End of file
>>>>>>> REPLACE`
const result = strategy.applyDiff(originalContent, diffContent, 4, 4)
expect(result.success).toBe(true)
if (result.success) {
expect(result.content).toBe(`function test() {
return true;
}
// End of file`)
}
})
it('should insert at the start of the file if no start_line is provided for insertion', () => {
const originalContent = `function test() {
return true;
}`
const diffContent = `test.ts
<<<<<<< SEARCH
=======
console.log("test");
>>>>>>> REPLACE`
const result = strategy.applyDiff(originalContent, diffContent)
expect(result.success).toBe(true)
if (result.success) {
expect(result.content).toBe(`console.log("test");
function test() {
return true;
}`)
}
})
})
})
describe('fuzzy matching', () => { describe('fuzzy matching', () => {
let strategy: SearchReplaceDiffStrategy let strategy: SearchReplaceDiffStrategy
@@ -1241,8 +1467,8 @@ function two() {
it('should document start_line and end_line parameters', () => { it('should document start_line and end_line parameters', () => {
const description = strategy.getToolDescription('/test') const description = strategy.getToolDescription('/test')
expect(description).toContain('start_line: (required) The line number where the search block starts.') expect(description).toContain('start_line: (required) The line number where the search block starts (inclusive).')
expect(description).toContain('end_line: (required) The line number where the search block ends.') expect(description).toContain('end_line: (required) The line number where the search block ends (inclusive).')
}) })
}) })
}) })

View File

@@ -33,6 +33,10 @@ function levenshteinDistance(a: string, b: string): number {
} }
function getSimilarity(original: string, search: string): number { function getSimilarity(original: string, search: string): number {
if (original === '' || search === '') {
return 1;
}
// Normalize strings by removing extra whitespace but preserve case // Normalize strings by removing extra whitespace but preserve case
const normalizeStr = (str: string) => str.replace(/\s+/g, ' ').trim(); const normalizeStr = (str: string) => str.replace(/\s+/g, ' ').trim();
@@ -71,8 +75,8 @@ If you're not confident in the exact content to search for, use the read_file to
Parameters: Parameters:
- path: (required) The path of the file to modify (relative to the current working directory ${cwd}) - path: (required) The path of the file to modify (relative to the current working directory ${cwd})
- diff: (required) The search/replace block defining the changes. - diff: (required) The search/replace block defining the changes.
- start_line: (required) The line number where the search block starts. - start_line: (required) The line number where the search block starts (inclusive).
- end_line: (required) The line number where the search block ends. - end_line: (required) The line number where the search block ends (inclusive).
Diff format: Diff format:
\`\`\` \`\`\`
@@ -94,35 +98,84 @@ Original file:
5 | return total 5 | return total
\`\`\` \`\`\`
Search/Replace content: 1. Search/replace a specific chunk of code:
\`\`\` \`\`\`
<apply_diff>
<path>File path here</path>
<diff>
<<<<<<< SEARCH <<<<<<< SEARCH
def calculate_total(items):
total = 0 total = 0
for item in items: for item in items:
total += item total += item
return total return total
======= =======
def calculate_total(items):
"""Calculate total with 10% markup""" """Calculate total with 10% markup"""
return sum(item * 1.1 for item in items) return sum(item * 1.1 for item in items)
>>>>>>> REPLACE >>>>>>> REPLACE
</diff>
<start_line>2</start_line>
<end_line>5</end_line>
</apply_diff>
\`\`\` \`\`\`
Usage: Result:
\`\`\`
1 | def calculate_total(items):
2 | """Calculate total with 10% markup"""
3 | return sum(item * 1.1 for item in items)
\`\`\`
2. Insert code at a specific line (start_line and end_line must be the same, and the content gets inserted before whatever is currently at that line):
\`\`\`
<apply_diff> <apply_diff>
<path>File path here</path> <path>File path here</path>
<diff> <diff>
Your search/replace content here <<<<<<< SEARCH
=======
"""TODO: Write a test for this"""
>>>>>>> REPLACE
</diff> </diff>
<start_line>1</start_line> <start_line>2</start_line>
<end_line>2</end_line>
</apply_diff>
\`\`\`
Result:
\`\`\`
1 | def calculate_total(items):
2 | """TODO: Write a test for this"""
3 | """Calculate total with 10% markup"""
4 | return sum(item * 1.1 for item in items)
\`\`\`
3. Delete code at a specific line range:
\`\`\`
<apply_diff>
<path>File path here</path>
<diff>
<<<<<<< SEARCH
total = 0
for item in items:
total += item
return total
=======
>>>>>>> REPLACE
</diff>
<start_line>2</start_line>
<end_line>5</end_line> <end_line>5</end_line>
</apply_diff>` </apply_diff>
\`\`\`
Result:
\`\`\`
1 | def calculate_total(items):
\`\`\`
`
} }
applyDiff(originalContent: string, diffContent: string, startLine?: number, endLine?: number): DiffResult { applyDiff(originalContent: string, diffContent: string, startLine?: number, endLine?: number): DiffResult {
// Extract the search and replace blocks // Extract the search and replace blocks
const match = diffContent.match(/<<<<<<< SEARCH\n([\s\S]*?)\n=======\n([\s\S]*?)\n>>>>>>> REPLACE/); const match = diffContent.match(/<<<<<<< SEARCH\n([\s\S]*?)\n?=======\n([\s\S]*?)\n?>>>>>>> REPLACE/);
if (!match) { if (!match) {
const debugInfo = this.debugEnabled ? `\n\nDebug Info:\n- Expected Format: <<<<<<< SEARCH\\n[search content]\\n=======\\n[replace content]\\n>>>>>>> REPLACE\n- Tip: Make sure to include both SEARCH and REPLACE sections with correct markers` : ''; const debugInfo = this.debugEnabled ? `\n\nDebug Info:\n- Expected Format: <<<<<<< SEARCH\\n[search content]\\n=======\\n[replace content]\\n>>>>>>> REPLACE\n- Tip: Make sure to include both SEARCH and REPLACE sections with correct markers` : '';
@@ -140,12 +193,12 @@ Your search/replace content here
// Strip line numbers from search and replace content if every line starts with a line number // Strip line numbers from search and replace content if every line starts with a line number
const hasLineNumbers = (content: string) => { const hasLineNumbers = (content: string) => {
const lines = content.split(/\r?\n/); const lines = content.split(/\r?\n/);
return lines.length > 0 && lines.every(line => /^\d+\s+\|(?!\|)/.test(line)); return lines.length > 0 && lines.every(line => /^\s*\d+\s+\|(?!\|)/.test(line));
}; };
if (hasLineNumbers(searchContent) && hasLineNumbers(replaceContent)) { if (hasLineNumbers(searchContent) && hasLineNumbers(replaceContent)) {
const stripLineNumbers = (content: string) => { const stripLineNumbers = (content: string) => {
return content.replace(/^\d+\s+\|(?!\|)/gm, '') return content.replace(/^\s*\d+\s+\|(?!\|)/gm, '');
}; };
searchContent = stripLineNumbers(searchContent); searchContent = stripLineNumbers(searchContent);
@@ -153,8 +206,8 @@ Your search/replace content here
} }
// Split content into lines, handling both \n and \r\n // Split content into lines, handling both \n and \r\n
const searchLines = searchContent.split(/\r?\n/); const searchLines = searchContent === '' ? [] : searchContent.split(/\r?\n/);
const replaceLines = replaceContent.split(/\r?\n/); const replaceLines = replaceContent === '' ? [] : replaceContent.split(/\r?\n/);
const originalLines = originalContent.split(/\r?\n/); const originalLines = originalContent.split(/\r?\n/);
// First try exact line range if provided // First try exact line range if provided
@@ -167,9 +220,15 @@ Your search/replace content here
const exactStartIndex = startLine - 1; const exactStartIndex = startLine - 1;
const exactEndIndex = endLine - 1; const exactEndIndex = endLine - 1;
if (exactStartIndex < 0 || exactEndIndex >= originalLines.length || exactStartIndex > exactEndIndex) { if (exactStartIndex < 0 || exactEndIndex > originalLines.length || exactStartIndex > exactEndIndex) {
const debugInfo = this.debugEnabled ? `\n\nDebug Info:\n- Requested Range: lines ${startLine}-${endLine}\n- File Bounds: lines 1-${originalLines.length}` : ''; const debugInfo = this.debugEnabled ? `\n\nDebug Info:\n- Requested Range: lines ${startLine}-${endLine}\n- File Bounds: lines 1-${originalLines.length}` : '';
// Log detailed debug information
console.log('Invalid Line Range Debug:', {
requestedRange: { start: startLine, end: endLine },
fileBounds: { start: 1, end: originalLines.length }
});
return { return {
success: false, success: false,
error: `Line range ${startLine}-${endLine} is invalid (file has ${originalLines.length} lines)${debugInfo}`, error: `Line range ${startLine}-${endLine} is invalid (file has ${originalLines.length} lines)${debugInfo}`,
@@ -263,7 +322,7 @@ Your search/replace content here
// Apply the replacement while preserving exact indentation // Apply the replacement while preserving exact indentation
const indentedReplaceLines = replaceLines.map((line, i) => { const indentedReplaceLines = replaceLines.map((line, i) => {
// Get the matched line's exact indentation // Get the matched line's exact indentation
const matchedIndent = originalIndents[0]; const matchedIndent = originalIndents[0] || '';
// Get the current line's indentation relative to the search content // Get the current line's indentation relative to the search content
const currentIndentMatch = line.match(/^[\t ]*/); const currentIndentMatch = line.match(/^[\t ]*/);

View File

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

View File

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

View File

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

View File

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

View File

@@ -34,7 +34,8 @@
}, },
"devDependencies": { "devDependencies": {
"@babel/plugin-proposal-private-property-in-object": "^7.21.11", "@babel/plugin-proposal-private-property-in-object": "^7.21.11",
"@types/vscode-webview": "^1.57.5" "@types/vscode-webview": "^1.57.5",
"eslint": "^8.57.0"
} }
}, },
"node_modules/@adobe/css-tools": { "node_modules/@adobe/css-tools": {

View File

@@ -31,7 +31,8 @@
"start": "react-scripts start", "start": "react-scripts start",
"build": "node ./scripts/build-react-no-split.js", "build": "node ./scripts/build-react-no-split.js",
"test": "react-scripts test --watchAll=false", "test": "react-scripts test --watchAll=false",
"eject": "react-scripts eject" "eject": "react-scripts eject",
"lint": "eslint src --ext ts,tsx"
}, },
"eslintConfig": { "eslintConfig": {
"extends": [ "extends": [
@@ -53,7 +54,8 @@
}, },
"devDependencies": { "devDependencies": {
"@babel/plugin-proposal-private-property-in-object": "^7.21.11", "@babel/plugin-proposal-private-property-in-object": "^7.21.11",
"@types/vscode-webview": "^1.57.5" "@types/vscode-webview": "^1.57.5",
"eslint": "^8.57.0"
}, },
"jest": { "jest": {
"transformIgnorePatterns": [ "transformIgnorePatterns": [

View File

@@ -64,7 +64,6 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
const [isAtBottom, setIsAtBottom] = useState(false) const [isAtBottom, setIsAtBottom] = useState(false)
const [wasStreaming, setWasStreaming] = useState<boolean>(false) const [wasStreaming, setWasStreaming] = useState<boolean>(false)
const [hasStarted, setHasStarted] = useState(false)
// UI layout depends on the last 2 messages // 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 // (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 }) vscode.postMessage({ type: "playSound", audioType })
} }
function playSoundOnMessage(audioType: AudioType) {
if (hasStarted && !isStreaming) {
playSound(audioType)
}
}
useDeepCompareEffect(() => { useDeepCompareEffect(() => {
// if last message is an ask, show user ask UI // 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. // 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 const isPartial = lastMessage.partial === true
switch (lastMessage.ask) { switch (lastMessage.ask) {
case "api_req_failed": case "api_req_failed":
playSoundOnMessage("progress_loop") playSound("progress_loop")
setTextAreaDisabled(true) setTextAreaDisabled(true)
setClineAsk("api_req_failed") setClineAsk("api_req_failed")
setEnableButtons(true) setEnableButtons(true)
@@ -99,7 +92,7 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
setSecondaryButtonText("Start New Task") setSecondaryButtonText("Start New Task")
break break
case "mistake_limit_reached": case "mistake_limit_reached":
playSoundOnMessage("progress_loop") playSound("progress_loop")
setTextAreaDisabled(false) setTextAreaDisabled(false)
setClineAsk("mistake_limit_reached") setClineAsk("mistake_limit_reached")
setEnableButtons(true) setEnableButtons(true)
@@ -107,7 +100,6 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
setSecondaryButtonText("Start New Task") setSecondaryButtonText("Start New Task")
break break
case "followup": case "followup":
playSoundOnMessage("notification")
setTextAreaDisabled(isPartial) setTextAreaDisabled(isPartial)
setClineAsk("followup") setClineAsk("followup")
setEnableButtons(isPartial) setEnableButtons(isPartial)
@@ -115,7 +107,9 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
// setSecondaryButtonText(undefined) // setSecondaryButtonText(undefined)
break break
case "tool": case "tool":
playSoundOnMessage("notification") if (!isAutoApproved(lastMessage)) {
playSound("notification")
}
setTextAreaDisabled(isPartial) setTextAreaDisabled(isPartial)
setClineAsk("tool") setClineAsk("tool")
setEnableButtons(!isPartial) setEnableButtons(!isPartial)
@@ -134,7 +128,9 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
} }
break break
case "browser_action_launch": case "browser_action_launch":
playSoundOnMessage("notification") if (!isAutoApproved(lastMessage)) {
playSound("notification")
}
setTextAreaDisabled(isPartial) setTextAreaDisabled(isPartial)
setClineAsk("browser_action_launch") setClineAsk("browser_action_launch")
setEnableButtons(!isPartial) setEnableButtons(!isPartial)
@@ -142,7 +138,9 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
setSecondaryButtonText("Reject") setSecondaryButtonText("Reject")
break break
case "command": case "command":
playSoundOnMessage("notification") if (!isAutoApproved(lastMessage)) {
playSound("notification")
}
setTextAreaDisabled(isPartial) setTextAreaDisabled(isPartial)
setClineAsk("command") setClineAsk("command")
setEnableButtons(!isPartial) setEnableButtons(!isPartial)
@@ -150,7 +148,6 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
setSecondaryButtonText("Reject") setSecondaryButtonText("Reject")
break break
case "command_output": case "command_output":
playSoundOnMessage("notification")
setTextAreaDisabled(false) setTextAreaDisabled(false)
setClineAsk("command_output") setClineAsk("command_output")
setEnableButtons(true) setEnableButtons(true)
@@ -166,7 +163,7 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
break break
case "completion_result": case "completion_result":
// extension waiting for feedback. but we can just present a new task button // extension waiting for feedback. but we can just present a new task button
playSoundOnMessage("celebration") playSound("celebration")
setTextAreaDisabled(isPartial) setTextAreaDisabled(isPartial)
setClineAsk("completion_result") setClineAsk("completion_result")
setEnableButtons(!isPartial) setEnableButtons(!isPartial)
@@ -174,7 +171,6 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
setSecondaryButtonText(undefined) setSecondaryButtonText(undefined)
break break
case "resume_task": case "resume_task":
playSoundOnMessage("notification")
setTextAreaDisabled(false) setTextAreaDisabled(false)
setClineAsk("resume_task") setClineAsk("resume_task")
setEnableButtons(true) setEnableButtons(true)
@@ -183,7 +179,7 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
setDidClickCancel(false) // special case where we reset the cancel button state setDidClickCancel(false) // special case where we reset the cancel button state
break break
case "resume_completed_task": case "resume_completed_task":
playSoundOnMessage("celebration") playSound("celebration")
setTextAreaDisabled(false) setTextAreaDisabled(false)
setClineAsk("resume_completed_task") setClineAsk("resume_completed_task")
setEnableButtons(true) setEnableButtons(true)
@@ -482,24 +478,109 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
return true return true
}) })
}, [modifiedMessages]) }, [modifiedMessages])
useEffect(() => {
if (isStreaming) { const isReadOnlyToolAction = useCallback((message: ClineMessage | undefined) => {
// Set to true once any request has started if (message?.type === "ask") {
setHasStarted(true) 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 = useCallback((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 = useCallback((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
}, [mcpServers])
const isAllowedCommand = useCallback((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
}, [allowedCommands])
const isAutoApproved = useCallback(
(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))
)
},
[
alwaysAllowBrowser,
alwaysAllowReadOnly,
alwaysAllowWrite,
alwaysAllowExecute,
alwaysAllowMcp,
isReadOnlyToolAction,
isWriteToolAction,
isAllowedCommand,
isMcpToolAlwaysAllowed
]
)
useEffect(() => {
// Only execute when isStreaming changes from true to false // Only execute when isStreaming changes from true to false
if (wasStreaming && !isStreaming && lastMessage) { if (wasStreaming && !isStreaming && lastMessage) {
// Play appropriate sound based on lastMessage content // Play appropriate sound based on lastMessage content
if (lastMessage.type === "ask") { if (lastMessage.type === "ask") {
// Don't play sounds for auto-approved actions
if (!isAutoApproved(lastMessage)) {
switch (lastMessage.ask) { switch (lastMessage.ask) {
case "api_req_failed": case "api_req_failed":
case "mistake_limit_reached": case "mistake_limit_reached":
playSound("progress_loop") playSound("progress_loop")
break break
case "tool":
case "followup": case "followup":
if (!lastMessage.partial) {
playSound("notification")
}
break
case "tool":
case "browser_action_launch": case "browser_action_launch":
case "resume_task": case "resume_task":
case "use_mcp_server":
playSound("notification") playSound("notification")
break break
case "completion_result": case "completion_result":
@@ -509,9 +590,10 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
} }
} }
} }
}
// Update previous value // Update previous value
setWasStreaming(isStreaming) setWasStreaming(isStreaming)
}, [isStreaming, lastMessage, wasStreaming]) }, [isStreaming, lastMessage, wasStreaming, isAutoApproved])
const isBrowserSessionMessage = (message: ClineMessage): boolean => { const isBrowserSessionMessage = (message: ClineMessage): boolean => {
// which of visible messages are browser session messages, see above // which of visible messages are browser session messages, see above
@@ -750,64 +832,10 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
// Only proceed if we have an ask and buttons are enabled // Only proceed if we have an ask and buttons are enabled
if (!clineAsk || !enableButtons) return if (!clineAsk || !enableButtons) return
const isReadOnlyToolAction = () => { if (isAutoApproved(lastMessage)) {
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())
) {
handlePrimaryButtonClick() handlePrimaryButtonClick()
} }
}, [clineAsk, enableButtons, handlePrimaryButtonClick, alwaysAllowBrowser, alwaysAllowReadOnly, alwaysAllowWrite, alwaysAllowExecute, alwaysAllowMcp, messages, allowedCommands, mcpServers]) }, [clineAsk, enableButtons, handlePrimaryButtonClick, alwaysAllowBrowser, alwaysAllowReadOnly, alwaysAllowWrite, alwaysAllowExecute, alwaysAllowMcp, messages, allowedCommands, mcpServers, isAutoApproved, lastMessage])
return ( return (
<div <div

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

@@ -23,7 +23,6 @@ jest.mock('@vscode/webview-ui-toolkit/react', () => ({
<label> <label>
<input <input
type="checkbox" type="checkbox"
role="checkbox"
checked={checked} checked={checked}
onChange={onChange} onChange={onChange}
/> />

View File

@@ -29,6 +29,8 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
setAlwaysAllowMcp, setAlwaysAllowMcp,
soundEnabled, soundEnabled,
setSoundEnabled, setSoundEnabled,
soundVolume,
setSoundVolume,
diffEnabled, diffEnabled,
setDiffEnabled, setDiffEnabled,
debugDiffEnabled, debugDiffEnabled,
@@ -60,6 +62,7 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
vscode.postMessage({ type: "alwaysAllowMcp", bool: alwaysAllowMcp }) vscode.postMessage({ type: "alwaysAllowMcp", bool: alwaysAllowMcp })
vscode.postMessage({ type: "allowedCommands", commands: allowedCommands ?? [] }) vscode.postMessage({ type: "allowedCommands", commands: allowedCommands ?? [] })
vscode.postMessage({ type: "soundEnabled", bool: soundEnabled }) vscode.postMessage({ type: "soundEnabled", bool: soundEnabled })
vscode.postMessage({ type: "soundVolume", value: soundVolume })
vscode.postMessage({ type: "diffEnabled", bool: diffEnabled }) vscode.postMessage({ type: "diffEnabled", bool: diffEnabled })
vscode.postMessage({ type: "debugDiffEnabled", bool: debugDiffEnabled }) vscode.postMessage({ type: "debugDiffEnabled", bool: debugDiffEnabled })
onDone() onDone()
@@ -318,6 +321,7 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
<h4 style={{ fontWeight: 500, marginBottom: 10 }}>Experimental Features</h4> <h4 style={{ fontWeight: 500, marginBottom: 10 }}>Experimental Features</h4>
<div style={{ marginBottom: 5 }}> <div style={{ marginBottom: 5 }}>
<div style={{ marginBottom: 10 }}>
<VSCodeCheckbox checked={soundEnabled} onChange={(e: any) => setSoundEnabled(e.target.checked)}> <VSCodeCheckbox checked={soundEnabled} onChange={(e: any) => setSoundEnabled(e.target.checked)}>
<span style={{ fontWeight: "500" }}>Enable sound effects</span> <span style={{ fontWeight: "500" }}>Enable sound effects</span>
</VSCodeCheckbox> </VSCodeCheckbox>
@@ -330,6 +334,30 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
When enabled, Cline will play sound effects for notifications and events. When enabled, Cline will play sound effects for notifications and events.
</p> </p>
</div> </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 }}> <div style={{ marginBottom: 5 }}>
<VSCodeCheckbox checked={debugDiffEnabled} onChange={(e: any) => setDebugDiffEnabled(e.target.checked)}> <VSCodeCheckbox checked={debugDiffEnabled} onChange={(e: any) => setDebugDiffEnabled(e.target.checked)}>

View File

@@ -40,7 +40,7 @@ jest.mock('@vscode/webview-ui-toolkit/react', () => ({
/> />
), ),
VSCodeTextArea: () => <textarea />, VSCodeTextArea: () => <textarea />,
VSCodeLink: () => <a />, VSCodeLink: ({ children, href }: any) => <a href={href || '#'}>{children}</a>,
VSCodeDropdown: ({ children, value, onChange }: any) => ( VSCodeDropdown: ({ children, value, onChange }: any) => (
<select value={value} onChange={onChange}> <select value={value} onChange={onChange}>
{children} {children}
@@ -104,6 +104,9 @@ describe('SettingsView - Sound Settings', () => {
name: /Enable sound effects/i name: /Enable sound effects/i
}) })
expect(soundCheckbox).not.toBeChecked() 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', () => { 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', () => { describe('SettingsView - Allowed Commands', () => {

View File

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