diff --git a/packages/amazonq/.changes/next-release/Feature-1707f610-2dde-4af1-b704-0e541efb7c4a.json b/packages/amazonq/.changes/next-release/Feature-1707f610-2dde-4af1-b704-0e541efb7c4a.json new file mode 100644 index 00000000000..4800cd44fa6 --- /dev/null +++ b/packages/amazonq/.changes/next-release/Feature-1707f610-2dde-4af1-b704-0e541efb7c4a.json @@ -0,0 +1,4 @@ +{ + "type": "Feature", + "description": "Amazon Q chat: Use `@` to add folders, files, and saved prompts as context" +} diff --git a/packages/amazonq/.changes/next-release/Feature-dc148db8-5936-478c-a8cf-909d0885105b.json b/packages/amazonq/.changes/next-release/Feature-dc148db8-5936-478c-a8cf-909d0885105b.json new file mode 100644 index 00000000000..d49889457e3 --- /dev/null +++ b/packages/amazonq/.changes/next-release/Feature-dc148db8-5936-478c-a8cf-909d0885105b.json @@ -0,0 +1,4 @@ +{ + "type": "Feature", + "description": "Amazon Q chat: Show list of files sent as context in chat response" +} diff --git a/packages/amazonq/test/e2e/amazonq/chat.test.ts b/packages/amazonq/test/e2e/amazonq/chat.test.ts index 3021be28782..52122c8e904 100644 --- a/packages/amazonq/test/e2e/amazonq/chat.test.ts +++ b/packages/amazonq/test/e2e/amazonq/chat.test.ts @@ -11,7 +11,7 @@ import { MynahUIDataModel } from '@aws/mynah-ui' import { assertContextCommands, assertQuickActions } from './assert' import { registerAuthHook, using } from 'aws-core-vscode/test' import { loginToIdC } from './utils/setup' -import { webviewConstants } from 'aws-core-vscode/amazonq' +import { webviewConstants, webviewTabConstants } from 'aws-core-vscode/amazonq' describe('Amazon Q Chat', function () { let framework: qTestingFramework @@ -60,7 +60,7 @@ describe('Amazon Q Chat', function () { }) it('Shows placeholder', () => { - assert.deepStrictEqual(store.promptInputPlaceholder, 'Ask a question or enter "/" for quick actions') + assert.deepStrictEqual(store.promptInputPlaceholder, webviewTabConstants.commonTabData.placeholder) }) it('Sends message', async () => { diff --git a/packages/amazonq/test/e2e/amazonq/framework/jsdomInjector.ts b/packages/amazonq/test/e2e/amazonq/framework/jsdomInjector.ts index ce8309c1039..f2aa5a283c9 100644 --- a/packages/amazonq/test/e2e/amazonq/framework/jsdomInjector.ts +++ b/packages/amazonq/test/e2e/amazonq/framework/jsdomInjector.ts @@ -40,6 +40,9 @@ export function injectJSDOM() { get() { return this.textContent }, + set(value) { + this.textContent = value + }, }) // jsdom doesn't have support for structuredClone. See https://github.com/jsdom/jsdom/issues/3363 diff --git a/packages/core/package.nls.json b/packages/core/package.nls.json index 23fed064a06..0a33858a232 100644 --- a/packages/core/package.nls.json +++ b/packages/core/package.nls.json @@ -309,6 +309,17 @@ "AWS.codewhisperer.customization.notification.new_customizations.learn_more": "Learn More", "AWS.amazonq.title": "Amazon Q", "AWS.amazonq.chat": "Chat", + "AWS.amazonq.context.folders.title": "Folders", + "AWS.amazonq.context.folders.description": "Add all files in a folder to context", + "AWS.amazonq.context.files.title": "Files", + "AWS.amazonq.context.files.description": "Add a file to context", + "AWS.amazonq.context.prompts.title": "Prompts", + "AWS.amazonq.context.prompts.description": "Add a saved prompt to context", + "AWS.amazonq.savedPrompts.title": "Prompt name", + "AWS.amazonq.savedPrompts.create": "Create", + "AWS.amazonq.savedPrompts.action": "Create a new prompt", + "AWS.amazonq.savedPrompts.placeholder": "Enter prompt name", + "AWS.amazonq.savedPrompts.description": "Use this prompt by typing '@' followed by the prompt name.", "AWS.amazonq.chat.workspacecontext.enable.message": "Amazon Q: Workspace index is now enabled. You can disable it from Amazon Q settings.", "AWS.amazonq.security": "Code Issues", "AWS.amazonq.login": "Login", diff --git a/packages/core/src/amazonq/index.ts b/packages/core/src/amazonq/index.ts index 2eee9e7d4b2..59a53ddf658 100644 --- a/packages/core/src/amazonq/index.ts +++ b/packages/core/src/amazonq/index.ts @@ -26,6 +26,7 @@ export { init as testChatAppInit } from '../amazonqTest/app' export { init as docChatAppInit } from '../amazonqDoc/app' export { amazonQHelpUrl } from '../shared/constants' export * as webviewConstants from './webview/ui/texts/constants' +export * as webviewTabConstants from './webview/ui/tabs/constants' export { listCodeWhispererCommandsWalkthrough } from '../codewhisperer/ui/statusBarMenu' export { focusAmazonQPanel, focusAmazonQPanelKeybinding } from '../codewhispererChat/commands/registerCommands' export { TryChatCodeLensProvider, tryChatCodeLensCommand } from '../codewhispererChat/editor/codelens' diff --git a/packages/core/src/amazonq/lsp/lspClient.ts b/packages/core/src/amazonq/lsp/lspClient.ts index b5b506818d8..5d96650ebf8 100644 --- a/packages/core/src/amazonq/lsp/lspClient.ts +++ b/packages/core/src/amazonq/lsp/lspClient.ts @@ -29,12 +29,18 @@ import { QueryRepomapIndexRequestType, GetRepomapIndexJSONRequestType, Usage, + GetContextCommandItemsRequestType, + ContextCommandItem, + GetIndexSequenceNumberRequestType, + GetContextCommandPromptRequestType, + AdditionalContextPrompt, } from './types' import { Writable } from 'stream' import { CodeWhispererSettings } from '../../codewhisperer/util/codewhispererSettings' import { fs } from '../../shared/fs/fs' import { getLogger } from '../../shared/logger/logger' import globals from '../../shared/extensionGlobals' +import { waitUntil } from '../../shared/utilities/timeoutUtils' const localize = nls.loadMessageBundle() @@ -168,6 +174,66 @@ export class LspClient { throw e } } + + async getContextCommandItems(): Promise { + try { + const workspaceFolders = vscode.workspace.workspaceFolders || [] + const request = JSON.stringify({ + workspaceFolders: workspaceFolders.map((it) => it.uri.fsPath), + }) + const resp: any = await this.client?.sendRequest( + GetContextCommandItemsRequestType, + await this.encrypt(request) + ) + return resp + } catch (e) { + getLogger().error(`LspClient: getContextCommandItems error: ${e}`) + throw e + } + } + + async getContextCommandPrompt(contextCommandItems: ContextCommandItem[]): Promise { + try { + const request = JSON.stringify({ + contextCommands: contextCommandItems, + }) + const resp: any = await this.client?.sendRequest( + GetContextCommandPromptRequestType, + await this.encrypt(request) + ) + return resp + } catch (e) { + getLogger().error(`LspClient: getContextCommandPrompt error: ${e}`) + throw e + } + } + + async getIndexSequenceNumber(): Promise { + try { + const request = JSON.stringify({}) + const resp: any = await this.client?.sendRequest( + GetIndexSequenceNumberRequestType, + await this.encrypt(request) + ) + return resp + } catch (e) { + getLogger().error(`LspClient: getIndexSequenceNumber error: ${e}`) + throw e + } + } + + async waitUntilReady() { + return waitUntil( + async () => { + if (this.client === undefined) { + return false + } + await this.client.onReady() + return true + }, + { interval: 500, timeout: 60_000 * 3, truthy: true } + ) + } } /** * Activates the language server, this will start LSP server running over IPC protocol. @@ -249,6 +315,37 @@ export async function activate(extensionContext: ExtensionContext) { let savedDocument: vscode.Uri | undefined = undefined + const onAdd = async (filePaths: string[]) => { + const indexSeqNum = await LspClient.instance.getIndexSequenceNumber() + await LspClient.instance.updateIndex(filePaths, 'add') + await waitUntil( + async () => { + const newIndexSeqNum = await LspClient.instance.getIndexSequenceNumber() + if (newIndexSeqNum > indexSeqNum) { + await vscode.commands.executeCommand(`aws.amazonq.updateContextCommandItems`) + return true + } + return false + }, + { interval: 500, timeout: 5_000, truthy: true } + ) + } + const onRemove = async (filePaths: string[]) => { + const indexSeqNum = await LspClient.instance.getIndexSequenceNumber() + await LspClient.instance.updateIndex(filePaths, 'remove') + await waitUntil( + async () => { + const newIndexSeqNum = await LspClient.instance.getIndexSequenceNumber() + if (newIndexSeqNum > indexSeqNum) { + await vscode.commands.executeCommand(`aws.amazonq.updateContextCommandItems`) + return true + } + return false + }, + { interval: 500, timeout: 5_000, truthy: true } + ) + } + toDispose.push( vscode.workspace.onDidSaveTextDocument((document) => { if (document.uri.scheme !== 'file') { @@ -260,18 +357,23 @@ export async function activate(extensionContext: ExtensionContext) { if (savedDocument && editor && editor.document.uri.fsPath !== savedDocument.fsPath) { void LspClient.instance.updateIndex([savedDocument.fsPath], 'update') } + // user created a new empty file using File -> New File + // these events will not be captured by vscode.workspace.onDidCreateFiles + // because it was created by File Explorer(Win) or Finder(MacOS) + // TODO: consider using a high performance fs watcher + if (editor?.document.getText().length === 0) { + void onAdd([editor.document.uri.fsPath]) + } }), - vscode.workspace.onDidCreateFiles((e) => { - void LspClient.instance.updateIndex( - e.files.map((f) => f.fsPath), - 'add' - ) + vscode.workspace.onDidCreateFiles(async (e) => { + await onAdd(e.files.map((f) => f.fsPath)) }), - vscode.workspace.onDidDeleteFiles((e) => { - void LspClient.instance.updateIndex( - e.files.map((f) => f.fsPath), - 'remove' - ) + vscode.workspace.onDidDeleteFiles(async (e) => { + await onRemove(e.files.map((f) => f.fsPath)) + }), + vscode.workspace.onDidRenameFiles(async (e) => { + await onRemove(e.files.map((f) => f.oldUri.fsPath)) + await onAdd(e.files.map((f) => f.newUri.fsPath)) }) ) diff --git a/packages/core/src/amazonq/lsp/lspController.ts b/packages/core/src/amazonq/lsp/lspController.ts index 37afed70db0..774392337ba 100644 --- a/packages/core/src/amazonq/lsp/lspController.ts +++ b/packages/core/src/amazonq/lsp/lspController.ts @@ -13,7 +13,6 @@ import fetch from 'node-fetch' import request from '../../shared/request' import { LspClient } from './lspClient' import AdmZip from 'adm-zip' -import { RelevantTextDocument } from '@amzn/codewhisperer-streaming' import { makeTemporaryToolkitFolder, tryRemoveFolder } from '../../shared/filesystemUtilities' import { activate as activateLsp } from './lspClient' import { telemetry } from '../../shared/telemetry/telemetry' @@ -24,6 +23,7 @@ import { ToolkitError } from '../../shared/errors' import { isWeb } from '../../shared/extensionGlobals' import { getUserAgent } from '../../shared/telemetry/util' import { isAmazonInternalOs } from '../../shared/vscode/env' +import { RelevantTextDocumentAddition } from '../../codewhispererChat/controllers/chat/model' export interface Chunk { readonly filePath: string @@ -31,6 +31,8 @@ export interface Chunk { readonly context?: string readonly relativePath?: string readonly programmingLanguage?: string + readonly startLine?: number + readonly endLine?: number } export interface Content { @@ -60,7 +62,7 @@ export interface Manifest { } const manifestUrl = 'https://aws-toolkit-language-servers.amazonaws.com/q-context/manifest.json' // this LSP client in Q extension is only going to work with these LSP server versions -const supportedLspServerVersions = ['0.1.35'] +const supportedLspServerVersions = ['0.1.42'] const nodeBinName = process.platform === 'win32' ? 'node.exe' : 'node' @@ -279,9 +281,9 @@ export class LspController { } } - async query(s: string): Promise { + async query(s: string): Promise { const chunks: Chunk[] | undefined = await LspClient.instance.queryVectorIndex(s) - const resp: RelevantTextDocument[] = [] + const resp: RelevantTextDocumentAddition[] = [] if (chunks) { for (const chunk of chunks) { const text = chunk.context ? chunk.context : chunk.content @@ -292,11 +294,15 @@ export class LspController { programmingLanguage: { languageName: chunk.programmingLanguage, }, + startLine: chunk.startLine ?? -1, + endLine: chunk.endLine ?? -1, }) } else { resp.push({ text: text, relativeFilePath: chunk.relativePath ? chunk.relativePath : path.basename(chunk.filePath), + startLine: chunk.startLine ?? -1, + endLine: chunk.endLine ?? -1, }) } } @@ -393,6 +399,7 @@ export class LspController { try { await activateLsp(context) getLogger().info('LspController: LSP activated') + await vscode.commands.executeCommand(`aws.amazonq.updateContextCommandItems`) void LspController.instance.buildIndex(buildIndexConfig) // log the LSP server CPU and Memory usage per 30 minutes. globals.clock.setInterval( diff --git a/packages/core/src/amazonq/lsp/types.ts b/packages/core/src/amazonq/lsp/types.ts index 3af943cb97d..1da8dfb00de 100644 --- a/packages/core/src/amazonq/lsp/types.ts +++ b/packages/core/src/amazonq/lsp/types.ts @@ -76,3 +76,43 @@ export type GetRepomapIndexJSONRequest = string export const GetRepomapIndexJSONRequestType: RequestType = new RequestType( 'lsp/getRepomapIndexJSON' ) + +export type GetContextCommandItemsRequestPayload = { workspaceFolders: string[] } +export type GetContextCommandItemsRequest = string +export const GetContextCommandItemsRequestType: RequestType = new RequestType( + 'lsp/getContextCommandItems' +) + +export type GetIndexSequenceNumberRequest = string +export const GetIndexSequenceNumberRequestType: RequestType = new RequestType( + 'lsp/getIndexSequenceNumber' +) + +export type ContextCommandItemType = 'file' | 'folder' + +export interface ContextCommandItem { + workspaceFolder: string + type: ContextCommandItemType + relativePath: string +} + +export type GetContextCommandPromptRequestPayload = { + contextCommands: { + workspaceFolder: string + type: 'file' | 'folder' + relativePath: string + }[] +} +export type GetContextCommandPromptRequest = string +export const GetContextCommandPromptRequestType: RequestType = + new RequestType('lsp/getContextCommandPrompt') + +export interface AdditionalContextPrompt { + content: string + name: string + description: string + startLine: number + endLine: number + filePath: string + relativePath: string +} diff --git a/packages/core/src/amazonq/webview/messages/messageDispatcher.ts b/packages/core/src/amazonq/webview/messages/messageDispatcher.ts index 483b4525298..cf24a6a2324 100644 --- a/packages/core/src/amazonq/webview/messages/messageDispatcher.ts +++ b/packages/core/src/amazonq/webview/messages/messageDispatcher.ts @@ -36,6 +36,8 @@ export function dispatchWebViewMessagesToApps( }) performance.clearMarks(amazonqMark.uiReady) performance.clearMarks(amazonqMark.open) + // let cwcController know the ui is ready + webViewToAppsMessagePublishers.get('cwc')?.publish(msg) return } case 'start-chat-message-telemetry': { diff --git a/packages/core/src/amazonq/webview/ui/apps/baseConnector.ts b/packages/core/src/amazonq/webview/ui/apps/baseConnector.ts index e28c599d910..3d5cdd71473 100644 --- a/packages/core/src/amazonq/webview/ui/apps/baseConnector.ts +++ b/packages/core/src/amazonq/webview/ui/apps/baseConnector.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { ChatItem, ChatItemAction, ChatItemType, FeedbackPayload } from '@aws/mynah-ui' +import { ChatItem, ChatItemAction, ChatItemType, FeedbackPayload, QuickActionCommand } from '@aws/mynah-ui' import { ExtensionMessage } from '../commands' import { CodeReference } from './amazonqCommonsConnector' import { TabOpenType, TabsStorage, TabType } from '../storages/tabsStorage' @@ -13,6 +13,7 @@ import { CWCChatItem } from '../connector' interface ChatPayload { chatMessage: string chatCommand?: string + chatContext?: string[] | QuickActionCommand[] } export interface BaseConnectorProps { @@ -212,6 +213,7 @@ export abstract class BaseConnector { command: 'chat-prompt', chatMessage: payload.chatMessage, chatCommand: payload.chatCommand, + chatContext: payload.chatContext, tabType: this.getTabType(), }) }) diff --git a/packages/core/src/amazonq/webview/ui/apps/cwChatConnector.ts b/packages/core/src/amazonq/webview/ui/apps/cwChatConnector.ts index 58f525857a8..995061dfd51 100644 --- a/packages/core/src/amazonq/webview/ui/apps/cwChatConnector.ts +++ b/packages/core/src/amazonq/webview/ui/apps/cwChatConnector.ts @@ -3,17 +3,27 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { ChatItemType } from '@aws/mynah-ui' +import { ChatItemButton, ChatItemFormItem, ChatItemType, MynahUIDataModel, QuickActionCommand } from '@aws/mynah-ui' import { TabType } from '../storages/tabsStorage' import { CWCChatItem } from '../connector' import { BaseConnector, BaseConnectorProps } from './baseConnector' export interface ConnectorProps extends BaseConnectorProps { onCWCContextCommandMessage: (message: CWCChatItem, command?: string) => string | undefined + onContextCommandDataReceived: (data: MynahUIDataModel['contextCommands']) => void + onShowCustomForm: ( + tabId: string, + formItems?: ChatItemFormItem[], + buttons?: ChatItemButton[], + title?: string, + description?: string + ) => void } export class Connector extends BaseConnector { private readonly onCWCContextCommandMessage + private readonly onContextCommandDataReceived + private readonly onShowCustomForm override getTabType(): TabType { return 'cwc' @@ -22,6 +32,8 @@ export class Connector extends BaseConnector { constructor(props: ConnectorProps) { super(props) this.onCWCContextCommandMessage = props.onCWCContextCommandMessage + this.onContextCommandDataReceived = props.onContextCommandDataReceived + this.onShowCustomForm = props.onShowCustomForm } onSourceLinkClick = (tabID: string, messageId: string, link: string): void => { @@ -83,6 +95,7 @@ export class Connector extends BaseConnector { codeReference: messageData.codeReference, userIntent: messageData.userIntent, codeBlockLanguage: messageData.codeBlockLanguage, + contextList: messageData.contextList, } // If it is not there we will not set it @@ -131,6 +144,22 @@ export class Connector extends BaseConnector { } } + processContextCommandData(messageData: any) { + if (messageData.data) { + this.onContextCommandDataReceived(messageData.data) + } + } + + private showCustomFormMessage = (messageData: any) => { + this.onShowCustomForm( + messageData.tabID, + messageData.formItems, + messageData.buttons, + messageData.title, + messageData.description + ) + } + handleMessageReceive = async (messageData: any): Promise => { if (messageData.type === 'chatMessage') { await this.processChatMessage(messageData) @@ -142,7 +171,72 @@ export class Connector extends BaseConnector { return } + if (messageData.type === 'contextCommandData') { + this.processContextCommandData(messageData) + return + } + if (messageData.type === 'showCustomFormMessage') { + this.showCustomFormMessage(messageData) + return + } + + if (messageData.type === 'customFormActionMessage') { + this.onCustomFormAction(messageData.tabID, messageData.action) + return + } // For other message types, call the base class handleMessageReceive await this.baseHandleMessageReceive(messageData) } + + onQuickCommandGroupActionClick = (tabID: string, action: { id: string }) => { + this.sendMessageToExtension({ + command: 'quick-command-group-action-click', + actionId: action.id, + tabID, + tabType: this.getTabType(), + }) + } + + onContextSelected = (tabID: string, contextItem: QuickActionCommand) => { + this.sendMessageToExtension({ + command: 'context-selected', + contextItem, + tabID, + tabType: this.getTabType(), + }) + if (contextItem.id === 'create-saved-prompt') { + return false + } + return true + } + + onCustomFormAction( + tabId: string, + action: { + id: string + text?: string | undefined + formItemValues?: Record | undefined + } + ) { + if (action === undefined) { + return + } + + this.sendMessageToExtension({ + command: 'form-action-click', + action: action, + tabType: this.getTabType(), + tabID: tabId, + }) + } + + onFileClick = (tabID: string, filePath: string, messageId?: string) => { + this.sendMessageToExtension({ + command: 'file-click', + tabID, + messageId, + filePath, + tabType: 'cwc', + }) + } } diff --git a/packages/core/src/amazonq/webview/ui/commands.ts b/packages/core/src/amazonq/webview/ui/commands.ts index bdf2490b3a1..5b79549e53e 100644 --- a/packages/core/src/amazonq/webview/ui/commands.ts +++ b/packages/core/src/amazonq/webview/ui/commands.ts @@ -42,5 +42,7 @@ type MessageCommand = | 'open-link' | 'send-telemetry' | 'update-welcome-count' + | 'quick-command-group-action-click' + | 'context-selected' export type ExtensionMessage = Record & { command: MessageCommand } diff --git a/packages/core/src/amazonq/webview/ui/connector.ts b/packages/core/src/amazonq/webview/ui/connector.ts index 900c9a6de96..0c1fd264338 100644 --- a/packages/core/src/amazonq/webview/ui/connector.ts +++ b/packages/core/src/amazonq/webview/ui/connector.ts @@ -12,6 +12,10 @@ import { ProgressField, ReferenceTrackerInformation, ChatPrompt, + MynahUIDataModel, + QuickActionCommand, + ChatItemFormItem, + ChatItemButton, } from '@aws/mynah-ui' import { Connector as CWChatConnector } from './apps/cwChatConnector' import { Connector as FeatureDevChatConnector } from './apps/featureDevChatConnector' @@ -50,6 +54,7 @@ export interface UploadHistory { export interface ChatPayload { chatMessage: string chatCommand?: string + chatContext?: string[] | QuickActionCommand[] | undefined } // Adding userIntent param by extending ChatItem to send userIntent as part of amazonq_interactWithMessage telemetry event @@ -57,6 +62,12 @@ export interface CWCChatItem extends ChatItem { traceId?: string userIntent?: UserIntent codeBlockLanguage?: string + contextList?: Context[] +} + +export interface Context { + relativeFilePath: string + lineRanges: Array<{ first: number; second: number }> // List of [startLine, endLine] tuples } export interface ConnectorProps { @@ -87,6 +98,14 @@ export interface ConnectorProps { onFileActionClick: (tabID: string, messageId: string, filePath: string, actionName: string) => void handleCommand: (chatPrompt: ChatPrompt, tabId: string) => void sendStaticMessages: (tabID: string, messages: ChatItem[]) => void + onContextCommandDataReceived: (message: MynahUIDataModel['contextCommands']) => void + onShowCustomForm: ( + tabId: string, + formItems?: ChatItemFormItem[], + buttons?: ChatItemButton[], + title?: string, + description?: string + ) => void tabsStorage: TabsStorage } @@ -591,6 +610,9 @@ export class Connector { case 'doc': this.docChatConnector.onOpenDiff(tabID, filePath, deleted) break + case 'cwc': + this.cwChatConnector.onFileClick(tabID, filePath, messageId) + break } } @@ -608,6 +630,29 @@ export class Connector { } } + onQuickCommandGroupActionClick = (tabId: string, action: { id: string }) => { + switch (this.tabsStorage.getTab(tabId)?.type) { + case 'welcome': + case 'unknown': + case 'cwc': + this.tabsStorage.updateTabTypeFromUnknown(tabId, 'cwc') + this.cwChatConnector.onQuickCommandGroupActionClick(tabId, action) + break + } + } + + onContextSelected = (contextItem: QuickActionCommand, tabId: string) => { + switch (this.tabsStorage.getTab(tabId)?.type) { + case 'welcome': + case 'unknown': + case 'cwc': + this.tabsStorage.updateTabTypeFromUnknown(tabId, 'cwc') + return this.cwChatConnector.onContextSelected(tabId, contextItem) + default: + return true + } + } + onChatItemVoted = (tabId: string, messageId: string, vote: 'upvote' | 'downvote'): void | undefined => { switch (this.tabsStorage.getTab(tabId)?.type) { case 'cwc': @@ -651,6 +696,8 @@ export class Connector { type: '', tabType: 'cwc', }) + } else { + this.cwChatConnector.onCustomFormAction(tabId, action) } break case 'agentWalkthrough': { diff --git a/packages/core/src/amazonq/webview/ui/main.ts b/packages/core/src/amazonq/webview/ui/main.ts index 1c940c6bea1..9a832eaf60f 100644 --- a/packages/core/src/amazonq/webview/ui/main.ts +++ b/packages/core/src/amazonq/webview/ui/main.ts @@ -13,6 +13,8 @@ import { NotificationType, ReferenceTrackerInformation, ProgressField, + ChatItemButton, + ChatItemFormItem, } from '@aws/mynah-ui' import { ChatPrompt } from '@aws/mynah-ui/dist/static' import { TabsStorage, TabType } from './storages/tabsStorage' @@ -55,6 +57,8 @@ export const createMynahUI = ( // Store the mapping between messageId and messageUserIntent for amazonq_interactWithMessage telemetry const responseMetadata = new Map() + let savedContextCommands: MynahUIDataModel['contextCommands'] = [] + window.addEventListener('error', (e) => { const { error, message } = e ideApi.postMessage({ @@ -347,6 +351,7 @@ export const createMynahUI = ( ...(item.relatedContent !== undefined ? { relatedContent: item.relatedContent } : {}), ...(item.followUp !== undefined ? { followUp: item.followUp } : {}), ...(item.fileList !== undefined ? { fileList: item.fileList } : {}), + ...(item.header !== undefined ? { header: item.header } : { header: undefined }), }) if ( item.messageId !== undefined && @@ -364,6 +369,35 @@ export const createMynahUI = ( return } + if (item.contextList !== undefined && item.contextList.length > 0) { + item.header = { + fileList: { + fileTreeTitle: '', + filePaths: item.contextList.map((file) => file.relativeFilePath), + rootFolderTitle: 'Context', + 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, + }, + ]) + ), + }, + } + } + if ( item.body !== undefined || item.relatedContent !== undefined || @@ -557,6 +591,26 @@ export const createMynahUI = ( mynahUI.addChatItem(tabID, message) } }, + onContextCommandDataReceived(data: MynahUIDataModel['contextCommands']) { + savedContextCommands = data + for (const tabID in mynahUI.getAllTabs()) { + const tabType = tabsStorage.getTab(tabID)?.type || '' + if (['cwc', 'unknown', 'welcome'].includes(tabType)) { + mynahUI.updateStore(tabID, { + contextCommands: savedContextCommands, + }) + } + } + }, + onShowCustomForm( + tabId: string, + formItems?: ChatItemFormItem[], + buttons?: ChatItemButton[], + title?: string, + description?: string + ) { + mynahUI.showCustomForm(tabId, formItems, buttons, title, description) + }, }) mynahUI = new MynahUI({ @@ -587,6 +641,12 @@ export const createMynahUI = ( quickActionCommands: tabDataGenerator.quickActionsGenerator.generateForTab('unknown'), ...(disclaimerCardActive ? { promptInputStickyCard: disclaimerCard } : {}), }) + // add the cached context commands for file, folder, etc selection + if (savedContextCommands && savedContextCommands.length > 0) { + mynahUI.updateStore(tabID, { + contextCommands: savedContextCommands, + }) + } connector.onTabAdd(tabID) }, onTabRemove: connector.onTabRemove, @@ -651,6 +711,8 @@ export const createMynahUI = ( // handler for the cwc panel textMessageHandler.handle(prompt, tabID, eventId as string) }, + onQuickCommandGroupActionClick: connector.onQuickCommandGroupActionClick, + onContextSelected: connector.onContextSelected, onVote: connector.onChatItemVoted, onInBodyButtonClicked: (tabId, messageId, action, eventId) => { switch (action.id) { diff --git a/packages/core/src/amazonq/webview/ui/messages/handler.ts b/packages/core/src/amazonq/webview/ui/messages/handler.ts index d85774d23f6..92a96d1c2da 100644 --- a/packages/core/src/amazonq/webview/ui/messages/handler.ts +++ b/packages/core/src/amazonq/webview/ui/messages/handler.ts @@ -46,6 +46,7 @@ export class TextMessageHandler { .requestGenerativeAIAnswer(tabID, eventID, { chatMessage: chatPrompt.prompt ?? '', chatCommand: chatPrompt.command, + chatContext: chatPrompt.context, }) .then(() => {}) } diff --git a/packages/core/src/amazonq/webview/ui/tabs/constants.ts b/packages/core/src/amazonq/webview/ui/tabs/constants.ts index efbf700b91d..1ac0c5ae156 100644 --- a/packages/core/src/amazonq/webview/ui/tabs/constants.ts +++ b/packages/core/src/amazonq/webview/ui/tabs/constants.ts @@ -13,7 +13,7 @@ export type TabTypeData = { contextCommands?: QuickActionCommandGroup[] } -const workspaceCommand: QuickActionCommandGroup = { +export const workspaceCommand: QuickActionCommandGroup = { groupName: 'Mention code', commands: [ { @@ -23,12 +23,12 @@ const workspaceCommand: QuickActionCommandGroup = { ], } -const commonTabData: TabTypeData = { +export const commonTabData: TabTypeData = { title: 'Chat', - placeholder: 'Ask a question or enter "/" for quick actions', + placeholder: 'Ask a question. Use @ to add context, / for quick actions', welcome: `Hi, I'm Amazon Q. I can answer your software development questions. Ask me to explain, debug, or optimize your code. - You can enter \`/\` to see a list of quick actions. Add @workspace to the beginning of your message to include your entire workspace as context.`, + You can enter \`/\` to see a list of quick actions. Use \`@\` to add saved prompts, files, folders, or your entire workspace as context.`, contextCommands: [workspaceCommand], } diff --git a/packages/core/src/amazonqFeatureDev/client/codewhispererruntime-2022-11-11.json b/packages/core/src/amazonqFeatureDev/client/codewhispererruntime-2022-11-11.json index ff9670742ec..43537b6df2d 100644 --- a/packages/core/src/amazonqFeatureDev/client/codewhispererruntime-2022-11-11.json +++ b/packages/core/src/amazonqFeatureDev/client/codewhispererruntime-2022-11-11.json @@ -2587,6 +2587,12 @@ }, "documentSymbols": { "shape": "DocumentSymbols" + }, + "startLine": { + "shape": "Integer" + }, + "endLine": { + "shape": "Integer" } } }, diff --git a/packages/core/src/codewhispererChat/app.ts b/packages/core/src/codewhispererChat/app.ts index 6781cde30e5..3ffa51b75eb 100644 --- a/packages/core/src/codewhispererChat/app.ts +++ b/packages/core/src/codewhispererChat/app.ts @@ -26,8 +26,11 @@ import { TriggerTabIDReceived, UIFocusMessage, AcceptDiff, + QuickCommandGroupActionClick, + FileClick, } from './controllers/chat/model' import { EditorContextCommand, registerCommands } from './commands/registerCommands' +import { ContextSelectedMessage, CustomFormActionMessage } from './view/connector/connector' export function init(appContext: AmazonQAppInitContext) { const cwChatControllerEventEmitters = { @@ -48,6 +51,11 @@ export function init(appContext: AmazonQAppInitContext) { processSourceLinkClick: new EventEmitter(), processResponseBodyLinkClick: new EventEmitter(), processFooterInfoLinkClick: new EventEmitter(), + processContextCommandUpdateMessage: new EventEmitter(), + processQuickCommandGroupActionClicked: new EventEmitter(), + processCustomFormAction: new EventEmitter(), + processContextSelected: new EventEmitter(), + processFileClick: new EventEmitter(), } const cwChatControllerMessageListeners = { @@ -96,6 +104,19 @@ export function init(appContext: AmazonQAppInitContext) { processFooterInfoLinkClick: new MessageListener( cwChatControllerEventEmitters.processFooterInfoLinkClick ), + processContextCommandUpdateMessage: new MessageListener( + cwChatControllerEventEmitters.processContextCommandUpdateMessage + ), + processQuickCommandGroupActionClicked: new MessageListener( + cwChatControllerEventEmitters.processQuickCommandGroupActionClicked + ), + processCustomFormAction: new MessageListener( + cwChatControllerEventEmitters.processCustomFormAction + ), + processContextSelected: new MessageListener( + cwChatControllerEventEmitters.processContextSelected + ), + processFileClick: new MessageListener(cwChatControllerEventEmitters.processFileClick), } const cwChatControllerMessagePublishers = { @@ -146,6 +167,19 @@ export function init(appContext: AmazonQAppInitContext) { processFooterInfoLinkClick: new MessagePublisher( cwChatControllerEventEmitters.processFooterInfoLinkClick ), + processContextCommandUpdateMessage: new MessagePublisher( + cwChatControllerEventEmitters.processContextCommandUpdateMessage + ), + processQuickCommandGroupActionClicked: new MessagePublisher( + cwChatControllerEventEmitters.processQuickCommandGroupActionClicked + ), + processCustomFormAction: new MessagePublisher( + cwChatControllerEventEmitters.processCustomFormAction + ), + processContextSelected: new MessagePublisher( + cwChatControllerEventEmitters.processContextSelected + ), + processFileClick: new MessagePublisher(cwChatControllerEventEmitters.processFileClick), } new CwChatController( diff --git a/packages/core/src/codewhispererChat/clients/chat/v0/chat.ts b/packages/core/src/codewhispererChat/clients/chat/v0/chat.ts index b849b328bac..3cf030b9b8e 100644 --- a/packages/core/src/codewhispererChat/clients/chat/v0/chat.ts +++ b/packages/core/src/codewhispererChat/clients/chat/v0/chat.ts @@ -14,6 +14,10 @@ import { UserWrittenCodeTracker } from '../../../../codewhisperer/tracker/userWr export class ChatSession { private sessionId?: string + contexts: Map = new Map() + // TODO: doesn't handle the edge case when two files share the same relativePath string but from different root + // e.g. root_a/file1 vs root_b/file1 + relativePathToWorkspaceRoot: Map = new Map() public get sessionIdentifier(): string | undefined { return this.sessionId } diff --git a/packages/core/src/codewhispererChat/commands/registerCommands.ts b/packages/core/src/codewhispererChat/commands/registerCommands.ts index abf6c6fa07c..da91f48ebea 100644 --- a/packages/core/src/codewhispererChat/commands/registerCommands.ts +++ b/packages/core/src/codewhispererChat/commands/registerCommands.ts @@ -102,6 +102,9 @@ export function registerCommands(controllerPublishers: ChatControllerMessagePubl }) }) }) + Commands.register('aws.amazonq.updateContextCommandItems', () => { + controllerPublishers.processContextCommandUpdateMessage.publish() + }) } export type EditorContextBaseCommandType = diff --git a/packages/core/src/codewhispererChat/constants.ts b/packages/core/src/codewhispererChat/constants.ts new file mode 100644 index 00000000000..4566d14ec64 --- /dev/null +++ b/packages/core/src/codewhispererChat/constants.ts @@ -0,0 +1,21 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +import * as path from 'path' +import fs from '../shared/fs/fs' + +export const promptFileExtension = '.md' + +export const additionalContentInnerContextLimit = 8192 + +export const aditionalContentNameLimit = 1024 + +// temporary limit for @workspace and @file combined context length +export const contextMaxLength = 40_000 + +export const getUserPromptsDirectory = () => { + return path.join(fs.getUserHomeDir(), '.aws', 'amazonq', 'prompts') +} + +export const createSavedPromptCommandId = 'create-saved-prompt' diff --git a/packages/core/src/codewhispererChat/controllers/chat/chatRequest/converter.ts b/packages/core/src/codewhispererChat/controllers/chat/chatRequest/converter.ts index c30d5aeb706..0a34463058e 100644 --- a/packages/core/src/codewhispererChat/controllers/chat/chatRequest/converter.ts +++ b/packages/core/src/codewhispererChat/controllers/chat/chatRequest/converter.ts @@ -116,6 +116,7 @@ export function triggerPayloadToChatRequest(triggerPayload: TriggerPayload): { c relevantDocuments, useRelevantDocuments, }, + additionalContext: triggerPayload.additionalContents, }, userIntent: triggerPayload.userIntent, }, diff --git a/packages/core/src/codewhispererChat/controllers/chat/controller.ts b/packages/core/src/codewhispererChat/controllers/chat/controller.ts index 79f1e75da14..b1ce1ac12e3 100644 --- a/packages/core/src/codewhispererChat/controllers/chat/controller.ts +++ b/packages/core/src/codewhispererChat/controllers/chat/controller.ts @@ -2,7 +2,9 @@ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. * SPDX-License-Identifier: Apache-2.0 */ -import { Event as VSCodeEvent, Uri } from 'vscode' +import * as path from 'path' +import * as vscode from 'vscode' +import { Event as VSCodeEvent, Uri, workspace, window, ViewColumn, Position, Selection } from 'vscode' import { EditorContextExtractor } from '../../editor/context/extractor' import { ChatSessionStorage } from '../../storages/chatSession' import { Messenger, MessengerResponseType, StaticTextResponseType } from './messenger/messenger' @@ -26,8 +28,16 @@ import { FooterInfoLinkClick, ViewDiff, AcceptDiff, + QuickCommandGroupActionClick, + DocumentReference, + FileClick, + RelevantTextDocumentAddition, } from './model' -import { AppToWebViewMessageDispatcher } from '../../view/connector/connector' +import { + AppToWebViewMessageDispatcher, + ContextSelectedMessage, + CustomFormActionMessage, +} from '../../view/connector/connector' import { MessagePublisher } from '../../../amazonq/messages/messagePublisher' import { MessageListener } from '../../../amazonq/messages/messageListener' import { EditorContentController } from '../../../amazonq/commons/controllers/contentController' @@ -54,6 +64,22 @@ import { isSsoConnection } from '../../../auth/connection' import { inspect } from '../../../shared/utilities/collectionUtils' import { DefaultAmazonQAppInitContext } from '../../../amazonq/apps/initContext' import globals from '../../../shared/extensionGlobals' +import { MynahIconsType, MynahUIDataModel, QuickActionCommand } from '@aws/mynah-ui' +import { LspClient } from '../../../amazonq/lsp/lspClient' +import { ContextCommandItem, ContextCommandItemType } from '../../../amazonq/lsp/types' +import { workspaceCommand } from '../../../amazonq/webview/ui/tabs/constants' +import fs from '../../../shared/fs/fs' +import { FeatureConfigProvider, Features } from '../../../shared/featureConfig' +import { i18n } from '../../../shared/i18n-helper' +import { + getUserPromptsDirectory, + promptFileExtension, + createSavedPromptCommandId, + aditionalContentNameLimit, + additionalContentInnerContextLimit, + contextMaxLength, +} from '../../constants' +import { ChatSession } from '../../clients/chat/v0/chat' export interface ChatControllerMessagePublishers { readonly processPromptChatMessage: MessagePublisher @@ -73,6 +99,11 @@ export interface ChatControllerMessagePublishers { readonly processSourceLinkClick: MessagePublisher readonly processResponseBodyLinkClick: MessagePublisher readonly processFooterInfoLinkClick: MessagePublisher + readonly processContextCommandUpdateMessage: MessagePublisher + readonly processQuickCommandGroupActionClicked: MessagePublisher + readonly processCustomFormAction: MessagePublisher + readonly processContextSelected: MessagePublisher + readonly processFileClick: MessagePublisher } export interface ChatControllerMessageListeners { @@ -93,6 +124,11 @@ export interface ChatControllerMessageListeners { readonly processSourceLinkClick: MessageListener readonly processResponseBodyLinkClick: MessageListener readonly processFooterInfoLinkClick: MessageListener + readonly processContextCommandUpdateMessage: MessageListener + readonly processQuickCommandGroupActionClicked: MessageListener + readonly processCustomFormAction: MessageListener + readonly processContextSelected: MessageListener + readonly processFileClick: MessageListener } export class ChatController { @@ -104,6 +140,7 @@ export class ChatController { private readonly promptGenerator: PromptsGenerator private readonly userIntentRecognizer: UserIntentRecognizer private readonly telemetryHelper: CWCTelemetryHelper + private userPromptsWatcher: vscode.FileSystemWatcher | undefined public constructor( private readonly chatControllerMessageListeners: ChatControllerMessageListeners, @@ -210,6 +247,36 @@ export class ChatController { this.chatControllerMessageListeners.processFooterInfoLinkClick.onMessage((data) => { return this.processFooterInfoLinkClick(data) }) + this.chatControllerMessageListeners.processContextCommandUpdateMessage.onMessage(() => { + return this.processContextCommandUpdateMessage() + }) + this.chatControllerMessageListeners.processQuickCommandGroupActionClicked.onMessage((data) => { + return this.processQuickCommandGroupActionClicked(data) + }) + this.chatControllerMessageListeners.processCustomFormAction.onMessage((data) => { + return this.processCustomFormAction(data) + }) + this.chatControllerMessageListeners.processContextSelected.onMessage((data) => { + return this.processContextSelected(data) + }) + this.chatControllerMessageListeners.processFileClick.onMessage((data) => { + return this.processFileClickMessage(data) + }) + } + + private registerUserPromptsWatcher() { + if (this.userPromptsWatcher) { + return + } + this.userPromptsWatcher = vscode.workspace.createFileSystemWatcher( + new vscode.RelativePattern(vscode.Uri.file(getUserPromptsDirectory()), `*${promptFileExtension}`), + false, + true, + false + ) + this.userPromptsWatcher.onDidCreate(() => this.processContextCommandUpdateMessage()) + this.userPromptsWatcher.onDidDelete(() => this.processContextCommandUpdateMessage()) + globals.context.subscriptions.push(this.userPromptsWatcher) } private processFooterInfoLinkClick(click: FooterInfoLinkClick) { @@ -350,6 +417,221 @@ export class ChatController { } } + private async processContextCommandUpdateMessage() { + // when UI is ready, refresh the context commands + this.registerUserPromptsWatcher() + const contextCommand: MynahUIDataModel['contextCommands'] = [ + { + commands: [ + ...workspaceCommand.commands, + { + command: i18n('AWS.amazonq.context.folders.title'), + children: [ + { + groupName: i18n('AWS.amazonq.context.folders.title'), + commands: [], + }, + ], + description: i18n('AWS.amazonq.context.folders.description'), + icon: 'folder' as MynahIconsType, + }, + { + command: i18n('AWS.amazonq.context.files.title'), + children: [ + { + groupName: i18n('AWS.amazonq.context.files.title'), + commands: [], + }, + ], + description: i18n('AWS.amazonq.context.files.description'), + icon: 'file' as MynahIconsType, + }, + { + command: i18n('AWS.amazonq.context.prompts.title'), + children: [ + { + groupName: i18n('AWS.amazonq.context.prompts.title'), + commands: [], + }, + ], + description: i18n('AWS.amazonq.context.prompts.description'), + icon: 'magic' as MynahIconsType, + }, + ], + }, + ] + + const feature = FeatureConfigProvider.getFeature(Features.highlightCommand) + const commandName = feature?.value.stringValue + if (commandName) { + const commandDescription = feature.variation + contextCommand.push({ + groupName: 'Additional Commands', + commands: [{ command: commandName, description: commandDescription }], + }) + } + const promptsCmd: QuickActionCommand = contextCommand[0].commands?.[3] + + // Check for user prompts + try { + const userPromptsDirectory = getUserPromptsDirectory() + const directoryExists = await fs.exists(userPromptsDirectory) + if (directoryExists) { + const systemPromptFiles = await fs.readdir(userPromptsDirectory) + promptsCmd.children?.[0].commands.push( + ...systemPromptFiles + .filter(([name]) => name.endsWith(promptFileExtension)) + .map(([name]) => ({ + command: path.basename(name, promptFileExtension), + icon: 'magic' as MynahIconsType, + id: 'prompt', + route: [userPromptsDirectory, name], + })) + ) + } + } catch (e) { + getLogger().verbose(`Could not read prompts from ~/.aws/prompts: ${e}`) + } + + // Add create prompt button to the bottom of the prompts list + promptsCmd.children?.[0].commands.push({ + command: i18n('AWS.amazonq.savedPrompts.action'), + id: createSavedPromptCommandId, + icon: 'list-add' as MynahIconsType, + }) + + const lspClientReady = await LspClient.instance.waitUntilReady() + if (lspClientReady) { + const contextCommandItems = await LspClient.instance.getContextCommandItems() + const folderCmd: QuickActionCommand = contextCommand[0].commands?.[1] + const filesCmd: QuickActionCommand = contextCommand[0].commands?.[2] + + for (const contextCommandItem of contextCommandItems) { + const wsFolderName = path.basename(contextCommandItem.workspaceFolder) + if (contextCommandItem.type === 'file') { + filesCmd.children?.[0].commands.push({ + command: path.basename(contextCommandItem.relativePath), + description: path.join(wsFolderName, contextCommandItem.relativePath), + route: [contextCommandItem.workspaceFolder, contextCommandItem.relativePath], + id: 'file', + icon: 'file' as MynahIconsType, + }) + } else { + folderCmd.children?.[0].commands.push({ + command: path.basename(contextCommandItem.relativePath), + description: path.join(wsFolderName, contextCommandItem.relativePath), + route: [contextCommandItem.workspaceFolder, contextCommandItem.relativePath], + id: 'folder', + icon: 'folder' as MynahIconsType, + }) + } + } + } + + this.messenger.sendContextCommandData(contextCommand) + } + + private handlePromptCreate(tabID: string) { + this.messenger.showCustomForm( + tabID, + [ + { + id: 'prompt-name', + type: 'textinput', + mandatory: true, + title: i18n('AWS.amazonq.savedPrompts.title'), + placeholder: i18n('AWS.amazonq.savedPrompts.placeholder'), + description: i18n('AWS.amazonq.savedPrompts.description'), + }, + ], + [ + { id: 'cancel-create-prompt', text: i18n('AWS.generic.cancel'), status: 'clear' }, + { id: 'submit-create-prompt', text: i18n('AWS.amazonq.savedPrompts.create'), status: 'main' }, + ], + `Create a saved prompt` + ) + } + + private processQuickCommandGroupActionClicked(message: QuickCommandGroupActionClick) { + if (message.actionId === createSavedPromptCommandId) { + this.handlePromptCreate(message.tabID) + } + } + + private async processCustomFormAction(message: CustomFormActionMessage) { + if (message.action.id === 'submit-create-prompt') { + const userPromptsDirectory = getUserPromptsDirectory() + + const title = message.action.formItemValues?.['prompt-name'] + const newFilePath = path.join( + userPromptsDirectory, + title ? `${title}${promptFileExtension}` : `default${promptFileExtension}` + ) + const newFileContent = new Uint8Array(Buffer.from('')) + await fs.writeFile(newFilePath, newFileContent) + const newFileDoc = await vscode.workspace.openTextDocument(newFilePath) + await vscode.window.showTextDocument(newFileDoc) + telemetry.ui_click.emit({ elementId: 'amazonq_createSavedPrompt' }) + } + } + + private async processContextSelected(message: ContextSelectedMessage) { + if (message.tabID && message.contextItem.id === createSavedPromptCommandId) { + this.handlePromptCreate(message.tabID) + } + } + private async processFileClickMessage(message: FileClick) { + const session = this.sessionStorage.getSession(message.tabID) + const lineRanges = session.contexts.get(message.filePath) + + if (!lineRanges) { + return + } + + // TODO: Fix for multiple workspace setup + const projectRoot = session.relativePathToWorkspaceRoot.get(message.filePath) + if (!projectRoot) { + return + } + let absoluteFilePath = path.join(projectRoot, message.filePath) + + // Handle clicking on a user prompt outside the workspace + if (message.filePath.endsWith(promptFileExtension)) { + try { + await vscode.workspace.fs.stat(vscode.Uri.file(absoluteFilePath)) + } catch { + absoluteFilePath = path.join(getUserPromptsDirectory(), message.filePath) + } + } + + try { + // Open the file in VSCode + const document = await workspace.openTextDocument(absoluteFilePath) + const editor = await window.showTextDocument(document, ViewColumn.Active) + + // Create multiple selections based on line ranges + const selections: Selection[] = lineRanges + .filter(({ first, second }) => first !== -1 && second !== -1) + .map(({ first, second }) => { + const startPosition = new Position(first - 1, 0) // Convert 1-based to 0-based + const endPosition = new Position(second - 1, document.lineAt(second - 1).range.end.character) + return new Selection( + startPosition.line, + startPosition.character, + endPosition.line, + endPosition.character + ) + }) + + // Apply multiple selections to the editor + if (selections.length > 0) { + editor.selection = selections[0] // Set the first selection as active + editor.selections = selections // Apply multiple selections + editor.revealRange(selections[0], vscode.TextEditorRevealType.InCenter) + } + } catch (error) {} + } + private processException(e: any, tabID: string) { let errorMessage = '' let requestID = undefined @@ -553,6 +835,7 @@ export class ChatController { codeQuery: context?.focusAreaContext?.names, userIntent: this.userIntentRecognizer.getFromPromptChatMessage(message), customization: getSelectedCustomization(), + context: message.context, }, triggerID ) @@ -594,6 +877,111 @@ export class ChatController { this.messenger.sendStaticTextResponse(responseType, triggerID, tabID) } + private async resolveContextCommandPayload( + triggerPayload: TriggerPayload, + session: ChatSession + ): Promise { + const contextCommands: ContextCommandItem[] = [] + const relativePaths: string[] = [] + + // Check for workspace rules to add to context + const workspaceRules = await vscode.workspace.findFiles(`.amazonq/rules/*${promptFileExtension}`) + if (workspaceRules.length > 0) { + contextCommands.push( + ...workspaceRules.map((rule) => { + const workspaceFolderPath = vscode.workspace.getWorkspaceFolder(rule)?.uri?.path || '' + return { + workspaceFolder: workspaceFolderPath, + // todo: add 'prompt' type to LSP model + type: 'file' as ContextCommandItemType, + relativePath: path.relative(workspaceFolderPath, rule.path), + } + }) + ) + } + triggerPayload.workspaceRulesCount = workspaceRules.length + + // Add context commands added by user to context + if (triggerPayload.context !== undefined && triggerPayload.context.length > 0) { + for (const context of triggerPayload.context) { + // todo: add handling of 'prompt' type (dependent on LSP changes) + if (typeof context !== 'string' && context.route && context.route.length === 2) { + contextCommands.push({ + workspaceFolder: context.route[0] || '', + type: context.icon === 'folder' ? 'folder' : 'file', + relativePath: context.route[1] || '', + }) + } + } + } + if (contextCommands.length === 0) { + return [] + } + const workspaceFolders = (vscode.workspace.workspaceFolders ?? []).map((folder) => folder.uri.fsPath) + if (!workspaceFolders) { + return [] + } + workspaceFolders.sort() + const workspaceFolder = workspaceFolders[0] + for (const contextCommand of contextCommands) { + const relativePath = path.relative( + workspaceFolder, + path.join(contextCommand.workspaceFolder, contextCommand.relativePath) + ) + session.relativePathToWorkspaceRoot.set(relativePath, contextCommand.workspaceFolder) + } + const prompts = await LspClient.instance.getContextCommandPrompt(contextCommands) + if (prompts.length === 0) { + return [] + } + + let currentContextLength = 0 + triggerPayload.additionalContents = [] + triggerPayload.additionalContextLengths = this.telemetryHelper.getContextLengths(prompts) + triggerPayload.truncatedAdditionalContextLengths = { + fileContextLength: 0, + promptContextLength: 0, + ruleContextLength: 0, + } + for (const prompt of prompts.slice(0, 20)) { + // Todo: add mechanism for sorting/prioritization of additional context + const entry = { + name: prompt.name.substring(0, aditionalContentNameLimit), + description: prompt.description.substring(0, aditionalContentNameLimit), + innerContext: prompt.content.substring(0, additionalContentInnerContextLimit), + } + // make sure the relevantDocument + additionalContext + // combined does not exceed 40k characters before generating the request payload. + // Do truncation and make sure triggerPayload.documentReferences is up-to-date after truncation + // TODO: Use a context length indicator + if (currentContextLength + entry.innerContext.length > contextMaxLength) { + getLogger().warn(`Selected context exceeds context size limit: ${entry.description} `) + break + } + + const contextType = this.telemetryHelper.getContextType(prompt) + if (contextType === 'rule') { + triggerPayload.truncatedAdditionalContextLengths.ruleContextLength += entry.innerContext.length + } else if (contextType === 'prompt') { + triggerPayload.truncatedAdditionalContextLengths.promptContextLength += entry.innerContext.length + } else if (contextType === 'file') { + triggerPayload.truncatedAdditionalContextLengths.fileContextLength += entry.innerContext.length + } + + triggerPayload.additionalContents.push(entry) + currentContextLength += entry.innerContext.length + let relativePath = path.relative(workspaceFolder, prompt.filePath) + // Handle user prompts outside the workspace + if (prompt.filePath.startsWith(getUserPromptsDirectory())) { + relativePath = path.basename(prompt.filePath) + } + relativePaths.push(relativePath) + } + getLogger().info(`Retrieved chunks of additional context count: ${triggerPayload.additionalContents.length} `) + + return relativePaths + } + private async generateResponse( triggerPayload: TriggerPayload & { projectContextQueryLatencyMs?: number }, triggerID: string @@ -626,29 +1014,75 @@ export class ChatController { await this.messenger.sendAuthNeededExceptionMessage(credentialsState, tabID, triggerID) return } - triggerPayload.useRelevantDocuments = false - if (triggerPayload.message) { - triggerPayload.useRelevantDocuments = triggerPayload.message.includes(`@workspace`) - if (triggerPayload.useRelevantDocuments) { - triggerPayload.message = triggerPayload.message.replace(/@workspace/g, '') - if (CodeWhispererSettings.instance.isLocalIndexEnabled()) { - const start = performance.now() - triggerPayload.relevantTextDocuments = await LspController.instance.query(triggerPayload.message) - for (const doc of triggerPayload.relevantTextDocuments) { - getLogger().info( - `amazonq: Using workspace files ${doc.relativeFilePath}, content(partial): ${doc.text?.substring(0, 200)}` - ) + + const session = this.sessionStorage.getSession(tabID) + const relativePathsOfContextCommandFiles = await this.resolveContextCommandPayload(triggerPayload, session) + triggerPayload.useRelevantDocuments = + triggerPayload.context?.some( + (context) => typeof context !== 'string' && context.command === '@workspace' + ) || false + triggerPayload.documentReferences = [] + if (triggerPayload.useRelevantDocuments && triggerPayload.message) { + triggerPayload.message = triggerPayload.message.replace(/workspace/, '') + if (CodeWhispererSettings.instance.isLocalIndexEnabled()) { + const start = performance.now() + let remainingContextLength = contextMaxLength + for (const additionalContent of triggerPayload.additionalContents || []) { + if (additionalContent.innerContext) { + remainingContextLength -= additionalContent.innerContext.length + } + } + triggerPayload.relevantTextDocuments = [] + const relevantTextDocuments = await LspController.instance.query(triggerPayload.message) + for (const relevantDocument of relevantTextDocuments) { + if (relevantDocument.text !== undefined && relevantDocument.text.length > 0) { + if (remainingContextLength > relevantDocument.text.length) { + triggerPayload.relevantTextDocuments.push(relevantDocument) + remainingContextLength -= relevantDocument.text.length + } else { + getLogger().warn( + `Retrieved context exceeds context size limit: ${relevantDocument.relativeFilePath} ` + ) + break + } } - triggerPayload.projectContextQueryLatencyMs = performance.now() - start - } else { - this.messenger.sendOpenSettingsMessage(triggerID, tabID) - return } + + for (const doc of triggerPayload.relevantTextDocuments) { + getLogger().info( + `amazonq: Using workspace files ${doc.relativeFilePath}, content(partial): ${doc.text?.substring(0, 200)}, start line: ${doc.startLine}, end line: ${doc.endLine}` + ) + } + triggerPayload.projectContextQueryLatencyMs = performance.now() - start + } else { + this.messenger.sendOpenSettingsMessage(triggerID, tabID) + return } } + triggerPayload.documentReferences = this.mergeRelevantTextDocuments(triggerPayload.relevantTextDocuments || []) + const request = triggerPayloadToChatRequest(triggerPayload) - const session = this.sessionStorage.getSession(tabID) + + if (triggerPayload.documentReferences !== undefined) { + const relativePathsOfMergedRelevantDocuments = triggerPayload.documentReferences.map( + (doc) => doc.relativeFilePath + ) + for (const relativePath of relativePathsOfContextCommandFiles) { + if (!relativePathsOfMergedRelevantDocuments.includes(relativePath)) { + triggerPayload.documentReferences.push({ + relativeFilePath: relativePath, + lineRanges: [{ first: -1, second: -1 }], + }) + } + } + if (triggerPayload.documentReferences) { + for (const doc of triggerPayload.documentReferences) { + session.contexts.set(doc.relativeFilePath, doc.lineRanges) + } + } + } + getLogger().info( `request from tab: ${tabID} conversationID: ${session.sessionIdentifier} request: ${inspect(request, { depth: 12, @@ -657,7 +1091,7 @@ export class ChatController { let response: MessengerResponseType | undefined = undefined session.createNewTokenSource() try { - this.messenger.sendInitalStream(tabID, triggerID) + this.messenger.sendInitalStream(tabID, triggerID, triggerPayload.documentReferences) this.telemetryHelper.setConversationStreamStartTime(tabID) if (isSsoConnection(AuthUtil.instance.conn)) { const { $metadata, generateAssistantResponseResponse } = await session.chatSso(request) @@ -687,4 +1121,42 @@ export class ChatController { this.processException(e, tabID) } } + + private mergeRelevantTextDocuments(documents: RelevantTextDocumentAddition[]): DocumentReference[] { + if (documents.length === 0) { + return [] + } + return Object.entries( + documents.reduce>((acc, doc) => { + if (!doc.relativeFilePath || doc.startLine === undefined || doc.endLine === undefined) { + return acc // Skip invalid documents + } + + if (!acc[doc.relativeFilePath]) { + acc[doc.relativeFilePath] = [] + } + acc[doc.relativeFilePath].push({ first: doc.startLine, second: doc.endLine }) + return acc + }, {}) + ).map(([filePath, ranges]) => { + // Sort by startLine + const sortedRanges = ranges.sort((a, b) => a.first - b.first) + + const mergedRanges: { first: number; second: number }[] = [] + for (const { first, second } of sortedRanges) { + if (mergedRanges.length === 0 || mergedRanges[mergedRanges.length - 1].second < first - 1) { + // If no overlap, add new range + mergedRanges.push({ first, second }) + } else { + // Merge overlapping or consecutive ranges + mergedRanges[mergedRanges.length - 1].second = Math.max( + mergedRanges[mergedRanges.length - 1].second, + second + ) + } + } + + return { relativeFilePath: filePath, lineRanges: mergedRanges } + }) + } } diff --git a/packages/core/src/codewhispererChat/controllers/chat/messenger/messenger.ts b/packages/core/src/codewhispererChat/controllers/chat/messenger/messenger.ts index 6604fd7bb21..0f434c6dc6d 100644 --- a/packages/core/src/codewhispererChat/controllers/chat/messenger/messenger.ts +++ b/packages/core/src/codewhispererChat/controllers/chat/messenger/messenger.ts @@ -8,9 +8,11 @@ import { AppToWebViewMessageDispatcher, AuthNeededException, CodeReference, + ContextCommandData, EditorContextCommandMessage, OpenSettingsMessage, QuickActionMessage, + ShowCustomFormMessage, } from '../../../view/connector/connector' import { EditorContextCommandType } from '../../../commands/registerCommands' import { ChatResponseStream as qdevChatResponseStream } from '@amzn/amazon-q-developer-streaming-client' @@ -23,7 +25,7 @@ import { ChatMessage, ErrorMessage, FollowUp, Suggestion } from '../../../view/c import { ChatSession } from '../../../clients/chat/v0/chat' import { ChatException } from './model' import { CWCTelemetryHelper } from '../telemetryHelper' -import { ChatPromptCommandType, TriggerPayload } from '../model' +import { ChatPromptCommandType, DocumentReference, TriggerPayload } from '../model' import { getHttpStatusCode, getRequestId, ToolkitError } from '../../../../shared/errors' import { keys } from '../../../../shared/utilities/tsUtils' import { getLogger } from '../../../../shared/logger/logger' @@ -35,6 +37,7 @@ import { LspController } from '../../../../amazonq/lsp/lspController' import { extractCodeBlockLanguage } from '../../../../shared/markdown' import { extractAuthFollowUp } from '../../../../amazonq/util/authUtils' import { helpMessage } from '../../../../amazonq/webview/ui/texts/constants' +import { ChatItemButton, ChatItemFormItem, MynahUIDataModel } from '@aws/mynah-ui' export type StaticTextResponseType = 'quick-action-help' | 'onboarding-help' | 'transform' | 'help' @@ -63,7 +66,11 @@ export class Messenger { ) } - public sendInitalStream(tabID: string, triggerID: string) { + public sendInitalStream( + tabID: string, + triggerID: string, + mergedRelevantDocuments: DocumentReference[] | undefined + ) { this.dispatcher.sendChatMessage( new ChatMessage( { @@ -76,6 +83,7 @@ export class Messenger { messageID: '', userIntent: undefined, codeBlockLanguage: undefined, + contextList: mergedRelevantDocuments, }, tabID ) @@ -188,6 +196,7 @@ export class Messenger { messageID, userIntent: triggerPayload.userIntent, codeBlockLanguage: codeBlockLanguage, + contextList: undefined, }, tabID ) @@ -266,6 +275,7 @@ export class Messenger { messageID, userIntent: triggerPayload.userIntent, codeBlockLanguage: codeBlockLanguage, + contextList: undefined, }, tabID ) @@ -285,6 +295,7 @@ export class Messenger { messageID, userIntent: triggerPayload.userIntent, codeBlockLanguage: undefined, + contextList: undefined, }, tabID ) @@ -303,6 +314,7 @@ export class Messenger { messageID, userIntent: triggerPayload.userIntent, codeBlockLanguage: undefined, + contextList: undefined, }, tabID ) @@ -401,6 +413,7 @@ export class Messenger { messageID: 'static_message_' + triggerID, userIntent: undefined, codeBlockLanguage: undefined, + contextList: undefined, }, tabID ) @@ -482,4 +495,20 @@ export class Messenger { public sendOpenSettingsMessage(triggerId: string, tabID: string) { this.dispatcher.sendOpenSettingsMessage(new OpenSettingsMessage(tabID)) } + + public sendContextCommandData(contextCommands: MynahUIDataModel['contextCommands']) { + this.dispatcher.sendContextCommandData(new ContextCommandData(contextCommands)) + } + + public showCustomForm( + tabID: string, + formItems?: ChatItemFormItem[], + buttons?: ChatItemButton[], + title?: string, + description?: string + ) { + this.dispatcher.sendShowCustomFormMessage( + new ShowCustomFormMessage(tabID, formItems, buttons, title, description) + ) + } } diff --git a/packages/core/src/codewhispererChat/controllers/chat/model.ts b/packages/core/src/codewhispererChat/controllers/chat/model.ts index f79510acacb..bc729dba954 100644 --- a/packages/core/src/codewhispererChat/controllers/chat/model.ts +++ b/packages/core/src/codewhispererChat/controllers/chat/model.ts @@ -4,12 +4,13 @@ */ import * as vscode from 'vscode' -import { RelevantTextDocument, UserIntent } from '@amzn/codewhisperer-streaming' +import { AdditionalContentEntry, RelevantTextDocument, UserIntent } from '@amzn/codewhisperer-streaming' import { MatchPolicy, CodeQuery } from '../../clients/chat/v0/model' import { Selection } from 'vscode' import { TabOpenType } from '../../../amazonq/webview/ui/storages/tabsStorage' import { CodeReference } from '../../view/connector/connector' import { Customization } from '../../../codewhisperer/client/codewhispereruserclient' +import { QuickActionCommand } from '@aws/mynah-ui' export interface TriggerTabIDReceived { tabID: string @@ -102,6 +103,7 @@ export interface PromptMessage { command: ChatPromptCommandType | undefined userIntent: UserIntent | undefined tabID: string + context?: string[] | QuickActionCommand[] } export interface PromptAnswer { @@ -139,6 +141,19 @@ export interface FooterInfoLinkClick { link: string } +export interface QuickCommandGroupActionClick { + command: string + actionId: string + tabID: string +} + +export interface FileClick { + command: string + tabID: string + messageId: string + filePath: string +} + export interface ChatItemVotedMessage { tabID: string command: string @@ -171,9 +186,31 @@ export interface TriggerPayload { readonly codeQuery: CodeQuery | undefined readonly userIntent: UserIntent | undefined readonly customization: Customization - relevantTextDocuments?: RelevantTextDocument[] + readonly context?: string[] | QuickActionCommand[] + relevantTextDocuments?: RelevantTextDocumentAddition[] + additionalContents?: AdditionalContentEntry[] + // a reference to all documents used in chat payload + // for providing better context transparency + documentReferences?: DocumentReference[] useRelevantDocuments?: boolean traceId?: string + additionalContextLengths?: AdditionalContextLengths + truncatedAdditionalContextLengths?: AdditionalContextLengths + workspaceRulesCount?: number +} + +export type AdditionalContextLengths = { + fileContextLength: number + promptContextLength: number + ruleContextLength: number +} + +// TODO move this to API definition (or just use this across the codebase) +export type RelevantTextDocumentAddition = RelevantTextDocument & { startLine: number; endLine: number } + +export interface DocumentReference { + readonly relativeFilePath: string + readonly lineRanges: Array<{ first: number; second: number }> } export interface InsertedCode { diff --git a/packages/core/src/codewhispererChat/controllers/chat/telemetryHelper.ts b/packages/core/src/codewhispererChat/controllers/chat/telemetryHelper.ts index 3488d02d4f9..519f3ceef87 100644 --- a/packages/core/src/codewhispererChat/controllers/chat/telemetryHelper.ts +++ b/packages/core/src/codewhispererChat/controllers/chat/telemetryHelper.ts @@ -27,6 +27,7 @@ import { ResponseBodyLinkClickMessage, SourceLinkClickMessage, TriggerPayload, + AdditionalContextLengths, } from './model' import { TriggerEvent, TriggerEventsStorage } from '../../storages/triggerEvents' import globals from '../../../shared/extensionGlobals' @@ -38,6 +39,8 @@ import { supportedLanguagesList } from '../chat/chatRequest/converter' import { AuthUtil } from '../../../codewhisperer/util/authUtil' import { getSelectedCustomization } from '../../../codewhisperer/util/customizationUtil' import { undefinedIfEmpty } from '../../../shared/utilities/textUtilities' +import { AdditionalContextPrompt } from '../../../amazonq/lsp/types' +import { getUserPromptsDirectory } from '../../constants' export function logSendTelemetryEventFailure(error: any) { let requestId: string | undefined @@ -142,6 +145,39 @@ export class CWCTelemetryHelper { telemetry.amazonq_exitFocusChat.emit({ result: 'Succeeded', passive: true }) } + public getContextType(prompt: AdditionalContextPrompt): string { + if (prompt.relativePath.startsWith('.amazonq/rules')) { + return 'rule' + } else if (prompt.filePath.startsWith(getUserPromptsDirectory())) { + return 'prompt' + } else { + return 'file' + } + } + + public getContextLengths(prompts: AdditionalContextPrompt[]): AdditionalContextLengths { + let fileContextLength = 0 + let promptContextLength = 0 + let ruleContextLength = 0 + + for (const prompt of prompts) { + const type = this.getContextType(prompt) + switch (type) { + case 'rule': + ruleContextLength += prompt.content.length + break + case 'file': + fileContextLength += prompt.content.length + break + case 'prompt': + promptContextLength += prompt.content.length + break + } + } + + return { fileContextLength, promptContextLength, ruleContextLength } + } + public async recordFeedback(message: ChatItemFeedbackMessage) { const logger = getLogger() try { @@ -420,6 +456,30 @@ export class CWCTelemetryHelper { }) } + private getAdditionalContextCounts(triggerPayload: TriggerPayload) { + const counts = { + fileContextCount: 0, + folderContextCount: 0, + promptContextCount: 0, + } + + if (triggerPayload.context) { + for (const context of triggerPayload.context) { + if (typeof context !== 'string') { + if (context.id === 'file') { + counts.fileContextCount++ + } else if (context.id === 'folder') { + counts.folderContextCount++ + } else if (context.id === 'prompt') { + counts.promptContextCount++ + } + } + } + } + + return counts + } + public emitAddMessage(tabID: string, fullDisplayLatency: number, traceId: string, startTime?: number) { const payload = this.messageStorage.get(tabID) if (!payload) { @@ -433,6 +493,9 @@ export class CWCTelemetryHelper { triggerPayload.relevantTextDocuments && triggerPayload.relevantTextDocuments.length > 0 && triggerPayload.useRelevantDocuments === true + + const contextCounts = this.getAdditionalContextCounts(triggerPayload) + const event: AmazonqAddMessage = { result: 'Succeeded', cwsprChatConversationId: this.getConversationId(message.tabID) ?? '', @@ -464,6 +527,20 @@ export class CWCTelemetryHelper { credentialStartUrl: AuthUtil.instance.startUrl, codewhispererCustomizationArn: triggerPayload.customization.arn, cwsprChatHasProjectContext: hasProjectLevelContext, + cwsprChatHasContextList: triggerPayload.documentReferences && triggerPayload.documentReferences?.length > 0, + cwsprChatFolderContextCount: contextCounts.folderContextCount, + cwsprChatFileContextCount: contextCounts.fileContextCount, + cwsprChatFileContextLength: triggerPayload.additionalContextLengths?.fileContextLength ?? 0, + cwsprChatFileContextTruncatedLength: + triggerPayload.truncatedAdditionalContextLengths?.fileContextLength ?? 0, + cwsprChatRuleContextCount: triggerPayload.workspaceRulesCount, + cwsprChatRuleContextLength: triggerPayload.additionalContextLengths?.ruleContextLength ?? 0, + cwsprChatRuleContextTruncatedLength: + triggerPayload.truncatedAdditionalContextLengths?.ruleContextLength ?? 0, + cwsprChatPromptContextCount: contextCounts.promptContextCount, + cwsprChatPromptContextLength: triggerPayload.additionalContextLengths?.promptContextLength ?? 0, + cwsprChatPromptContextTruncatedLength: + triggerPayload.truncatedAdditionalContextLengths?.promptContextLength ?? 0, traceId, } diff --git a/packages/core/src/codewhispererChat/view/connector/connector.ts b/packages/core/src/codewhispererChat/view/connector/connector.ts index 02794af5fb3..0b2b29498c4 100644 --- a/packages/core/src/codewhispererChat/view/connector/connector.ts +++ b/packages/core/src/codewhispererChat/view/connector/connector.ts @@ -7,13 +7,15 @@ import { Timestamp } from 'aws-sdk/clients/apigateway' import { MessagePublisher } from '../../../amazonq/messages/messagePublisher' import { EditorContextCommandType } from '../../commands/registerCommands' import { AuthFollowUpType } from '../../../amazonq/auth/model' +import { ChatItemButton, ChatItemFormItem, MynahUIDataModel, QuickActionCommand } from '@aws/mynah-ui' +import { DocumentReference } from '../../controllers/chat/model' class UiMessage { readonly time: number = Date.now() readonly sender: string = 'CWChat' readonly type: string = '' - public constructor(protected tabID: string | undefined) {} + public constructor(public tabID: string | undefined) {} } export class ErrorMessage extends UiMessage { @@ -132,6 +134,68 @@ export class OpenSettingsMessage extends UiMessage { override type = 'openSettingsMessage' } +export class ContextCommandData extends UiMessage { + readonly data: MynahUIDataModel['contextCommands'] + override type = 'contextCommandData' + constructor(data: MynahUIDataModel['contextCommands']) { + super('tab-1') + this.data = data + } +} + +export class CustomFormActionMessage extends UiMessage { + override type = 'customFormActionMessage' + readonly action: { + id: string + text?: string | undefined + formItemValues?: Record | undefined + } + + constructor( + tabID: string, + action: { + id: string + text?: string | undefined + formItemValues?: Record | undefined + } + ) { + super(tabID) + this.action = action + } +} + +export class ShowCustomFormMessage extends UiMessage { + override type = 'showCustomFormMessage' + readonly formItems?: ChatItemFormItem[] + readonly buttons?: ChatItemButton[] + readonly title?: string + readonly description?: string + + constructor( + tabID: string, + formItems?: ChatItemFormItem[], + buttons?: ChatItemButton[], + title?: string, + description?: string + ) { + super(tabID) + this.formItems = formItems + this.buttons = buttons + this.title = title + this.description = description + } +} + +export class ContextSelectedMessage extends UiMessage { + override type = 'contextSelectedMessage' + readonly contextItem: QuickActionCommand + + constructor(tabID: string, contextItem: QuickActionCommand) { + super(tabID) + this.contextItem = contextItem + } +} + export interface ChatMessageProps { readonly message: string | undefined readonly messageType: ChatMessageType @@ -143,6 +207,7 @@ export interface ChatMessageProps { readonly messageID: string readonly userIntent: string | undefined readonly codeBlockLanguage: string | undefined + readonly contextList: DocumentReference[] | undefined } export class ChatMessage extends UiMessage { @@ -157,6 +222,7 @@ export class ChatMessage extends UiMessage { readonly messageID: string | undefined readonly userIntent: string | undefined readonly codeBlockLanguage: string | undefined + readonly contextList: DocumentReference[] | undefined override type = 'chatMessage' constructor(props: ChatMessageProps, tabID: string) { @@ -171,6 +237,7 @@ export class ChatMessage extends UiMessage { this.messageID = props.messageID this.userIntent = props.userIntent this.codeBlockLanguage = props.codeBlockLanguage + this.contextList = props.contextList } } @@ -243,4 +310,12 @@ export class AppToWebViewMessageDispatcher { public sendOpenSettingsMessage(message: OpenSettingsMessage) { this.appsToWebViewMessagePublisher.publish(message) } + + public sendContextCommandData(message: ContextCommandData) { + this.appsToWebViewMessagePublisher.publish(message) + } + + public sendShowCustomFormMessage(message: ShowCustomFormMessage) { + this.appsToWebViewMessagePublisher.publish(message) + } } diff --git a/packages/core/src/codewhispererChat/view/messages/messageListener.ts b/packages/core/src/codewhispererChat/view/messages/messageListener.ts index 9c1dcb4b095..bb2871957c8 100644 --- a/packages/core/src/codewhispererChat/view/messages/messageListener.ts +++ b/packages/core/src/codewhispererChat/view/messages/messageListener.ts @@ -103,9 +103,45 @@ export class UIMessageListener { break case 'open-settings': this.processOpenSettings(msg) + break + case 'ui-is-ready': + this.processUIIsReady() + break + case 'quick-command-group-action-click': + this.quickCommandGroupActionClicked(msg) + break + case 'form-action-click': + this.processCustomFormAction(msg) + break + case 'context-selected': + this.processContextSelected(msg) + break + case 'file-click': + this.fileClick(msg) + break } } + private processUIIsReady() { + this.chatControllerMessagePublishers.processContextCommandUpdateMessage.publish() + } + + private processCustomFormAction(msg: any) { + this.chatControllerMessagePublishers.processCustomFormAction.publish({ tabID: msg.tabID, ...msg }) + } + + private processContextSelected(msg: any) { + this.chatControllerMessagePublishers.processContextSelected.publish({ tabID: msg.tabID, ...msg }) + } + + private quickCommandGroupActionClicked(msg: any) { + this.chatControllerMessagePublishers.processQuickCommandGroupActionClicked.publish({ + tabID: msg.tabID, + actionId: msg.actionId, + command: 'quick-command-group-action-click', + }) + } + private processOpenSettings(msg: any) { void openSettingsId(`amazonQ.workspaceIndex`) } @@ -228,6 +264,7 @@ export class UIMessageListener { tabID: msg.tabID, messageId: msg.messageId, userIntent: msg.userIntent !== '' ? msg.userIntent : undefined, + context: msg.chatContext, }) } @@ -255,4 +292,13 @@ export class UIMessageListener { comment: msg.comment, }) } + + private fileClick(msg: any) { + this.chatControllerMessagePublishers.processFileClick.publish({ + messageId: msg.messageId, + tabID: msg.tabID, + command: msg.command, + filePath: msg.filePath, + }) + } } diff --git a/packages/core/src/shared/featureConfig.ts b/packages/core/src/shared/featureConfig.ts index 6e58c8688bc..5ad84b1ded4 100644 --- a/packages/core/src/shared/featureConfig.ts +++ b/packages/core/src/shared/featureConfig.ts @@ -41,6 +41,7 @@ export const Features = { projectContextFeature: 'ProjectContextV2', workspaceContextFeature: 'WorkspaceContext', test: 'testFeature', + highlightCommand: 'highlightCommand', } as const export type FeatureName = (typeof Features)[keyof typeof Features] diff --git a/packages/core/src/shared/telemetry/vscodeTelemetry.json b/packages/core/src/shared/telemetry/vscodeTelemetry.json index 3487d7b4157..09339283bee 100644 --- a/packages/core/src/shared/telemetry/vscodeTelemetry.json +++ b/packages/core/src/shared/telemetry/vscodeTelemetry.json @@ -83,6 +83,61 @@ "type": "int", "description": "Query latency in ms for local project context" }, + { + "name": "cwsprChatFolderContextCount", + "type": "int", + "description": "Number of folders manually added to context" + }, + { + "name": "cwsprChatFileContextCount", + "type": "int", + "description": "Number of files manually added to context" + }, + { + "name": "cwsprChatFileContextLength", + "type": "int", + "description": "Total length of files added to context" + }, + { + "name": "cwsprChatFileContextTruncatedLength", + "type": "int", + "description": "Truncated length of files added to context" + }, + { + "name": "cwsprChatPromptContextCount", + "type": "int", + "description": "Number of saved prompts manually added to context" + }, + { + "name": "cwsprChatPromptContextLength", + "type": "int", + "description": "Total length of saved prompts added to context" + }, + { + "name": "cwsprChatPromptContextTruncatedLength", + "type": "int", + "description": "Truncated length of saved prompts added to context" + }, + { + "name": "cwsprChatRuleContextCount", + "type": "int", + "description": "Number of workspace rules automatically added to context" + }, + { + "name": "cwsprChatRuleContextLength", + "type": "int", + "description": "Total length of workspace rules added to context" + }, + { + "name": "cwsprChatRuleContextTruncatedLength", + "type": "int", + "description": "Truncated length of workspace rules added to context" + }, + { + "name": "cwsprChatHasContextList", + "type": "boolean", + "description": "true if context list is displayed to user" + }, { "name": "amazonqIndexFileSizeInMB", "type": "int", @@ -804,6 +859,50 @@ { "type": "codewhispererCustomizationArn", "required": false + }, + { + "type": "cwsprChatHasContextList", + "required": false + }, + { + "type": "cwsprChatFolderContextCount", + "required": false + }, + { + "type": "cwsprChatFileContextCount", + "required": false + }, + { + "type": "cwsprChatFileContextLength", + "required": false + }, + { + "type": "cwsprChatFileContextTruncatedLength", + "required": false + }, + { + "type": "cwsprChatPromptContextCount", + "required": false + }, + { + "type": "cwsprChatPromptContextLength", + "required": false + }, + { + "type": "cwsprChatRuleContextTruncatedLength", + "required": false + }, + { + "type": "cwsprChatRuleContextCount", + "required": false + }, + { + "type": "cwsprChatRuleContextLength", + "required": false + }, + { + "type": "cwsprChatPromptContextTruncatedLength", + "required": false } ] },