From 24c4e6583803520cfdaa54416edee5173cdcb413 Mon Sep 17 00:00:00 2001 From: Rile Ge Date: Tue, 8 Apr 2025 10:49:55 -0700 Subject: [PATCH 01/18] Adding the grep search tool. 1. Implemented the tool using https://github.com/microsoft/vscode-ripgrep. 2. The grepped result show as clickable file path. 3. The tool currently is disable in current pr, once grepSearch tool tested, will update the tool_index.json to enable this tool. --- package.json | 1 + packages/amazonq/scripts/build/copyFiles.ts | 5 + .../src/codewhispererChat/tools/grepSearch.ts | 214 +++++++++++++++ .../src/codewhispererChat/tools/toolUtils.ts | 19 ++ .../codewhispererChat/tools/tool_index.json | 34 +++ packages/core/src/shared/logger/logger.ts | 1 + .../tools/grepSearch.test.ts | 256 ++++++++++++++++++ 7 files changed, 530 insertions(+) create mode 100644 packages/core/src/codewhispererChat/tools/grepSearch.ts create mode 100644 packages/core/src/test/codewhispererChat/tools/grepSearch.test.ts diff --git a/package.json b/package.json index f36a4d32d84..a746e55b01a 100644 --- a/package.json +++ b/package.json @@ -67,6 +67,7 @@ "pretty-quick": "^4.1.1", "ts-node": "^10.9.1", "typescript": "^5.0.4", + "vscode-ripgrep": "^1.13.2", "webpack": "^5.95.0", "webpack-cli": "^5.1.4", "webpack-dev-server": "^4.15.2", diff --git a/packages/amazonq/scripts/build/copyFiles.ts b/packages/amazonq/scripts/build/copyFiles.ts index 45b1d263f0b..aefd5c5f403 100644 --- a/packages/amazonq/scripts/build/copyFiles.ts +++ b/packages/amazonq/scripts/build/copyFiles.ts @@ -60,6 +60,11 @@ const tasks: CopyTask[] = [ target: path.join('../../node_modules', 'web-tree-sitter', 'tree-sitter.wasm'), destination: path.join('src', 'tree-sitter.wasm'), }, + // ripgrep binary + { + target: path.join('../../node_modules', 'vscode-ripgrep', 'bin'), + destination: 'bin/', + }, ] function copy(task: CopyTask): void { diff --git a/packages/core/src/codewhispererChat/tools/grepSearch.ts b/packages/core/src/codewhispererChat/tools/grepSearch.ts new file mode 100644 index 00000000000..c0b47a1967f --- /dev/null +++ b/packages/core/src/codewhispererChat/tools/grepSearch.ts @@ -0,0 +1,214 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +import * as vscode from 'vscode' +import { getLogger } from '../../shared/logger/logger' +import { sanitizePath, InvokeOutput, OutputKind } from './toolShared' +import fs from '../../shared/fs/fs' +import { Writable } from 'stream' +import { ChildProcess, ChildProcessOptions } from '../../shared/utilities/processUtils' +import { rgPath } from 'vscode-ripgrep' +import path from 'path' + +export interface GrepSearchParams { + path?: string + query: string + caseSensitive?: boolean + excludePattern?: string + includePattern?: string + explanation?: string +} + +export class GrepSearch { + private fsPath: string | undefined + private query: string + private caseSensitive: boolean + private excludePattern?: string + private includePattern?: string + private readonly logger = getLogger('grepSearch') + + constructor(params: GrepSearchParams) { + this.fsPath = params.path + this.query = params.query + this.caseSensitive = params.caseSensitive ?? false + this.excludePattern = params.excludePattern + this.includePattern = params.includePattern + } + + public async validate(): Promise { + if (!this.query || this.query.trim().length === 0) { + throw new Error('Grep search query cannot be empty.') + } + + // Handle optional path parameter + if (!this.fsPath || this.fsPath.trim().length === 0) { + // Use current workspace folder as default if path is not provided + const workspaceFolders = vscode.workspace.workspaceFolders + if (!workspaceFolders || workspaceFolders.length === 0) { + throw new Error('Path cannot be empty and no workspace folder is available.') + } + this.fsPath = workspaceFolders[0].uri.fsPath + this.logger.debug(`Using default workspace folder: ${this.fsPath}`) + } + + const sanitized = sanitizePath(this.fsPath) + this.fsPath = sanitized + + const pathUri = vscode.Uri.file(this.fsPath) + let pathExists: boolean + try { + pathExists = await fs.existsDir(pathUri) + if (!pathExists) { + throw new Error(`Path: "${this.fsPath}" does not exist or cannot be accessed.`) + } + } catch (err) { + throw new Error(`Path: "${this.fsPath}" does not exist or cannot be accessed. (${err})`) + } + } + + public queueDescription(updates: Writable): void { + const searchDirectory = this.getSearchDirectory(this.fsPath) + updates.write(`Grepping for "${this.query}" in directory: ${searchDirectory}`) + updates.end() + } + + public async invoke(updates?: Writable): Promise { + const searchDirectory = this.getSearchDirectory(this.fsPath) + try { + const results = await this.executeRipgrep(updates) + return this.createOutput(results) + } catch (error: any) { + this.logger.error(`Failed to search in "${searchDirectory}": ${error.message || error}`) + throw new Error(`Failed to search in "${searchDirectory}": ${error.message || error}`) + } + } + + private getSearchDirectory(fsPath?: string): string { + const workspaceFolders = vscode.workspace.workspaceFolders + const searchLocation = fsPath + ? fsPath + : !workspaceFolders || workspaceFolders.length === 0 + ? '' + : workspaceFolders[0].uri.fsPath + return searchLocation + } + + private async executeRipgrep(updates?: Writable): Promise { + const searchDirectory = this.getSearchDirectory(this.fsPath) + return new Promise(async (resolve, reject) => { + const args: string[] = [] + + // Add search options + if (!this.caseSensitive) { + args.push('-i') // Case insensitive search + } + args.push('--line-number') // Show line numbers + + // No heading (don't group matches by file) + args.push('--no-heading') + + // Don't use color in output + args.push('--color', 'never') + + // Add include/exclude patterns + if (this.includePattern) { + // Support multiple include patterns + const patterns = this.includePattern.split(',') + for (const pattern of patterns) { + args.push('--glob', pattern.trim()) + } + } + + if (this.excludePattern) { + // Support multiple exclude patterns + const patterns = this.excludePattern.split(',') + for (const pattern of patterns) { + args.push('--glob', `!${pattern.trim()}`) + } + } + + // Add search pattern and path + args.push(this.query, searchDirectory) + + this.logger.debug(`Executing ripgrep with args: ${args.join(' ')}`) + + const options: ChildProcessOptions = { + collect: true, + logging: 'yes', + rejectOnErrorCode: (code) => { + if (code !== 0 && code !== 1) { + this.logger.error(`Ripgrep process exited with code ${code}`) + return new Error(`Ripgrep process exited with code ${code}`) + } + return new Error() + }, + } + + try { + const rg = new ChildProcess(rgPath, args, options) + const result = await rg.run() + this.logger.info(`Executing ripgrep with exitCode: ${result.exitCode}`) + // Process the output to format with file URLs and remove matched content + const processedOutput = this.processRipgrepOutput(result.stdout) + + // If updates is provided, write the processed output + if (updates) { + updates.write('\n\nGreped Results:\n\n') + updates.write(processedOutput) + } + + this.logger.info(`Processed ripgrep result: ${processedOutput}`) + resolve(processedOutput) + } catch (err) { + reject(err) + } + }) + } + + /** + * Process ripgrep output to: + * 1. Remove matched content (keep only file:line) + * 2. Add file URLs for clickable links + */ + private processRipgrepOutput(output: string): string { + if (!output || output.trim() === '') { + return 'No matches found.' + } + + const lines = output.split('\n') + const processedLines = lines + .map((line) => { + if (!line || line.trim() === '') { + return '' + } + + // Extract file path and line number + const parts = line.split(':') + if (parts.length < 2) { + return line + } + + const filePath = parts[0] + const lineNumber = parts[1] + + const fileName = path.basename(filePath) + const fileUri = vscode.Uri.file(filePath) + + // Format as a markdown link + return `[${fileName}:${lineNumber}](${fileUri}:${lineNumber})` + }) + .filter(Boolean) + + return processedLines.join('\n') + } + + private createOutput(content: string): InvokeOutput { + return { + output: { + kind: OutputKind.Text, + content: content || 'No matches found.', + }, + } + } +} diff --git a/packages/core/src/codewhispererChat/tools/toolUtils.ts b/packages/core/src/codewhispererChat/tools/toolUtils.ts index 67c1a08362e..4630bdb3439 100644 --- a/packages/core/src/codewhispererChat/tools/toolUtils.ts +++ b/packages/core/src/codewhispererChat/tools/toolUtils.ts @@ -9,12 +9,14 @@ import { CommandValidation, ExecuteBash, ExecuteBashParams } from './executeBash import { ToolResult, ToolResultContentBlock, ToolResultStatus, ToolUse } from '@amzn/codewhisperer-streaming' import { InvokeOutput, maxToolResponseSize } from './toolShared' import { ListDirectory, ListDirectoryParams } from './listDirectory' +import { GrepSearch, GrepSearchParams } from './grepSearch' export enum ToolType { FsRead = 'fsRead', FsWrite = 'fsWrite', ExecuteBash = 'executeBash', ListDirectory = 'listDirectory', + GrepSearch = 'grepSearch', } export type Tool = @@ -22,6 +24,7 @@ export type Tool = | { type: ToolType.FsWrite; tool: FsWrite } | { type: ToolType.ExecuteBash; tool: ExecuteBash } | { type: ToolType.ListDirectory; tool: ListDirectory } + | { type: ToolType.GrepSearch; tool: GrepSearch } export class ToolUtils { static displayName(tool: Tool): string { @@ -34,6 +37,8 @@ export class ToolUtils { return 'Execute shell command' case ToolType.ListDirectory: return 'List directory from filesystem' + case ToolType.GrepSearch: + return 'Run Fast text-based regex search' } } @@ -47,6 +52,8 @@ export class ToolUtils { return tool.tool.requiresAcceptance() case ToolType.ListDirectory: return { requiresAcceptance: false } + case ToolType.GrepSearch: + return { requiresAcceptance: false } } } @@ -60,6 +67,8 @@ export class ToolUtils { return tool.tool.invoke(updates ?? undefined) case ToolType.ListDirectory: return tool.tool.invoke(updates) + case ToolType.GrepSearch: + return tool.tool.invoke(updates) } } @@ -83,6 +92,9 @@ export class ToolUtils { case ToolType.ListDirectory: tool.tool.queueDescription(updates) break + case ToolType.GrepSearch: + tool.tool.queueDescription(updates) + break } } @@ -96,6 +108,8 @@ export class ToolUtils { return tool.tool.validate() case ToolType.ListDirectory: return tool.tool.validate() + case ToolType.GrepSearch: + return tool.tool.validate() } } @@ -133,6 +147,11 @@ export class ToolUtils { type: ToolType.ListDirectory, tool: new ListDirectory(value.input as unknown as ListDirectoryParams), } + case ToolType.GrepSearch: + return { + type: ToolType.GrepSearch, + tool: new GrepSearch(value.input as unknown as GrepSearchParams), + } default: return { toolUseId: value.toolUseId, diff --git a/packages/core/src/codewhispererChat/tools/tool_index.json b/packages/core/src/codewhispererChat/tools/tool_index.json index 89f13622303..0c6ac36760b 100644 --- a/packages/core/src/codewhispererChat/tools/tool_index.json +++ b/packages/core/src/codewhispererChat/tools/tool_index.json @@ -94,5 +94,39 @@ }, "required": ["path"] } + }, + "grepSearch": { + "name": "grepSearch", + "description": "Fast text-based regex search that finds exact pattern matches within files or directories, utilizing the ripgrep command for efficient searching.\\nResults will be formatted in the style of ripgrep and can be configured to include line numbers and content.\\nTo avoid overwhelming output, the results are capped at 50 matches.\\nUse the include or exclude patterns to filter the search scope by file type or specific paths.\\n\\nThis is best for finding exact text matches or regex patterns.\\nMore precise than semantic search for finding specific strings or patterns.\\nThis is preferred over semantic search when we know the exact symbol/function name/etc. to search in some set of directories/file types.", + "inputSchema": { + "type": "object", + "properties": { + "caseSensitive": { + "description": "Whether the search should be case sensitive", + "type": "boolean" + }, + "excludePattern": { + "description": "Glob pattern for files to exclude", + "type": "string" + }, + "explanation": { + "description": "One sentence explanation as to why this tool is being used, and how it contributes to the goal.", + "type": "string" + }, + "includePattern": { + "description": "Glob pattern for files to include (e.g. '*.ts' for TypeScript files)", + "type": "string" + }, + "query": { + "description": "The regex pattern to search for", + "type": "string" + }, + "path": { + "description": "Absolute path to a directory, e.g., `/repo`.", + "type": "string" + } + }, + "required": ["query"] + } } } diff --git a/packages/core/src/shared/logger/logger.ts b/packages/core/src/shared/logger/logger.ts index 5786799ae99..336e0a80978 100644 --- a/packages/core/src/shared/logger/logger.ts +++ b/packages/core/src/shared/logger/logger.ts @@ -19,6 +19,7 @@ export type LogTopic = | 'fsRead' | 'fsWrite' | 'executeBash' + | 'grepSearch' | 'listDirectory' | 'chatStream' | 'chatHistoryDb' diff --git a/packages/core/src/test/codewhispererChat/tools/grepSearch.test.ts b/packages/core/src/test/codewhispererChat/tools/grepSearch.test.ts new file mode 100644 index 00000000000..50d70cf5060 --- /dev/null +++ b/packages/core/src/test/codewhispererChat/tools/grepSearch.test.ts @@ -0,0 +1,256 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import assert from 'assert' +import sinon from 'sinon' +import { GrepSearch, GrepSearchParams } from '../../../codewhispererChat/tools/grepSearch' +import { ChildProcess } from '../../../shared/utilities/processUtils' +import { Writable } from 'stream' +import { OutputKind } from '../../../codewhispererChat/tools/toolShared' + +describe('GrepSearch', () => { + let sandbox: sinon.SinonSandbox + let mockUpdates: Writable + + beforeEach(() => { + sandbox = sinon.createSandbox() + + // Create a mock Writable stream for updates + mockUpdates = new Writable({ + write: (chunk, encoding, callback) => { + callback() + }, + }) + sandbox.spy(mockUpdates, 'write') + sandbox.spy(mockUpdates, 'end') + }) + + afterEach(() => { + sandbox.restore() + }) + + describe('constructor', () => { + it('should initialize with default values', () => { + const params: GrepSearchParams = { + query: 'test-query', + } + const grepSearch = new GrepSearch(params) + + assert.strictEqual((grepSearch as any).query, 'test-query') + assert.strictEqual((grepSearch as any).caseSensitive, false) + assert.strictEqual((grepSearch as any).excludePattern, undefined) + assert.strictEqual((grepSearch as any).includePattern, undefined) + }) + + it('should initialize with provided values', () => { + const params: GrepSearchParams = { + query: 'test-query', + caseSensitive: true, + excludePattern: '*.log', + includePattern: '*.ts', + } + + const grepSearch = new GrepSearch(params) + + assert.strictEqual((grepSearch as any).query, 'test-query') + assert.strictEqual((grepSearch as any).caseSensitive, true) + assert.strictEqual((grepSearch as any).excludePattern, '*.log') + assert.strictEqual((grepSearch as any).includePattern, '*.ts') + }) + }) + + describe('validate', () => { + it('should throw an error if query is empty', async () => { + const grepSearch = new GrepSearch({ query: '' }) + await assert.rejects(async () => await grepSearch.validate(), /Search query cannot be empty/) + }) + + it('should throw an error if query is only whitespace', async () => { + const grepSearch = new GrepSearch({ query: ' ' }) + + await assert.rejects(async () => await grepSearch.validate(), /Search query cannot be empty/) + }) + + it('should pass validation with valid query', async () => { + const grepSearch = new GrepSearch({ query: 'test-query' }) + await assert.doesNotReject(async () => await grepSearch.validate()) + }) + }) + + describe('queueDescription', () => { + it('should write description to updates stream', () => { + const grepSearch = new GrepSearch({ + query: 'test-query', + }) + + grepSearch.queueDescription(mockUpdates) + + // eslint-disable-next-line @typescript-eslint/unbound-method + sinon.assert.calledWith(mockUpdates.write as sinon.SinonSpy, `Searching for "test-query"`) + // eslint-disable-next-line @typescript-eslint/unbound-method + sinon.assert.calledOnce(mockUpdates.end as sinon.SinonSpy) + }) + }) + + describe('invoke', () => { + let grepSearch: GrepSearch + beforeEach(async () => { + grepSearch = new GrepSearch({ + query: 'test-query', + }) + await grepSearch.validate() + // Setup ChildProcess run method + const mockRun = sandbox.stub() + mockRun.resolves({ stdout: 'search-results', stderr: '' }) + sandbox.stub(ChildProcess.prototype, 'run').callsFake(mockRun) + }) + it('should execute ripgrep and return results', async () => { + const result = await grepSearch.invoke() + assert.deepStrictEqual(result, { + output: { + kind: OutputKind.Text, + content: 'search-results', + }, + }) + }) + it('should write updates to the provided stream', async () => { + const mockStream = new Writable({ + write: (chunk, encoding, callback) => { + callback() + }, + }) + sandbox.spy(mockStream, 'write') + await grepSearch.invoke(mockStream) + // The write should be called in the executeRipgrep method via onStdout + // We can't directly test this since it's called inside the ChildProcess + assert.ok(true) + }) + it('should throw an error if ripgrep execution fails', async () => { + sandbox.restore() + sandbox = sinon.createSandbox() + // Make ChildProcess.run throw an error + sandbox.stub(ChildProcess.prototype, 'run').rejects(new Error('Command failed')) + grepSearch = new GrepSearch({ + query: 'test-query', + }) + await assert.rejects(async () => await grepSearch.invoke(), /Failed to search/) + }) + }) + + describe('executeRipgrep', () => { + beforeEach(() => { + // Setup the run method to return a successful result + const mockRun = sandbox.stub().resolves({ stdout: 'search-results', stderr: '' }) + sandbox.stub(ChildProcess.prototype, 'run').callsFake(mockRun) + }) + + it('should use case insensitive search by default', async () => { + const grepSearch = new GrepSearch({ + query: 'test-query', + }) + // Mock the ChildProcess constructor + // let capturedArgs: string[] = [] + sandbox.stub(global, 'run').callsFake(function (this: any, cmd: string, args: string[], options: any) { + // capturedArgs = args + return { + run: () => Promise.resolve({ stdout: 'search-results', stderr: '' }), + } + } as any) + + // Call the private method using any type assertion + await (grepSearch as any).executeRipgrep() + + // Since we can't directly test the constructor arguments, we'll just verify + // the test completes successfully + assert.ok(true) + }) + + it('should use case sensitive search when specified', async () => { + const grepSearch = new GrepSearch({ + query: 'test-query', + caseSensitive: true, + }) + + // Call the private method using any type assertion + await (grepSearch as any).executeRipgrep() + + // We can't directly verify the arguments, but we can check that the method completed + assert.ok(true) + }) + + it('should add include pattern when specified', async () => { + const grepSearch = new GrepSearch({ + query: 'test-query', + includePattern: '*.ts', + }) + + // Call the private method using any type assertion + await (grepSearch as any).executeRipgrep() + + // We can't directly verify the arguments, but we can check that the method completed + assert.ok(true) + }) + + it('should add exclude pattern when specified', async () => { + const grepSearch = new GrepSearch({ + query: 'test-query', + excludePattern: '*.log', + }) + + // Call the private method using any type assertion + await (grepSearch as any).executeRipgrep() + + // We can't directly verify the arguments, but we can check that the method completed + assert.ok(true) + }) + + it('should handle multiple include patterns', async () => { + const grepSearch = new GrepSearch({ + query: 'test-query', + includePattern: '*.ts, *.js', + }) + + // Call the private method using any type assertion + await (grepSearch as any).executeRipgrep() + + // We can't directly verify the arguments, but we can check that the method completed + assert.ok(true) + }) + + it('should handle multiple exclude patterns', async () => { + const grepSearch = new GrepSearch({ + query: 'test-query', + excludePattern: '*.log, *.tmp', + }) + + // Call the private method using any type assertion + await (grepSearch as any).executeRipgrep() + + // We can't directly verify the arguments, but we can check that the method completed + assert.ok(true) + }) + + it('should handle ripgrep exit code 1 (no matches)', async () => { + // Setup ChildProcess to simulate exit code 1 (no matches found) + const error = new Error() + error.name = 'ChildProcessError' + ;(error as any).code = 1 + + sandbox.restore() + sandbox = sinon.createSandbox() + sandbox.stub(ChildProcess.prototype, 'run').rejects(error) + + const grepSearch = new GrepSearch({ + query: 'no-matches-query', + }) + + // This should not throw an error since code 1 is handled in rejectOnErrorCode + const result = await grepSearch.invoke() + + // Should still return a valid output + assert.deepStrictEqual(result.output.kind, OutputKind.Text) + }) + }) +}) From caf97757f18ebf503de6710d266362db8f1b7c92 Mon Sep 17 00:00:00 2001 From: "Justin M. Keyes" Date: Thu, 10 Apr 2025 21:25:45 -0700 Subject: [PATCH 02/18] fix(amazonq): "Sign in" does not focus Q panel #7015 This was only a potential performance optimization. Remove it since it prevents tryExecute from working with vscode-defined commands (which are not found in `this.resources`). --- packages/core/src/shared/vscode/commands2.ts | 5 ----- 1 file changed, 5 deletions(-) diff --git a/packages/core/src/shared/vscode/commands2.ts b/packages/core/src/shared/vscode/commands2.ts index 342acfdfbac..c55cd66cc7a 100644 --- a/packages/core/src/shared/vscode/commands2.ts +++ b/packages/core/src/shared/vscode/commands2.ts @@ -159,11 +159,6 @@ export class Commands { id: string, ...args: Parameters ): Promise | undefined> { - const cmd = this.resources.get(id) - if (!cmd) { - getLogger().debug('command not found: "%s"', id) - return undefined - } return this.commands.executeCommand>(id, ...args)?.then(undefined, (e: Error) => { getLogger().warn('command failed (not registered?): "%s"', id) return undefined From fc5043675162eb6b8f3311920916bb058abb9ca9 Mon Sep 17 00:00:00 2001 From: aws-toolkit-automation <> Date: Fri, 11 Apr 2025 04:31:02 +0000 Subject: [PATCH 03/18] Release 1.58.0 --- package-lock.json | 4 ++-- packages/amazonq/.changes/1.58.0.json | 22 +++++++++++++++++++ ...-45379b8c-1faa-4b04-951a-26e234c6dc03.json | 4 ---- ...-9d840c3e-80a2-45a0-961f-8441b2339860.json | 4 ---- ...-e8dc0bd0-b232-429c-b16a-f3bf993b19b4.json | 4 ---- ...-ee90e4d2-aa78-403f-8b58-1464c6419778.json | 4 ---- packages/amazonq/CHANGELOG.md | 7 ++++++ packages/amazonq/package.json | 2 +- 8 files changed, 32 insertions(+), 19 deletions(-) create mode 100644 packages/amazonq/.changes/1.58.0.json delete mode 100644 packages/amazonq/.changes/next-release/Bug Fix-45379b8c-1faa-4b04-951a-26e234c6dc03.json delete mode 100644 packages/amazonq/.changes/next-release/Bug Fix-9d840c3e-80a2-45a0-961f-8441b2339860.json delete mode 100644 packages/amazonq/.changes/next-release/Bug Fix-e8dc0bd0-b232-429c-b16a-f3bf993b19b4.json delete mode 100644 packages/amazonq/.changes/next-release/Bug Fix-ee90e4d2-aa78-403f-8b58-1464c6419778.json diff --git a/package-lock.json b/package-lock.json index 9b03dd9119a..05cbd85251e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -44,7 +44,7 @@ "prettier": "^3.3.3", "prettier-plugin-sh": "^0.14.0", "pretty-quick": "^4.0.0", - "ts-node": "^10.9.1", + "ts-node": "^10.9.2", "typescript": "^5.0.4", "webpack": "^5.95.0", "webpack-cli": "^5.1.4", @@ -26696,7 +26696,7 @@ }, "packages/amazonq": { "name": "amazon-q-vscode", - "version": "1.58.0-SNAPSHOT", + "version": "1.58.0", "license": "Apache-2.0", "dependencies": { "aws-core-vscode": "file:../core/" diff --git a/packages/amazonq/.changes/1.58.0.json b/packages/amazonq/.changes/1.58.0.json new file mode 100644 index 00000000000..02395a6dfd8 --- /dev/null +++ b/packages/amazonq/.changes/1.58.0.json @@ -0,0 +1,22 @@ +{ + "date": "2025-04-11", + "version": "1.58.0", + "entries": [ + { + "type": "Bug Fix", + "description": "inline chat activates properly when using 'aws.experiments.amazonqChatLSP' feature flag" + }, + { + "type": "Bug Fix", + "description": "Amazon Q Chat: code blocks in responses flicker, switching tabs during answer streaming makes expand button disappear" + }, + { + "type": "Bug Fix", + "description": "Amazon Q Chat: tab bar buttons disappear when closing non-active tab" + }, + { + "type": "Bug Fix", + "description": "Amazon Q Chat: chat history list does not truncate markdown" + } + ] +} \ No newline at end of file diff --git a/packages/amazonq/.changes/next-release/Bug Fix-45379b8c-1faa-4b04-951a-26e234c6dc03.json b/packages/amazonq/.changes/next-release/Bug Fix-45379b8c-1faa-4b04-951a-26e234c6dc03.json deleted file mode 100644 index e02212b84ca..00000000000 --- a/packages/amazonq/.changes/next-release/Bug Fix-45379b8c-1faa-4b04-951a-26e234c6dc03.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "type": "Bug Fix", - "description": "inline chat activates properly when using 'aws.experiments.amazonqChatLSP' feature flag" -} diff --git a/packages/amazonq/.changes/next-release/Bug Fix-9d840c3e-80a2-45a0-961f-8441b2339860.json b/packages/amazonq/.changes/next-release/Bug Fix-9d840c3e-80a2-45a0-961f-8441b2339860.json deleted file mode 100644 index 5118a1bd777..00000000000 --- a/packages/amazonq/.changes/next-release/Bug Fix-9d840c3e-80a2-45a0-961f-8441b2339860.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "type": "Bug Fix", - "description": "Amazon Q Chat: code blocks in responses flicker, switching tabs during answer streaming makes expand button disappear" -} diff --git a/packages/amazonq/.changes/next-release/Bug Fix-e8dc0bd0-b232-429c-b16a-f3bf993b19b4.json b/packages/amazonq/.changes/next-release/Bug Fix-e8dc0bd0-b232-429c-b16a-f3bf993b19b4.json deleted file mode 100644 index b16dc2c1ac1..00000000000 --- a/packages/amazonq/.changes/next-release/Bug Fix-e8dc0bd0-b232-429c-b16a-f3bf993b19b4.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "type": "Bug Fix", - "description": "Amazon Q Chat: tab bar buttons disappear when closing non-active tab" -} diff --git a/packages/amazonq/.changes/next-release/Bug Fix-ee90e4d2-aa78-403f-8b58-1464c6419778.json b/packages/amazonq/.changes/next-release/Bug Fix-ee90e4d2-aa78-403f-8b58-1464c6419778.json deleted file mode 100644 index 8f327d047c1..00000000000 --- a/packages/amazonq/.changes/next-release/Bug Fix-ee90e4d2-aa78-403f-8b58-1464c6419778.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "type": "Bug Fix", - "description": "Amazon Q Chat: chat history list does not truncate markdown" -} diff --git a/packages/amazonq/CHANGELOG.md b/packages/amazonq/CHANGELOG.md index ac52aa0e297..79c1e2d6c01 100644 --- a/packages/amazonq/CHANGELOG.md +++ b/packages/amazonq/CHANGELOG.md @@ -1,3 +1,10 @@ +## 1.58.0 2025-04-11 + +- **Bug Fix** inline chat activates properly when using 'aws.experiments.amazonqChatLSP' feature flag +- **Bug Fix** Amazon Q Chat: code blocks in responses flicker, switching tabs during answer streaming makes expand button disappear +- **Bug Fix** Amazon Q Chat: tab bar buttons disappear when closing non-active tab +- **Bug Fix** Amazon Q Chat: chat history list does not truncate markdown + ## 1.57.0 2025-04-10 - **Bug Fix** Fix bug where generate fix does not work diff --git a/packages/amazonq/package.json b/packages/amazonq/package.json index ec26ba1b56c..2a2c0085f49 100644 --- a/packages/amazonq/package.json +++ b/packages/amazonq/package.json @@ -2,7 +2,7 @@ "name": "amazon-q-vscode", "displayName": "Amazon Q", "description": "The most capable generative AI-powered assistant for building, operating, and transforming software, with advanced capabilities for managing data and AI", - "version": "1.58.0-SNAPSHOT", + "version": "1.58.0", "extensionKind": [ "workspace" ], From f1dded7edcf7d4d95410f31cfb87da208e91fb27 Mon Sep 17 00:00:00 2001 From: aws-toolkit-automation <> Date: Fri, 11 Apr 2025 04:49:41 +0000 Subject: [PATCH 04/18] Update version to snapshot version: 1.59.0-SNAPSHOT --- package-lock.json | 4 ++-- packages/amazonq/package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 05cbd85251e..2a0d079f189 100644 --- a/package-lock.json +++ b/package-lock.json @@ -44,7 +44,7 @@ "prettier": "^3.3.3", "prettier-plugin-sh": "^0.14.0", "pretty-quick": "^4.0.0", - "ts-node": "^10.9.2", + "ts-node": "^10.9.1", "typescript": "^5.0.4", "webpack": "^5.95.0", "webpack-cli": "^5.1.4", @@ -26696,7 +26696,7 @@ }, "packages/amazonq": { "name": "amazon-q-vscode", - "version": "1.58.0", + "version": "1.59.0-SNAPSHOT", "license": "Apache-2.0", "dependencies": { "aws-core-vscode": "file:../core/" diff --git a/packages/amazonq/package.json b/packages/amazonq/package.json index 2a2c0085f49..1ad0c724725 100644 --- a/packages/amazonq/package.json +++ b/packages/amazonq/package.json @@ -2,7 +2,7 @@ "name": "amazon-q-vscode", "displayName": "Amazon Q", "description": "The most capable generative AI-powered assistant for building, operating, and transforming software, with advanced capabilities for managing data and AI", - "version": "1.58.0", + "version": "1.59.0-SNAPSHOT", "extensionKind": [ "workspace" ], From 4c0d0982688155c3d15e0a7e8b75a5d434302a64 Mon Sep 17 00:00:00 2001 From: "Justin M. Keyes" Date: Fri, 11 Apr 2025 10:46:25 -0700 Subject: [PATCH 05/18] fix(childprocess): noisy "memory threshold" logs #7017 ## Problem "memory threshold" message logged multiple times for a the same PID: 2025-04-11 ... Process 23662 exceeded memory threshold: 506986496 2025-04-11 ... Process 23662 exceeded memory threshold: 507019264 2025-04-11 ... Process 23662 exceeded memory threshold: 507052032 2025-04-11 ... Process 23662 exceeded memory threshold: 507084800 This is noisy in the logs. ## Solution Only log "memory threshold" once per PID. --- .../src/shared/utilities/collectionUtils.ts | 27 +++++++++++++++++++ .../core/src/shared/utilities/processUtils.ts | 17 +++++++++--- .../shared/utilities/processUtils.test.ts | 5 ++++ 3 files changed, 45 insertions(+), 4 deletions(-) diff --git a/packages/core/src/shared/utilities/collectionUtils.ts b/packages/core/src/shared/utilities/collectionUtils.ts index f723e0096cc..9f9fe9875b9 100644 --- a/packages/core/src/shared/utilities/collectionUtils.ts +++ b/packages/core/src/shared/utilities/collectionUtils.ts @@ -566,3 +566,30 @@ export function createCollectionFromPages(...pages: T[]): AsyncCollection export function isPresent(value: T | undefined): value is T { return value !== undefined } + +export class CircularBuffer { + private buffer = new Set() + private maxSize: number + + constructor(size: number) { + this.maxSize = size + } + + add(value: number): void { + if (this.buffer.size >= this.maxSize) { + // Set iterates its keys in insertion-order. + // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Set + const firstKey = this.buffer.keys().next().value + this.buffer.delete(firstKey) + } + this.buffer.add(value) + } + + contains(value: number): boolean { + return this.buffer.has(value) + } + + clear(): void { + this.buffer.clear() + } +} diff --git a/packages/core/src/shared/utilities/processUtils.ts b/packages/core/src/shared/utilities/processUtils.ts index 25af4418e1e..31f2ec238f3 100644 --- a/packages/core/src/shared/utilities/processUtils.ts +++ b/packages/core/src/shared/utilities/processUtils.ts @@ -8,6 +8,7 @@ import * as crossSpawn from 'cross-spawn' import * as logger from '../logger/logger' import { Timeout, CancellationError, waitUntil } from './timeoutUtils' import { PollingSet } from './pollingSet' +import { CircularBuffer } from './collectionUtils' export interface RunParameterContext { /** Reports an error parsed from the stdin/stdout streams. */ @@ -73,6 +74,7 @@ export class ChildProcessTracker { cpu: 50, } static readonly logger = logger.getLogger('childProcess') + static readonly loggedPids = new CircularBuffer(1000) #processByPid: Map = new Map() #pids: PollingSet @@ -100,21 +102,28 @@ export class ChildProcessTracker { private async checkProcessUsage(pid: number): Promise { if (!this.#pids.has(pid)) { - ChildProcessTracker.logger.warn(`Missing process with id ${pid}`) + ChildProcessTracker.logOnce(pid, `Missing process with id ${pid}`) return } const stats = this.getUsage(pid) if (stats) { ChildProcessTracker.logger.debug(`Process ${pid} usage: %O`, stats) if (stats.memory > ChildProcessTracker.thresholds.memory) { - ChildProcessTracker.logger.warn(`Process ${pid} exceeded memory threshold: ${stats.memory}`) + ChildProcessTracker.logOnce(pid, `Process ${pid} exceeded memory threshold: ${stats.memory}`) } if (stats.cpu > ChildProcessTracker.thresholds.cpu) { - ChildProcessTracker.logger.warn(`Process ${pid} exceeded cpu threshold: ${stats.cpu}`) + ChildProcessTracker.logOnce(pid, `Process ${pid} exceeded cpu threshold: ${stats.cpu}`) } } } + public static logOnce(pid: number, msg: string) { + if (!ChildProcessTracker.loggedPids.contains(pid)) { + ChildProcessTracker.loggedPids.add(pid) + ChildProcessTracker.logger.warn(msg) + } + } + public add(childProcess: ChildProcess) { const pid = childProcess.pid() this.#processByPid.set(pid, childProcess) @@ -147,7 +156,7 @@ export class ChildProcessTracker { // isWin() leads to circular dependency. return process.platform === 'win32' ? getWindowsUsage() : getUnixUsage() } catch (e) { - ChildProcessTracker.logger.warn(`Failed to get process stats for ${pid}: ${e}`) + ChildProcessTracker.logOnce(pid, `Failed to get process stats for ${pid}: ${e}`) return { cpu: 0, memory: 0 } } diff --git a/packages/core/src/test/shared/utilities/processUtils.test.ts b/packages/core/src/test/shared/utilities/processUtils.test.ts index 0e6f474ed10..436ac48ecc4 100644 --- a/packages/core/src/test/shared/utilities/processUtils.test.ts +++ b/packages/core/src/test/shared/utilities/processUtils.test.ts @@ -393,6 +393,10 @@ describe('ChildProcessTracker', function () { usageMock = sinon.stub(ChildProcessTracker.prototype, 'getUsage') }) + beforeEach(function () { + ChildProcessTracker.loggedPids.clear() + }) + afterEach(function () { tracker.clear() usageMock.reset() @@ -463,6 +467,7 @@ describe('ChildProcessTracker', function () { await clock.tickAsync(ChildProcessTracker.pollingInterval) assertLogsContain('exceeded cpu threshold', false, 'warn') + ChildProcessTracker.loggedPids.clear() usageMock.returns(highMemory) await clock.tickAsync(ChildProcessTracker.pollingInterval) assertLogsContain('exceeded memory threshold', false, 'warn') From 5af2aa9008bdfe9b95807b217c253a7705c75fd9 Mon Sep 17 00:00:00 2001 From: Tai Lai Date: Thu, 10 Apr 2025 16:00:54 -0700 Subject: [PATCH 06/18] feat(chat): add directive messages (#7010) ## Problem Missing the following UI elements: - Pair programmer mode toggle message - ExecuteBash confirmation direction ## Solution Make use of the new directive message type introduced in https://github.com/aws/mynah-ui/pull/273 --- - Treat all work as PUBLIC. Private `feature/x` branches will not be squash-merged at release time. - Your code changes must meet the guidelines in [CONTRIBUTING.md](https://github.com/aws/aws-toolkit-vscode/blob/master/CONTRIBUTING.md#guidelines). - License: I confirm that my contribution is made under the terms of the Apache 2.0 license. --- packages/core/package.nls.json | 3 +++ .../src/amazonq/webview/ui/tabs/generator.ts | 2 +- .../controllers/chat/controller.ts | 11 +++++++++- .../controllers/chat/messenger/messenger.ts | 20 +++++++++++++++++++ .../src/codewhispererChat/tools/chatStream.ts | 8 ++++++++ .../view/connector/connector.ts | 2 +- 6 files changed, 43 insertions(+), 3 deletions(-) diff --git a/packages/core/package.nls.json b/packages/core/package.nls.json index dd3bca4df0b..a17953ea252 100644 --- a/packages/core/package.nls.json +++ b/packages/core/package.nls.json @@ -457,6 +457,9 @@ "AWS.amazonq.opensettings:": "Open settings", "AWS.amazonq.executeBash.run": "Run", "AWS.amazonq.executeBash.reject": "Reject", + "AWS.amazonq.chat.directive.pairProgrammingModeOn": "You are using **pair programming mode**: Q can now list files, preview code diffs and allow you to run shell commands.", + "AWS.amazonq.chat.directive.pairProgrammingModeOff": "You turned off **pair programming mode**. Q will not include code diffs or run commands in the chat.", + "AWS.amazonq.chat.directive.runCommandToProceed": "Run the command to proceed.", "AWS.toolkit.lambda.walkthrough.quickpickTitle": "Application Builder Walkthrough", "AWS.toolkit.lambda.walkthrough.title": "Get started building your application", "AWS.toolkit.lambda.walkthrough.description": "Your quick guide to build an application visually, iterate locally, and deploy to the cloud!", diff --git a/packages/core/src/amazonq/webview/ui/tabs/generator.ts b/packages/core/src/amazonq/webview/ui/tabs/generator.ts index a551b76bcd7..dfb98f23590 100644 --- a/packages/core/src/amazonq/webview/ui/tabs/generator.ts +++ b/packages/core/src/amazonq/webview/ui/tabs/generator.ts @@ -94,7 +94,7 @@ export class TabDataGenerator { type: 'toggle', id: 'prompt-type', value: 'pair-programming-on', - tooltip: 'Pair programmar on', + tooltip: 'Pair programmer on', options: [ { value: 'pair-programming-on', diff --git a/packages/core/src/codewhispererChat/controllers/chat/controller.ts b/packages/core/src/codewhispererChat/controllers/chat/controller.ts index 47abd38dceb..c1814ac047c 100644 --- a/packages/core/src/codewhispererChat/controllers/chat/controller.ts +++ b/packages/core/src/codewhispererChat/controllers/chat/controller.ts @@ -893,10 +893,19 @@ export class ChatController { private async processPromptInputOptionChange(message: PromptInputOptionChange) { const session = this.sessionStorage.getSession(message.tabID) const promptTypeValue = message.optionsValues['prompt-type'] - // TODO: display message: You turned off pair programmer mode. Q will not include code diffs or run commands in the chat. if (promptTypeValue === 'pair-programming-on') { session.setPairProgrammingModeOn(true) + this.messenger.sendDirectiveMessage( + message.tabID, + promptTypeValue, + i18n('AWS.amazonq.chat.directive.pairProgrammingModeOn') + ) } else { + this.messenger.sendDirectiveMessage( + message.tabID, + promptTypeValue, + i18n('AWS.amazonq.chat.directive.pairProgrammingModeOff') + ) session.setPairProgrammingModeOn(false) } } diff --git a/packages/core/src/codewhispererChat/controllers/chat/messenger/messenger.ts b/packages/core/src/codewhispererChat/controllers/chat/messenger/messenger.ts index 818f4f961df..f9d48887579 100644 --- a/packages/core/src/codewhispererChat/controllers/chat/messenger/messenger.ts +++ b/packages/core/src/codewhispererChat/controllers/chat/messenger/messenger.ts @@ -875,4 +875,24 @@ export class Messenger { ) ) } + + public sendDirectiveMessage(tabID: string, triggerID: string, message: string) { + this.dispatcher.sendChatMessage( + new ChatMessage( + { + message, + messageType: 'directive', + followUps: undefined, + followUpsHeader: undefined, + relatedSuggestions: undefined, + triggerID, + messageID: '', + userIntent: undefined, + codeBlockLanguage: undefined, + contextList: undefined, + }, + tabID + ) + ) + } } diff --git a/packages/core/src/codewhispererChat/tools/chatStream.ts b/packages/core/src/codewhispererChat/tools/chatStream.ts index 7e6e4d3ae3a..83a7935bf05 100644 --- a/packages/core/src/codewhispererChat/tools/chatStream.ts +++ b/packages/core/src/codewhispererChat/tools/chatStream.ts @@ -9,6 +9,7 @@ import { Messenger } from '../controllers/chat/messenger/messenger' import { ToolUse } from '@amzn/codewhisperer-streaming' import { CommandValidation } from './executeBash' import { Change } from 'diff' +import { i18n } from '../../shared/i18n-helper' /** * A writable stream that feeds each chunk/line to the chat UI. @@ -28,6 +29,13 @@ export class ChatStream extends Writable { ) { super() this.logger.debug(`ChatStream created for tabID: ${tabID}, triggerID: ${triggerID}`) + if (validation.requiresAcceptance) { + this.messenger.sendDirectiveMessage( + tabID, + triggerID, + i18n('AWS.amazonq.chat.directive.runCommandToProceed') + ) + } this.messenger.sendInitalStream(tabID, triggerID) } diff --git a/packages/core/src/codewhispererChat/view/connector/connector.ts b/packages/core/src/codewhispererChat/view/connector/connector.ts index 6f04d09cd46..1f10eeb3134 100644 --- a/packages/core/src/codewhispererChat/view/connector/connector.ts +++ b/packages/core/src/codewhispererChat/view/connector/connector.ts @@ -113,7 +113,7 @@ export class SearchView extends UiMessage { override type = 'drawNewSearchViewState' } -export type ChatMessageType = 'answer-stream' | 'answer-part' | 'answer' +export type ChatMessageType = 'answer-stream' | 'answer-part' | 'answer' | 'directive' export interface CodeReference { licenseName?: string From befd2117ca74190d8e2fb44b8e538cd77b59530d Mon Sep 17 00:00:00 2001 From: Jason Guo <81202082+jguoamz@users.noreply.github.com> Date: Thu, 10 Apr 2025 16:01:24 -0700 Subject: [PATCH 07/18] fix(chat): Set valid tooluse Input when parsing fails (#7011) ## Problem - Set valid tooluse Input when parsing fails ## Solution - Server side cannot handle the raw string --- - Treat all work as PUBLIC. Private `feature/x` branches will not be squash-merged at release time. - Your code changes must meet the guidelines in [CONTRIBUTING.md](https://github.com/aws/aws-toolkit-vscode/blob/master/CONTRIBUTING.md#guidelines). - License: I confirm that my contribution is made under the terms of the Apache 2.0 license. --- .../codewhispererChat/controllers/chat/messenger/messenger.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/core/src/codewhispererChat/controllers/chat/messenger/messenger.ts b/packages/core/src/codewhispererChat/controllers/chat/messenger/messenger.ts index f9d48887579..8a7de87cc6b 100644 --- a/packages/core/src/codewhispererChat/controllers/chat/messenger/messenger.ts +++ b/packages/core/src/codewhispererChat/controllers/chat/messenger/messenger.ts @@ -269,8 +269,8 @@ export class Messenger { toolUse.input = JSON.parse(toolUseInput) } catch (error: any) { getLogger().error(`JSON parse error for toolUseInput: ${toolUseInput}`) - // set toolUse.input to the raw value - toolUse.input = toolUseInput + // set toolUse.input to be empty valid json object + toolUse.input = {} error.message = `Tool input has invalid JSON format: ${error.message}` // throw it out to allow the error to be handled in the catch block throw error From c3ab521e40cf996deba0454537a562f9e845d525 Mon Sep 17 00:00:00 2001 From: Boyu Date: Thu, 10 Apr 2025 16:55:48 -0700 Subject: [PATCH 08/18] feat(chat): Add validation to fileRead, ListDir and ExecBash tools (#7008) We should ask for consensus tools accessing files/dirs outside of workspace Add validation step when accessing files/dirs outside of workspace using fsRead or listDirectories tools. --- - Treat all work as PUBLIC. Private `feature/x` branches will not be squash-merged at release time. - Your code changes must meet the guidelines in [CONTRIBUTING.md](https://github.com/aws/aws-toolkit-vscode/blob/master/CONTRIBUTING.md#guidelines). - License: I confirm that my contribution is made under the terms of the Apache 2.0 license. --- .../webview/ui/apps/cwChatConnector.ts | 31 ++++++++++- .../controllers/chat/controller.ts | 9 +++- .../controllers/chat/messenger/messenger.ts | 26 +++++++++- .../codewhispererChat/tools/executeBash.ts | 28 ++++++++++ .../src/codewhispererChat/tools/fsRead.ts | 15 +++++- .../codewhispererChat/tools/listDirectory.ts | 15 +++++- .../src/codewhispererChat/tools/toolShared.ts | 5 ++ .../src/codewhispererChat/tools/toolUtils.ts | 6 ++- packages/core/src/shared/localizedText.ts | 2 + .../tools/executeBash.test.ts | 52 +++++++++++++++++++ .../codewhispererChat/tools/fsRead.test.ts | 34 ++++++++++++ .../tools/listDirectory.test.ts | 34 ++++++++++++ .../tools/toolShared.test.ts | 2 + 13 files changed, 252 insertions(+), 7 deletions(-) diff --git a/packages/core/src/amazonq/webview/ui/apps/cwChatConnector.ts b/packages/core/src/amazonq/webview/ui/apps/cwChatConnector.ts index b52d913e7a2..d89d4f65c14 100644 --- a/packages/core/src/amazonq/webview/ui/apps/cwChatConnector.ts +++ b/packages/core/src/amazonq/webview/ui/apps/cwChatConnector.ts @@ -323,7 +323,14 @@ export class Connector extends BaseConnector { if ( !this.onChatAnswerUpdated || - !['accept-code-diff', 'reject-code-diff', 'run-shell-command', 'reject-shell-command'].includes(action.id) + ![ + 'accept-code-diff', + 'reject-code-diff', + 'run-shell-command', + 'reject-shell-command', + 'confirm-tool-use', + 'reject-tool-use', + ].includes(action.id) ) { return } @@ -381,6 +388,28 @@ export class Connector extends BaseConnector { }, } break + case 'confirm-tool-use': + answer.header = { + icon: 'shell' as MynahIconsType, + body: 'shell', + status: { + icon: 'ok' as MynahIconsType, + text: 'Accepted', + status: 'success', + }, + } + break + case 'reject-tool-use': + answer.header = { + icon: 'shell' as MynahIconsType, + body: 'shell', + status: { + icon: 'cancel' as MynahIconsType, + text: 'Rejected', + status: 'error', + }, + } + break default: break } diff --git a/packages/core/src/codewhispererChat/controllers/chat/controller.ts b/packages/core/src/codewhispererChat/controllers/chat/controller.ts index c1814ac047c..8b1f733d0e2 100644 --- a/packages/core/src/codewhispererChat/controllers/chat/controller.ts +++ b/packages/core/src/codewhispererChat/controllers/chat/controller.ts @@ -827,7 +827,12 @@ export class ChatController { const session = this.sessionStorage.getSession(message.tabID!) const currentToolUse = session.toolUseWithError?.toolUse - if (currentToolUse && currentToolUse.name === ToolType.ExecuteBash) { + if ( + currentToolUse && + (currentToolUse.name === ToolType.ExecuteBash || + currentToolUse.name === ToolType.FsRead || + currentToolUse.name === ToolType.ListDirectory) + ) { session.toolUseWithError.error = new Error('Tool use was rejected by the user.') } else { getLogger().error( @@ -843,6 +848,7 @@ export class ChatController { break case 'run-shell-command': case 'generic-tool-execution': + case 'confirm-tool-use': await this.processToolUseMessage(message) if (message.action.id === 'run-shell-command' && message.action.text === 'Run') { this.telemetryHelper.recordInteractionWithAgenticChat( @@ -860,6 +866,7 @@ export class ChatController { this.telemetryHelper.recordInteractionWithAgenticChat(AgenticChatInteractionType.RejectDiff, message) break case 'reject-shell-command': + case 'reject-tool-use': await this.rejectShellCommand(message) await this.processToolUseMessage(message) break diff --git a/packages/core/src/codewhispererChat/controllers/chat/messenger/messenger.ts b/packages/core/src/codewhispererChat/controllers/chat/messenger/messenger.ts index 8a7de87cc6b..f8306b00874 100644 --- a/packages/core/src/codewhispererChat/controllers/chat/messenger/messenger.ts +++ b/packages/core/src/codewhispererChat/controllers/chat/messenger/messenger.ts @@ -573,13 +573,13 @@ export class Messenger { const buttons: ChatItemButton[] = [ { id: 'reject-shell-command', - text: localize('AWS.amazonq.executeBash.reject', 'Reject'), + text: localize('AWS.generic.reject', 'Reject'), status: 'clear', icon: 'cancel' as MynahIconsType, }, { id: 'run-shell-command', - text: localize('AWS.amazonq.executeBash.run', 'Run'), + text: localize('AWS.generic.run', 'Run'), status: 'clear', icon: 'play' as MynahIconsType, }, @@ -624,6 +624,28 @@ export class Messenger { buttons, fileList, } + } else if (toolUse?.name === ToolType.ListDirectory || toolUse?.name === ToolType.FsRead) { + if (validation.requiresAcceptance) { + const buttons: ChatItemButton[] = [ + { + id: 'confirm-tool-use', + text: localize('AWS.generic.run', 'Run'), + status: 'main', + icon: 'play' as MynahIconsType, + }, + { + id: 'reject-tool-use', + text: localize('AWS.generic.reject', 'Reject'), + status: 'clear', + icon: 'cancel' as MynahIconsType, + }, + ] + header = { + icon: 'shell' as MynahIconsType, + body: 'shell', + buttons, + } + } } this.dispatcher.sendChatMessage( diff --git a/packages/core/src/codewhispererChat/tools/executeBash.ts b/packages/core/src/codewhispererChat/tools/executeBash.ts index 605d6bbeb8f..4e9b946c448 100644 --- a/packages/core/src/codewhispererChat/tools/executeBash.ts +++ b/packages/core/src/codewhispererChat/tools/executeBash.ts @@ -9,6 +9,9 @@ import { fs } from '../../shared/fs/fs' import { ChildProcess, ChildProcessOptions } from '../../shared/utilities/processUtils' import { InvokeOutput, OutputKind, sanitizePath } from './toolShared' import { split } from 'shlex' +import path from 'path' +import * as vscode from 'vscode' +import { isInDirectory } from '../../shared/filesystemUtilities' export enum CommandCategory { ReadOnly, @@ -185,6 +188,27 @@ export class ExecuteBash { return { requiresAcceptance: true } } + // For each command, validate arguments for path safety within workspace + for (const arg of cmdArgs) { + if (this.looksLikePath(arg)) { + // If not absolute, resolve using workingDirectory if available. + let fullPath = arg + if (!path.isAbsolute(arg) && this.workingDirectory) { + fullPath = path.join(this.workingDirectory, arg) + } + const workspaceFolders = vscode.workspace.workspaceFolders + if (!workspaceFolders || workspaceFolders.length === 0) { + return { requiresAcceptance: true, warning: destructiveCommandWarningMessage } + } + const isInWorkspace = workspaceFolders.some((folder) => + isInDirectory(folder.uri.fsPath, fullPath) + ) + if (!isInWorkspace) { + return { requiresAcceptance: true, warning: destructiveCommandWarningMessage } + } + } + } + const command = cmdArgs[0] const category = commandCategories.get(command) @@ -371,4 +395,8 @@ export class ExecuteBash { updates.write('```shell\n' + this.command + '\n```') updates.end() } + + private looksLikePath(arg: string): boolean { + return arg.startsWith('/') || arg.startsWith('./') || arg.startsWith('../') + } } diff --git a/packages/core/src/codewhispererChat/tools/fsRead.ts b/packages/core/src/codewhispererChat/tools/fsRead.ts index 05519641bd0..5dfc30d26f8 100644 --- a/packages/core/src/codewhispererChat/tools/fsRead.ts +++ b/packages/core/src/codewhispererChat/tools/fsRead.ts @@ -5,9 +5,10 @@ import * as vscode from 'vscode' import { getLogger } from '../../shared/logger/logger' import fs from '../../shared/fs/fs' -import { InvokeOutput, OutputKind, sanitizePath } from './toolShared' import { Writable } from 'stream' import path from 'path' +import { InvokeOutput, OutputKind, sanitizePath, CommandValidation } from './toolShared' +import { isInDirectory } from '../../shared/filesystemUtilities' export interface FsReadParams { path: string @@ -68,6 +69,18 @@ export class FsRead { updates.end() } + public requiresAcceptance(): CommandValidation { + const workspaceFolders = vscode.workspace.workspaceFolders + if (!workspaceFolders || workspaceFolders.length === 0) { + return { requiresAcceptance: true } + } + const isInWorkspace = workspaceFolders.some((folder) => isInDirectory(folder.uri.fsPath, this.fsPath)) + if (!isInWorkspace) { + return { requiresAcceptance: true } + } + return { requiresAcceptance: false } + } + public async invoke(updates?: Writable): Promise { try { const fileUri = vscode.Uri.file(this.fsPath) diff --git a/packages/core/src/codewhispererChat/tools/listDirectory.ts b/packages/core/src/codewhispererChat/tools/listDirectory.ts index 96ac6972bdc..7a15bd4be8f 100644 --- a/packages/core/src/codewhispererChat/tools/listDirectory.ts +++ b/packages/core/src/codewhispererChat/tools/listDirectory.ts @@ -6,9 +6,10 @@ import * as vscode from 'vscode' import { getLogger } from '../../shared/logger/logger' import { readDirectoryRecursively } from '../../shared/utilities/workspaceUtils' import fs from '../../shared/fs/fs' -import { InvokeOutput, OutputKind, sanitizePath } from './toolShared' import { Writable } from 'stream' import path from 'path' +import { InvokeOutput, OutputKind, sanitizePath, CommandValidation } from './toolShared' +import { isInDirectory } from '../../shared/filesystemUtilities' export interface ListDirectoryParams { path: string @@ -61,6 +62,18 @@ export class ListDirectory { updates.end() } + public requiresAcceptance(): CommandValidation { + const workspaceFolders = vscode.workspace.workspaceFolders + if (!workspaceFolders || workspaceFolders.length === 0) { + return { requiresAcceptance: true } + } + const isInWorkspace = workspaceFolders.some((folder) => isInDirectory(folder.uri.fsPath, this.fsPath)) + if (!isInWorkspace) { + return { requiresAcceptance: true } + } + return { requiresAcceptance: false } + } + public async invoke(updates?: Writable): Promise { try { const fileUri = vscode.Uri.file(this.fsPath) diff --git a/packages/core/src/codewhispererChat/tools/toolShared.ts b/packages/core/src/codewhispererChat/tools/toolShared.ts index 0fc349bde3b..a7d9a29a311 100644 --- a/packages/core/src/codewhispererChat/tools/toolShared.ts +++ b/packages/core/src/codewhispererChat/tools/toolShared.ts @@ -32,3 +32,8 @@ export function sanitizePath(inputPath: string): string { } return sanitized } + +export interface CommandValidation { + requiresAcceptance: boolean + warning?: string +} diff --git a/packages/core/src/codewhispererChat/tools/toolUtils.ts b/packages/core/src/codewhispererChat/tools/toolUtils.ts index 4630bdb3439..b118b8fd4a6 100644 --- a/packages/core/src/codewhispererChat/tools/toolUtils.ts +++ b/packages/core/src/codewhispererChat/tools/toolUtils.ts @@ -45,15 +45,19 @@ export class ToolUtils { static requiresAcceptance(tool: Tool): CommandValidation { switch (tool.type) { case ToolType.FsRead: - return { requiresAcceptance: false } + return tool.tool.requiresAcceptance() case ToolType.FsWrite: return { requiresAcceptance: false } case ToolType.ExecuteBash: return tool.tool.requiresAcceptance() case ToolType.ListDirectory: +<<<<<<< HEAD return { requiresAcceptance: false } case ToolType.GrepSearch: return { requiresAcceptance: false } +======= + return tool.tool.requiresAcceptance() +>>>>>>> 4e0659cbc (feat(chat): Add validation to fileRead, ListDir and ExecBash tools (#7008)) } } diff --git a/packages/core/src/shared/localizedText.ts b/packages/core/src/shared/localizedText.ts index 9bd727fb1bd..c2acf092915 100644 --- a/packages/core/src/shared/localizedText.ts +++ b/packages/core/src/shared/localizedText.ts @@ -18,6 +18,8 @@ export const invalidArn = localize('AWS.error.invalidArn', 'Invalid ARN') export const localizedDelete = localize('AWS.generic.delete', 'Delete') export const cancel = localize('AWS.generic.cancel', 'Cancel') export const help = localize('AWS.generic.help', 'Help') +export const run = localize('AWS.generic.run', 'Run') +export const reject = localize('AWS.generic.reject', 'Reject') export const invalidNumberWarning = localize('AWS.validateTime.error.invalidNumber', 'Input must be a positive number') export const viewDocs = localize('AWS.generic.viewDocs', 'View Documentation') export const recentlyUsed = localize('AWS.generic.recentlyUsed', 'recently used') diff --git a/packages/core/src/test/codewhispererChat/tools/executeBash.test.ts b/packages/core/src/test/codewhispererChat/tools/executeBash.test.ts index b8c4baf9439..54dfb3f02a0 100644 --- a/packages/core/src/test/codewhispererChat/tools/executeBash.test.ts +++ b/packages/core/src/test/codewhispererChat/tools/executeBash.test.ts @@ -7,6 +7,7 @@ import { strict as assert } from 'assert' import sinon from 'sinon' import { destructiveCommandWarningMessage, ExecuteBash } from '../../../codewhispererChat/tools/executeBash' import { ChildProcess } from '../../../shared/utilities/processUtils' +import * as vscode from 'vscode' describe('ExecuteBash Tool', () => { let runStub: sinon.SinonStub @@ -114,4 +115,55 @@ describe('ExecuteBash Tool', () => { assert.strictEqual(invokeStub.callCount, 1) }) + + it('requires acceptance if the command references an absolute file path outside the workspace', () => { + // Stub workspace folders to simulate a workspace at '/workspace/folder' + const workspaceStub = sinon + .stub(vscode.workspace, 'workspaceFolders') + .value([{ uri: { fsPath: '/workspace/folder' } } as any]) + // Command references an absolute path outside the workspace + const execBash = new ExecuteBash({ command: 'cat /not/in/workspace/file.txt', cwd: '/workspace/folder' }) + const result = execBash.requiresAcceptance() + + assert.equal( + result.requiresAcceptance, + true, + 'Should require acceptance for an absolute path outside of workspace' + ) + workspaceStub.restore() + }) + + it('does NOT require acceptance if the command references a relative file path inside the workspace', () => { + // Stub workspace folders to simulate a workspace at '/workspace/folder' + const workspaceStub = sinon + .stub(vscode.workspace, 'workspaceFolders') + .value([{ uri: { fsPath: '/workspace/folder' } } as any]) + + // Command references a relative path that resolves within the workspace + const execBash = new ExecuteBash({ command: 'cat ./file.txt', cwd: '/workspace/folder' }) + const result = execBash.requiresAcceptance() + + assert.equal(result.requiresAcceptance, false, 'Relative path inside workspace should not require acceptance') + + workspaceStub.restore() + }) + + it('does NOT require acceptance if there is no path-like token in the command', () => { + // Stub workspace folders (even though they are not used in this case) + const workspaceStub = sinon + .stub(vscode.workspace, 'workspaceFolders') + .value([{ uri: { fsPath: '/workspace/folder' } } as any]) + + // Command with tokens that do not look like file paths + const execBash = new ExecuteBash({ command: 'echo hello world', cwd: '/workspace/folder' }) + const result = execBash.requiresAcceptance() + + assert.equal( + result.requiresAcceptance, + false, + 'A command without any path-like token should not require acceptance' + ) + + workspaceStub.restore() + }) }) diff --git a/packages/core/src/test/codewhispererChat/tools/fsRead.test.ts b/packages/core/src/test/codewhispererChat/tools/fsRead.test.ts index 985c1a86a05..568c2314ee0 100644 --- a/packages/core/src/test/codewhispererChat/tools/fsRead.test.ts +++ b/packages/core/src/test/codewhispererChat/tools/fsRead.test.ts @@ -6,6 +6,8 @@ import assert from 'assert' import { FsRead } from '../../../codewhispererChat/tools/fsRead' import { TestFolder } from '../../testUtil' import path from 'path' +import * as vscode from 'vscode' +import sinon from 'sinon' describe('FsRead Tool', () => { let testFolder: TestFolder @@ -14,6 +16,10 @@ describe('FsRead Tool', () => { testFolder = await TestFolder.create() }) + afterEach(() => { + sinon.restore() + }) + it('throws if path is empty', async () => { const fsRead = new FsRead({ path: '' }) await assert.rejects(fsRead.validate(), /Path cannot be empty/i, 'Expected an error about empty path') @@ -63,4 +69,32 @@ describe('FsRead Tool', () => { assert.strictEqual(result.output.kind, 'text') assert.strictEqual(result.output.content, '') }) + + it('should require acceptance if fsPath is outside the workspace', () => { + const workspaceStub = sinon + .stub(vscode.workspace, 'workspaceFolders') + .value([{ uri: { fsPath: '/workspace/folder' } } as any]) + const fsRead = new FsRead({ path: '/not/in/workspace/file.txt' }) + const result = fsRead.requiresAcceptance() + assert.equal( + result.requiresAcceptance, + true, + 'Expected requiresAcceptance to be true for a path outside the workspace' + ) + workspaceStub.restore() + }) + + it('should not require acceptance if fsPath is inside the workspace', () => { + const workspaceStub = sinon + .stub(vscode.workspace, 'workspaceFolders') + .value([{ uri: { fsPath: '/workspace/folder' } } as any]) + const fsRead = new FsRead({ path: '/workspace/folder/file.txt' }) + const result = fsRead.requiresAcceptance() + assert.equal( + result.requiresAcceptance, + false, + 'Expected requiresAcceptance to be false for a path inside the workspace' + ) + workspaceStub.restore() + }) }) diff --git a/packages/core/src/test/codewhispererChat/tools/listDirectory.test.ts b/packages/core/src/test/codewhispererChat/tools/listDirectory.test.ts index 6cab7d005b8..19642f36f39 100644 --- a/packages/core/src/test/codewhispererChat/tools/listDirectory.test.ts +++ b/packages/core/src/test/codewhispererChat/tools/listDirectory.test.ts @@ -6,6 +6,8 @@ import assert from 'assert' import { ListDirectory } from '../../../codewhispererChat/tools/listDirectory' import { TestFolder } from '../../testUtil' import path from 'path' +import * as vscode from 'vscode' +import sinon from 'sinon' describe('ListDirectory Tool', () => { let testFolder: TestFolder @@ -14,6 +16,10 @@ describe('ListDirectory Tool', () => { testFolder = await TestFolder.create() }) + afterEach(() => { + sinon.restore() + }) + it('throws if path is empty', async () => { const listDirectory = new ListDirectory({ path: '', maxDepth: 0 }) await assert.rejects(listDirectory.validate(), /Path cannot be empty/i, 'Expected an error about empty path') @@ -86,4 +92,32 @@ describe('ListDirectory Tool', () => { assert.strictEqual(result.output.kind, 'text') assert.ok(result.output.content.length > 0) }) + + it('should require acceptance if fsPath is outside the workspace', () => { + const workspaceStub = sinon + .stub(vscode.workspace, 'workspaceFolders') + .value([{ uri: { fsPath: '/workspace/folder' } } as any]) + const listDir = new ListDirectory({ path: '/not/in/workspace/dir', maxDepth: 0 }) + const result = listDir.requiresAcceptance() + assert.equal( + result.requiresAcceptance, + true, + 'Expected requiresAcceptance to be true for a path outside the workspace' + ) + workspaceStub.restore() + }) + + it('should not require acceptance if fsPath is inside the workspace', () => { + const workspaceStub = sinon + .stub(vscode.workspace, 'workspaceFolders') + .value([{ uri: { fsPath: '/workspace/folder' } } as any]) + const listDir = new ListDirectory({ path: '/workspace/folder/mydir', maxDepth: 0 }) + const result = listDir.requiresAcceptance() + assert.equal( + result.requiresAcceptance, + false, + 'Expected requiresAcceptance to be false for a path inside the workspace' + ) + workspaceStub.restore() + }) }) diff --git a/packages/core/src/test/codewhispererChat/tools/toolShared.test.ts b/packages/core/src/test/codewhispererChat/tools/toolShared.test.ts index 93a0b06085a..023eddce60c 100644 --- a/packages/core/src/test/codewhispererChat/tools/toolShared.test.ts +++ b/packages/core/src/test/codewhispererChat/tools/toolShared.test.ts @@ -33,6 +33,8 @@ describe('ToolUtils', function () { mockWritable = { write: sandbox.stub(), } as unknown as sinon.SinonStubbedInstance + ;(mockFsRead.requiresAcceptance as sinon.SinonStub).returns({ requiresAcceptance: false }) + ;(mockListDirectory.requiresAcceptance as sinon.SinonStub).returns({ requiresAcceptance: false }) }) afterEach(function () { From b352b016033a758fba6d81ebeae318c92286a1e2 Mon Sep 17 00:00:00 2001 From: Tai Lai Date: Thu, 10 Apr 2025 18:51:41 -0700 Subject: [PATCH 09/18] style(chat): undo padding change (#7012) ## Problem Padding override is not needed anymore ## Solution Revert padding change since it looks like it was already fixed in mynah-ui --- - Treat all work as PUBLIC. Private `feature/x` branches will not be squash-merged at release time. - Your code changes must meet the guidelines in [CONTRIBUTING.md](https://github.com/aws/aws-toolkit-vscode/blob/master/CONTRIBUTING.md#guidelines). - License: I confirm that my contribution is made under the terms of the Apache 2.0 license. --- packages/core/resources/css/amazonq-webview.css | 4 ---- 1 file changed, 4 deletions(-) diff --git a/packages/core/resources/css/amazonq-webview.css b/packages/core/resources/css/amazonq-webview.css index 953a7313dc0..1117755cb6e 100644 --- a/packages/core/resources/css/amazonq-webview.css +++ b/packages/core/resources/css/amazonq-webview.css @@ -128,7 +128,3 @@ body .mynah-card-body h4 { div.mynah-card.padding-large { padding: var(--mynah-sizing-4) var(--mynah-sizing-3); } - -div.mynah-chat-items-container .mynah-chat-item-card.no-padding > .mynah-card { - padding: 0; -} From 4842941483ade89decfb2c98bcfaf388b2345d5c Mon Sep 17 00:00:00 2001 From: Jason Guo Date: Thu, 10 Apr 2025 18:28:17 -0700 Subject: [PATCH 10/18] fix(chat): Reduce maxToolResponseSize from 800K to 200K, update toolSpec for fsRead and fsWrite --- packages/core/src/codewhispererChat/tools/toolShared.ts | 2 +- packages/core/src/codewhispererChat/tools/tool_index.json | 6 +++++- .../src/test/codewhispererChat/tools/toolShared.test.ts | 6 +++--- 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/packages/core/src/codewhispererChat/tools/toolShared.ts b/packages/core/src/codewhispererChat/tools/toolShared.ts index a7d9a29a311..d65c819567b 100644 --- a/packages/core/src/codewhispererChat/tools/toolShared.ts +++ b/packages/core/src/codewhispererChat/tools/toolShared.ts @@ -6,7 +6,7 @@ import path from 'path' import fs from '../../shared/fs/fs' -export const maxToolResponseSize = 800_000 +export const maxToolResponseSize = 200_000 export enum OutputKind { Text = 'text', diff --git a/packages/core/src/codewhispererChat/tools/tool_index.json b/packages/core/src/codewhispererChat/tools/tool_index.json index 0c6ac36760b..1d23525599f 100644 --- a/packages/core/src/codewhispererChat/tools/tool_index.json +++ b/packages/core/src/codewhispererChat/tools/tool_index.json @@ -10,7 +10,7 @@ "type": "string" }, "readRange": { - "description": "Optional parameter when reading files.\n * If none is given, the full file is shown. If provided, the file will be shown in the indicated line number range, e.g. [11, 12] will show lines 11 and 12. Indexing at 1 to start. Setting `[startLine, -1]` shows all lines from `startLine` to the end of the file.", + "description": "Optional parameter when reading files.\n * If none is given, the full file is shown. If provided, the file will be shown in the indicated line number range, e.g. [11, 12] will show lines 11 and 12. Indexing at 1 to start. Setting `[startLine, -1]` shows all lines from `startLine` to the end of the file. If the whole file is too large, try reading 4000 lines at once, for example: after reading [1, 4000], read [4000, 8000] next and repeat.", "items": { "type": "integer" }, @@ -26,6 +26,10 @@ "inputSchema": { "type": "object", "properties": { + "explanation": { + "description": "One sentence explanation as to why this tool is being used, and how it contributes to the goal.", + "type": "string" + }, "command": { "type": "string", "enum": ["create", "strReplace", "insert", "append"], diff --git a/packages/core/src/test/codewhispererChat/tools/toolShared.test.ts b/packages/core/src/test/codewhispererChat/tools/toolShared.test.ts index 023eddce60c..903ceb7ab4d 100644 --- a/packages/core/src/test/codewhispererChat/tools/toolShared.test.ts +++ b/packages/core/src/test/codewhispererChat/tools/toolShared.test.ts @@ -162,7 +162,7 @@ describe('ToolUtils', function () { const output: InvokeOutput = { output: { kind: OutputKind.Text, - content: 'a'.repeat(700_000), + content: 'a'.repeat(150_000), }, } assert.doesNotThrow(() => ToolUtils.validateOutput(output)) @@ -171,12 +171,12 @@ describe('ToolUtils', function () { const output: InvokeOutput = { output: { kind: OutputKind.Text, - content: 'a'.repeat(900_000), // 900,000 characters + content: 'a'.repeat(200_001), // 200,001 characters }, } assert.throws(() => ToolUtils.validateOutput(output), { name: 'Error', - message: 'Tool output exceeds maximum character limit of 800000', + message: 'Tool output exceeds maximum character limit of 200000', }) }) }) From d02e5524018e66b507a3df2de81c2ee54d78df08 Mon Sep 17 00:00:00 2001 From: "Justin M. Keyes" Date: Thu, 10 Apr 2025 21:25:45 -0700 Subject: [PATCH 11/18] fix(amazonq): "Sign in" does not focus Q panel #7015 This was only a potential performance optimization. Remove it since it prevents tryExecute from working with vscode-defined commands (which are not found in `this.resources`). --- packages/core/src/shared/vscode/commands2.ts | 5 ----- 1 file changed, 5 deletions(-) diff --git a/packages/core/src/shared/vscode/commands2.ts b/packages/core/src/shared/vscode/commands2.ts index 342acfdfbac..c55cd66cc7a 100644 --- a/packages/core/src/shared/vscode/commands2.ts +++ b/packages/core/src/shared/vscode/commands2.ts @@ -159,11 +159,6 @@ export class Commands { id: string, ...args: Parameters ): Promise | undefined> { - const cmd = this.resources.get(id) - if (!cmd) { - getLogger().debug('command not found: "%s"', id) - return undefined - } return this.commands.executeCommand>(id, ...args)?.then(undefined, (e: Error) => { getLogger().warn('command failed (not registered?): "%s"', id) return undefined From 27d84c9ffb9cd37765ed9154286c222a70c9005c Mon Sep 17 00:00:00 2001 From: aws-toolkit-automation <> Date: Fri, 11 Apr 2025 04:31:02 +0000 Subject: [PATCH 12/18] Release 1.58.0 --- package-lock.json | 7 +++++- packages/amazonq/.changes/1.58.0.json | 22 +++++++++++++++++++ ...-45379b8c-1faa-4b04-951a-26e234c6dc03.json | 4 ---- ...-9d840c3e-80a2-45a0-961f-8441b2339860.json | 4 ---- ...-e8dc0bd0-b232-429c-b16a-f3bf993b19b4.json | 4 ---- ...-ee90e4d2-aa78-403f-8b58-1464c6419778.json | 4 ---- packages/amazonq/CHANGELOG.md | 7 ++++++ packages/amazonq/package.json | 2 +- 8 files changed, 36 insertions(+), 18 deletions(-) create mode 100644 packages/amazonq/.changes/1.58.0.json delete mode 100644 packages/amazonq/.changes/next-release/Bug Fix-45379b8c-1faa-4b04-951a-26e234c6dc03.json delete mode 100644 packages/amazonq/.changes/next-release/Bug Fix-9d840c3e-80a2-45a0-961f-8441b2339860.json delete mode 100644 packages/amazonq/.changes/next-release/Bug Fix-e8dc0bd0-b232-429c-b16a-f3bf993b19b4.json delete mode 100644 packages/amazonq/.changes/next-release/Bug Fix-ee90e4d2-aa78-403f-8b58-1464c6419778.json diff --git a/package-lock.json b/package-lock.json index 48ff2856183..3bade28461a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -43,8 +43,13 @@ "husky": "^9.0.7", "prettier": "^3.3.3", "prettier-plugin-sh": "^0.14.0", +<<<<<<< HEAD "pretty-quick": "^4.1.1", "ts-node": "^10.9.1", +======= + "pretty-quick": "^4.0.0", + "ts-node": "^10.9.2", +>>>>>>> fc5043675 (Release 1.58.0) "typescript": "^5.0.4", "webpack": "^5.95.0", "webpack-cli": "^5.1.4", @@ -26734,7 +26739,7 @@ }, "packages/amazonq": { "name": "amazon-q-vscode", - "version": "1.58.0-SNAPSHOT", + "version": "1.58.0", "license": "Apache-2.0", "dependencies": { "aws-core-vscode": "file:../core/" diff --git a/packages/amazonq/.changes/1.58.0.json b/packages/amazonq/.changes/1.58.0.json new file mode 100644 index 00000000000..02395a6dfd8 --- /dev/null +++ b/packages/amazonq/.changes/1.58.0.json @@ -0,0 +1,22 @@ +{ + "date": "2025-04-11", + "version": "1.58.0", + "entries": [ + { + "type": "Bug Fix", + "description": "inline chat activates properly when using 'aws.experiments.amazonqChatLSP' feature flag" + }, + { + "type": "Bug Fix", + "description": "Amazon Q Chat: code blocks in responses flicker, switching tabs during answer streaming makes expand button disappear" + }, + { + "type": "Bug Fix", + "description": "Amazon Q Chat: tab bar buttons disappear when closing non-active tab" + }, + { + "type": "Bug Fix", + "description": "Amazon Q Chat: chat history list does not truncate markdown" + } + ] +} \ No newline at end of file diff --git a/packages/amazonq/.changes/next-release/Bug Fix-45379b8c-1faa-4b04-951a-26e234c6dc03.json b/packages/amazonq/.changes/next-release/Bug Fix-45379b8c-1faa-4b04-951a-26e234c6dc03.json deleted file mode 100644 index e02212b84ca..00000000000 --- a/packages/amazonq/.changes/next-release/Bug Fix-45379b8c-1faa-4b04-951a-26e234c6dc03.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "type": "Bug Fix", - "description": "inline chat activates properly when using 'aws.experiments.amazonqChatLSP' feature flag" -} diff --git a/packages/amazonq/.changes/next-release/Bug Fix-9d840c3e-80a2-45a0-961f-8441b2339860.json b/packages/amazonq/.changes/next-release/Bug Fix-9d840c3e-80a2-45a0-961f-8441b2339860.json deleted file mode 100644 index 5118a1bd777..00000000000 --- a/packages/amazonq/.changes/next-release/Bug Fix-9d840c3e-80a2-45a0-961f-8441b2339860.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "type": "Bug Fix", - "description": "Amazon Q Chat: code blocks in responses flicker, switching tabs during answer streaming makes expand button disappear" -} diff --git a/packages/amazonq/.changes/next-release/Bug Fix-e8dc0bd0-b232-429c-b16a-f3bf993b19b4.json b/packages/amazonq/.changes/next-release/Bug Fix-e8dc0bd0-b232-429c-b16a-f3bf993b19b4.json deleted file mode 100644 index b16dc2c1ac1..00000000000 --- a/packages/amazonq/.changes/next-release/Bug Fix-e8dc0bd0-b232-429c-b16a-f3bf993b19b4.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "type": "Bug Fix", - "description": "Amazon Q Chat: tab bar buttons disappear when closing non-active tab" -} diff --git a/packages/amazonq/.changes/next-release/Bug Fix-ee90e4d2-aa78-403f-8b58-1464c6419778.json b/packages/amazonq/.changes/next-release/Bug Fix-ee90e4d2-aa78-403f-8b58-1464c6419778.json deleted file mode 100644 index 8f327d047c1..00000000000 --- a/packages/amazonq/.changes/next-release/Bug Fix-ee90e4d2-aa78-403f-8b58-1464c6419778.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "type": "Bug Fix", - "description": "Amazon Q Chat: chat history list does not truncate markdown" -} diff --git a/packages/amazonq/CHANGELOG.md b/packages/amazonq/CHANGELOG.md index ac52aa0e297..79c1e2d6c01 100644 --- a/packages/amazonq/CHANGELOG.md +++ b/packages/amazonq/CHANGELOG.md @@ -1,3 +1,10 @@ +## 1.58.0 2025-04-11 + +- **Bug Fix** inline chat activates properly when using 'aws.experiments.amazonqChatLSP' feature flag +- **Bug Fix** Amazon Q Chat: code blocks in responses flicker, switching tabs during answer streaming makes expand button disappear +- **Bug Fix** Amazon Q Chat: tab bar buttons disappear when closing non-active tab +- **Bug Fix** Amazon Q Chat: chat history list does not truncate markdown + ## 1.57.0 2025-04-10 - **Bug Fix** Fix bug where generate fix does not work diff --git a/packages/amazonq/package.json b/packages/amazonq/package.json index ec26ba1b56c..2a2c0085f49 100644 --- a/packages/amazonq/package.json +++ b/packages/amazonq/package.json @@ -2,7 +2,7 @@ "name": "amazon-q-vscode", "displayName": "Amazon Q", "description": "The most capable generative AI-powered assistant for building, operating, and transforming software, with advanced capabilities for managing data and AI", - "version": "1.58.0-SNAPSHOT", + "version": "1.58.0", "extensionKind": [ "workspace" ], From 2a0c0ae02824cdc38bf7b9270cdf96f8c78946f0 Mon Sep 17 00:00:00 2001 From: aws-toolkit-automation <> Date: Fri, 11 Apr 2025 04:49:41 +0000 Subject: [PATCH 13/18] Update version to snapshot version: 1.59.0-SNAPSHOT --- package-lock.json | 6 +++++- packages/amazonq/package.json | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index 3bade28461a..abdfc73bdfe 100644 --- a/package-lock.json +++ b/package-lock.json @@ -48,8 +48,12 @@ "ts-node": "^10.9.1", ======= "pretty-quick": "^4.0.0", +<<<<<<< HEAD "ts-node": "^10.9.2", >>>>>>> fc5043675 (Release 1.58.0) +======= + "ts-node": "^10.9.1", +>>>>>>> f1dded7ed (Update version to snapshot version: 1.59.0-SNAPSHOT) "typescript": "^5.0.4", "webpack": "^5.95.0", "webpack-cli": "^5.1.4", @@ -26739,7 +26743,7 @@ }, "packages/amazonq": { "name": "amazon-q-vscode", - "version": "1.58.0", + "version": "1.59.0-SNAPSHOT", "license": "Apache-2.0", "dependencies": { "aws-core-vscode": "file:../core/" diff --git a/packages/amazonq/package.json b/packages/amazonq/package.json index 2a2c0085f49..1ad0c724725 100644 --- a/packages/amazonq/package.json +++ b/packages/amazonq/package.json @@ -2,7 +2,7 @@ "name": "amazon-q-vscode", "displayName": "Amazon Q", "description": "The most capable generative AI-powered assistant for building, operating, and transforming software, with advanced capabilities for managing data and AI", - "version": "1.58.0", + "version": "1.59.0-SNAPSHOT", "extensionKind": [ "workspace" ], From 360ce215f8ac0b2b83c4bda59c709cc678ed4704 Mon Sep 17 00:00:00 2001 From: "Justin M. Keyes" Date: Fri, 11 Apr 2025 10:46:25 -0700 Subject: [PATCH 14/18] fix(childprocess): noisy "memory threshold" logs #7017 ## Problem "memory threshold" message logged multiple times for a the same PID: 2025-04-11 ... Process 23662 exceeded memory threshold: 506986496 2025-04-11 ... Process 23662 exceeded memory threshold: 507019264 2025-04-11 ... Process 23662 exceeded memory threshold: 507052032 2025-04-11 ... Process 23662 exceeded memory threshold: 507084800 This is noisy in the logs. ## Solution Only log "memory threshold" once per PID. --- .../src/shared/utilities/collectionUtils.ts | 27 +++++++++++++++++++ .../core/src/shared/utilities/processUtils.ts | 17 +++++++++--- .../shared/utilities/processUtils.test.ts | 5 ++++ 3 files changed, 45 insertions(+), 4 deletions(-) diff --git a/packages/core/src/shared/utilities/collectionUtils.ts b/packages/core/src/shared/utilities/collectionUtils.ts index f723e0096cc..9f9fe9875b9 100644 --- a/packages/core/src/shared/utilities/collectionUtils.ts +++ b/packages/core/src/shared/utilities/collectionUtils.ts @@ -566,3 +566,30 @@ export function createCollectionFromPages(...pages: T[]): AsyncCollection export function isPresent(value: T | undefined): value is T { return value !== undefined } + +export class CircularBuffer { + private buffer = new Set() + private maxSize: number + + constructor(size: number) { + this.maxSize = size + } + + add(value: number): void { + if (this.buffer.size >= this.maxSize) { + // Set iterates its keys in insertion-order. + // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Set + const firstKey = this.buffer.keys().next().value + this.buffer.delete(firstKey) + } + this.buffer.add(value) + } + + contains(value: number): boolean { + return this.buffer.has(value) + } + + clear(): void { + this.buffer.clear() + } +} diff --git a/packages/core/src/shared/utilities/processUtils.ts b/packages/core/src/shared/utilities/processUtils.ts index 25af4418e1e..31f2ec238f3 100644 --- a/packages/core/src/shared/utilities/processUtils.ts +++ b/packages/core/src/shared/utilities/processUtils.ts @@ -8,6 +8,7 @@ import * as crossSpawn from 'cross-spawn' import * as logger from '../logger/logger' import { Timeout, CancellationError, waitUntil } from './timeoutUtils' import { PollingSet } from './pollingSet' +import { CircularBuffer } from './collectionUtils' export interface RunParameterContext { /** Reports an error parsed from the stdin/stdout streams. */ @@ -73,6 +74,7 @@ export class ChildProcessTracker { cpu: 50, } static readonly logger = logger.getLogger('childProcess') + static readonly loggedPids = new CircularBuffer(1000) #processByPid: Map = new Map() #pids: PollingSet @@ -100,21 +102,28 @@ export class ChildProcessTracker { private async checkProcessUsage(pid: number): Promise { if (!this.#pids.has(pid)) { - ChildProcessTracker.logger.warn(`Missing process with id ${pid}`) + ChildProcessTracker.logOnce(pid, `Missing process with id ${pid}`) return } const stats = this.getUsage(pid) if (stats) { ChildProcessTracker.logger.debug(`Process ${pid} usage: %O`, stats) if (stats.memory > ChildProcessTracker.thresholds.memory) { - ChildProcessTracker.logger.warn(`Process ${pid} exceeded memory threshold: ${stats.memory}`) + ChildProcessTracker.logOnce(pid, `Process ${pid} exceeded memory threshold: ${stats.memory}`) } if (stats.cpu > ChildProcessTracker.thresholds.cpu) { - ChildProcessTracker.logger.warn(`Process ${pid} exceeded cpu threshold: ${stats.cpu}`) + ChildProcessTracker.logOnce(pid, `Process ${pid} exceeded cpu threshold: ${stats.cpu}`) } } } + public static logOnce(pid: number, msg: string) { + if (!ChildProcessTracker.loggedPids.contains(pid)) { + ChildProcessTracker.loggedPids.add(pid) + ChildProcessTracker.logger.warn(msg) + } + } + public add(childProcess: ChildProcess) { const pid = childProcess.pid() this.#processByPid.set(pid, childProcess) @@ -147,7 +156,7 @@ export class ChildProcessTracker { // isWin() leads to circular dependency. return process.platform === 'win32' ? getWindowsUsage() : getUnixUsage() } catch (e) { - ChildProcessTracker.logger.warn(`Failed to get process stats for ${pid}: ${e}`) + ChildProcessTracker.logOnce(pid, `Failed to get process stats for ${pid}: ${e}`) return { cpu: 0, memory: 0 } } diff --git a/packages/core/src/test/shared/utilities/processUtils.test.ts b/packages/core/src/test/shared/utilities/processUtils.test.ts index 0e6f474ed10..436ac48ecc4 100644 --- a/packages/core/src/test/shared/utilities/processUtils.test.ts +++ b/packages/core/src/test/shared/utilities/processUtils.test.ts @@ -393,6 +393,10 @@ describe('ChildProcessTracker', function () { usageMock = sinon.stub(ChildProcessTracker.prototype, 'getUsage') }) + beforeEach(function () { + ChildProcessTracker.loggedPids.clear() + }) + afterEach(function () { tracker.clear() usageMock.reset() @@ -463,6 +467,7 @@ describe('ChildProcessTracker', function () { await clock.tickAsync(ChildProcessTracker.pollingInterval) assertLogsContain('exceeded cpu threshold', false, 'warn') + ChildProcessTracker.loggedPids.clear() usageMock.returns(highMemory) await clock.tickAsync(ChildProcessTracker.pollingInterval) assertLogsContain('exceeded memory threshold', false, 'warn') From 2db1d85346f29734f9fbf96a6648d71ad25f9a34 Mon Sep 17 00:00:00 2001 From: Laxman Reddy <141967714+laileni-aws@users.noreply.github.com> Date: Fri, 11 Apr 2025 10:58:22 -0700 Subject: [PATCH 15/18] feat(chat): Grouping read and list directory messages UX (#7006) ### Problem: We identified and fixed an issue where the IDE's UI would prematurely stop displaying when executing certain commands. This occurred specifically when the Language Model (LLM) attempted to run the executeBash command before the Read/List Directory tools. ### Example: When a user asks "write a script that tells me whoami", the operation only requires the fsWrite command and not any directory reading tools. In such cases, the IDE would execute fsWrite but fail to display the executeBash tool in the chat interface. This[ UI behavior has been corrected ](https://github.com/aws/aws-toolkit-vscode/blob/2028754f3d933909f86bb65f18f06dab526d2708/packages/core/src/codewhispererChat/tools/chatStream.ts#L43-L47)in this PR, along with improvements to the Reading File user experience. - Major changes lies in this [commit](https://github.com/aws/aws-toolkit-vscode/blob/2028754f3d933909f86bb65f18f06dab526d2708/packages/core/src/codewhispererChat/tools/chatStream.ts#L43-L47) in `ChatStream.ts` ``` // For FsRead and ListDirectory tools If messageIdToUpdate is undefined, we need to first create an empty message with messageId so it can be updated later if (isReadorList && !messageIdToUpdate) { this.messenger.sendInitialToolMessage(tabID, triggerID, toolUse?.toolUseId) } else { this.messenger.sendInitalStream(tabID, triggerID) } ``` --- - Treat all work as PUBLIC. Private `feature/x` branches will not be squash-merged at release time. - Your code changes must meet the guidelines in [CONTRIBUTING.md](https://github.com/aws/aws-toolkit-vscode/blob/master/CONTRIBUTING.md#guidelines). - License: I confirm that my contribution is made under the terms of the Apache 2.0 license. --- .../webview/ui/apps/cwChatConnector.ts | 66 +++++--- .../core/src/amazonq/webview/ui/connector.ts | 2 +- packages/core/src/amazonq/webview/ui/main.ts | 67 ++++---- .../codewhispererChat/clients/chat/v0/chat.ts | 36 ++++- .../controllers/chat/controller.ts | 17 ++- .../controllers/chat/messenger/messenger.ts | 144 +++++++++++++++++- .../src/codewhispererChat/tools/chatStream.ts | 26 +++- .../src/codewhispererChat/tools/fsRead.ts | 19 +-- .../codewhispererChat/tools/listDirectory.ts | 6 +- .../codewhispererChat/tools/tool_index.json | 2 +- .../view/connector/connector.ts | 8 + 11 files changed, 308 insertions(+), 85 deletions(-) diff --git a/packages/core/src/amazonq/webview/ui/apps/cwChatConnector.ts b/packages/core/src/amazonq/webview/ui/apps/cwChatConnector.ts index d89d4f65c14..174dfa1a9a1 100644 --- a/packages/core/src/amazonq/webview/ui/apps/cwChatConnector.ts +++ b/packages/core/src/amazonq/webview/ui/apps/cwChatConnector.ts @@ -179,6 +179,33 @@ export class Connector extends BaseConnector { } } + private processToolMessage = async (messageData: any): Promise => { + if (this.onChatAnswerUpdated === undefined) { + return + } + const answer: CWCChatItem = { + type: messageData.messageType, + messageId: messageData.messageID ?? messageData.triggerID, + body: messageData.message, + followUp: messageData.followUps, + canBeVoted: messageData.canBeVoted ?? false, + codeReference: messageData.codeReference, + userIntent: messageData.contextList, + codeBlockLanguage: messageData.codeBlockLanguage, + contextList: messageData.contextList, + title: messageData.title, + buttons: messageData.buttons, + fileList: messageData.fileList, + header: messageData.header ?? undefined, + padding: messageData.padding ?? undefined, + fullWidth: messageData.fullWidth ?? undefined, + codeBlockActions: messageData.codeBlockActions ?? undefined, + rootFolderTitle: messageData.rootFolderTitle, + } + this.onChatAnswerUpdated(messageData.tabID, answer) + return + } + private storeChatItem(tabId: string, messageId: string, item: ChatItem): void { if (!this.chatItems.has(tabId)) { this.chatItems.set(tabId, new Map()) @@ -238,6 +265,11 @@ export class Connector extends BaseConnector { return } + if (messageData.type === 'toolMessage') { + await this.processToolMessage(messageData) + return + } + if (messageData.type === 'editorContextCommandMessage') { await this.processEditorContextCommandMessage(messageData) return @@ -369,45 +401,43 @@ export class Connector extends BaseConnector { } break case 'run-shell-command': - answer.header = { - body: 'shell', - status: { + if (answer.header) { + answer.header.status = { icon: 'ok' as MynahIconsType, text: 'Accepted', status: 'success', - }, + } + answer.header.buttons = [] } break case 'reject-shell-command': - answer.header = { - body: 'shell', - status: { + if (answer.header) { + answer.header.status = { icon: 'cancel' as MynahIconsType, text: 'Rejected', status: 'error', - }, + } + answer.header.buttons = [] } break case 'confirm-tool-use': - answer.header = { - icon: 'shell' as MynahIconsType, - body: 'shell', - status: { + if (answer.header) { + answer.header.status = { icon: 'ok' as MynahIconsType, text: 'Accepted', status: 'success', - }, + } + answer.header.buttons = [] } break case 'reject-tool-use': - answer.header = { - icon: 'shell' as MynahIconsType, - body: 'shell', - status: { + if (answer.header) { + answer.header.status = { icon: 'cancel' as MynahIconsType, text: 'Rejected', status: 'error', - }, + } + answer.header.buttons = [] } break default: diff --git a/packages/core/src/amazonq/webview/ui/connector.ts b/packages/core/src/amazonq/webview/ui/connector.ts index 9ed46f8f58d..04a740624f1 100644 --- a/packages/core/src/amazonq/webview/ui/connector.ts +++ b/packages/core/src/amazonq/webview/ui/connector.ts @@ -78,7 +78,7 @@ export interface ConnectorProps { sendMessageToExtension: (message: ExtensionMessage) => void onMessageReceived?: (tabID: string, messageData: any, needToShowAPIDocsTab: boolean) => void onRunTestMessageReceived?: (tabID: string, showRunTestMessage: boolean) => void - onChatAnswerUpdated?: (tabID: string, message: ChatItem) => void + onChatAnswerUpdated?: (tabID: string, message: CWCChatItem) => void onChatAnswerReceived?: (tabID: string, message: ChatItem, messageData: any) => void onWelcomeFollowUpClicked: (tabID: string, welcomeFollowUpType: WelcomeFollowupType) => void onAsyncEventProgress: (tabID: string, inProgress: boolean, message: string | undefined) => void diff --git a/packages/core/src/amazonq/webview/ui/main.ts b/packages/core/src/amazonq/webview/ui/main.ts index 580229d3ee2..3204ebe65b7 100644 --- a/packages/core/src/amazonq/webview/ui/main.ts +++ b/packages/core/src/amazonq/webview/ui/main.ts @@ -100,6 +100,41 @@ export const createMynahUI = ( welcomeCount += 1 } + /** + * Creates a file list header from context list + * @param contextList List of file contexts + * @param rootFolderTitle Title for the root folder + * @returns Header object with file list + */ + const createFileListHeader = (contextList: any[], rootFolderTitle?: string) => { + return { + fileList: { + fileTreeTitle: '', + filePaths: contextList.map((file) => file.relativeFilePath), + rootFolderTitle: rootFolderTitle, + flatList: true, + collapsed: true, + hideFileCount: true, + details: Object.fromEntries( + contextList.map((file) => [ + file.relativeFilePath, + { + label: file.lineRanges + .map((range: { first: number; second: number }) => + range.first === -1 || range.second === -1 + ? '' + : `line ${range.first} - ${range.second}` + ) + .join(', '), + description: file.relativeFilePath, + clickable: true, + }, + ]) + ), + }, + } + } + // Adding the first tab as CWC tab tabsStorage.addTab({ id: 'tab-1', @@ -346,8 +381,11 @@ export const createMynahUI = ( sendMessageToExtension: (message) => { ideApi.postMessage(message) }, - onChatAnswerUpdated: (tabID: string, item: ChatItem) => { + onChatAnswerUpdated: (tabID: string, item: CWCChatItem) => { if (item.messageId !== undefined) { + if (item.contextList !== undefined && item.contextList.length > 0) { + item.header = createFileListHeader(item.contextList, item.rootFolderTitle) + } mynahUI.updateChatAnswerWithMessageId(tabID, item.messageId, { ...(item.body !== undefined ? { body: item.body } : {}), ...(item.buttons !== undefined ? { buttons: item.buttons } : {}), @@ -409,32 +447,7 @@ export const createMynahUI = ( } if (item.contextList !== undefined && item.contextList.length > 0) { - item.header = { - fileList: { - fileTreeTitle: '', - filePaths: item.contextList.map((file) => file.relativeFilePath), - rootFolderTitle: item.rootFolderTitle, - flatList: true, - collapsed: true, - hideFileCount: true, - details: Object.fromEntries( - item.contextList.map((file) => [ - file.relativeFilePath, - { - label: file.lineRanges - .map((range) => - range.first === -1 || range.second === -1 - ? '' - : `line ${range.first} - ${range.second}` - ) - .join(', '), - description: file.relativeFilePath, - clickable: true, - }, - ]) - ), - }, - } + item.header = createFileListHeader(item.contextList, item.rootFolderTitle) } if ( diff --git a/packages/core/src/codewhispererChat/clients/chat/v0/chat.ts b/packages/core/src/codewhispererChat/clients/chat/v0/chat.ts index 53551b254f5..10409be4ada 100644 --- a/packages/core/src/codewhispererChat/clients/chat/v0/chat.ts +++ b/packages/core/src/codewhispererChat/clients/chat/v0/chat.ts @@ -14,7 +14,7 @@ import { ToolkitError } from '../../../../shared/errors' import { createCodeWhispererChatStreamingClient } from '../../../../shared/clients/codewhispererChatClient' import { createQDeveloperStreamingClient } from '../../../../shared/clients/qDeveloperChatClient' import { UserWrittenCodeTracker } from '../../../../codewhisperer/tracker/userWrittenCodeTracker' -import { PromptMessage } from '../../../controllers/chat/model' +import { DocumentReference, PromptMessage } from '../../../controllers/chat/model' import { FsWriteBackup } from '../../../../codewhispererChat/tools/fsWrite' export type ToolUseWithError = { @@ -30,8 +30,10 @@ export class ChatSession { * _readFiles = list of files read from the project to gather context before generating response. * _showDiffOnFileWrite = Controls whether to show diff view (true) or file context view (false) to the user * _context = Additional context to be passed to the LLM for generating the response + * _messageIdToUpdate = messageId of a chat message to be updated, used for reducing consecutive tool messages */ - private _readFiles: string[] = [] + private _readFiles: DocumentReference[] = [] + private _readFolders: DocumentReference[] = [] private _toolUseWithError: ToolUseWithError | undefined private _showDiffOnFileWrite: boolean = false private _context: PromptMessage['context'] @@ -41,6 +43,8 @@ export class ChatSession { * True if messages from local history have been sent to session. */ localHistoryHydrated: boolean = false + private _messageIdToUpdate: string | undefined + private _messageIdToUpdateListDirectory: string | undefined contexts: Map = new Map() // TODO: doesn't handle the edge case when two files share the same relativePath string but from different root @@ -49,6 +53,21 @@ export class ChatSession { public get sessionIdentifier(): string | undefined { return this.sessionId } + public get messageIdToUpdate(): string | undefined { + return this._messageIdToUpdate + } + + public setMessageIdToUpdate(messageId: string | undefined) { + this._messageIdToUpdate = messageId + } + + public get messageIdToUpdateListDirectory(): string | undefined { + return this._messageIdToUpdateListDirectory + } + + public setMessageIdToUpdateListDirectory(messageId: string | undefined) { + this._messageIdToUpdateListDirectory = messageId + } public get pairProgrammingModeOn(): boolean { return this._pairProgrammingModeOn @@ -95,21 +114,30 @@ export class ChatSession { public setSessionID(id?: string) { this.sessionId = id } - public get readFiles(): string[] { + public get readFiles(): DocumentReference[] { return this._readFiles } + public get readFolders(): DocumentReference[] { + return this._readFolders + } public get showDiffOnFileWrite(): boolean { return this._showDiffOnFileWrite } public setShowDiffOnFileWrite(value: boolean) { this._showDiffOnFileWrite = value } - public addToReadFiles(filePath: string) { + public addToReadFiles(filePath: DocumentReference) { this._readFiles.push(filePath) } public clearListOfReadFiles() { this._readFiles = [] } + public setReadFolders(folder: DocumentReference) { + this._readFolders.push(folder) + } + public clearListOfReadFolders() { + this._readFolders = [] + } async chatIam(chatRequest: SendMessageRequest): Promise { const client = await createQDeveloperStreamingClient() diff --git a/packages/core/src/codewhispererChat/controllers/chat/controller.ts b/packages/core/src/codewhispererChat/controllers/chat/controller.ts index 8b1f733d0e2..e347351100b 100644 --- a/packages/core/src/codewhispererChat/controllers/chat/controller.ts +++ b/packages/core/src/codewhispererChat/controllers/chat/controller.ts @@ -728,9 +728,19 @@ export class ChatController { try { await ToolUtils.validate(tool) - const chatStream = new ChatStream(this.messenger, tabID, triggerID, toolUse, { - requiresAcceptance: false, - }) + const chatStream = new ChatStream( + this.messenger, + tabID, + triggerID, + toolUse, + session, + undefined, + false, + { + requiresAcceptance: false, + }, + false + ) if (tool.type === ToolType.FsWrite && toolUse.toolUseId) { const backup = await tool.tool.getBackup() session.setFsWriteBackup(toolUse.toolUseId, backup) @@ -1221,6 +1231,7 @@ export class ChatController { private async processPromptMessageAsNewThread(message: PromptMessage) { const session = this.sessionStorage.getSession(message.tabID) session.clearListOfReadFiles() + session.clearListOfReadFolders() session.setShowDiffOnFileWrite(false) this.editorContextExtractor .extractContextForTrigger('ChatMessage') diff --git a/packages/core/src/codewhispererChat/controllers/chat/messenger/messenger.ts b/packages/core/src/codewhispererChat/controllers/chat/messenger/messenger.ts index f8306b00874..839f8ea087a 100644 --- a/packages/core/src/codewhispererChat/controllers/chat/messenger/messenger.ts +++ b/packages/core/src/codewhispererChat/controllers/chat/messenger/messenger.ts @@ -22,6 +22,7 @@ import { CloseDetailedListMessage, SelectTabMessage, ChatItemHeader, + ToolMessage, } from '../../../view/connector/connector' import { EditorContextCommandType } from '../../../commands/registerCommands' import { ChatResponseStream as qdevChatResponseStream } from '@amzn/amazon-q-developer-streaming-client' @@ -54,6 +55,8 @@ import { MynahIconsType, DetailedList, MynahUIDataModel, + MynahIcons, + Status, } from '@aws/mynah-ui' import { Database } from '../../../../shared/db/chatDb/chatDb' import { TabType } from '../../../../amazonq/webview/ui/storages/tabsStorage' @@ -69,6 +72,8 @@ import { FsWriteParams } from '../../../tools/fsWrite' import { AsyncEventProgressMessage } from '../../../../amazonq/commons/connector/connectorMessages' import { localize } from '../../../../shared/utilities/vsCodeUtils' import { getDiffLinesFromChanges } from '../../../../shared/utilities/diffUtils' +import { FsReadParams } from '../../../tools/fsRead' +import { ListDirectoryParams } from '../../../tools/listDirectory' export type StaticTextResponseType = 'quick-action-help' | 'onboarding-help' | 'transform' | 'help' @@ -284,20 +289,65 @@ export class Messenger { const tool = ToolUtils.tryFromToolUse(toolUse) if ('type' in tool) { let changeList: Change[] | undefined = undefined + let messageIdToUpdate: string | undefined = undefined + const isReadOrList: boolean = [ToolType.FsRead, ToolType.ListDirectory].includes( + tool.type + ) if (tool.type === ToolType.FsWrite) { session.setShowDiffOnFileWrite(true) changeList = await tool.tool.getDiffChanges() } + if (tool.type === ToolType.FsRead) { + messageIdToUpdate = session.messageIdToUpdate + const input = toolUse.input as unknown as FsReadParams + // Check if this file path is already in the readFiles list + const isFileAlreadyRead = session.readFiles.some( + (file) => file.relativeFilePath === input.path + ) + if (!isFileAlreadyRead) { + session.addToReadFiles({ + relativeFilePath: input?.path, + lineRanges: [{ first: -1, second: -1 }], + }) + } + } else if (tool.type === ToolType.ListDirectory) { + messageIdToUpdate = session.messageIdToUpdateListDirectory + const input = toolUse.input as unknown as ListDirectoryParams + // Check if this folder is already in the readFolders list + const isFolderAlreadyRead = session.readFolders.some( + (folder) => folder.relativeFilePath === input.path + ) + if (!isFolderAlreadyRead) { + session.setReadFolders({ + relativeFilePath: input?.path, + lineRanges: [{ first: -1, second: -1 }], + }) + } + } const validation = ToolUtils.requiresAcceptance(tool) const chatStream = new ChatStream( this, tabID, triggerID, toolUse, + session, + messageIdToUpdate, + true, validation, + isReadOrList, changeList ) await ToolUtils.queueDescription(tool, chatStream) + if (session.messageIdToUpdate === undefined && tool.type === ToolType.FsRead) { + // Store the first messageId in a chain of tool uses + session.setMessageIdToUpdate(toolUse.toolUseId) + } + if ( + session.messageIdToUpdateListDirectory === undefined && + tool.type === ToolType.ListDirectory + ) { + session.setMessageIdToUpdateListDirectory(toolUse.toolUseId) + } getLogger().debug( `SetToolUseWithError: ${toolUse.name}:${toolUse.toolUseId} with no error` ) @@ -541,6 +591,26 @@ export class Messenger { }) } + public sendInitialToolMessage(tabID: string, triggerID: string, toolUseId: string | undefined) { + this.dispatcher.sendChatMessage( + new ChatMessage( + { + message: '', + messageType: 'answer', + followUps: undefined, + followUpsHeader: undefined, + relatedSuggestions: undefined, + triggerID, + messageID: toolUseId ?? 'toolUse', + userIntent: undefined, + codeBlockLanguage: undefined, + contextList: undefined, + }, + tabID + ) + ) + } + public sendErrorMessage( errorMessage: string | undefined, tabID: string, @@ -558,14 +628,66 @@ export class Messenger { ) } + private sendReadAndListDirToolMessage( + toolUse: ToolUse, + session: ChatSession, + tabID: string, + triggerID: string, + messageIdToUpdate?: string + ) { + const contextList = toolUse.name === ToolType.ListDirectory ? session.readFolders : session.readFiles + const isFileRead = toolUse.name === ToolType.FsRead + const items = isFileRead ? session.readFiles : session.readFolders + const itemCount = items.length + + const title = + itemCount < 1 + ? 'Gathering context' + : isFileRead + ? `${itemCount} file${itemCount > 1 ? 's' : ''} read` + : `${itemCount} ${itemCount === 1 ? 'directory' : 'directories'} listed` + + this.dispatcher.sendToolMessage( + new ToolMessage( + { + message: '', + messageType: 'answer-part', + followUps: undefined, + followUpsHeader: undefined, + relatedSuggestions: undefined, + triggerID, + messageID: messageIdToUpdate ?? toolUse?.toolUseId ?? '', + userIntent: undefined, + codeBlockLanguage: undefined, + contextList, + canBeVoted: false, + buttons: undefined, + fullWidth: false, + padding: false, + codeBlockActions: undefined, + rootFolderTitle: title, + }, + tabID + ) + ) + } + public sendPartialToolLog( message: string, tabID: string, triggerID: string, toolUse: ToolUse | undefined, + session: ChatSession, + messageIdToUpdate: string | undefined, validation: CommandValidation, changeList?: Change[] ) { + // Handle read tool and list directory messages + if (toolUse?.name === ToolType.FsRead || toolUse?.name === ToolType.ListDirectory) { + return this.sendReadAndListDirToolMessage(toolUse, session, tabID, triggerID, messageIdToUpdate) + } + + // Handle file write tool, execute bash tool and bash command output log messages const buttons: ChatItemButton[] = [] let header: ChatItemHeader | undefined = undefined if (toolUse?.name === ToolType.ExecuteBash && message.startsWith('```shell')) { @@ -614,14 +736,23 @@ export class Messenger { status: 'clear', icon: 'cancel' as MynahIconsType, }, - { - id: 'accept-code-diff', - status: 'clear', - icon: 'ok' as MynahIconsType, - }, ] + const status: { + icon?: MynahIcons | MynahIconsType + status?: { + status?: Status + icon?: MynahIcons | MynahIconsType + text?: string + } + } = { + status: { + text: 'Accepted', + status: 'success', + }, + } header = { buttons, + ...status, fileList, } } else if (toolUse?.name === ToolType.ListDirectory || toolUse?.name === ToolType.FsRead) { @@ -630,7 +761,7 @@ export class Messenger { { id: 'confirm-tool-use', text: localize('AWS.generic.run', 'Run'), - status: 'main', + status: 'clear', icon: 'play' as MynahIconsType, }, { @@ -641,7 +772,6 @@ export class Messenger { }, ] header = { - icon: 'shell' as MynahIconsType, body: 'shell', buttons, } diff --git a/packages/core/src/codewhispererChat/tools/chatStream.ts b/packages/core/src/codewhispererChat/tools/chatStream.ts index 83a7935bf05..a51aa97aa00 100644 --- a/packages/core/src/codewhispererChat/tools/chatStream.ts +++ b/packages/core/src/codewhispererChat/tools/chatStream.ts @@ -9,6 +9,7 @@ import { Messenger } from '../controllers/chat/messenger/messenger' import { ToolUse } from '@amzn/codewhisperer-streaming' import { CommandValidation } from './executeBash' import { Change } from 'diff' +import { ChatSession } from '../clients/chat/v0/chat' import { i18n } from '../../shared/i18n-helper' /** @@ -23,12 +24,22 @@ export class ChatStream extends Writable { private readonly tabID: string, private readonly triggerID: string, private readonly toolUse: ToolUse | undefined, + private readonly session: ChatSession, + private readonly messageIdToUpdate: string | undefined, + // emitEvent decides to show the streaming message or read/list directory tool message to the user. + private readonly emitEvent: boolean, private readonly validation: CommandValidation, + private readonly isReadorList: boolean, private readonly changeList?: Change[], private readonly logger = getLogger('chatStream') ) { super() - this.logger.debug(`ChatStream created for tabID: ${tabID}, triggerID: ${triggerID}`) + this.logger.debug( + `ChatStream created for tabID: ${tabID}, triggerID: ${triggerID}, readFiles: ${session.readFiles}, emitEvent to mynahUI: ${emitEvent}` + ) + if (!emitEvent) { + return + } if (validation.requiresAcceptance) { this.messenger.sendDirectiveMessage( tabID, @@ -36,18 +47,27 @@ export class ChatStream extends Writable { i18n('AWS.amazonq.chat.directive.runCommandToProceed') ) } - this.messenger.sendInitalStream(tabID, triggerID) + // For FsRead and ListDirectory tools If messageIdToUpdate is undefined, we need to first create an empty message with messageId so it can be updated later + if (isReadorList && !messageIdToUpdate) { + this.messenger.sendInitialToolMessage(tabID, triggerID, toolUse?.toolUseId) + } else { + this.messenger.sendInitalStream(tabID, triggerID) + } } override _write(chunk: Buffer, encoding: BufferEncoding, callback: (error?: Error | null) => void): void { const text = chunk.toString() this.accumulatedLogs += text - this.logger.debug(`ChatStream received chunk: ${text}`) + this.logger.debug( + `ChatStream received chunk: ${text}, emitEvent to mynahUI: ${this.emitEvent}, isReadorList tool: ${this.isReadorList}` + ) this.messenger.sendPartialToolLog( this.accumulatedLogs, this.tabID, this.triggerID, this.toolUse, + this.session, + this.messageIdToUpdate, this.validation, this.changeList ) diff --git a/packages/core/src/codewhispererChat/tools/fsRead.ts b/packages/core/src/codewhispererChat/tools/fsRead.ts index 5dfc30d26f8..5b0011a2167 100644 --- a/packages/core/src/codewhispererChat/tools/fsRead.ts +++ b/packages/core/src/codewhispererChat/tools/fsRead.ts @@ -6,7 +6,6 @@ import * as vscode from 'vscode' import { getLogger } from '../../shared/logger/logger' import fs from '../../shared/fs/fs' import { Writable } from 'stream' -import path from 'path' import { InvokeOutput, OutputKind, sanitizePath, CommandValidation } from './toolShared' import { isInDirectory } from '../../shared/filesystemUtilities' @@ -49,23 +48,7 @@ export class FsRead { } public queueDescription(updates: Writable): void { - const fileName = path.basename(this.fsPath) - const fileUri = vscode.Uri.file(this.fsPath) - updates.write(`Reading file: [${fileName}](${fileUri}), `) - - const [start, end] = this.readRange ?? [] - - if (start && end) { - updates.write(`from line ${start} to ${end}`) - } else if (start) { - if (start > 0) { - updates.write(`from line ${start} to end of file`) - } else { - updates.write(`${start} line from the end of file to end of file`) - } - } else { - updates.write('all lines') - } + updates.write('') updates.end() } diff --git a/packages/core/src/codewhispererChat/tools/listDirectory.ts b/packages/core/src/codewhispererChat/tools/listDirectory.ts index 7a15bd4be8f..a1eb6a37858 100644 --- a/packages/core/src/codewhispererChat/tools/listDirectory.ts +++ b/packages/core/src/codewhispererChat/tools/listDirectory.ts @@ -52,12 +52,12 @@ export class ListDirectory { public queueDescription(updates: Writable): void { const fileName = path.basename(this.fsPath) if (this.maxDepth === undefined) { - updates.write(`Listing directory recursively: ${fileName}`) + updates.write(`Analyzing directories recursively: ${fileName}`) } else if (this.maxDepth === 0) { - updates.write(`Listing directory: ${fileName}`) + updates.write(`Analyzing directory: ${fileName}`) } else { const level = this.maxDepth > 1 ? 'levels' : 'level' - updates.write(`Listing directory: ${fileName} limited to ${this.maxDepth} subfolder ${level}`) + updates.write(`Analyzing directory: ${fileName} limited to ${this.maxDepth} subfolder ${level}`) } updates.end() } diff --git a/packages/core/src/codewhispererChat/tools/tool_index.json b/packages/core/src/codewhispererChat/tools/tool_index.json index 1d23525599f..e5d725c2f4b 100644 --- a/packages/core/src/codewhispererChat/tools/tool_index.json +++ b/packages/core/src/codewhispererChat/tools/tool_index.json @@ -10,7 +10,7 @@ "type": "string" }, "readRange": { - "description": "Optional parameter when reading files.\n * If none is given, the full file is shown. If provided, the file will be shown in the indicated line number range, e.g. [11, 12] will show lines 11 and 12. Indexing at 1 to start. Setting `[startLine, -1]` shows all lines from `startLine` to the end of the file. If the whole file is too large, try reading 4000 lines at once, for example: after reading [1, 4000], read [4000, 8000] next and repeat.", + "description": "Optional parameter when reading files.\n * If none is given, the full file is shown. If provided, the file will be shown in the indicated line number range, e.g. [11, 12] will show lines 11 and 12. Indexing at 1 to start. Setting `[startLine, -1]` shows all lines from `startLine` to the end of the file. If the whole file is too large, try reading 4000 lines at once, for example: after reading [1, 4000], read [4000, 8000] next and repeat. You should read atleast 250 lines per invocation of the tool. In some cases, if reading a range of lines results in too many invocations instead attempt to read 4000 lines.", "items": { "type": "integer" }, diff --git a/packages/core/src/codewhispererChat/view/connector/connector.ts b/packages/core/src/codewhispererChat/view/connector/connector.ts index 1f10eeb3134..a718762d8af 100644 --- a/packages/core/src/codewhispererChat/view/connector/connector.ts +++ b/packages/core/src/codewhispererChat/view/connector/connector.ts @@ -404,6 +404,10 @@ export class ChatMessage extends UiMessage { } } +export class ToolMessage extends ChatMessage { + override type = 'toolMessage' +} + export interface FollowUp { readonly type: string readonly pillText: string @@ -458,6 +462,10 @@ export class AppToWebViewMessageDispatcher { this.appsToWebViewMessagePublisher.publish(message) } + public sendToolMessage(message: ToolMessage) { + this.appsToWebViewMessagePublisher.publish(message) + } + public sendEditorContextCommandMessage(message: EditorContextCommandMessage) { this.appsToWebViewMessagePublisher.publish(message) } From 02d8d49f93b91a4ed50d57d2ec9b8f3a3b450db6 Mon Sep 17 00:00:00 2001 From: Rile Ge Date: Tue, 8 Apr 2025 10:49:55 -0700 Subject: [PATCH 16/18] Adding the grep search tool. 1. Implemented the tool using https://github.com/microsoft/vscode-ripgrep. 2. The grepped result show as clickable file path. 3. The tool currently is disable in current pr, once grepSearch tool tested, will update the tool_index.json to enable this tool. --- packages/core/src/codewhispererChat/tools/toolUtils.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/packages/core/src/codewhispererChat/tools/toolUtils.ts b/packages/core/src/codewhispererChat/tools/toolUtils.ts index b118b8fd4a6..c6eacf9e45d 100644 --- a/packages/core/src/codewhispererChat/tools/toolUtils.ts +++ b/packages/core/src/codewhispererChat/tools/toolUtils.ts @@ -51,13 +51,9 @@ export class ToolUtils { case ToolType.ExecuteBash: return tool.tool.requiresAcceptance() case ToolType.ListDirectory: -<<<<<<< HEAD - return { requiresAcceptance: false } + return tool.tool.requiresAcceptance() case ToolType.GrepSearch: return { requiresAcceptance: false } -======= - return tool.tool.requiresAcceptance() ->>>>>>> 4e0659cbc (feat(chat): Add validation to fileRead, ListDir and ExecBash tools (#7008)) } } From 422ac75273079f3e43979bfbe873d1ed471125f3 Mon Sep 17 00:00:00 2001 From: Rile Ge Date: Thu, 10 Apr 2025 16:30:52 -0700 Subject: [PATCH 17/18] Adding the package.json --- package-lock.json | 38 ++++++ .../src/codewhispererChat/tools/grepSearch.ts | 118 +++++++++++------- 2 files changed, 109 insertions(+), 47 deletions(-) diff --git a/package-lock.json b/package-lock.json index abdfc73bdfe..f59eb8d781f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -55,6 +55,7 @@ "ts-node": "^10.9.1", >>>>>>> f1dded7ed (Update version to snapshot version: 1.59.0-SNAPSHOT) "typescript": "^5.0.4", + "vscode-ripgrep": "^1.13.2", "webpack": "^5.95.0", "webpack-cli": "^5.1.4", "webpack-dev-server": "^4.15.2", @@ -25656,6 +25657,43 @@ "node": ">=4.0.0" } }, + "node_modules/vscode-ripgrep": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/vscode-ripgrep/-/vscode-ripgrep-1.13.2.tgz", + "integrity": "sha512-RlK9U87EokgHfiOjDQ38ipQQX936gWOcWPQaJpYf+kAkz1PQ1pK2n7nhiscdOmLu6XGjTs7pWFJ/ckonpN7twQ==", + "deprecated": "This package has been renamed to @vscode/ripgrep, please update to the new name", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "https-proxy-agent": "^4.0.0", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/vscode-ripgrep/node_modules/agent-base": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-5.1.1.tgz", + "integrity": "sha512-TMeqbNl2fMW0nMjTEPOwe3J/PRFP4vqeoNuQMG0HlMrtm5QxKqdvAkZ1pRBQ/ulIyDD5Yq0nJ7YbdD8ey0TO3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/vscode-ripgrep/node_modules/https-proxy-agent": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-4.0.0.tgz", + "integrity": "sha512-zoDhWrkR3of1l9QAL8/scJZyLu8j/gBkcwcaQOZh7Gyh/+uJQzGVETdgT30akuwkpL8HTRfssqI3BZuV18teDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "5", + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, "node_modules/vscode-uri": { "version": "2.1.2", "license": "MIT" diff --git a/packages/core/src/codewhispererChat/tools/grepSearch.ts b/packages/core/src/codewhispererChat/tools/grepSearch.ts index c0b47a1967f..f1b3e9756c4 100644 --- a/packages/core/src/codewhispererChat/tools/grepSearch.ts +++ b/packages/core/src/codewhispererChat/tools/grepSearch.ts @@ -21,7 +21,7 @@ export interface GrepSearchParams { } export class GrepSearch { - private fsPath: string | undefined + private path: string private query: string private caseSensitive: boolean private excludePattern?: string @@ -29,7 +29,7 @@ export class GrepSearch { private readonly logger = getLogger('grepSearch') constructor(params: GrepSearchParams) { - this.fsPath = params.path + this.path = this.getSearchDirectory(params.path) this.query = params.query this.caseSensitive = params.caseSensitive ?? false this.excludePattern = params.excludePattern @@ -41,61 +41,58 @@ export class GrepSearch { throw new Error('Grep search query cannot be empty.') } - // Handle optional path parameter - if (!this.fsPath || this.fsPath.trim().length === 0) { - // Use current workspace folder as default if path is not provided - const workspaceFolders = vscode.workspace.workspaceFolders - if (!workspaceFolders || workspaceFolders.length === 0) { - throw new Error('Path cannot be empty and no workspace folder is available.') - } - this.fsPath = workspaceFolders[0].uri.fsPath - this.logger.debug(`Using default workspace folder: ${this.fsPath}`) + if (this.path.trim().length === 0) { + throw new Error('Path cannot be empty and no workspace folder is available.') } - const sanitized = sanitizePath(this.fsPath) - this.fsPath = sanitized + const sanitized = sanitizePath(this.path) + this.path = sanitized - const pathUri = vscode.Uri.file(this.fsPath) + const pathUri = vscode.Uri.file(this.path) let pathExists: boolean try { pathExists = await fs.existsDir(pathUri) if (!pathExists) { - throw new Error(`Path: "${this.fsPath}" does not exist or cannot be accessed.`) + throw new Error(`Path: "${this.path}" does not exist or cannot be accessed.`) } } catch (err) { - throw new Error(`Path: "${this.fsPath}" does not exist or cannot be accessed. (${err})`) + throw new Error(`Path: "${this.path}" does not exist or cannot be accessed. (${err})`) } } public queueDescription(updates: Writable): void { - const searchDirectory = this.getSearchDirectory(this.fsPath) - updates.write(`Grepping for "${this.query}" in directory: ${searchDirectory}`) + updates.write(`Grepping for "${this.query}" in directory: ${this.path}`) updates.end() } public async invoke(updates?: Writable): Promise { - const searchDirectory = this.getSearchDirectory(this.fsPath) try { const results = await this.executeRipgrep(updates) return this.createOutput(results) } catch (error: any) { - this.logger.error(`Failed to search in "${searchDirectory}": ${error.message || error}`) - throw new Error(`Failed to search in "${searchDirectory}": ${error.message || error}`) + this.logger.error(`Failed to search in "${this.path}": ${error.message || error}`) + throw new Error(`Failed to search in "${this.path}": ${error.message || error}`) } } - private getSearchDirectory(fsPath?: string): string { - const workspaceFolders = vscode.workspace.workspaceFolders - const searchLocation = fsPath - ? fsPath - : !workspaceFolders || workspaceFolders.length === 0 - ? '' - : workspaceFolders[0].uri.fsPath + private getSearchDirectory(path?: string): string { + let searchLocation = '' + if (path && path.trim().length !== 0) { + searchLocation = path + } else { + // Handle optional path parameter + // Use current workspace folder as default if path is not provided + const workspaceFolders = vscode.workspace.workspaceFolders + this.logger.info(`Using default workspace folder: ${workspaceFolders?.length}`) + if (workspaceFolders && workspaceFolders.length !== 0) { + searchLocation = workspaceFolders[0].uri.fsPath + this.logger.debug(`Using default workspace folder: ${searchLocation}`) + } + } return searchLocation } private async executeRipgrep(updates?: Writable): Promise { - const searchDirectory = this.getSearchDirectory(this.fsPath) return new Promise(async (resolve, reject) => { const args: string[] = [] @@ -129,7 +126,7 @@ export class GrepSearch { } // Add search pattern and path - args.push(this.query, searchDirectory) + args.push(this.query, this.path) this.logger.debug(`Executing ripgrep with args: ${args.join(' ')}`) @@ -171,36 +168,63 @@ export class GrepSearch { * 1. Remove matched content (keep only file:line) * 2. Add file URLs for clickable links */ + /** + * Process ripgrep output to: + * 1. Group results by file + * 2. Format as collapsible sections + * 3. Add file URLs for clickable links + */ private processRipgrepOutput(output: string): string { if (!output || output.trim() === '') { return 'No matches found.' } const lines = output.split('\n') - const processedLines = lines - .map((line) => { - if (!line || line.trim() === '') { - return '' - } - // Extract file path and line number - const parts = line.split(':') - if (parts.length < 2) { - return line - } + // Group by file path + const fileGroups: Record = {} + + for (const line of lines) { + if (!line || line.trim() === '') { + continue + } - const filePath = parts[0] - const lineNumber = parts[1] + // Extract file path and line number + const parts = line.split(':') + if (parts.length < 2) { + continue + } + + const filePath = parts[0] + const lineNumber = parts[1] + // Don't include match content + if (!fileGroups[filePath]) { + fileGroups[filePath] = [] + } + + // Create a clickable link with line number only + fileGroups[filePath].push(`- [Line ${lineNumber}](${vscode.Uri.file(filePath).toString()}:${lineNumber})`) + } + + // Sort files by match count (most matches first) + const sortedFiles = Object.entries(fileGroups).sort((a, b) => b[1].length - a[1].length) + + // Format as collapsible sections + const processedOutput = sortedFiles + .map(([filePath, matches]) => { const fileName = path.basename(filePath) - const fileUri = vscode.Uri.file(filePath) + const matchCount = matches.length + + return `
+ ${fileName} (${matchCount}) - // Format as a markdown link - return `[${fileName}:${lineNumber}](${fileUri}:${lineNumber})` +${matches.join('\n')} +
` }) - .filter(Boolean) + .join('\n\n') - return processedLines.join('\n') + return processedOutput } private createOutput(content: string): InvokeOutput { From a96f91a8d53d1403f91166423b24fab597e1a1c8 Mon Sep 17 00:00:00 2001 From: Rile Ge Date: Fri, 11 Apr 2025 10:37:52 -0700 Subject: [PATCH 18/18] Fix the grepSearch. --- package-lock.json | 9 ----- .../src/codewhispererChat/tools/grepSearch.ts | 35 ++++++++++--------- 2 files changed, 18 insertions(+), 26 deletions(-) diff --git a/package-lock.json b/package-lock.json index f59eb8d781f..e4f28c8510f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -43,17 +43,8 @@ "husky": "^9.0.7", "prettier": "^3.3.3", "prettier-plugin-sh": "^0.14.0", -<<<<<<< HEAD "pretty-quick": "^4.1.1", "ts-node": "^10.9.1", -======= - "pretty-quick": "^4.0.0", -<<<<<<< HEAD - "ts-node": "^10.9.2", ->>>>>>> fc5043675 (Release 1.58.0) -======= - "ts-node": "^10.9.1", ->>>>>>> f1dded7ed (Update version to snapshot version: 1.59.0-SNAPSHOT) "typescript": "^5.0.4", "vscode-ripgrep": "^1.13.2", "webpack": "^5.95.0", diff --git a/packages/core/src/codewhispererChat/tools/grepSearch.ts b/packages/core/src/codewhispererChat/tools/grepSearch.ts index f1b3e9756c4..05302d24cd5 100644 --- a/packages/core/src/codewhispererChat/tools/grepSearch.ts +++ b/packages/core/src/codewhispererChat/tools/grepSearch.ts @@ -147,42 +147,39 @@ export class GrepSearch { const result = await rg.run() this.logger.info(`Executing ripgrep with exitCode: ${result.exitCode}`) // Process the output to format with file URLs and remove matched content - const processedOutput = this.processRipgrepOutput(result.stdout) + const { sanitizedOutput, totalMatchCount } = this.processRipgrepOutput(result.stdout) // If updates is provided, write the processed output if (updates) { - updates.write('\n\nGreped Results:\n\n') - updates.write(processedOutput) + updates.write(`\n\n${totalMatchCount} matches found:\n\n`) + updates.write(sanitizedOutput) } - this.logger.info(`Processed ripgrep result: ${processedOutput}`) - resolve(processedOutput) + this.logger.info(`Processed ripgrep result: ${totalMatchCount} matches found`) + resolve(sanitizedOutput) } catch (err) { reject(err) } }) } - /** - * Process ripgrep output to: - * 1. Remove matched content (keep only file:line) - * 2. Add file URLs for clickable links - */ /** * Process ripgrep output to: * 1. Group results by file * 2. Format as collapsible sections * 3. Add file URLs for clickable links + * @returns An object containing the processed output and total match count */ - private processRipgrepOutput(output: string): string { + private processRipgrepOutput(output: string): { sanitizedOutput: string; totalMatchCount: number } { if (!output || output.trim() === '') { - return 'No matches found.' + return { sanitizedOutput: 'No matches found.', totalMatchCount: 0 } } const lines = output.split('\n') // Group by file path const fileGroups: Record = {} + let totalMatchCount = 0 for (const line of lines) { if (!line || line.trim() === '') { @@ -203,28 +200,32 @@ export class GrepSearch { fileGroups[filePath] = [] } - // Create a clickable link with line number only - fileGroups[filePath].push(`- [Line ${lineNumber}](${vscode.Uri.file(filePath).toString()}:${lineNumber})`) + // Create a clickable link with line number using VS Code's Uri.with() method + const uri = vscode.Uri.file(filePath) + // Use the with() method to add the line number as a fragment + const uriWithLine = uri.with({ fragment: `L${lineNumber}` }) + fileGroups[filePath].push(`- [Line ${lineNumber}](${uriWithLine.toString(true)})`) + totalMatchCount++ } // Sort files by match count (most matches first) const sortedFiles = Object.entries(fileGroups).sort((a, b) => b[1].length - a[1].length) // Format as collapsible sections - const processedOutput = sortedFiles + const sanitizedOutput = sortedFiles .map(([filePath, matches]) => { const fileName = path.basename(filePath) const matchCount = matches.length return `
- ${fileName} (${matchCount}) + ${fileName} - match count: ${matchCount} ${matches.join('\n')}
` }) .join('\n\n') - return processedOutput + return { sanitizedOutput, totalMatchCount } } private createOutput(content: string): InvokeOutput {