diff --git a/packages/core/src/amazonq/webview/ui/apps/cwChatConnector.ts b/packages/core/src/amazonq/webview/ui/apps/cwChatConnector.ts index 6fb36db08b9..ab175c1da6f 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 @@ -363,7 +395,6 @@ export class Connector extends BaseConnector { break case 'run-shell-command': answer.header = { - icon: 'shell' as MynahIconsType, body: 'shell', status: { icon: 'ok' as MynahIconsType, @@ -374,7 +405,6 @@ export class Connector extends BaseConnector { break case 'reject-shell-command': answer.header = { - icon: 'shell' as MynahIconsType, body: 'shell', status: { icon: 'cancel' as MynahIconsType, 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 d2d08403312..8e666467b8f 100644 --- a/packages/core/src/amazonq/webview/ui/main.ts +++ b/packages/core/src/amazonq/webview/ui/main.ts @@ -98,6 +98,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', @@ -342,8 +377,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 } : {}), @@ -405,32 +443,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 53413c5a30f..859d0254b72 100644 --- a/packages/core/src/codewhispererChat/controllers/chat/controller.ts +++ b/packages/core/src/codewhispererChat/controllers/chat/controller.ts @@ -715,9 +715,18 @@ 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, + } + ) if (tool.type === ToolType.FsWrite && toolUse.toolUseId) { const backup = await tool.tool.getBackup() session.setFsWriteBackup(toolUse.toolUseId, backup) @@ -1180,6 +1189,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 cdfe873c175..3da4fd83195 100644 --- a/packages/core/src/codewhispererChat/controllers/chat/messenger/messenger.ts +++ b/packages/core/src/codewhispererChat/controllers/chat/messenger/messenger.ts @@ -21,6 +21,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' @@ -68,6 +69,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' @@ -280,16 +283,51 @@ export class Messenger { session.setShowDiffOnFileWrite(true) changeList = await tool.tool.getDiffChanges() } + if (tool.type === ToolType.FsRead) { + 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) { + const input = toolUse.input as unknown as ListDirectoryParams + session.setReadFolders({ + relativeFilePath: input?.path, + lineRanges: [{ first: -1, second: -1 }], + }) + } const validation = ToolUtils.requiresAcceptance(tool) const chatStream = new ChatStream( this, tabID, triggerID, toolUse, + session, + tool.type === ToolType.FsRead + ? session.messageIdToUpdate + : session.messageIdToUpdateListDirectory, + true, validation, 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) + } if (!validation.requiresAcceptance) { // Need separate id for read tool and safe bash command execution as 'run-shell-command' id is required to state in cwChatConnector.ts which will impact generic tool execution. @@ -509,6 +547,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, requestID: string | undefined) { this.showChatExceptionMessage( { @@ -521,37 +579,85 @@ 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 - let fullWidth: boolean | undefined = undefined - let padding: boolean | undefined = undefined - let codeBlockActions: ChatItemContent['codeBlockActions'] = undefined if (toolUse?.name === ToolType.ExecuteBash && message.startsWith('```shell')) { if (validation.requiresAcceptance) { const buttons: ChatItemButton[] = [ - { - id: 'run-shell-command', - text: localize('AWS.amazonq.executeBash.run', 'Run'), - status: 'main', - icon: 'play' as MynahIconsType, - }, { id: 'reject-shell-command', text: localize('AWS.amazonq.executeBash.reject', 'Reject'), status: 'clear', icon: 'cancel' as MynahIconsType, }, + { + id: 'run-shell-command', + text: localize('AWS.amazonq.executeBash.run', 'Run'), + status: 'clear', + icon: 'play' as MynahIconsType, + }, ] header = { - icon: 'shell' as MynahIconsType, body: 'shell', buttons, } @@ -559,10 +665,6 @@ export class Messenger { if (validation.warning) { message = validation.warning + message } - fullWidth = true - padding = false - // eslint-disable-next-line unicorn/no-null - codeBlockActions = { 'insert-to-cursor': null, copy: null } } else if (toolUse?.name === ToolType.FsWrite) { const input = toolUse.input as unknown as FsWriteParams const fileName = path.basename(input.path) @@ -595,8 +697,6 @@ export class Messenger { buttons, fileList, } - fullWidth = true - padding = false } this.dispatcher.sendChatMessage( @@ -612,12 +712,14 @@ export class Messenger { userIntent: undefined, codeBlockLanguage: undefined, contextList: undefined, + title: undefined, canBeVoted: false, buttons, - fullWidth, - padding, + fullWidth: true, + padding: false, header, - codeBlockActions, + // eslint-disable-next-line unicorn/no-null + codeBlockActions: { 'insert-to-cursor': null, copy: null }, }, tabID ) diff --git a/packages/core/src/codewhispererChat/tools/chatStream.ts b/packages/core/src/codewhispererChat/tools/chatStream.ts index 7e6e4d3ae3a..8f38e789f15 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' /** * A writable stream that feeds each chunk/line to the chat UI. @@ -22,24 +23,38 @@ 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 changeList?: Change[], private readonly logger = getLogger('chatStream') ) { super() - this.logger.debug(`ChatStream created for tabID: ${tabID}, triggerID: ${triggerID}`) - this.messenger.sendInitalStream(tabID, triggerID) + this.logger.debug( + `ChatStream created for tabID: ${tabID}, triggerID: ${triggerID}, session: ${session.readFiles}, emitEvent to mynahUI: ${emitEvent}` + ) + if (!emitEvent) { + return + } + // If messageIdToUpdate is undefined, we need to first create an empty message with messageId so it can be updated later + messageIdToUpdate + ? this.messenger.sendInitalStream(tabID, triggerID) + : this.messenger.sendInitialToolMessage(tabID, triggerID, toolUse?.toolUseId) } 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}`) 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 05519641bd0..ecf90ea6dd3 100644 --- a/packages/core/src/codewhispererChat/tools/fsRead.ts +++ b/packages/core/src/codewhispererChat/tools/fsRead.ts @@ -7,7 +7,6 @@ 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' export interface FsReadParams { path: string @@ -48,23 +47,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 96ac6972bdc..a7379169a4d 100644 --- a/packages/core/src/codewhispererChat/tools/listDirectory.ts +++ b/packages/core/src/codewhispererChat/tools/listDirectory.ts @@ -51,12 +51,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/view/connector/connector.ts b/packages/core/src/codewhispererChat/view/connector/connector.ts index 6f04d09cd46..6e2aa92fe63 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) }