diff --git a/packages/core/src/amazonq/webview/ui/apps/baseConnector.ts b/packages/core/src/amazonq/webview/ui/apps/baseConnector.ts index 3d5cdd71473..d6c7b235b7d 100644 --- a/packages/core/src/amazonq/webview/ui/apps/baseConnector.ts +++ b/packages/core/src/amazonq/webview/ui/apps/baseConnector.ts @@ -194,6 +194,15 @@ export abstract class BaseConnector { }) } + onPromptInputOptionChange = (tabId: string, optionsValues: Record): void => { + this.sendMessageToExtension({ + command: 'prompt-input-option-change', + optionsValues, + tabType: this.getTabType(), + tabID: tabId, + }) + } + requestGenerativeAIAnswer = (tabID: string, messageId: string, payload: ChatPayload): Promise => { /** * When a user presses "enter" send an event that indicates diff --git a/packages/core/src/amazonq/webview/ui/commands.ts b/packages/core/src/amazonq/webview/ui/commands.ts index 5b79549e53e..fb216d063cd 100644 --- a/packages/core/src/amazonq/webview/ui/commands.ts +++ b/packages/core/src/amazonq/webview/ui/commands.ts @@ -25,6 +25,7 @@ type MessageCommand = | 'help' | 'chat-item-voted' | 'chat-item-feedback' + | 'prompt-input-option-change' | 'link-was-clicked' | 'onboarding-page-interaction' | 'source-link-click' diff --git a/packages/core/src/amazonq/webview/ui/connector.ts b/packages/core/src/amazonq/webview/ui/connector.ts index 6968761d8ac..61cf70ba7d4 100644 --- a/packages/core/src/amazonq/webview/ui/connector.ts +++ b/packages/core/src/amazonq/webview/ui/connector.ts @@ -97,6 +97,7 @@ export interface ConnectorProps { onUpdateAuthentication: (featureDevEnabled: boolean, authenticatingTabIDs: string[]) => void onNewTab: (tabType: TabType) => void onFileActionClick: (tabID: string, messageId: string, filePath: string, actionName: string) => void + onPromptInputOptionChange: (tabId: string, optionsValues: Record, eventId?: string) => void handleCommand: (chatPrompt: ChatPrompt, tabId: string) => void sendStaticMessages: (tabID: string, messages: ChatItem[]) => void onContextCommandDataReceived: (message: MynahUIDataModel['contextCommands']) => void @@ -617,6 +618,15 @@ export class Connector { } } + onPromptInputOptionChange = (tabId: string, optionsValues: Record): void => { + switch (this.tabsStorage.getTab(tabId)?.type) { + case 'unknown': + case 'cwc': + this.cwChatConnector.onPromptInputOptionChange(tabId, optionsValues) + break + } + } + sendFeedback = (tabId: string, feedbackPayload: FeedbackPayload): void | undefined => { switch (this.tabsStorage.getTab(tabId)?.type) { case 'featuredev': diff --git a/packages/core/src/amazonq/webview/ui/main.ts b/packages/core/src/amazonq/webview/ui/main.ts index b816586b83b..f7ed50dbd6f 100644 --- a/packages/core/src/amazonq/webview/ui/main.ts +++ b/packages/core/src/amazonq/webview/ui/main.ts @@ -239,6 +239,7 @@ export const createMynahUI = ( } }, onFileActionClick: (tabID: string, messageId: string, filePath: string, actionName: string): void => {}, + onPromptInputOptionChange: (tabId: string, optionsValues: Record, eventId?: string): void => {}, onQuickHandlerCommand: (tabID: string, command?: string, eventId?: string) => { tabsStorage.updateTabLastCommand(tabID, command) if (command === 'aws.awsq.transform') { @@ -940,6 +941,9 @@ export const createMynahUI = ( onFileActionClick: async (tabID: string, messageId: string, filePath: string, actionName: string) => { connector.onFileActionClick(tabID, messageId, filePath, actionName) }, + onPromptInputOptionChange: (tabId, optionsValues) => { + connector.onPromptInputOptionChange(tabId, optionsValues) + }, onFileClick: connector.onFileClick, tabs: { 'tab-1': { diff --git a/packages/core/src/amazonq/webview/ui/tabs/generator.ts b/packages/core/src/amazonq/webview/ui/tabs/generator.ts index aeff92c33f2..6f887f5817b 100644 --- a/packages/core/src/amazonq/webview/ui/tabs/generator.ts +++ b/packages/core/src/amazonq/webview/ui/tabs/generator.ts @@ -72,6 +72,23 @@ export class TabDataGenerator { }, ] : [], + promptInputOptions: [ + { + type: 'toggle', + id: 'prompt-type', + value: 'ask', + options: [ + { + value: 'pair-programming-on', + icon: 'code-block', // TODO: correct icons + }, + { + value: 'pair-programming-off', + icon: 'chat', // TODO: correct icons + }, + ], + }, + ], } return tabData } diff --git a/packages/core/src/codewhispererChat/app.ts b/packages/core/src/codewhispererChat/app.ts index 3ffa51b75eb..f4b84df4167 100644 --- a/packages/core/src/codewhispererChat/app.ts +++ b/packages/core/src/codewhispererChat/app.ts @@ -28,6 +28,7 @@ import { AcceptDiff, QuickCommandGroupActionClick, FileClick, + PromptInputOptionChange, } from './controllers/chat/model' import { EditorContextCommand, registerCommands } from './commands/registerCommands' import { ContextSelectedMessage, CustomFormActionMessage } from './view/connector/connector' @@ -56,6 +57,7 @@ export function init(appContext: AmazonQAppInitContext) { processCustomFormAction: new EventEmitter(), processContextSelected: new EventEmitter(), processFileClick: new EventEmitter(), + processPromptInputOptionChange: new EventEmitter(), } const cwChatControllerMessageListeners = { @@ -117,6 +119,9 @@ export function init(appContext: AmazonQAppInitContext) { cwChatControllerEventEmitters.processContextSelected ), processFileClick: new MessageListener(cwChatControllerEventEmitters.processFileClick), + processPromptInputOptionChange: new MessageListener( + cwChatControllerEventEmitters.processPromptInputOptionChange + ), } const cwChatControllerMessagePublishers = { @@ -180,6 +185,9 @@ export function init(appContext: AmazonQAppInitContext) { cwChatControllerEventEmitters.processContextSelected ), processFileClick: new MessagePublisher(cwChatControllerEventEmitters.processFileClick), + processPromptInputOptionChange: new MessagePublisher( + cwChatControllerEventEmitters.processPromptInputOptionChange + ), } 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 35948e8eb79..17b26586772 100644 --- a/packages/core/src/codewhispererChat/clients/chat/v0/chat.ts +++ b/packages/core/src/codewhispererChat/clients/chat/v0/chat.ts @@ -28,6 +28,7 @@ export class ChatSession { private _toolUse: ToolUse | undefined private _showDiffOnFileWrite: boolean = false private _context: PromptMessage['context'] + private _pairProgrammingModeOn: boolean = true private _messageIdToUpdate: string | undefined contexts: Map = new Map() @@ -38,6 +39,14 @@ export class ChatSession { return this.sessionId } + public get pairProgrammingModeOn(): boolean { + return this._pairProgrammingModeOn + } + + public setPairProgrammingModeOn(pairProgrammingModeOn: boolean) { + this._pairProgrammingModeOn = pairProgrammingModeOn + } + public get toolUse(): ToolUse | undefined { return this._toolUse } diff --git a/packages/core/src/codewhispererChat/constants.ts b/packages/core/src/codewhispererChat/constants.ts index 8c1226a0055..9f0ac623f8c 100644 --- a/packages/core/src/codewhispererChat/constants.ts +++ b/packages/core/src/codewhispererChat/constants.ts @@ -30,6 +30,11 @@ export const tools: Tool[] = Object.entries(toolsJson).map(([, toolSpec]) => ({ inputSchema: { json: toolSpec.inputSchema }, }, })) + +export const noWriteTools: Tool[] = tools.filter( + (tool) => !['fsWrite', 'executeBash'].includes(tool.toolSpecification?.name || '') +) + export const defaultContextLengths: ContextLengths = { additionalContextLengths: { fileContextLength: 0, diff --git a/packages/core/src/codewhispererChat/controllers/chat/chatRequest/converter.ts b/packages/core/src/codewhispererChat/controllers/chat/chatRequest/converter.ts index 93947bdfe78..c634fff8270 100644 --- a/packages/core/src/codewhispererChat/controllers/chat/chatRequest/converter.ts +++ b/packages/core/src/codewhispererChat/controllers/chat/chatRequest/converter.ts @@ -6,9 +6,9 @@ import { ConversationState, CursorState, DocumentSymbol, SymbolType, TextDocument } from '@amzn/codewhisperer-streaming' import { AdditionalContentEntryAddition, ChatTriggerType, RelevantTextDocumentAddition, TriggerPayload } from '../model' import { undefinedIfEmpty } from '../../../../shared/utilities/textUtilities' -import { tools } from '../../../constants' import { getLogger } from '../../../../shared/logger/logger' import vscode from 'vscode' +import { noWriteTools, tools } from '../../../constants' const fqnNameSizeDownLimit = 1 const fqnNameSizeUpLimit = 256 @@ -164,7 +164,7 @@ export function triggerPayloadToChatRequest(triggerPayload: TriggerPayload): { c workspaceFolders: vscode.workspace.workspaceFolders?.map(({ uri }) => uri.fsPath) ?? [], }, additionalContext: triggerPayload.additionalContents, - tools, + tools: triggerPayload.pairProgrammingModeOn ? tools : noWriteTools, ...(triggerPayload.toolResults !== undefined && triggerPayload.toolResults !== null && { toolResults: triggerPayload.toolResults }), }, diff --git a/packages/core/src/codewhispererChat/controllers/chat/controller.ts b/packages/core/src/codewhispererChat/controllers/chat/controller.ts index 0c5574a0a20..8df4cbcf903 100644 --- a/packages/core/src/codewhispererChat/controllers/chat/controller.ts +++ b/packages/core/src/codewhispererChat/controllers/chat/controller.ts @@ -32,6 +32,7 @@ import { DocumentReference, FileClick, RelevantTextDocumentAddition, + PromptInputOptionChange, } from './model' import { AppToWebViewMessageDispatcher, @@ -117,6 +118,7 @@ export interface ChatControllerMessagePublishers { readonly processCustomFormAction: MessagePublisher readonly processContextSelected: MessagePublisher readonly processFileClick: MessagePublisher + readonly processPromptInputOptionChange: MessagePublisher } export interface ChatControllerMessageListeners { @@ -142,6 +144,7 @@ export interface ChatControllerMessageListeners { readonly processCustomFormAction: MessageListener readonly processContextSelected: MessageListener readonly processFileClick: MessageListener + readonly processPromptInputOptionChange: MessageListener } export class ChatController { @@ -277,6 +280,9 @@ export class ChatController { this.chatControllerMessageListeners.processFileClick.onMessage((data) => { return this.processFileClickMessage(data) }) + this.chatControllerMessageListeners.processPromptInputOptionChange.onMessage((data) => { + return this.processPromptInputOptionChange(data) + }) } private registerUserPromptsWatcher() { @@ -631,6 +637,69 @@ export class ChatController { telemetry.ui_click.emit({ elementId: 'amazonq_createSavedPrompt' }) } + private async processUnavailableToolUseMessage(message: CustomFormActionMessage) { + const tabID = message.tabID + if (!tabID) { + return + } + this.editorContextExtractor + .extractContextForTrigger('ChatMessage') + .then(async (context) => { + const triggerID = randomUUID() + this.triggerEventsStorage.addTriggerEvent({ + id: triggerID, + tabID: message.tabID, + message: undefined, + type: 'chat_message', + context, + }) + const session = this.sessionStorage.getSession(tabID) + const toolUse = session.toolUse + if (!toolUse || !toolUse.input) { + return + } + session.setToolUse(undefined) + + const toolResults: ToolResult[] = [] + + toolResults.push({ + content: [{ text: 'This tool is not an available tool in this mode' }], + toolUseId: toolUse.toolUseId, + status: ToolResultStatus.ERROR, + }) + + await this.generateResponse( + { + message: '', + trigger: ChatTriggerType.ChatMessage, + query: undefined, + codeSelection: context?.focusAreaContext?.selectionInsideExtendedCodeBlock, + fileText: context?.focusAreaContext?.extendedCodeBlock ?? '', + fileLanguage: context?.activeFileContext?.fileLanguage, + filePath: context?.activeFileContext?.filePath, + matchPolicy: context?.activeFileContext?.matchPolicy, + codeQuery: context?.focusAreaContext?.names, + userIntent: undefined, + customization: getSelectedCustomization(), + toolResults: toolResults, + origin: Origin.IDE, + context: session.context ?? [], + relevantTextDocuments: [], + additionalContents: [], + documentReferences: [], + useRelevantDocuments: false, + contextLengths: { + ...defaultContextLengths, + }, + }, + triggerID + ) + }) + .catch((e) => { + this.processException(e, tabID) + }) + } + private async processToolUseMessage(message: CustomFormActionMessage) { const tabID = message.tabID if (!tabID) { @@ -763,6 +832,9 @@ export class ChatController { case 'reject-code-diff': await this.closeDiffView() break + case 'tool-unavailable': + await this.processUnavailableToolUseMessage(message) + break default: getLogger().warn(`Unhandled action: ${message.action.id}`) } @@ -773,6 +845,18 @@ export class ChatController { this.handlePromptCreate(message.tabID) } } + + private async processPromptInputOptionChange(message: PromptInputOptionChange) { + const session = this.sessionStorage.getSession(message.tabID) + const promptTypeValue = message.optionsValues['prompt-type'] + // TODO: display message: You turned off pair programmer mode. Q will not include code diffs or run commands in the chat. + if (promptTypeValue === 'pair-programming-on') { + session.setPairProgrammingModeOn(true) + } else { + session.setPairProgrammingModeOn(false) + } + } + private async processFileClickMessage(message: FileClick) { const session = this.sessionStorage.getSession(message.tabID) // Check if user clicked on filePath in the contextList or in the fileListTree and perform the functionality accordingly. @@ -1353,6 +1437,7 @@ export class ChatController { triggerPayload.contextLengths.userInputContextLength = triggerPayload.message.length triggerPayload.contextLengths.focusFileContextLength = triggerPayload.fileText.length + triggerPayload.pairProgrammingModeOn = session.pairProgrammingModeOn const request = triggerPayloadToChatRequest(triggerPayload) diff --git a/packages/core/src/codewhispererChat/controllers/chat/messenger/messenger.ts b/packages/core/src/codewhispererChat/controllers/chat/messenger/messenger.ts index f2f386f9f91..3fbe6a03f08 100644 --- a/packages/core/src/codewhispererChat/controllers/chat/messenger/messenger.ts +++ b/packages/core/src/codewhispererChat/controllers/chat/messenger/messenger.ts @@ -46,6 +46,7 @@ import { ToolType, ToolUtils } from '../../../tools/toolUtils' import { ChatStream } from '../../../tools/chatStream' import path from 'path' import { CommandValidation } from '../../../tools/executeBash' +import { noWriteTools, tools } from '../../../constants' import { Change } from 'diff' import { FsWriteParams } from '../../../tools/fsWrite' @@ -218,6 +219,18 @@ export class Messenger { toolUse.name = cwChatEvent.toolUseEvent.name ?? '' session.setToolUse(toolUse) + const availableToolsNames = (session.pairProgrammingModeOn ? tools : noWriteTools).map( + (item) => item.toolSpecification?.name + ) + if (!availableToolsNames.includes(toolUse.name)) { + this.dispatcher.sendCustomFormActionMessage( + new CustomFormActionMessage(tabID, { + id: 'tool-unavailable', + }) + ) + return + } + const tool = ToolUtils.tryFromToolUse(toolUse) if ('type' in tool) { let changeList: Change[] | undefined = undefined diff --git a/packages/core/src/codewhispererChat/controllers/chat/model.ts b/packages/core/src/codewhispererChat/controllers/chat/model.ts index 1ec4d9f1666..609aed09a31 100644 --- a/packages/core/src/codewhispererChat/controllers/chat/model.ts +++ b/packages/core/src/codewhispererChat/controllers/chat/model.ts @@ -160,6 +160,13 @@ export interface FileClick { filePath: string } +export interface PromptInputOptionChange { + command: string + tabID: string + messageId: string + optionsValues: Record +} + export interface ChatItemVotedMessage { tabID: string command: string @@ -204,6 +211,7 @@ export interface TriggerPayload { workspaceRulesCount?: number toolResults?: ToolResult[] origin?: Origin + pairProgrammingModeOn?: boolean } export type ContextLengths = { diff --git a/packages/core/src/codewhispererChat/view/messages/messageListener.ts b/packages/core/src/codewhispererChat/view/messages/messageListener.ts index 82110e8354f..75f54fd640a 100644 --- a/packages/core/src/codewhispererChat/view/messages/messageListener.ts +++ b/packages/core/src/codewhispererChat/view/messages/messageListener.ts @@ -89,6 +89,9 @@ export class UIMessageListener { getLogger().error('chatItemFeedback failed: %s', (e as Error).message) }) break + case 'prompt-input-option-change': + this.promptInputOptionChange(msg) + break case 'ui-focus': this.processUIFocus(msg) break @@ -293,4 +296,13 @@ export class UIMessageListener { filePath: msg.filePath, }) } + + private promptInputOptionChange(msg: any) { + this.chatControllerMessagePublishers.processPromptInputOptionChange.publish({ + messageId: msg.messageId, + tabID: msg.tabID, + command: msg.command, + optionsValues: msg.optionsValues, + }) + } }