Skip to content

Commit c920e68

Browse files
authored
feat(chat): support multi file write with reject and open diff (aws#6955)
## Problem Support workflows with multiple file writes without confirmation ## Solution - Set `requiresAcceptance` to `false` for all FsWrite executions - Store backups of each FsWrite so we can revert the changes if needed --- - Treat all work as PUBLIC. Private `feature/x` branches will not be squash-merged at release time. - Your code changes must meet the guidelines in [CONTRIBUTING.md](https://github.com/aws/aws-toolkit-vscode/blob/master/CONTRIBUTING.md#guidelines). - License: I confirm that my contribution is made under the terms of the Apache 2.0 license.
1 parent 3def040 commit c920e68

File tree

9 files changed

+166
-116
lines changed

9 files changed

+166
-116
lines changed

packages/core/package.nls.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -454,6 +454,8 @@
454454
"AWS.amazonq.doc.pillText.makeChanges": "Make changes",
455455
"AWS.amazonq.inline.invokeChat": "Inline chat",
456456
"AWS.amazonq.opensettings:": "Open settings",
457+
"AWS.amazonq.executeBash.run": "Run",
458+
"AWS.amazonq.executeBash.reject": "Reject",
457459
"AWS.toolkit.lambda.walkthrough.quickpickTitle": "Application Builder Walkthrough",
458460
"AWS.toolkit.lambda.walkthrough.title": "Get started building your application",
459461
"AWS.toolkit.lambda.walkthrough.description": "Your quick guide to build an application visually, iterate locally, and deploy to the cloud!",

packages/core/src/amazonq/webview/ui/apps/cwChatConnector.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -185,7 +185,10 @@ export class Connector extends BaseConnector {
185185
this.chatItems.get(tabId)?.set(messageId, { ...item })
186186
}
187187

188-
private getCurrentChatItem(tabId: string, messageId: string): ChatItem | undefined {
188+
private getCurrentChatItem(tabId: string, messageId: string | undefined): ChatItem | undefined {
189+
if (!messageId) {
190+
return
191+
}
189192
return this.chatItems.get(tabId)?.get(messageId)
190193
}
191194

@@ -293,7 +296,7 @@ export class Connector extends BaseConnector {
293296

294297
onCustomFormAction(
295298
tabId: string,
296-
messageId: string,
299+
messageId: string | undefined,
297300
action: {
298301
id: string
299302
text?: string | undefined
@@ -304,6 +307,10 @@ export class Connector extends BaseConnector {
304307
return
305308
}
306309

310+
if (messageId?.startsWith('tooluse_')) {
311+
action.formItemValues = { ...action.formItemValues, toolUseId: messageId }
312+
}
313+
307314
this.sendMessageToExtension({
308315
command: 'form-action-click',
309316
action: action,

packages/core/src/codewhispererChat/clients/chat/v0/chat.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { createCodeWhispererChatStreamingClient } from '../../../../shared/clien
1515
import { createQDeveloperStreamingClient } from '../../../../shared/clients/qDeveloperChatClient'
1616
import { UserWrittenCodeTracker } from '../../../../codewhisperer/tracker/userWrittenCodeTracker'
1717
import { PromptMessage } from '../../../controllers/chat/model'
18+
import { FsWriteBackup } from '../../../../codewhispererChat/tools/fsWrite'
1819

1920
export type ToolUseWithError = {
2021
toolUse: ToolUse
@@ -33,6 +34,7 @@ export class ChatSession {
3334
private _showDiffOnFileWrite: boolean = false
3435
private _context: PromptMessage['context']
3536
private _pairProgrammingModeOn: boolean = true
37+
private _fsWriteBackups: Map<string, FsWriteBackup> = new Map()
3638
/**
3739
* True if messages from local history have been sent to session.
3840
*/
@@ -70,6 +72,14 @@ export class ChatSession {
7072
this._context = context
7173
}
7274

75+
public get fsWriteBackups(): Map<string, FsWriteBackup> {
76+
return this._fsWriteBackups
77+
}
78+
79+
public setFsWriteBackup(toolUseId: string, backup: FsWriteBackup) {
80+
this._fsWriteBackups.set(toolUseId, backup)
81+
}
82+
7383
public tokenSource!: vscode.CancellationTokenSource
7484

7585
constructor() {

packages/core/src/codewhispererChat/controllers/chat/controller.ts

Lines changed: 36 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@ import { OutputKind } from '../../tools/toolShared'
9797
import { ToolUtils, Tool, ToolType } from '../../tools/toolUtils'
9898
import { ChatStream } from '../../tools/chatStream'
9999
import { ChatHistoryStorage } from '../../storages/chatHistoryStorage'
100-
import { FsWrite, FsWriteParams } from '../../tools/fsWrite'
100+
import { FsWriteParams } from '../../tools/fsWrite'
101101
import { tempDirPath } from '../../../shared/filesystemUtilities'
102102
import { Database } from '../../../shared/db/chatDb/chatDb'
103103
import { TabBarController } from './tabBarController'
@@ -722,6 +722,10 @@ export class ChatController {
722722
const chatStream = new ChatStream(this.messenger, tabID, triggerID, toolUse, {
723723
requiresAcceptance: false,
724724
})
725+
if (tool.type === ToolType.FsWrite && toolUse.toolUseId) {
726+
const backup = await tool.tool.getBackup()
727+
session.setFsWriteBackup(toolUse.toolUseId, backup)
728+
}
725729
const output = await ToolUtils.invoke(tool, chatStream)
726730
ToolUtils.validateOutput(output)
727731

@@ -810,13 +814,15 @@ export class ChatController {
810814
case 'submit-create-prompt':
811815
await this.handleCreatePrompt(message)
812816
break
813-
case 'accept-code-diff':
814817
case 'run-shell-command':
815818
case 'generic-tool-execution':
816-
await this.closeDiffView()
817819
await this.processToolUseMessage(message)
818820
break
821+
case 'accept-code-diff':
822+
await this.closeDiffView()
823+
break
819824
case 'reject-code-diff':
825+
await this.restoreBackup(message)
820826
await this.closeDiffView()
821827
break
822828
case 'reject-shell-command':
@@ -827,6 +833,22 @@ export class ChatController {
827833
}
828834
}
829835

836+
private async restoreBackup(message: CustomFormActionMessage) {
837+
const tabID = message.tabID
838+
const toolUseId = message.action.formItemValues?.toolUseId
839+
if (!tabID || !toolUseId) {
840+
return
841+
}
842+
843+
const session = this.sessionStorage.getSession(tabID)
844+
const { content, filePath, isNew } = session.fsWriteBackups.get(toolUseId) ?? {}
845+
if (filePath && isNew) {
846+
await fs.delete(filePath)
847+
} else if (filePath && content !== undefined) {
848+
await fs.writeFile(filePath, content)
849+
}
850+
}
851+
830852
private async processContextSelected(message: ContextSelectedMessage) {
831853
if (message.tabID && message.contextItem.id === createSavedPromptCommandId) {
832854
this.handlePromptCreate(message.tabID)
@@ -848,8 +870,15 @@ export class ChatController {
848870
const session = this.sessionStorage.getSession(message.tabID)
849871
// Check if user clicked on filePath in the contextList or in the fileListTree and perform the functionality accordingly.
850872
if (session.showDiffOnFileWrite) {
873+
const toolUseId = message.messageId
874+
const { filePath, content } = session.fsWriteBackups.get(toolUseId) ?? {}
875+
if (!filePath || content === undefined) {
876+
return
877+
}
878+
851879
try {
852880
// Create a temporary file path to show the diff view
881+
// TODO: Use amazonQDiffScheme for temp file
853882
const pathToArchiveDir = path.join(tempDirPath, 'q-chat')
854883
const archivePathExists = await fs.existsDir(pathToArchiveDir)
855884
if (archivePathExists) {
@@ -858,39 +887,12 @@ export class ChatController {
858887
await fs.mkdir(pathToArchiveDir)
859888
const resultArtifactsDir = path.join(pathToArchiveDir, 'resultArtifacts')
860889
await fs.mkdir(resultArtifactsDir)
861-
const tempFilePath = path.join(
862-
resultArtifactsDir,
863-
`temp-${path.basename((session.toolUseWithError?.toolUse.input as unknown as FsWriteParams).path)}`
864-
)
865-
866-
// If we have existing filePath copy file content from existing file to temporary file.
867-
const filePath = (session.toolUseWithError?.toolUse.input as any).path ?? message.filePath
868-
const fileExists = await fs.existsFile(filePath)
869-
if (fileExists) {
870-
const fileContent = await fs.readFileText(filePath)
871-
await fs.writeFile(tempFilePath, fileContent)
872-
}
873890

874-
// Create a deep clone of the toolUse object and pass this toolUse to FsWrite tool execution to get the modified temporary file.
875-
const clonedToolUse = structuredClone(session.toolUseWithError?.toolUse)
876-
if (!clonedToolUse) {
877-
return
878-
}
879-
const input = clonedToolUse.input as unknown as FsWriteParams
880-
input.path = tempFilePath
881-
882-
const fsWrite = new FsWrite(input)
883-
await fsWrite.invoke()
891+
const tempFilePath = path.join(resultArtifactsDir, `temp-${path.basename(filePath)}`)
892+
await fs.writeFile(tempFilePath, content)
884893

885-
// Check if fileExists=false, If yes, return instead of showing broken diff experience.
886-
if (!tempFilePath) {
887-
void vscode.window.showInformationMessage(
888-
'Generated code changes have been reviewed and processed.'
889-
)
890-
return
891-
}
892-
const leftUri = fileExists ? vscode.Uri.file(filePath) : vscode.Uri.from({ scheme: 'untitled' })
893-
const rightUri = vscode.Uri.file(tempFilePath ?? filePath)
894+
const leftUri = vscode.Uri.file(tempFilePath)
895+
const rightUri = vscode.Uri.file(filePath)
894896
const fileName = path.basename(filePath)
895897
await vscode.commands.executeCommand(
896898
'vscode.diff',

packages/core/src/codewhispererChat/controllers/chat/messenger/messenger.ts

Lines changed: 58 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import {
2020
UpdateDetailedListMessage,
2121
CloseDetailedListMessage,
2222
SelectTabMessage,
23+
ChatItemHeader,
2324
} from '../../../view/connector/connector'
2425
import { EditorContextCommandType } from '../../../commands/registerCommands'
2526
import { ChatResponseStream as qdevChatResponseStream } from '@amzn/amazon-q-developer-streaming-client'
@@ -65,6 +66,8 @@ import { noWriteTools, tools } from '../../../constants'
6566
import { Change } from 'diff'
6667
import { FsWriteParams } from '../../../tools/fsWrite'
6768
import { AsyncEventProgressMessage } from '../../../../amazonq/commons/connector/connectorMessages'
69+
import { localize } from '../../../../shared/utilities/vsCodeUtils'
70+
import { getDiffLinesFromChanges } from '../../../../shared/utilities/diffUtils'
6871

6972
export type StaticTextResponseType =
7073
| 'quick-action-help'
@@ -496,49 +499,44 @@ export class Messenger {
496499
changeList?: Change[]
497500
) {
498501
const buttons: ChatItemButton[] = []
499-
let fileList: ChatItemContent['fileList'] = undefined
500-
let shellCommandHeader = undefined
502+
let header: ChatItemHeader | undefined = undefined
503+
let fullWidth: boolean | undefined = undefined
504+
let padding: boolean | undefined = undefined
505+
let codeBlockActions: ChatItemContent['codeBlockActions'] = undefined
501506
if (toolUse?.name === ToolType.ExecuteBash && message.startsWith('```shell')) {
502507
if (validation.requiresAcceptance) {
503-
buttons.push({
504-
id: 'run-shell-command',
505-
text: 'Run',
506-
status: 'main',
507-
icon: 'play' as MynahIconsType,
508-
})
509-
buttons.push({
510-
id: 'reject-shell-command',
511-
text: 'Reject',
512-
status: 'clear',
513-
icon: 'cancel' as MynahIconsType,
514-
})
515-
}
516-
517-
shellCommandHeader = {
518-
icon: 'code-block' as MynahIconsType,
519-
body: 'shell',
520-
buttons: buttons,
508+
const buttons: ChatItemButton[] = [
509+
{
510+
id: 'run-shell-command',
511+
text: localize('AWS.amazonq.executeBash.run', 'Run'),
512+
status: 'main',
513+
icon: 'play' as MynahIconsType,
514+
},
515+
{
516+
id: 'reject-shell-command',
517+
text: localize('AWS.amazonq.executeBash.reject', 'Reject'),
518+
status: 'clear',
519+
icon: 'cancel' as MynahIconsType,
520+
},
521+
]
522+
header = {
523+
icon: 'code-block' as MynahIconsType,
524+
body: 'shell',
525+
buttons,
526+
}
521527
}
522-
523528
if (validation.warning) {
524529
message = validation.warning + message
525530
}
531+
fullWidth = true
532+
padding = false
533+
// eslint-disable-next-line unicorn/no-null
534+
codeBlockActions = { 'insert-to-cursor': null, copy: null }
526535
} else if (toolUse?.name === ToolType.FsWrite) {
527536
const input = toolUse.input as unknown as FsWriteParams
528537
const fileName = path.basename(input.path)
529-
const changes = changeList?.reduce(
530-
(acc, { count = 0, added, removed }) => {
531-
if (added) {
532-
acc.added += count
533-
} else if (removed) {
534-
acc.deleted += count
535-
}
536-
return acc
537-
},
538-
{ added: 0, deleted: 0 }
539-
)
540-
// FileList
541-
fileList = {
538+
const changes = getDiffLinesFromChanges(changeList)
539+
const fileList: ChatItemContent['fileList'] = {
542540
fileTreeTitle: '',
543541
hideFileCount: true,
544542
filePaths: [fileName],
@@ -550,17 +548,27 @@ export class Messenger {
550548
},
551549
},
552550
}
553-
// Buttons
554-
buttons.push({
555-
id: 'reject-code-diff',
556-
status: 'clear',
557-
icon: 'cancel' as MynahIconsType,
558-
})
559-
buttons.push({
560-
id: 'accept-code-diff',
561-
status: 'clear',
562-
icon: 'ok' as MynahIconsType,
563-
})
551+
const buttons: ChatItemButton[] = [
552+
{
553+
id: 'reject-code-diff',
554+
status: 'clear',
555+
icon: 'cancel' as MynahIconsType,
556+
},
557+
{
558+
id: 'accept-code-diff',
559+
status: 'clear',
560+
icon: 'ok' as MynahIconsType,
561+
},
562+
]
563+
header = {
564+
icon: 'code-block' as MynahIconsType,
565+
buttons,
566+
fileList,
567+
}
568+
fullWidth = true
569+
padding = false
570+
// eslint-disable-next-line unicorn/no-null
571+
codeBlockActions = { 'insert-to-cursor': null, copy: null }
564572
}
565573

566574
this.dispatcher.sendChatMessage(
@@ -577,23 +585,11 @@ export class Messenger {
577585
codeBlockLanguage: undefined,
578586
contextList: undefined,
579587
canBeVoted: false,
580-
buttons:
581-
toolUse?.name === ToolType.FsWrite || toolUse?.name === ToolType.ExecuteBash
582-
? undefined
583-
: buttons,
584-
fullWidth: toolUse?.name === ToolType.FsWrite || toolUse?.name === ToolType.ExecuteBash,
585-
padding: !(toolUse?.name === ToolType.FsWrite || toolUse?.name === ToolType.ExecuteBash),
586-
header:
587-
toolUse?.name === ToolType.FsWrite
588-
? { icon: 'code-block' as MynahIconsType, buttons: buttons, fileList: fileList }
589-
: toolUse?.name === ToolType.ExecuteBash
590-
? shellCommandHeader
591-
: undefined,
592-
codeBlockActions:
593-
// eslint-disable-next-line unicorn/no-null, prettier/prettier
594-
toolUse?.name === ToolType.FsWrite || toolUse?.name === ToolType.ExecuteBash
595-
? { 'insert-to-cursor': undefined, copy: undefined }
596-
: undefined,
588+
buttons,
589+
fullWidth,
590+
padding,
591+
header,
592+
codeBlockActions,
597593
},
598594
tabID
599595
)

0 commit comments

Comments
 (0)