diff --git a/extensions/codestory/package-lock.json b/extensions/codestory/package-lock.json index a46df8a9fb4..afb885e92b3 100644 --- a/extensions/codestory/package-lock.json +++ b/extensions/codestory/package-lock.json @@ -23,8 +23,8 @@ "semver": "^7.6.3", "ts-morph": "^19.0.0", "web-tree-sitter": "^0.20.8", - "winston": "^3.10.0", - "winston-vscode": "^1.0.0" + "winston": "^3.17.0", + "winston-transport-vscode": "^0.1.0" }, "devDependencies": { "@types/diff": "^5.0.3", @@ -1166,9 +1166,10 @@ "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" }, "node_modules/logform": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/logform/-/logform-2.6.1.tgz", - "integrity": "sha512-CdaO738xRapbKIMVn2m4F6KTj4j7ooJ8POVnebSgKo3KBz5axNXRAL7ZdRjIV6NOr2Uf4vjtRkxrFETOioCqSA==", + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/logform/-/logform-2.7.0.tgz", + "integrity": "sha512-TFYA4jnP7PVbmlBIfhlSe+WKxs9dklXMTEGcBCIvLhE/Tn3H6Gk1norupVW7m5Cnd4bLcr08AytbyV/xj7f/kQ==", + "license": "MIT", "dependencies": { "@colors/colors": "1.6.0", "@types/triple-beam": "^1.3.2", @@ -1970,32 +1971,34 @@ } }, "node_modules/winston": { - "version": "3.14.2", - "resolved": "https://registry.npmjs.org/winston/-/winston-3.14.2.tgz", - "integrity": "sha512-CO8cdpBB2yqzEf8v895L+GNKYJiEq8eKlHU38af3snQBQ+sdAIUepjMSguOIJC7ICbzm0ZI+Af2If4vIJrtmOg==", + "version": "3.17.0", + "resolved": "https://registry.npmjs.org/winston/-/winston-3.17.0.tgz", + "integrity": "sha512-DLiFIXYC5fMPxaRg832S6F5mJYvePtmO5G9v9IgUFPhXm9/GkXarH/TUrBAVzhTCzAj9anE/+GjrgXp/54nOgw==", + "license": "MIT", "dependencies": { "@colors/colors": "^1.6.0", "@dabh/diagnostics": "^2.0.2", "async": "^3.2.3", "is-stream": "^2.0.0", - "logform": "^2.6.0", + "logform": "^2.7.0", "one-time": "^1.0.0", "readable-stream": "^3.4.0", "safe-stable-stringify": "^2.3.1", "stack-trace": "0.0.x", "triple-beam": "^1.3.0", - "winston-transport": "^4.7.0" + "winston-transport": "^4.9.0" }, "engines": { "node": ">= 12.0.0" } }, "node_modules/winston-transport": { - "version": "4.7.1", - "resolved": "https://registry.npmjs.org/winston-transport/-/winston-transport-4.7.1.tgz", - "integrity": "sha512-wQCXXVgfv/wUPOfb2x0ruxzwkcZfxcktz6JIMUaPLmcNhO4bZTwA/WtDWK74xV3F2dKu8YadrFv0qhwYjVEwhA==", + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/winston-transport/-/winston-transport-4.9.0.tgz", + "integrity": "sha512-8drMJ4rkgaPo1Me4zD/3WLfI/zPdA9o2IipKODunnGDcuqbHwjsbB79ylv04LCGGzU0xQ6vTznOMpQGaLhhm6A==", + "license": "MIT", "dependencies": { - "logform": "^2.6.1", + "logform": "^2.7.0", "readable-stream": "^3.6.2", "triple-beam": "^1.3.0" }, @@ -2003,15 +2006,21 @@ "node": ">= 12.0.0" } }, - "node_modules/winston-vscode": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/winston-vscode/-/winston-vscode-1.0.0.tgz", - "integrity": "sha512-pC9WBAJd+1RzsKlorv1YBRSO0kmQlA4VG5+twxggpr3z7wCTj4lXFe0uLNwZ+GXCJ4BlYCX5kf3iEKRPFpZ0Hw==", + "node_modules/winston-transport-vscode": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/winston-transport-vscode/-/winston-transport-vscode-0.1.0.tgz", + "integrity": "sha512-iHJEJP5vinrVxMId+gWN00kUR4haP+VMY5pydB53Ug3ZyXZ0EmWZp46hFJQtFQGP+BnxdCA0UElLjUz8QQPEGw==", + "license": "Apache-2.0", "dependencies": { - "winston-transport": "^4.5.0" + "logform": "^2.6.0", + "triple-beam": "^1.4.1", + "winston-transport": "^4.7.0" }, "engines": { - "node": ">=10" + "node": ">=18.0.0" + }, + "peerDependencies": { + "winston": ">=3.0.0" } }, "node_modules/wrappy": { diff --git a/extensions/codestory/package.json b/extensions/codestory/package.json index 4c99bc4fe32..6b55c1ebaab 100644 --- a/extensions/codestory/package.json +++ b/extensions/codestory/package.json @@ -197,7 +197,7 @@ "semver": "^7.6.3", "ts-morph": "^19.0.0", "web-tree-sitter": "^0.20.8", - "winston": "^3.10.0", - "winston-vscode": "^1.0.0" + "winston": "^3.17.0", + "winston-transport-vscode": "^0.1.0" } } diff --git a/extensions/codestory/src/chatState/convertStreamToMessage.ts b/extensions/codestory/src/chatState/convertStreamToMessage.ts index 4c00a177e18..6dd7ba08633 100644 --- a/extensions/codestory/src/chatState/convertStreamToMessage.ts +++ b/extensions/codestory/src/chatState/convertStreamToMessage.ts @@ -153,6 +153,11 @@ export const readJsonFile = (filePath: string): any => { // Math.floor(Math.random() * (max - min + 1)) + min; const pattern = /(?:^|\s)(\w+\s+at\s+[\w/.-]+)?(.*)/s; +export interface EditMapValue { + answerSplitter: AnswerSplitOnNewLineAccumulatorStreaming; + streamProcessor: StreamProcessor; +} + export const reportAgentEventsToChat = async ( editMode: boolean, stream: AsyncIterableIterator, @@ -166,7 +171,7 @@ export const reportAgentEventsToChat = async ( // we are sending async edits and they might go out of scope limiter: Limiter, ): Promise => { - const editsMap = new Map(); + const editsMap = new Map(); const asyncIterable = { [Symbol.asyncIterator]: () => stream }; @@ -319,8 +324,8 @@ export const reportAgentEventsToChat = async ( continue; } const documentLines = document.getText().split(/\r\n|\r|\n/g); - console.log('editStreaming.start', editStreamEvent.fs_file_path); - console.log(editStreamEvent.range); + // console.log('editStreaming.start', editStreamEvent.fs_file_path); + // console.log(editStreamEvent.range); editsMap.set(editStreamEvent.edit_request_id, { answerSplitter: new AnswerSplitOnNewLineAccumulatorStreaming(), streamProcessor: new StreamProcessor( @@ -340,29 +345,31 @@ export const reportAgentEventsToChat = async ( } else if ('End' === editStreamEvent.event) { // drain the lines which might be still present const editsManager = editsMap.get(editStreamEvent.edit_request_id); - while (true) { - const currentLine = editsManager.answerSplitter.getLine(); - if (currentLine === null) { - break; + if (editsManager) { + while (true) { + const currentLine = editsManager.answerSplitter.getLine(); + if (currentLine === null) { + break; + } + // console.log('end::process_line'); + await editsManager.streamProcessor.processLine(currentLine); } - console.log('end::process_line'); - await editsManager.streamProcessor.processLine(currentLine); + // console.log('end::cleanup'); + editsManager.streamProcessor.complete(); } - console.log('end::cleanup'); - editsManager.streamProcessor.cleanup(); // delete this from our map editsMap.delete(editStreamEvent.edit_request_id); // we have the updated code (we know this will be always present, the types are a bit meh) } else if (editStreamEvent.event.Delta) { const editsManager = editsMap.get(editStreamEvent.edit_request_id); - if (editsManager !== undefined) { + if (editsManager) { editsManager.answerSplitter.addDelta(editStreamEvent.event.Delta); while (true) { const currentLine = editsManager.answerSplitter.getLine(); if (currentLine === null) { break; } - console.log('delta::process_line'); + // console.log('delta::process_line'); await editsManager.streamProcessor.processLine(currentLine); } } @@ -548,12 +555,13 @@ export class StreamProcessor { this.sentEdits = false; } - async cleanup() { + async complete() { // for cleanup we are going to replace the lines from the documentLineIndex to the documentLineLimit with "" // console.log('cleanup', this.documentLineIndex, this.documentLineLimit); if (this.documentLineIndex <= this.documentLineLimit) { this.document.replaceLines(this.documentLineIndex, this.documentLineLimit, new AdjustedLineContent('', 0, '', 0)); } + this.document.complete(); } async processLine(answerStreamLine: AnswerStreamLine) { @@ -702,46 +710,40 @@ class DocumentManager { // Replace a specific line and report the change async replaceLine(index: number, newLine: AdjustedLineContent) { this.lines[index] = new LineContent(newLine.adjustedContent, this.indentStyle); - //console.log('sidecar.replaceLine', index); - // console.table({ - // 'edit': 'replace_line', - // 'index': index, - // 'content': newLine.adjustedContent, - // }); - const edits = new vscode.WorkspaceEdit(); if (newLine.adjustedContent === '') { - // console.log('What line are we replaceLine', newLine.adjustedContent); - edits.delete(this.uri, new vscode.Range(index, 0, index, 1000), { - label: this.uniqueId.toString(), - needsConfirmation: false, - }); + const edit = vscode.TextEdit.delete( + new vscode.Range(index, 0, index, 1000), + ); this.iterationEdits.delete(this.uri, new vscode.Range(index, 0, index, 1000)); if (this.applyDirectly) { - await vscode.workspace.applyEdit(edits); + const workspaceEdit = new vscode.WorkspaceEdit(); + workspaceEdit.set(this.uri, [edit]); + await vscode.workspace.applyEdit(workspaceEdit); } else if (this.limiter === null) { - await this.progress.codeEdit(edits); + this.progress.textEdit(this.uri, edit); } else { await this.limiter.queue(async () => { - await this.progress.codeEdit(edits); + this.progress.textEdit(this.uri, edit); }); } return index + 1; } else { - // console.log('What line are we replaceLine', newLine.adjustedContent); - edits.replace(this.uri, new vscode.Range(index, 0, index, 1000), newLine.adjustedContent, { - label: this.uniqueId.toString(), - needsConfirmation: false, - }); + const edit = vscode.TextEdit.replace( + new vscode.Range(index, 0, index, 1000), + newLine.adjustedContent + ); this.iterationEdits.replace(this.uri, new vscode.Range(index, 0, index, 1000), newLine.adjustedContent); if (this.applyDirectly) { - await vscode.workspace.applyEdit(edits); + const workspaceEdit = new vscode.WorkspaceEdit(); + workspaceEdit.set(this.uri, [edit]); + await vscode.workspace.applyEdit(workspaceEdit); } else if (this.limiter === null) { - await this.progress.codeEdit(edits); + this.progress.textEdit(this.uri, edit); } else { await this.limiter.queue(async () => { - await this.progress.codeEdit(edits); + this.progress.textEdit(this.uri, edit); }); } return index + 1; @@ -750,13 +752,6 @@ class DocumentManager { // Replace multiple lines starting from a specific index async replaceLines(startIndex: number, endIndex: number, newLine: AdjustedLineContent) { - //console.log('sidecar.replaceLine', startIndex, endIndex); - // console.table({ - // 'edit': 'replace_lines', - // 'start_index': startIndex, - // 'end_index': endIndex, - // 'content': newLine.adjustedContent, - // }); if (startIndex === endIndex) { return await this.replaceLine(startIndex, newLine); } else { @@ -765,21 +760,21 @@ class DocumentManager { endIndex - startIndex + 1, new LineContent(newLine.adjustedContent, this.indentStyle) ); - const edits = new vscode.WorkspaceEdit(); - // console.log('sidecar.What line are we replaceLines', newLine.adjustedContent, startIndex, endIndex); - edits.replace(this.uri, new vscode.Range(startIndex, 0, endIndex, 1000), newLine.adjustedContent, { - label: this.uniqueId.toString(), - needsConfirmation: false, - }); + const edit = vscode.TextEdit.replace( + new vscode.Range(startIndex, 0, endIndex, 1000), + newLine.adjustedContent + ); this.iterationEdits.replace(this.uri, new vscode.Range(startIndex, 0, endIndex, 1000), newLine.adjustedContent); if (this.applyDirectly) { - await vscode.workspace.applyEdit(edits); + const workspaceEdit = new vscode.WorkspaceEdit(); + workspaceEdit.set(this.uri, [edit]); + await vscode.workspace.applyEdit(workspaceEdit); } else if (this.limiter === null) { - await this.progress.codeEdit(edits); + this.progress.textEdit(this.uri, edit); } else { await this.limiter.queue(async () => { - await this.progress.codeEdit(edits); + this.progress.textEdit(this.uri, edit); }); } return startIndex + 1; @@ -788,28 +783,23 @@ class DocumentManager { // Add a new line at the end async appendLine(newLine: AdjustedLineContent) { - //console.log('sidecar.appendLine', this.lines.length - 1); this.lines.push(new LineContent(newLine.adjustedContent, this.indentStyle)); - const edits = new vscode.WorkspaceEdit(); - // console.table({ - // 'edit': 'append_line', - // 'start_index': this.lines.length - 2, - // 'content': newLine.adjustedContent, - // }); - // console.log('what line are we appendLine', newLine.adjustedContent); - edits.replace(this.uri, new vscode.Range(this.lines.length - 2, 1000, this.lines.length - 2, 1000), '\n' + newLine.adjustedContent, { - label: this.uniqueId.toString(), - needsConfirmation: false, - }); + + const edit = vscode.TextEdit.replace( + new vscode.Range(this.lines.length - 2, 1000, this.lines.length - 2, 1000), + '\n' + newLine.adjustedContent + ); this.iterationEdits.replace(this.uri, new vscode.Range(this.lines.length - 2, 1000, this.lines.length - 2, 1000), '\n' + newLine.adjustedContent); if (this.applyDirectly) { - await vscode.workspace.applyEdit(edits); + const workspaceEdit = new vscode.WorkspaceEdit(); + workspaceEdit.set(this.uri, [edit]); + await vscode.workspace.applyEdit(workspaceEdit); } else if (this.limiter === null) { - await this.progress.codeEdit(edits); + this.progress.textEdit(this.uri, edit); } else { await this.limiter.queue(async () => { - await this.progress.codeEdit(edits); + this.progress.textEdit(this.uri, edit); }); } return this.lines.length; @@ -817,30 +807,29 @@ class DocumentManager { // Insert a new line after a specific index async insertLineAfter(index: number, newLine: AdjustedLineContent) { - //console.log('insertLineAfter', index); - // console.table({ - // 'edit': 'insert_line_after', - // 'index': index, - // 'content': newLine.adjustedContent, - // }); this.lines.splice(index + 1, 0, new LineContent(newLine.adjustedContent, this.indentStyle)); - const edits = new vscode.WorkspaceEdit(); - // console.log('what line are we inserting insertLineAfter', newLine.adjustedContent); - edits.replace(this.uri, new vscode.Range(index, 1000, index, 1000), '\n' + newLine.adjustedContent, { - label: this.uniqueId.toString(), - needsConfirmation: false, - }); + + const edit = vscode.TextEdit.replace( + new vscode.Range(index, 1000, index, 1000), + '\n' + newLine.adjustedContent + ); this.iterationEdits.replace(this.uri, new vscode.Range(index, 1000, index, 1000), '\n' + newLine.adjustedContent); if (this.applyDirectly) { - await vscode.workspace.applyEdit(edits); + const workspaceEdit = new vscode.WorkspaceEdit(); + workspaceEdit.set(this.uri, [edit]); + await vscode.workspace.applyEdit(workspaceEdit); } else if (this.limiter === null) { - await this.progress.codeEdit(edits); + this.progress.textEdit(this.uri, edit); } else { await this.limiter.queue(async () => { - await this.progress.codeEdit(edits); + this.progress.textEdit(this.uri, edit); }); } return index + 2; } + + complete() { + this.progress.textEdit(this.uri, true); + } } diff --git a/extensions/codestory/src/completions/providers/aideAgentProvider.ts b/extensions/codestory/src/completions/providers/aideAgentProvider.ts index d644f361af9..f451914b5a8 100644 --- a/extensions/codestory/src/completions/providers/aideAgentProvider.ts +++ b/extensions/codestory/src/completions/providers/aideAgentProvider.ts @@ -7,15 +7,14 @@ import * as http from 'http'; import * as net from 'net'; import * as os from 'os'; import * as vscode from 'vscode'; - -import { AnswerSplitOnNewLineAccumulatorStreaming, StreamProcessor } from '../../chatState/convertStreamToMessage'; +import { AnswerSplitOnNewLineAccumulatorStreaming, EditMapValue, StreamProcessor } from '../../chatState/convertStreamToMessage'; import { CSEventHandler } from '../../csEvents/csEventHandler'; import postHogClient from '../../posthog/client'; import { applyEdits, applyEditsDirectly, } from '../../server/applyEdits'; import { createFileIfNotExists } from '../../server/createFile'; import { RecentEditsRetriever } from '../../server/editedFiles'; import { handleRequest } from '../../server/requestHandler'; -import { EditedCodeStreamingRequest, SideCarAgentEvent, SidecarApplyEditsRequest, SidecarContextEvent, SidecarUndoPlanStep, ToolInputPartial } from '../../server/types'; +import { EditedCodeStreamingRequest, SideCarAgentEvent, SidecarApplyEditsRequest, SidecarContextEvent, ToolInputPartial } from '../../server/types'; import { RepoRef, SideCarClient } from '../../sidecar/client'; import { getUniqueId, getUserId } from '../../utilities/uniqueId'; import { ProjectContext } from '../../utilities/workspaceContext'; @@ -98,7 +97,7 @@ export class AideAgentSessionProvider implements vscode.AideSessionParticipant { editorUrl: string | undefined; private iterationEdits = new vscode.WorkspaceEdit(); private requestHandler: http.Server | null = null; - private editsMap = new Map(); + private editsMap = new Map(); private eventQueue: vscode.AideAgentRequest[] = []; private openResponseStream: vscode.AideAgentResponseStream | undefined; private processingEvents: Map = new Map(); @@ -157,7 +156,6 @@ export class AideAgentSessionProvider implements vscode.AideSessionParticipant { this.provideEditStreamed.bind(this), this.newExchangeIdForSession.bind(this), recentEditsRetriever.retrieveSidecar.bind(recentEditsRetriever), - this.undoToCheckpoint.bind(this), ) ); this.recentEditsRetriever = recentEditsRetriever; @@ -200,39 +198,6 @@ export class AideAgentSessionProvider implements vscode.AideSessionParticipant { await this.sidecarClient.sendContextRecording(events, this.editorUrl); } - async undoToCheckpoint(request: SidecarUndoPlanStep): Promise<{ - success: boolean; - }> { - const exchangeId = request.exchange_id; - const sessionId = request.session_id; - const planStep = request.index; - const responseStream = this.responseStreamCollection.getResponseStream({ - sessionId, - exchangeId, - }); - if (responseStream === undefined) { - return { - success: false, - }; - } - let label = exchangeId; - if (planStep !== null) { - label = `${exchangeId}::${planStep}`; - } - - // This creates a very special code edit which is handled by the aideAgentCodeEditingService - // where we intercept this edit and instead do a global rollback - const edit = new vscode.WorkspaceEdit(); - edit.delete(vscode.Uri.file('/undoCheck'), new vscode.Range(new vscode.Position(0, 0), new vscode.Position(0, 0)), { - label, - needsConfirmation: false, - }); - responseStream.stream.codeEdit(edit); - return { - success: true, - }; - } - async newExchangeIdForSession(sessionId: string): Promise<{ exchange_id: string | undefined; }> { @@ -336,14 +301,16 @@ export class AideAgentSessionProvider implements vscode.AideSessionParticipant { } else if ('End' === editStreamEvent.event) { // drain the lines which might be still present const editsManager = this.editsMap.get(editStreamEvent.edit_request_id); - while (true) { - const currentLine = editsManager.answerSplitter.getLine(); - if (currentLine === null) { - break; + if (editsManager) { + while (true) { + const currentLine = editsManager.answerSplitter.getLine(); + if (currentLine === null) { + break; + } + await editsManager.streamProcessor.processLine(currentLine); } - await editsManager.streamProcessor.processLine(currentLine); + editsManager.streamProcessor.complete(); } - editsManager.streamProcessor.cleanup(); await vscode.workspace.save(vscode.Uri.file(editStreamEvent.fs_file_path)); // save files upon stream completion // delete this from our map diff --git a/extensions/codestory/src/completions/providers/chatprovider.ts b/extensions/codestory/src/completions/providers/chatprovider.ts deleted file mode 100644 index 658287760b9..00000000000 --- a/extensions/codestory/src/completions/providers/chatprovider.ts +++ /dev/null @@ -1,591 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -/* -import * as vscode from 'vscode'; -import * as uuid from 'uuid'; - -import { reportFromStreamToSearchProgress } from '../../chatState/convertStreamToMessage'; -import { UserMessageType, deterministicClassifier } from '../../chatState/promptClassifier'; -import logger from '../../logger'; -import { logChatPrompt, logSearchPrompt } from '../../posthog/logChatPrompt'; -import { RepoRef, SideCarClient } from '../../sidecar/client'; -import { InLineAgentContextSelection } from '../../sidecar/types'; -import { getSelectedCodeContextForExplain } from '../../utilities/getSelectionContext'; -import { getUserId } from '../../utilities/uniqueId'; -import { ProjectContext } from '../../utilities/workspaceContext'; -// import { registerOpenFiles } from './openFiles'; -import { IndentStyleSpaces, IndentationHelper, provideInteractiveEditorResponse } from './editorSessionProvider'; -import { AdjustedLineContent, AnswerSplitOnNewLineAccumulator, AnswerStreamContext, AnswerStreamLine, LineContent, LineIndentManager, StateEnum } from './reportEditorSessionAnswerStream'; -// import { registerTerminalSelection } from './terminalSelection'; -import { AideProbeProvider } from './probeProvider'; -import { AidePlanTimer } from '../../utilities/planTimer'; - -class CSChatParticipant implements vscode.ChatRequesterInformation { - name: string; - icon?: vscode.Uri | undefined; - - constructor(name: string, icon?: vscode.Uri | undefined) { - this.name = name; - this.icon = icon; - } - - toString(): string { - return `CSChatParticipant { name: "${this.name}", icon: "${this.icon?.toString()}" }`; - } -} - -class CSChatResponseErrorDetails implements vscode.ChatErrorDetails { - message: string; - responseIsIncomplete?: boolean | undefined; - responseIsFiltered?: boolean | undefined; - - constructor(message: string, responseIsIncomplete?: boolean | undefined, responseIsFiltered?: boolean | undefined) { - this.message = message; - this.responseIsIncomplete = responseIsIncomplete; - this.responseIsFiltered = responseIsFiltered; - } - - toString(): string { - return `CSChatResponseErrorDetails { message: "${this.message}", responseIsIncomplete: "${this.responseIsIncomplete}", responseIsFiltered: "${this.responseIsFiltered}" }`; - } -} - - -class CSChatResponseForProgress implements vscode.ChatResult { - errorDetails?: CSChatResponseErrorDetails | undefined; - readonly metadata?: { readonly [key: string]: any }; - - constructor( - errorDetails?: CSChatResponseErrorDetails | undefined, - metadata?: { readonly [key: string]: any }, - ) { - this.errorDetails = errorDetails; - this.metadata = metadata; - } - - toString(): string { - return `CSChatResponseForProgress { errorDetails: ${this.errorDetails?.toString()}, metadata: ${this.metadata} }`; - } -} - -export class CSChatAgentProvider implements vscode.Disposable { - private chatAgent: vscode.ChatParticipant; - - private _workingDirectory: string; - private _repoName: string; - private _repoHash: string; - private _uniqueUserId: string; - private _sideCarClient: SideCarClient; - private _currentRepoRef: RepoRef; - private _projectContext: ProjectContext; - private _probeProvider: AideProbeProvider; - private _aidePlanTimer: AidePlanTimer; - - constructor( - workingDirectory: string, - repoName: string, - repoHash: string, - uniqueUserId: string, - sideCarClient: SideCarClient, - repoRef: RepoRef, - projectContext: ProjectContext, - probeProvider: AideProbeProvider, - aidePlanTimer: AidePlanTimer, - ) { - this._workingDirectory = workingDirectory; - this._repoHash = repoHash; - this._repoName = repoName; - this._uniqueUserId = uniqueUserId; - this._sideCarClient = sideCarClient; - this._currentRepoRef = repoRef; - this._projectContext = projectContext; - this._probeProvider = probeProvider; - this._aidePlanTimer = aidePlanTimer; - - this.chatAgent = vscode.aideAgent.createChatParticipant('aide', this.defaultAgentRequestHandler); - this.chatAgent.iconPath = vscode.Uri.joinPath( - vscode.extensions.getExtension('codestory-ghost.codestoryai')?.extensionUri ?? vscode.Uri.parse(''), - 'assets', - 'aide-agent.png' - ); - this.chatAgent.requester = new CSChatParticipant(getUserId(), vscode.Uri.joinPath( - vscode.extensions.getExtension('codestory-ghost.codestoryai')?.extensionUri ?? vscode.Uri.parse(''), - 'assets', - 'aide-user.png' - )); - this.chatAgent.supportIssueReporting = false; - this.chatAgent.welcomeMessageProvider = { - provideWelcomeMessage: async () => { - return [ - 'Hi, I\'m **Aide**, your personal coding assistant! I can find, understand, explain, debug or write code for you.', - ]; - } - }; - - // register the extra variables here - // registerOpenFiles(); - // registerTerminalSelection(); - // TODO(skcd): Toggle this at will to debug the planning module - // registerGeneratePlan(extensionContext); - this.chatAgent.editsProvider = this.editsProvider; - } - - defaultAgentRequestHandler: vscode.ChatExtendedRequestHandler = async (request, _context, response, token) => { - let requestType: UserMessageType = 'general'; - const slashCommand = request.command; - if (request.location === vscode.ChatLocation.Editor) { - await provideInteractiveEditorResponse( - this._currentRepoRef, - this._sideCarClient, - this._workingDirectory, - request, - response, - token - ); - return new CSChatResponseForProgress(); - } - - if (slashCommand) { - requestType = slashCommand as UserMessageType; - } else { - const deterministicRequestType = deterministicClassifier(request.prompt.toString()); - if (deterministicRequestType) { - requestType = deterministicRequestType; - } - } - - const threadId = uuid.v4(); - - logger.info(`[codestory][request_type][provideResponseWithProgress] ${requestType}`); - if (requestType === 'explain') { - // Implement the explain feature here - const explainString = request.prompt.toString().slice('/explain'.length).trim(); - const currentSelection = getSelectedCodeContextForExplain(this._workingDirectory, this._currentRepoRef); - if (currentSelection === null) { - response.markdown('Selecting code on the editor can help us explain it better'); - return new CSChatResponseForProgress(); - } else { - const explainResponse = this._sideCarClient.explainQuery(explainString, this._currentRepoRef, currentSelection, request.threadId); - await reportFromStreamToSearchProgress(explainResponse, response, this._aidePlanTimer, token, this._workingDirectory); - return new CSChatResponseForProgress(); - } - } else if (requestType === 'search') { - logSearchPrompt( - request.prompt.toString(), - this._repoName, - this._repoHash, - this._uniqueUserId, - ); - const searchString = request.prompt.toString().slice('/search'.length).trim(); - const searchResponse = this._sideCarClient.searchQuery(searchString, this._currentRepoRef, request.threadId); - await reportFromStreamToSearchProgress(searchResponse, response, this._aidePlanTimer, token, this._workingDirectory); - // We get back here a bunch of responses which we have to pass properly to the agent - return new CSChatResponseForProgress(); - } else { - const query = request.prompt.toString().trim(); - logChatPrompt( - request.prompt.toString(), - this._repoName, - this._repoHash, - this._uniqueUserId, - ); - const projectLabels = this._projectContext.labels; - const followupResponse = this._sideCarClient.followupQuestion(query, this._currentRepoRef, request.threadId, request.references, projectLabels, this._probeProvider, this._aidePlanTimer); - await reportFromStreamToSearchProgress(followupResponse, response, this._aidePlanTimer, token, this._workingDirectory); - return new CSChatResponseForProgress(); - } - }; - - editsProvider: vscode.ChatEditsProvider = { - provideEdits: async (request, progress, _token) => { - // Notes to @theskcd: This API currently applies the edits without any decoration. - // - // WIP items on editor side, in order of priority: - // 1. When edits are made, add a decoration to the changes to highlight agent changes. - // 2. Displaying the list of edits performed in the chat widget as links (something like the references box). - // 3. Allow cancelling an ongoing edit operation. - // 4. Add options above the inline decorations and in the chat widget to accept/reject the changes. - // 5. Add an option to export all codeblocks within a response, rather than one at a time. The API already - // accepts a list so your implementation need not change. - // - // The code below uses the open file for testing purposes. - // You can pass in any file uri(s) and it should apply correctly. - const activeDocument = vscode.window.activeTextEditor?.document; - if (!activeDocument) { - return { edits: new vscode.WorkspaceEdit(), codeBlockIndex: 0 }; - } - const filePath = activeDocument.uri.fsPath; - const fileContent = activeDocument.getText(); - const language = activeDocument.languageId; - const activeEditorUri = vscode.window.activeTextEditor?.document.uri; - const codeblocks = request.context; - if (activeEditorUri && codeblocks.length > 0) { - for (const codeblock of codeblocks) { - const llmContent = codeblock.code; - const codeBlockIndex = codeblock.codeBlockIndex; - const messageContent = request.response; - const sessionId = request.threadId; - const editFileResponseStream = this._sideCarClient.editFileRequest( - filePath, - fileContent, - language, - llmContent, - messageContent, - codeBlockIndex, - sessionId - ); - // let enteredTextEdit = false; - // let startOfEdit = false; - let answerSplitOnNewLineAccumulator = new AnswerSplitOnNewLineAccumulator(); - let streamProcessor = null; - let finalAnswer = ''; - for await (const editResponse of editFileResponseStream) { - if ('TextEditStreaming' in editResponse) { - const textEditStreaming = editResponse.TextEditStreaming.data; - if ('Start' in textEditStreaming) { - // startOfEdit = true; - // console.log('Start of edit', startOfEdit); - const codeBlockIndex = textEditStreaming.Start.code_block_index; - const agentContext = textEditStreaming.Start.context_selection; - streamProcessor = new StreamProcessor( - progress, - activeDocument, - activeDocument.getText().split(/\r\n|\r|\n/g), - agentContext, - undefined, - activeEditorUri, - codeBlockIndex, - true, - ); - answerSplitOnNewLineAccumulator = new AnswerSplitOnNewLineAccumulator(); - continue; - } - if ('EditStreaming' in textEditStreaming) { - // const codeBlockIndex = textEditStreaming.EditStreaming.code_block_index; - answerSplitOnNewLineAccumulator.addDelta(textEditStreaming.EditStreaming.content_delta); - // check if we can get any lines back here - while (true) { - const currentLine = answerSplitOnNewLineAccumulator.getLine(); - if (currentLine === null) { - break; - } - // Let's process the line - if (streamProcessor !== null) { - streamProcessor.processLine(currentLine); - } - finalAnswer = finalAnswer + currentLine.line + '\n'; - } - } - if ('End' in textEditStreaming) { - // startOfEdit = false; - // console.log('End of edit', startOfEdit); - // enteredTextEdit = false; - // console.log('Entered text edit', enteredTextEdit); - streamProcessor = null; - answerSplitOnNewLineAccumulator = new AnswerSplitOnNewLineAccumulator(); - finalAnswer = ''; - } - } - } - } - } - return { edits: new vscode.WorkspaceEdit(), codeBlockIndex: 0 }; - } - }; - - dispose() { - // console.log('Dispose CSChatAgentProvider'); - } -} - - -class StreamProcessor { - filePathMarker: string; - beginMarker: string; - endMarker: string; - document: DocumentManager; - currentState: StateEnum; - endDetected: boolean; - beginDetected: boolean; - previousLine: LineIndentManager | null; - documentLineIndex: number; - sentEdits: boolean; - uri: vscode.Uri; - allowFlaky: boolean; - constructor(progress: vscode.Progress, - document: vscode.TextDocument, - lines: string[], - contextSelection: InLineAgentContextSelection, - indentStyle: IndentStyleSpaces | undefined, - uri: vscode.Uri, - codeBlockIndex: number, - allowFlaky = false, - ) { - this.allowFlaky = allowFlaky; - // Initialize document with the given parameters - this.document = new DocumentManager( - progress, - document, - lines, - contextSelection, - indentStyle, - uri, - codeBlockIndex, - ); - - // Set markers for file path, begin, and end - this.filePathMarker = '// FILEPATH:'; - this.beginMarker = '// BEGIN'; - this.endMarker = '// END'; - this.beginDetected = false; - this.endDetected = false; - this.currentState = StateEnum.Initial; - this.previousLine = null; - this.documentLineIndex = this.document.firstSentLineIndex; - this.sentEdits = false; - this.uri = uri; - } - - async processLine(answerStreamLine: AnswerStreamLine) { - // console.log('codestory.streamProcessor.processLine'); - // console.log('prepareLine', answerStreamLine.line, this.documentLineIndex); - if (answerStreamLine.context !== AnswerStreamContext.InCodeBlock) { - return; - } - const line = answerStreamLine.line; - if (!this.allowFlaky) { - // in which case thats also okay, we should still be able to do - if ((line.startsWith(this.filePathMarker) && this.currentState === StateEnum.Initial) || (this.currentState === StateEnum.Initial && this.allowFlaky)) { - this.currentState = StateEnum.InitialAfterFilePath; - // but if we allow flaky, we should not be returning here - return; - } - if (line.startsWith(this.beginMarker) || line.startsWith(this.endMarker)) { - this.endDetected = true; - return; - } - } else if (this.allowFlaky && !line.startsWith(this.filePathMarker) && this.currentState === StateEnum.Initial) { - this.endDetected = true; - this.currentState = StateEnum.InitialAfterFilePath; - return; - // repeat the logic above if this is flaky and we still get // BEGIN and // END markers along with the // FILEPATH markers - } else if (this.allowFlaky && line.startsWith(this.filePathMarker)) { - this.allowFlaky = false; - } - // if this is flaky, then we might not have the being and the end markers - if (this.endDetected && (this.currentState === StateEnum.InitialAfterFilePath || this.currentState === StateEnum.InProgress)) { - if (this.previousLine) { - // if previous line is there, then we can reindent the current line - // contents here - const adjustedLine = this.previousLine.reindent(line, this.document.indentStyle); - // find the anchor point for the current line - const anchor = this.findAnchor(adjustedLine, this.documentLineIndex); - if (anchor !== null) { - this.sentEdits = true; - // if no anchor line, then we have to replace the current line - // console.log('replaceLines', this.documentLineIndex, anchor, adjustedLine); - this.documentLineIndex = this.document.replaceLines(this.documentLineIndex, anchor, adjustedLine); - } else if (this.documentLineIndex >= this.document.getLineCount()) { - // we found the anchor point but we have more lines in our own index - // than the original document, so here the right thing to do is - // append to the document - this.sentEdits = true; - // console.log('appendLine', adjustedLine, this.document.getLineCount()); - this.documentLineIndex = this.document.appendLine(adjustedLine); - } else { - // we need to get the current line right now - const currentLine = this.document.getLine(this.documentLineIndex); - this.sentEdits = true; - // console.log(this.documentLineIndex, currentLine.content); - // TODO(skcd): This bit is a bit unclear, so lets try to understand this properly - // isSent is set when we are part of the original lines - // if the current line has an indent level which is less than the adjusted line indent level - // then we are trying to insert the line after the previous line - // otherwise we just replace - // to think of this we can imagine a scenario like the following: - // def fun(a, b): - // if a > 0: - // return a + b <- adjusted line - // else: < - original current line - // return a - b - // since adjusted line is indented more, we want to insert it after the previous document line - // but if that's not the case, then we just replace the current line - if (!currentLine.isSent || adjustedLine.adjustedContent === '' || (currentLine.content !== '' && currentLine.indentLevel < adjustedLine.adjustedIndentLevel)) { - // console.log('insertLineAfter', this.documentLineIndex - 1, adjustedLine); - this.documentLineIndex = this.document.insertLineAfter(this.documentLineIndex - 1, adjustedLine); - } else { - // console.log('replaceLine', this.documentLineIndex, adjustedLine); - this.documentLineIndex = this.document.replaceLine(this.documentLineIndex, adjustedLine); - } - } - } else { - const initialAnchor = this.findInitialAnchor(line); - this.previousLine = new LineIndentManager(this.document.getLine(initialAnchor).indentLevel, line); - const adjustedInitialLine = this.previousLine.reindent(line, this.document.indentStyle); - // console.log('noPreviousLine', 'replaceLine', initialAnchor, adjustedInitialLine); - this.documentLineIndex = this.document.replaceLine(initialAnchor, adjustedInitialLine); - } - this.beginDetected = true; - } - return this.beginDetected; - } - - // Find the initial anchor line in the document - findInitialAnchor(lineContent: string): number { - const trimmedContent = lineContent.trim(); - for (let index = this.document.firstSentLineIndex; index < this.document.getLineCount(); index++) { - const line = this.document.getLine(index); - if (line.isSent && line.trimmedContent === trimmedContent) { - return index; - } - } - return this.document.firstRangeLine; - } - - // Find the anchor line in the document based on indentation and content - findAnchor(adjustedLine: AdjustedLineContent, startIndex: number): number | null { - for (let index = startIndex; index < this.document.getLineCount(); index++) { - const line = this.document.getLine(index); - if (line.isSent) { - // This checks for when we want to insert code which has more indent - // that the current line, but in that case we can never find an anchor - // cause our code is deeper than the code which is present on the line - if (line.trimmedContent.length > 0 && line.indentLevel < adjustedLine.adjustedIndentLevel) { - return null; - } - if (line.content === adjustedLine.adjustedContent) { - return index; - } - } - } - return null; - } -} - - -class DocumentManager { - indentStyle: IndentStyleSpaces; - progress: vscode.Progress; - lines: LineContent[]; - firstSentLineIndex: number; - firstRangeLine: number; - uri: vscode.Uri; - codeBlockIndex: number; - - constructor( - progress: vscode.Progress, - document: vscode.TextDocument, - lines: string[], - // Fix the way we provide context over here? - contextSelection: InLineAgentContextSelection, - indentStyle: IndentStyleSpaces | undefined, - uri: vscode.Uri, - codeBlockIndex: number, - ) { - // console.log('sidecar.logged.document_manager'); - this.progress = progress; // Progress tracking - this.lines = []; // Stores all the lines in the document - this.indentStyle = IndentationHelper.getDocumentIndentStyle(lines, indentStyle); - this.codeBlockIndex = codeBlockIndex; - // this.indentStyle = IndentationHelper.getDocumentIndentStyleUsingSelection(contextSelection); // Determines the indentation style - - // Split the editor's text into lines and initialize each line - const editorLines = document.getText().split(/\r\n|\r|\n/g); - for (let i = 0; i < editorLines.length; i++) { - this.lines[i] = new LineContent(editorLines[i], this.indentStyle); - } - - // Mark the lines as 'sent' based on the location provided - const locationSections = [contextSelection.range]; - for (const section of locationSections) { - for (let j = 0; j < section.lines.length; j++) { - const lineIndex = section.first_line_index + j; - this.lines[lineIndex].markSent(); - } - } - - this.firstSentLineIndex = contextSelection.range.first_line_index; - - // Determine the index of the first 'sent' line - // this.firstSentLineIndex = contextSelection.above.has_content - // ? contextSelection.above.first_line_index - // : contextSelection.range.first_line_index; - - // this.firstRangeLine = contextSelection.range.first_line_index; - this.firstRangeLine = contextSelection.range.first_line_index; - this.uri = uri; - } - - // Returns the total number of lines - getLineCount() { - return this.lines.length; - } - - // Retrieve a specific line - getLine(index: number): LineContent { - return this.lines[index]; - } - - // Replace a specific line and report the change - replaceLine(index: number, newLine: AdjustedLineContent) { - // console.log('sidecar.replaceLine'); - // console.log('sidecar.replaceLine', index, JSON.stringify(newLine)); - this.lines[index] = new LineContent(newLine.adjustedContent, this.indentStyle); - const edits = new vscode.WorkspaceEdit(); - // console.log('What line are we replaceLine', newLine.adjustedContent); - edits.replace(this.uri, new vscode.Range(index, 0, index, 1000), newLine.adjustedContent); - this.progress.report({ edits, codeBlockIndex: this.codeBlockIndex }); - return index + 1; - } - - // Replace multiple lines starting from a specific index - replaceLines(startIndex: number, endIndex: number, newLine: AdjustedLineContent) { - // console.log('sidecar.replaceLine'); - // console.log('sidecar.replaceLines', startIndex, endIndex, JSON.stringify(newLine)); - if (startIndex === endIndex) { - return this.replaceLine(startIndex, newLine); - } else { - this.lines.splice( - startIndex, - endIndex - startIndex + 1, - new LineContent(newLine.adjustedContent, this.indentStyle) - ); - const edits = new vscode.WorkspaceEdit(); - if (newLine.adjustedContent === '') { - // console.log('sidecar.[extension]empty_line', 'replace_lines'); - } - // console.log('sidecar.What line are we replaceLines', newLine.adjustedContent, startIndex, endIndex); - edits.replace(this.uri, new vscode.Range(startIndex, 0, endIndex, 1000), newLine.adjustedContent); - this.progress.report({ edits, codeBlockIndex: this.codeBlockIndex }); - return startIndex + 1; - } - } - - // Add a new line at the end - appendLine(newLine: AdjustedLineContent) { - // console.log('sidecar.appendLine'); - // console.log('sidecar.appendLine', JSON.stringify(newLine)); - this.lines.push(new LineContent(newLine.adjustedContent, this.indentStyle)); - const edits = new vscode.WorkspaceEdit(); - // console.log('what line are we appendLine', newLine.adjustedContent); - edits.replace(this.uri, new vscode.Range(this.lines.length - 1, 1000, this.lines.length - 1, 1000), '\n' + newLine.adjustedContent); - this.progress.report({ edits, codeBlockIndex: this.codeBlockIndex }); - return this.lines.length; - } - - // Insert a new line after a specific index - insertLineAfter(index: number, newLine: AdjustedLineContent) { - // console.log('sidecar.insertLineAfter'); - // console.log('sidecar.insertLineAfter', index, newLine); - this.lines.splice(index + 1, 0, new LineContent(newLine.adjustedContent, this.indentStyle)); - const edits = new vscode.WorkspaceEdit(); - // console.log('what line are we inserting insertLineAfter', newLine.adjustedContent); - edits.replace(this.uri, new vscode.Range(index, 1000, index, 1000), '\n' + newLine.adjustedContent); - this.progress.report({ edits, codeBlockIndex: this.codeBlockIndex }); - return index + 2; - } -} -*/ diff --git a/extensions/codestory/src/completions/providers/probeProvider.ts b/extensions/codestory/src/completions/providers/probeProvider.ts deleted file mode 100644 index 721bb01cfd4..00000000000 --- a/extensions/codestory/src/completions/providers/probeProvider.ts +++ /dev/null @@ -1,320 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -/* -import * as http from 'http'; -import * as net from 'net'; -import * as os from 'os'; -import * as vscode from 'vscode'; - -import { AnswerSplitOnNewLineAccumulatorStreaming, reportAgentEventsToChat, StreamProcessor } from '../../chatState/convertStreamToMessage'; -import postHogClient from '../../posthog/client'; -import { applyEdits, applyEditsDirectly, Limiter } from '../../server/applyEdits'; -import { handleRequest } from '../../server/requestHandler'; -import { EditedCodeStreamingRequest, SidecarApplyEditsRequest, SidecarContextEvent } from '../../server/types'; -import { SideCarClient } from '../../sidecar/client'; -import { getUniqueId } from '../../utilities/uniqueId'; -import { RecentEditsRetriever } from '../../server/editedFiles'; - -export class AideProbeProvider implements vscode.Disposable { - private _sideCarClient: SideCarClient; - private _editorUrl: string | undefined; - // private _rootPath: string; - private _limiter = new Limiter(1); - private editsMap = new Map(); - - private _requestHandler: http.Server | null = null; - private _openResponseStream: vscode.AgentResponseStream | undefined; - private _iterationEdits = new vscode.WorkspaceEdit(); - - private async isPortOpen(port: number): Promise { - return new Promise((resolve, _) => { - const s = net.createServer(); - s.once('error', (err) => { - s.close(); - // @ts-ignore - if (err['code'] === 'EADDRINUSE') { - resolve(false); - } else { - resolve(false); // or throw error!! - // reject(err); - } - }); - s.once('listening', () => { - resolve(true); - s.close(); - }); - s.listen(port); - }); - } - - private async getNextOpenPort(startFrom: number = 42427) { - let openPort: number | null = null; - while (startFrom < 65535 || !!openPort) { - if (await this.isPortOpen(startFrom)) { - openPort = startFrom; - break; - } - startFrom++; - } - return openPort; - } - - constructor( - sideCarClient: SideCarClient, - _rootPath: string, - recentEditsRetriever: RecentEditsRetriever, - ) { - this._sideCarClient = sideCarClient; - // this._rootPath = rootPath; - - // Server for the sidecar to talk to the editor - this._requestHandler = http.createServer( - handleRequest(this.provideEdit.bind(this), this.provideEditStreamed.bind(this), recentEditsRetriever.retrieveSidecar.bind(recentEditsRetriever)) - ); - this.getNextOpenPort().then((port) => { - if (port === null) { - throw new Error('Could not find an open port'); - } - - // can still grab it by listenting to port 0 - this._requestHandler?.listen(port); - const editorUrl = `http://localhost:${port}`; - console.log('editorUrl', editorUrl); - this._editorUrl = editorUrl; - // console.log(this._editorUrl); - }); - - /* - vscode.aideProbe.registerProbeResponseProvider( - 'aideProbeProvider', - { - provideProbeResponse: this.provideProbeResponse.bind(this), - onDidSessionAction: this.sessionFollowup.bind(this), - onDidUserAction: this.userFollowup.bind(this), - } - ); - } - - /** - * - * @returns Retuns the optional editor url (which is weird, maybe we should just crash - * if we don't get the editor url as its a necessary component now?) - *\/ - editorUrl(): string | undefined { - return this._editorUrl; - } - - async sendContextRecording(events: SidecarContextEvent[]) { - await this._sideCarClient.sendContextRecording(events, this._editorUrl); - } - - /* - async sessionFollowup(sessionAction: vscode.AideProbeSessionAction) { - if (sessionAction.action.type === 'newIteration') { - // @theskcd - This is where we can accept the iteration - console.log('newIteration', sessionAction); - await this._sideCarClient.codeSculptingFollowup(sessionAction.action.newPrompt, sessionAction.sessionId); - } - - if (sessionAction.action.type === 'followUpRequest') { - console.log('followUpRequest'); - this._iterationEdits = new vscode.WorkspaceEdit(); - await this._sideCarClient.codeSculptingFollowups(sessionAction.sessionId, this._rootPath); - } - - postHogClient?.capture({ - distinctId: getUniqueId(), - event: sessionAction.action.type, - properties: { - platform: os.platform(), - requestId: sessionAction.sessionId, - }, - }); - } - - async userFollowup(userAction: vscode.AideProbeUserAction) { - if (userAction.type === 'contextChange') { - console.log('contextChange'); - if (!this._editorUrl) { - console.log('skipping_no_editor_url'); - return; - } - await this._sideCarClient.warmupCodeSculptingCache(userAction.newContext, this._editorUrl); - } - postHogClient?.capture({ - distinctId: getUniqueId(), - event: userAction.type, - properties: { - platform: os.platform(), - }, - }); - } - - async provideEditStreamed(request: EditedCodeStreamingRequest): Promise<{ - fs_file_path: String; - success: boolean; - }> { - // if (!request.apply_directly && !this._openResponseStream) { - // console.log('editing_streamed::no_open_response_stream'); - // return { - // fs_file_path: '', - // success: false, - // }; - // } - const editStreamEvent = request; - editStreamEvent.apply_directly = true; - const fileDocument = editStreamEvent.fs_file_path; - if ('Start' === editStreamEvent.event) { - const timeNow = Date.now(); - const document = await vscode.workspace.openTextDocument(fileDocument); - if (document === undefined || document === null) { - return { - fs_file_path: '', - success: false, - }; - } - console.log('editsStreamed::content', timeNow, document.getText()); - const documentLines = document.getText().split(/\r\n|\r|\n/g); - console.log('editStreaming.start', editStreamEvent.fs_file_path); - console.log(editStreamEvent.range); - console.log(documentLines); - this.editsMap.set(editStreamEvent.edit_request_id, { - answerSplitter: new AnswerSplitOnNewLineAccumulatorStreaming(), - streamProcessor: new StreamProcessor( - this._openResponseStream as vscode.ProbeResponseStream, - documentLines, - undefined, - vscode.Uri.file(editStreamEvent.fs_file_path), - editStreamEvent.range, - null, - this._iterationEdits, - editStreamEvent.apply_directly, - ), - }); - } else if ('End' === editStreamEvent.event) { - // drain the lines which might be still present - const editsManager = this.editsMap.get(editStreamEvent.edit_request_id); - while (true) { - const currentLine = editsManager.answerSplitter.getLine(); - if (currentLine === null) { - break; - } - await editsManager.streamProcessor.processLine(currentLine); - } - editsManager.streamProcessor.cleanup(); - - await vscode.workspace.save(vscode.Uri.file(editStreamEvent.fs_file_path)); // save files upon stream completion - console.log('provideEditsStreamed::finished', editStreamEvent.fs_file_path); - // delete this from our map - this.editsMap.delete(editStreamEvent.edit_request_id); - // we have the updated code (we know this will be always present, the types are a bit meh) - } else if (editStreamEvent.event.Delta) { - const editsManager = this.editsMap.get(editStreamEvent.edit_request_id); - if (editsManager !== undefined) { - editsManager.answerSplitter.addDelta(editStreamEvent.event.Delta); - while (true) { - const currentLine = editsManager.answerSplitter.getLine(); - if (currentLine === null) { - break; - } - await editsManager.streamProcessor.processLine(currentLine); - } - } - } - return { - fs_file_path: '', - success: true, - }; - } - - async provideEdit(request: SidecarApplyEditsRequest): Promise<{ - fs_file_path: String; - success: boolean; - }> { - if (request.apply_directly) { - applyEditsDirectly(request); - return { - fs_file_path: request.fs_file_path, - success: true, - }; - } - if (!this._openResponseStream) { - console.log('returning early over here'); - return { - fs_file_path: request.fs_file_path, - success: true, - }; - } - const response = await applyEdits(request, this._openResponseStream, this._iterationEdits); - return response; - } - - private async provideProbeResponse(request: vscode.AgentTrigger, response: vscode.AgentResponseStream, token: vscode.CancellationToken) { - if (!this._editorUrl) { - return; - } - - this._openResponseStream = response; - let { message: query } = request; - - query = query.trim(); - - const startTime = process.hrtime(); - - postHogClient?.capture({ - distinctId: getUniqueId(), - event: 'probe_requested', - properties: { - platform: os.platform(), - query, - requestId: request.id, - }, - }); - - //if there is a selection present in the references: this is what it looks like: - const isAnchorEditing = isAnchorBasedEditing(request.scope); - - // let probeResponse: AsyncIterableIterator; - - // if (request.mode === 'AGENTIC' || request.mode === 'ANCHORED') { - const probeResponse = this._sideCarClient.startAgentCodeEdit(query, [], this._editorUrl, request.id, request.scope === 'WholeCodebase', isAnchorEditing); - // } else { - // probeResponse = this._sideCarClient.startAgentProbe(query, request.references, this._editorUrl, request.requestId,); - // } - - // const isEditMode = request.mode === 'AGENTIC' || request.mode === 'ANCHORED'; - await reportAgentEventsToChat(true, probeResponse, response, request.id, token, this._sideCarClient, this._iterationEdits, this._limiter); - - const endTime = process.hrtime(startTime); - postHogClient?.capture({ - distinctId: getUniqueId(), - event: 'probe_completed', - properties: { - platform: os.platform(), - query, - timeElapsed: `${endTime[0]}s ${endTime[1] / 1000000}ms`, - requestId: request.id, - }, - }); - - return { - errorDetails: undefined, - }; - } - - dispose() { - this._requestHandler?.close(); - } -} - -function isAnchorBasedEditing(scope: vscode.AideAgentScope): boolean { - if (scope === 'Selection') { - return true; - } else { - return false; - } -} -*/ diff --git a/extensions/codestory/src/logger.ts b/extensions/codestory/src/logger.ts index b160dc44e7a..3673c9397a5 100644 --- a/extensions/codestory/src/logger.ts +++ b/extensions/codestory/src/logger.ts @@ -3,25 +3,18 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ import { window } from 'vscode'; -import { createLogger, format } from 'winston'; -// @ts-ignore -import VSCTransport from 'winston-vscode'; +import { createLogger } from 'winston'; +import { LogOutputChannelTransport } from 'winston-transport-vscode'; -const transport = new VSCTransport({ - window: window, - name: 'CodeStory', +const outputChannel = window.createOutputChannel('CodeStory', { + log: true, }); const logger = createLogger({ - level: 'info', - format: format.combine( - format.splat(), - format.printf(({ message }: { message: string }) => { - return message; - }), - format.errors({ stack: true }) - ), - transports: [transport], + level: 'trace', + levels: LogOutputChannelTransport.config.levels, + format: LogOutputChannelTransport.format(), + transports: [new LogOutputChannelTransport({ outputChannel })], }); export default logger; diff --git a/extensions/codestory/src/server/applyEdits.ts b/extensions/codestory/src/server/applyEdits.ts index eec2aa53b18..ffd10cdcc31 100644 --- a/extensions/codestory/src/server/applyEdits.ts +++ b/extensions/codestory/src/server/applyEdits.ts @@ -80,19 +80,19 @@ export async function applyEdits( const range = new vscode.Range(new vscode.Position(startPosition.line, 0), new vscode.Position(endPosition.line, endPosition.character)); const fileUri = vscode.Uri.file(filePath); - const workspaceEdit = new vscode.WorkspaceEdit(); - workspaceEdit.replace(fileUri, range, replacedText); + const edit = vscode.TextEdit.replace(range, replacedText); iterationEdits.replace(fileUri, range, replacedText); if (request.apply_directly) { // apply the edits to it + const workspaceEdit = new vscode.WorkspaceEdit(); + workspaceEdit.set(fileUri, [edit]); await vscode.workspace.applyEdit(workspaceEdit); // we also want to save the file at this point after applying the edit await vscode.workspace.save(fileUri); } else { - await response.codeEdit(workspaceEdit); + response.textEdit(fileUri, edit); } - // we calculate how many lines we get after replacing the text // once we make the edit on the range, the new range is presented to us // we have to calculate the new range and use that instead diff --git a/extensions/codestory/src/server/requestHandler.ts b/extensions/codestory/src/server/requestHandler.ts index 16fe23062ce..fc8c0c324d4 100644 --- a/extensions/codestory/src/server/requestHandler.ts +++ b/extensions/codestory/src/server/requestHandler.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ import * as http from 'http'; -import { SidecarApplyEditsRequest, LSPDiagnostics, SidecarGoToDefinitionRequest, SidecarGoToImplementationRequest, SidecarGoToReferencesRequest, SidecarOpenFileToolRequest, LSPQuickFixInvocationRequest, SidecarQuickFixRequest, SidecarSymbolSearchRequest, SidecarInlayHintsRequest, SidecarGetOutlineNodesRequest, SidecarOutlineNodesWithContentRequest, EditedCodeStreamingRequest, SidecarRecentEditsRetrieverRequest, SidecarRecentEditsRetrieverResponse, SidecarCreateFileRequest, LSPFileDiagnostics, SidecarGetPreviousWordRangeRequest, SidecarDiagnosticsResponse, SidecarCreateNewExchangeRequest, SidecarUndoPlanStep, SidecarExecuteTerminalCommandRequest, SidecarListFilesEndpoint } from './types'; +import { SidecarApplyEditsRequest, LSPDiagnostics, SidecarGoToDefinitionRequest, SidecarGoToImplementationRequest, SidecarGoToReferencesRequest, SidecarOpenFileToolRequest, LSPQuickFixInvocationRequest, SidecarQuickFixRequest, SidecarSymbolSearchRequest, SidecarInlayHintsRequest, SidecarGetOutlineNodesRequest, SidecarOutlineNodesWithContentRequest, EditedCodeStreamingRequest, SidecarRecentEditsRetrieverRequest, SidecarRecentEditsRetrieverResponse, SidecarCreateFileRequest, LSPFileDiagnostics, SidecarGetPreviousWordRangeRequest, SidecarDiagnosticsResponse, SidecarCreateNewExchangeRequest, SidecarExecuteTerminalCommandRequest, SidecarListFilesEndpoint } from './types'; import { Position, Range, workspace } from 'vscode'; import { getDiagnosticsFromEditor, getEnrichedDiagnostics, getFileDiagnosticsFromEditor, getFullWorkspaceDiagnostics, getHoverInformation } from './diagnostics'; import { openFileEditor } from './openFile'; @@ -52,7 +52,6 @@ export function handleRequest( exchange_id: string | undefined; }>, recentEditsRetriever: (request: SidecarRecentEditsRetrieverRequest) => Promise, - undoToCheckpoint: (request: SidecarUndoPlanStep) => Promise<{ success: boolean }>, ) { return async (req: http.IncomingMessage, res: http.ServerResponse) => { try { @@ -207,12 +206,6 @@ export function handleRequest( const response = await newExchangeId(request.session_id); res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(response)); - } else if (req.method === 'POST' && req.url === '/undo_session_changes') { - const body = await readRequestBody(req); - const request: SidecarUndoPlanStep = JSON.parse(body); - const response = await undoToCheckpoint(request); - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify(response)); } else if (req.method === 'POST' && req.url === '/rip_grep_path') { const ripGrepPath = await getRipGrepPath(); res.writeHead(200, { 'Content-Type': 'application/json' }); diff --git a/src/vs/platform/actions/common/actions.ts b/src/vs/platform/actions/common/actions.ts index 02afce8cd69..35ec87324cb 100644 --- a/src/vs/platform/actions/common/actions.ts +++ b/src/vs/platform/actions/common/actions.ts @@ -254,7 +254,12 @@ export class MenuId { static readonly AideAgentInlineSymbolAnchorContext = new MenuId('AideAgentInlineSymbolAnchorContext'); // TODO(@ghostwriternr): This one isn't currently actually initialised anywhere static readonly AideAgentInputSymbolAttachmentContext = new MenuId('AideAgentInputSymbolAttachmentContext'); - static readonly AideAgentEditPreviewWidget = new MenuId('AideAgentEditPreviewWidget'); + static readonly AideAgentEditingWidgetToolbar = new MenuId('AideAgentEditingWidgetToolbar'); + static readonly AideAgentEditingEditorContent = new MenuId('AideAgentEditingEditorContent'); + static readonly AideAgentEditingEditorHunk = new MenuId('AideAgentEditingEditorHunk'); + static readonly AideAgentEditingWidgetModifiedFilesToolbar = new MenuId('AideAgentEditingWidgetModifiedFilesToolbar'); + static readonly AideAgentEditingCodeBlockContext = new MenuId('AideAgentEditingCodeBlockContext'); + static readonly AideAgentEditingRevertToolbar = new MenuId('AideAgentEditingRevertToolbar'); static readonly AccessibleView = new MenuId('AccessibleView'); static readonly MultiDiffEditorFileToolbar = new MenuId('MultiDiffEditorFileToolbar'); static readonly DiffEditorHunkToolbar = new MenuId('DiffEditorHunkToolbar'); diff --git a/src/vs/workbench/api/common/extHost.api.impl.ts b/src/vs/workbench/api/common/extHost.api.impl.ts index 95698866b75..4028e2c0c0d 100644 --- a/src/vs/workbench/api/common/extHost.api.impl.ts +++ b/src/vs/workbench/api/common/extHost.api.impl.ts @@ -1958,7 +1958,6 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I AideAgentScope: extHostTypes.AideAgentScope, AideAgentReferenceKind: extHostTypes.AideAgentReferenceKind, AideAgentToolTypeError: extHostTypes.AideAgentToolTypeError, - ChatResponseCodeEditPart: extHostTypes.ChatResponseCodeEditPart, NewSymbolName: extHostTypes.NewSymbolName, NewSymbolNameTag: extHostTypes.NewSymbolNameTag, NewSymbolNameTriggerKind: extHostTypes.NewSymbolNameTriggerKind, diff --git a/src/vs/workbench/api/common/extHost.protocol.ts b/src/vs/workbench/api/common/extHost.protocol.ts index 9661a273011..24ddf2011fe 100644 --- a/src/vs/workbench/api/common/extHost.protocol.ts +++ b/src/vs/workbench/api/common/extHost.protocol.ts @@ -54,7 +54,7 @@ import { SaveReason } from '../../common/editor.js'; import { IRevealOptions, ITreeItem, IViewBadge } from '../../common/views.js'; import { ChatAgentLocation as AideAgentLocation } from '../../contrib/aideAgent/common/aideAgentAgents.js'; import { IChatProgressResponseContent as IAideAgentProgressResponseContent } from '../../contrib/aideAgent/common/aideAgentModel.js'; -import { IAideAgentPlanStep, IAideAgentProgressStage, IChatCodeEdit, IChatEndResponse, IAideAgentToolTypeError } from '../../contrib/aideAgent/common/aideAgentService.js'; +import { IAideAgentPlanStep, IAideAgentProgressStage, IChatEndResponse, IAideAgentToolTypeError } from '../../contrib/aideAgent/common/aideAgentService.js'; import { DevtoolsStatus, InspectionResult } from '../../contrib/aideAgent/common/devtoolsService.js'; import { SidecarDownloadStatus, SidecarRunningStatus } from '../../contrib/aideAgent/common/sidecarService.js'; import { CallHierarchyItem } from '../../contrib/callHierarchy/common/callHierarchy.js'; @@ -1461,11 +1461,8 @@ export interface ExtHostAideAgentAgentsShape { $releaseSession(sessionId: string): void; } -export type IChatCodeEditDto = Pick & { edits: IWorkspaceEditDto }; - export type IAideAgentProgressDto = | IChatProgressDto - | IChatCodeEditDto | Dto; export type IAideAgentContentProgressDto = diff --git a/src/vs/workbench/api/common/extHostAideAgentAgents2.ts b/src/vs/workbench/api/common/extHostAideAgentAgents2.ts index 4a973fb2d6e..12a6baf037f 100644 --- a/src/vs/workbench/api/common/extHostAideAgentAgents2.ts +++ b/src/vs/workbench/api/common/extHostAideAgentAgents2.ts @@ -211,14 +211,6 @@ class AideAgentResponseStream { _report(dto); return this; }, - codeEdit(edits) { - throwIfDone(this.codeEdit); - - const part = new extHostTypes.ChatResponseCodeEditPart(edits); - const dto = typeConvert.ChatResponseCodeEditPart.from(part); - _report(dto); - return this; - }, detectedParticipant(participant, command) { throwIfDone(this.detectedParticipant); @@ -240,7 +232,6 @@ class AideAgentResponseStream { if ( part instanceof extHostTypes.ChatResponseTextEditPart || - part instanceof extHostTypes.ChatResponseCodeEditPart || part instanceof extHostTypes.ChatResponseMarkdownWithVulnerabilitiesPart || part instanceof extHostTypes.ChatResponseDetectedParticipantPart || part instanceof extHostTypes.ChatResponseWarningPart || diff --git a/src/vs/workbench/api/common/extHostTypeConverters.ts b/src/vs/workbench/api/common/extHostTypeConverters.ts index ca8f30ab37b..0508e3ffedb 100644 --- a/src/vs/workbench/api/common/extHostTypeConverters.ts +++ b/src/vs/workbench/api/common/extHostTypeConverters.ts @@ -3052,36 +3052,15 @@ export namespace AideAgentRequest { } } -export namespace ChatResponseCodeEditPart { - export function from(part: vscode.ChatResponseCodeEditPart): extHostProtocol.IChatCodeEditDto { - return { - kind: 'codeEdit', - edits: WorkspaceEdit.from(part.edits), - }; - } - - export function to(part: extHostProtocol.IChatCodeEditDto): vscode.ChatResponseCodeEditPart { - return new types.ChatResponseCodeEditPart(WorkspaceEdit.to(part.edits)); - } -} - export namespace AideAgentResponsePart { - export function from(part: ChatResponsePartType | vscode.ChatResponseCodeEditPart, commandsConverter: CommandsConverter, commandDisposables: DisposableStore): extHostProtocol.IAideAgentProgressDto { - if (part instanceof types.ChatResponseCodeEditPart) { - return ChatResponseCodeEditPart.from(part); - } else { - const chatResponsePart = part as ChatResponsePartType; - return ChatResponsePart.from(chatResponsePart, commandsConverter, commandDisposables); - } + export function from(part: ChatResponsePartType, commandsConverter: CommandsConverter, commandDisposables: DisposableStore): extHostProtocol.IAideAgentProgressDto { + const chatResponsePart = part as ChatResponsePartType; + return ChatResponsePart.from(chatResponsePart, commandsConverter, commandDisposables); } export function to(part: extHostProtocol.IAideAgentProgressDto, commandsConverter: CommandsConverter): vscode.AideAgentResponsePart | undefined { - if (part.kind === 'codeEdit') { - return ChatResponseCodeEditPart.to(part); - } else { - const chatResponsePart = part as extHostProtocol.IChatProgressDto; - return ChatResponsePart.to(chatResponsePart, commandsConverter); - } + const chatResponsePart = part as extHostProtocol.IChatProgressDto; + return ChatResponsePart.to(chatResponsePart, commandsConverter); } export function toContent(part: extHostProtocol.IAideAgentContentProgressDto, commandsConverter: CommandsConverter): vscode.ChatResponseMarkdownPart | vscode.ChatResponseFileTreePart | vscode.ChatResponseAnchorPart | vscode.ChatResponseCommandButtonPart | undefined { diff --git a/src/vs/workbench/api/common/extHostTypes.ts b/src/vs/workbench/api/common/extHostTypes.ts index 129274801d0..6a3d57bfb14 100644 --- a/src/vs/workbench/api/common/extHostTypes.ts +++ b/src/vs/workbench/api/common/extHostTypes.ts @@ -4933,13 +4933,6 @@ export enum AideAgentReferenceKind { Code = 2 } -export class ChatResponseCodeEditPart { - edits: vscode.WorkspaceEdit; - constructor(edits: vscode.WorkspaceEdit) { - this.edits = edits; - } -} - export class AideAgentResponsePlanPart { index: number; description: string | vscode.MarkdownString; diff --git a/src/vs/workbench/contrib/aideAgent/browser/actions/aideAgentCodeEditActions.ts b/src/vs/workbench/contrib/aideAgent/browser/actions/aideAgentCodeEditActions.ts deleted file mode 100644 index 0f270f619c0..00000000000 --- a/src/vs/workbench/contrib/aideAgent/browser/actions/aideAgentCodeEditActions.ts +++ /dev/null @@ -1,133 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { Codicon } from '../../../../../base/common/codicons.js'; -import { KeyCode, KeyMod } from '../../../../../base/common/keyCodes.js'; -import { localize2 } from '../../../../../nls.js'; -import { Action2, MenuId, registerAction2 } from '../../../../../platform/actions/common/actions.js'; -import { ICommandService } from '../../../../../platform/commands/common/commands.js'; -import { ContextKeyExpr } from '../../../../../platform/contextkey/common/contextkey.js'; -import { ServicesAccessor } from '../../../../../platform/instantiation/common/instantiation.js'; -import { KeybindingWeight } from '../../../../../platform/keybinding/common/keybindingsRegistry.js'; -import { SAVE_FILES_COMMAND_ID } from '../../../files/browser/fileConstants.js'; -import { IAideAgentCodeEditingService } from '../../common/aideAgentCodeEditingService.js'; -import { CONTEXT_CHAT_INPUT_HAS_FOCUS, CONTEXT_CHAT_LAST_EXCHANGE_COMPLETE, CONTEXT_CHAT_SESSION_WITH_EDITS } from '../../common/aideAgentContextKeys.js'; -import { IAideAgentWidgetService } from '../aideAgent.js'; -import { CHAT_CATEGORY } from './aideAgentActions.js'; - -export function registerCodeEditActions() { - registerAction2(class SaveAllAction extends Action2 { - static readonly ID = 'aideAgent.saveAll'; - - constructor() { - super({ - id: SaveAllAction.ID, - title: localize2('aideAgent.saveAll', "Save all"), - f1: false, - category: CHAT_CATEGORY, - icon: Codicon.saveAll, - precondition: CONTEXT_CHAT_INPUT_HAS_FOCUS, - keybinding: { - when: CONTEXT_CHAT_INPUT_HAS_FOCUS, - primary: KeyMod.CtrlCmd | KeyCode.KeyS, - weight: KeybindingWeight.WorkbenchContrib - }, - menu: { - id: MenuId.AideAgentEditPreviewWidget, - group: 'navigation', - order: 0, - when: ContextKeyExpr.and(CONTEXT_CHAT_LAST_EXCHANGE_COMPLETE, CONTEXT_CHAT_SESSION_WITH_EDITS), - } - }); - } - - run(accessor: ServicesAccessor, ...args: any[]) { - const commandService = accessor.get(ICommandService); - commandService.executeCommand(SAVE_FILES_COMMAND_ID); - } - }); - - registerAction2(class RejectAllAction extends Action2 { - static readonly ID = 'aideAgent.rejectAll'; - - constructor() { - super({ - id: RejectAllAction.ID, - title: localize2('aideAgent.rejectAll', "Reject all"), - f1: false, - category: CHAT_CATEGORY, - icon: Codicon.closeAll, - precondition: ContextKeyExpr.and(CONTEXT_CHAT_LAST_EXCHANGE_COMPLETE, CONTEXT_CHAT_SESSION_WITH_EDITS), - keybinding: { - when: ContextKeyExpr.and(CONTEXT_CHAT_LAST_EXCHANGE_COMPLETE, CONTEXT_CHAT_SESSION_WITH_EDITS, CONTEXT_CHAT_INPUT_HAS_FOCUS), - primary: KeyMod.CtrlCmd | KeyCode.Backspace, - weight: KeybindingWeight.WorkbenchContrib - }, - menu: { - id: MenuId.AideAgentEditPreviewWidget, - group: 'navigation', - order: 1, - when: ContextKeyExpr.and(CONTEXT_CHAT_LAST_EXCHANGE_COMPLETE, CONTEXT_CHAT_SESSION_WITH_EDITS), - } - }); - } - - run(accessor: ServicesAccessor, ...args: any[]) { - const widgetService = accessor.get(IAideAgentWidgetService); - const commandService = accessor.get(ICommandService); - const sessionId = widgetService.lastFocusedWidget?.viewModel?.sessionId; - if (!sessionId) { - return; - } - - const aideAgentCodeEditingService = accessor.get(IAideAgentCodeEditingService); - const editingSession = aideAgentCodeEditingService.getExistingCodeEditingSession(sessionId); - if (editingSession) { - editingSession.reject(); - commandService.executeCommand(SAVE_FILES_COMMAND_ID); - } - } - }); - - registerAction2(class AcceptAllAction extends Action2 { - static readonly ID = 'aideAgent.acceptAll'; - - constructor() { - super({ - id: AcceptAllAction.ID, - title: localize2('aideAgent.acceptAll', "Accept all"), - f1: false, - category: CHAT_CATEGORY, - icon: Codicon.checkAll, - precondition: ContextKeyExpr.and(CONTEXT_CHAT_LAST_EXCHANGE_COMPLETE, CONTEXT_CHAT_SESSION_WITH_EDITS), - keybinding: { - when: ContextKeyExpr.and(CONTEXT_CHAT_LAST_EXCHANGE_COMPLETE, CONTEXT_CHAT_SESSION_WITH_EDITS, CONTEXT_CHAT_INPUT_HAS_FOCUS), - primary: KeyMod.CtrlCmd | KeyCode.Enter, - weight: KeybindingWeight.WorkbenchContrib, - }, - menu: { - id: MenuId.AideAgentEditPreviewWidget, - group: 'navigation', - order: 2, - when: ContextKeyExpr.and(CONTEXT_CHAT_LAST_EXCHANGE_COMPLETE, CONTEXT_CHAT_SESSION_WITH_EDITS), - } - }); - } - - run(accessor: ServicesAccessor, ...args: any[]) { - const widgetService = accessor.get(IAideAgentWidgetService); - const sessionId = widgetService.lastFocusedWidget?.viewModel?.sessionId; - if (!sessionId) { - return; - } - - const aideAgentCodeEditingService = accessor.get(IAideAgentCodeEditingService); - const editingSession = aideAgentCodeEditingService.getExistingCodeEditingSession(sessionId); - if (editingSession) { - editingSession.accept(); - } - } - }); -} diff --git a/src/vs/workbench/contrib/aideAgent/browser/actions/aideAgentTitleActions.ts b/src/vs/workbench/contrib/aideAgent/browser/actions/aideAgentTitleActions.ts deleted file mode 100644 index ced9464aecf..00000000000 --- a/src/vs/workbench/contrib/aideAgent/browser/actions/aideAgentTitleActions.ts +++ /dev/null @@ -1,80 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -export const MarkUnhelpfulActionId = 'workbench.action.aideAgent.markUnhelpful'; - -export function registerChatTitleActions() { - /* TODO(@ghostwriternr): Completely get rid of this if removing a request no longer makes sense. But I can think of use-cases for now, so leaving it be. - registerAction2(class RemoveAction extends Action2 { - constructor() { - super({ - id: 'workbench.action.aideAgent.remove', - title: localize2('chat.remove.label', "Remove Request and Response"), - f1: false, - category: CHAT_CATEGORY, - icon: Codicon.x, - keybinding: { - primary: KeyCode.Delete, - mac: { - primary: KeyMod.CtrlCmd | KeyCode.Backspace, - }, - when: ContextKeyExpr.and(CONTEXT_IN_CHAT_SESSION, CONTEXT_IN_CHAT_INPUT.negate()), - weight: KeybindingWeight.WorkbenchContrib, - }, - menu: { - id: MenuId.AideAgentMessageTitle, - group: 'navigation', - order: 2, - when: CONTEXT_REQUEST - } - }); - } - - run(accessor: ServicesAccessor, ...args: any[]) { - let item = args[0]; - if (!isRequestVM(item)) { - const chatWidgetService = accessor.get(IAideAgentWidgetService); - const widget = chatWidgetService.lastFocusedWidget; - item = widget?.getFocus(); - } - - const requestId = isRequestVM(item) ? item.id : - isResponseVM(item) ? item.requestId : undefined; - - if (requestId) { - const chatService = accessor.get(IAideAgentService); - chatService.removeRequest(item.sessionId, requestId); - } - } - }); - */ - - /* TODO(@ghostwriternr): This is actually useful, but because the response part re-renders entirely when streaming, the button is impossible - to click in the midst of it - which is when it's actually needed. Add this back when we fix the re-render logic for good (which is... not easy). - registerAction2(class StopAction extends Action2 { - constructor() { - super({ - id: 'workbench.action.aideAgent.stop', - title: localize2('aideAgent.stop.label', "Stop"), - f1: false, - category: CHAT_CATEGORY, - icon: Codicon.debugStop, - menu: { - id: MenuId.AideAgentMessageTitle, - group: 'navigation', - order: 1, - when: ContextKeyExpr.and(CONTEXT_RESPONSE, CONTEXT_RESPONSE_FILTERED.negate()) - } - }); - } - - run(accessor: ServicesAccessor, ...args: any[]) { - const item = args[0]; - const chatService = accessor.get(IAideAgentService); - chatService.cancelExchange(item.id); - } - }); - */ -} diff --git a/src/vs/workbench/contrib/aideAgent/browser/aideAgent.contribution.ts b/src/vs/workbench/contrib/aideAgent/browser/aideAgent.contribution.ts index 5538e55d8c8..a878dc9da23 100644 --- a/src/vs/workbench/contrib/aideAgent/browser/aideAgent.contribution.ts +++ b/src/vs/workbench/contrib/aideAgent/browser/aideAgent.contribution.ts @@ -7,6 +7,7 @@ import { MarkdownString, isMarkdownString } from '../../../../base/common/htmlCo import { Disposable } from '../../../../base/common/lifecycle.js'; import { Schemas } from '../../../../base/common/network.js'; import { isMacintosh } from '../../../../base/common/platform.js'; +import { EditorContributionInstantiation, registerEditorContribution } from '../../../../editor/browser/editorExtensions.js'; import { registerEditorFeature } from '../../../../editor/common/editorFeatures.js'; import * as nls from '../../../../nls.js'; import { AccessibleViewRegistry } from '../../../../platform/accessibility/browser/accessibleViewRegistry.js'; @@ -22,15 +23,17 @@ import { EditorExtensions, IEditorFactoryRegistry } from '../../../common/editor import { IEditorResolverService, RegisteredEditorPriority } from '../../../services/editor/common/editorResolverService.js'; import { LifecyclePhase } from '../../../services/lifecycle/common/lifecycle.js'; import { ChatAgentLocation, ChatAgentNameService, ChatAgentService, IAideAgentAgentNameService, IAideAgentAgentService } from '../common/aideAgentAgents.js'; -import { IAideAgentCodeEditingService } from '../common/aideAgentCodeEditingService.js'; import { CodeMapperService, IAideAgentCodeMapperService } from '../common/aideAgentCodeMapperService.js'; import '../common/aideAgentColors.js'; +import { IAideAgentEditingService } from '../common/aideAgentEditingService.js'; import { chatVariableLeader } from '../common/aideAgentParserTypes.js'; import { IAideAgentService } from '../common/aideAgentService.js'; import { ChatService } from '../common/aideAgentServiceImpl.js'; import { ChatSlashCommandService, IAideAgentSlashCommandService } from '../common/aideAgentSlashCommands.js'; +import { IAideAgentTerminalService } from '../common/aideAgentTerminalService.js'; import { IAideAgentVariablesService } from '../common/aideAgentVariables.js'; import { ChatWidgetHistoryService, IAideAgentWidgetHistoryService } from '../common/aideAgentWidgetHistoryService.js'; +import { IDevtoolsService } from '../common/devtoolsService.js'; import { IAideAgentLMService, LanguageModelsService } from '../common/languageModels.js'; import { IAideAgentLMStatsService, LanguageModelStatsService } from '../common/languageModelStats.js'; import { IAideAgentLMToolsService, LanguageModelToolsService } from '../common/languageModelToolsService.js'; @@ -40,7 +43,6 @@ import { PanelChatAccessibilityHelp } from './actions/aideAgentAccessibilityHelp import { registerChatActions } from './actions/aideAgentActions.js'; import { ACTION_ID_NEW_CHAT, registerNewChatActions } from './actions/aideAgentClearActions.js'; import { registerChatCodeBlockActions, registerChatCodeCompareBlockActions } from './actions/aideAgentCodeblockActions.js'; -import { registerCodeEditActions } from './actions/aideAgentCodeEditActions.js'; import { registerChatContextActions } from './actions/aideAgentContextActions.js'; import { registerChatCopyActions } from './actions/aideAgentCopyActions.js'; import { registerChatDeveloperActions } from './actions/aideAgentDeveloperActions.js'; @@ -48,27 +50,28 @@ import { ExecuteChatAction, registerChatExecuteActions } from './actions/aideAge import { registerChatFileTreeActions } from './actions/aideAgentFileTreeActions.js'; import { registerAideAgentFloatingWidgetActions } from './actions/aideAgentFloatingWidgetActions.js'; import { ChatGettingStartedContribution } from './actions/aideAgentGettingStarted.js'; -import { registerChatTitleActions } from './actions/aideAgentTitleActions.js'; +import { registerDevtoolsActions } from './actions/devtoolsActions.js'; import { IAideAgentAccessibilityService, IAideAgentCodeBlockContextProviderService, IAideAgentWidgetService } from './aideAgent.js'; import { AideAgentAccessibilityService } from './aideAgentAccessibilityService.js'; -import { AideAgentCodeEditingService } from './aideAgentCodeEditingService.js'; +import { ChatInputBoxContentProvider } from './aideAgentEdinputInputContentProvider.js'; +import { ChatEditingService } from './aideAgentEditing/aideAgentEditingService.js'; import { ChatEditor, IChatEditorOptions } from './aideAgentEditor.js'; +import { registerChatEditorActions } from './aideAgentEditorActions.js'; +import { ChatEditorController } from './aideAgentEditorController.js'; import { ChatEditorInput, ChatEditorInputSerializer } from './aideAgentEditorInput.js'; +import { ChatEditorOverlayController } from './aideAgentEditorOverlay.js'; import { AideAgentFloatingWidgetService, IAideAgentFloatingWidgetService } from './aideAgentFloatingWidgetService.js'; import { agentSlashCommandToMarkdown, agentToMarkdown } from './aideAgentMarkdownDecorationsRenderer.js'; import { ChatCompatibilityNotifier, ChatExtensionPointHandler } from './aideAgentParticipantContributions.js'; import { ChatPasteProvidersFeature } from './aideAgentPasteProviders.js'; import { ChatResponseAccessibleView } from './aideAgentResponseAccessibleView.js'; +import { AideAgentTerminalService } from './aideAgentTerminalServiceImpl.js'; import { ChatVariablesService } from './aideAgentVariables.js'; import { ChatWidgetService } from './aideAgentWidget.js'; import { AideAgentCodeBlockContextProviderService } from './codeBlockContextProviderService.js'; import './contrib/aideAgentInputCompletions.js'; import './contrib/aideAgentInputEditorContrib.js'; -import { IDevtoolsService } from '../common/devtoolsService.js'; import { DevtoolsService } from './devtoolsServiceImpl.js'; -import { IAideAgentTerminalService } from '../common/aideAgentTerminalService.js'; -import { AideAgentTerminalService } from './aideAgentTerminalServiceImpl.js'; -import { registerDevtoolsActions } from './actions/devtoolsActions.js'; // Register configuration const configurationRegistry = Registry.as(ConfigurationExtensions.Configuration); @@ -175,6 +178,8 @@ class ChatResolverContribution extends Disposable { AccessibleViewRegistry.register(new ChatResponseAccessibleView()); AccessibleViewRegistry.register(new PanelChatAccessibilityHelp()); +registerEditorFeature(ChatInputBoxContentProvider); + class ChatSlashStaticSlashCommandsContribution extends Disposable { constructor( @@ -277,17 +282,18 @@ registerChatCopyActions(); registerChatCodeBlockActions(); registerChatCodeCompareBlockActions(); registerChatFileTreeActions(); -registerChatTitleActions(); registerChatExecuteActions(); registerNewChatActions(); registerChatContextActions(); registerChatDeveloperActions(); registerAideAgentFloatingWidgetActions(); -registerCodeEditActions(); +registerChatEditorActions(); registerDevtoolsActions(); registerEditorFeature(ChatPasteProvidersFeature); +registerEditorContribution(ChatEditorOverlayController.ID, ChatEditorOverlayController, EditorContributionInstantiation.Lazy); +registerEditorContribution(ChatEditorController.ID, ChatEditorController, EditorContributionInstantiation.Eventually); registerSingleton(IAideAgentService, ChatService, InstantiationType.Delayed); registerSingleton(IAideAgentWidgetService, ChatWidgetService, InstantiationType.Delayed); @@ -302,8 +308,8 @@ registerSingleton(IAideAgentVariablesService, ChatVariablesService, Instantiatio registerSingleton(IAideAgentLMToolsService, LanguageModelToolsService, InstantiationType.Delayed); registerSingleton(IAideAgentCodeBlockContextProviderService, AideAgentCodeBlockContextProviderService, InstantiationType.Delayed); registerSingleton(IAideAgentCodeMapperService, CodeMapperService, InstantiationType.Delayed); +registerSingleton(IAideAgentEditingService, ChatEditingService, InstantiationType.Delayed); registerSingleton(IAideAgentFloatingWidgetService, AideAgentFloatingWidgetService, InstantiationType.Delayed); -registerSingleton(IAideAgentCodeEditingService, AideAgentCodeEditingService, InstantiationType.Delayed); registerSingleton(ISidecarService, SidecarService, InstantiationType.Delayed); registerSingleton(IDevtoolsService, DevtoolsService, InstantiationType.Delayed); registerSingleton(IAideAgentTerminalService, AideAgentTerminalService, InstantiationType.Delayed); diff --git a/src/vs/workbench/contrib/aideAgent/browser/aideAgent.ts b/src/vs/workbench/contrib/aideAgent/browser/aideAgent.ts index c3a9f350323..0ddd6cb0992 100644 --- a/src/vs/workbench/contrib/aideAgent/browser/aideAgent.ts +++ b/src/vs/workbench/contrib/aideAgent/browser/aideAgent.ts @@ -38,8 +38,11 @@ export interface IAideAgentWidgetService { */ readonly lastFocusedWidget: IChatWidget | undefined; + readonly onDidAddWidget: Event; + getWidgetByInputUri(uri: URI): IChatWidget | undefined; getWidgetBySessionId(sessionId: string): IChatWidget | undefined; + getWidgetsByLocations(location: ChatAgentLocation): ReadonlyArray; } export async function showChatView(viewsService: IViewsService): Promise { @@ -100,9 +103,10 @@ export interface IChatListItemRendererOptions { } export interface IChatWidgetViewOptions { + autoScroll?: boolean; renderInputOnTop?: boolean; renderFollowups?: boolean; - renderStyle?: 'default' | 'compact' | 'minimal'; + renderStyle?: 'compact' | 'minimal'; supportsFileReferences?: boolean; filter?: (item: ChatTreeItem) => boolean; rendererOptions?: IChatListItemRendererOptions; diff --git a/src/vs/workbench/contrib/aideAgent/browser/aideAgentCodeEditingService.ts b/src/vs/workbench/contrib/aideAgent/browser/aideAgentCodeEditingService.ts deleted file mode 100644 index d3e68bdc243..00000000000 --- a/src/vs/workbench/contrib/aideAgent/browser/aideAgentCodeEditingService.ts +++ /dev/null @@ -1,357 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { Queue } from '../../../../base/common/async.js'; -import { Emitter, Event } from '../../../../base/common/event.js'; -import { Disposable, DisposableMap } from '../../../../base/common/lifecycle.js'; -import { Schemas } from '../../../../base/common/network.js'; -import { themeColorFromId } from '../../../../base/common/themables.js'; -import { URI } from '../../../../base/common/uri.js'; -import { generateUuid } from '../../../../base/common/uuid.js'; -import { ICodeEditor, isCodeEditor } from '../../../../editor/browser/editorBrowser.js'; -import { Range } from '../../../../editor/common/core/range.js'; -import { IWorkspaceTextEdit } from '../../../../editor/common/languages.js'; -import { ITextModel, MinimapPosition, OverviewRulerLane, TrackedRangeStickiness } from '../../../../editor/common/model.js'; -import { createTextBufferFactoryFromSnapshot, ModelDecorationOptions } from '../../../../editor/common/model/textModel.js'; -import { ICSEventsService } from '../../../../editor/common/services/csEvents.js'; -import { IEditorWorkerService } from '../../../../editor/common/services/editorWorker.js'; -import { IModelService } from '../../../../editor/common/services/model.js'; -import { DefaultModelSHA1Computer } from '../../../../editor/common/services/modelService.js'; -import { ITextModelService } from '../../../../editor/common/services/resolverService.js'; -import { IModelContentChange } from '../../../../editor/common/textModelEvents.js'; -import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; -import { IEditorService } from '../../../services/editor/common/editorService.js'; -import { minimapInlineChatDiffInserted, overviewRulerInlineChatDiffInserted } from '../../inlineChat/common/inlineChat.js'; -import { IAideAgentCodeEditingService, IAideAgentCodeEditingSession } from '../common/aideAgentCodeEditingService.js'; -import { calculateChanges, HunkData, HunkDisplayData, HunkInformation, HunkState } from '../common/aideAgentEditingSession.js'; -import { IAideAgentEdits, IChatTextEditGroupState } from '../common/aideAgentModel.js'; - -const editDecorationOptions = ModelDecorationOptions.register({ - description: 'aide-probe-edit-modified', - className: 'inline-chat-inserted-range', - stickiness: TrackedRangeStickiness.NeverGrowsWhenTypingAtEdges, -}); - -const editLineDecorationOptions = ModelDecorationOptions.register({ - description: 'aide-probe-edit-modified-line', - className: 'inline-chat-inserted-range-linehighlight', - isWholeLine: true, - overviewRuler: { - position: OverviewRulerLane.Full, - color: themeColorFromId(overviewRulerInlineChatDiffInserted), - }, - minimap: { - position: MinimapPosition.Inline, - color: themeColorFromId(minimapInlineChatDiffInserted), - } -}); - -class AideAgentCodeEditingSession extends Disposable implements IAideAgentCodeEditingSession { - private readonly _onDidChange = this._register(new Emitter()); - readonly onDidChange = this._onDidChange.event; - - private readonly _onDidDispose = this._register(new Emitter()); - readonly onDidDispose = this._onDidDispose.event; - - private readonly _onDidComplete = this._register(new Emitter()); - readonly onDidComplete = this._onDidComplete.event; - - private activeEditor: ICodeEditor | undefined; - - private readonly _hunkDisplayData = new Map(); - private readonly _progressiveEditsQueue = this._register(new Queue()); - private readonly _codeEdits = new Map(); - private readonly _workingSet = new Set(); - - get codeEdits(): Map { - const result = new Map(); - - for (const [uriString, edits] of this._codeEdits) { - const uri = URI.parse(uriString); - for (const hunkInfo of edits.hunkData.getInfo()) { - if (hunkInfo.getState() === HunkState.Pending && hunkInfo.getRangesN().length > 0) { - result.set(uri, [hunkInfo.getRangesN()[0]]); - } - } - } - - return result; - } - - constructor( - readonly sessionId: string, - @IEditorService private readonly editorService: IEditorService, - @IEditorWorkerService private readonly _editorWorkerService: IEditorWorkerService, - @IModelService private readonly _modelService: IModelService, - @ITextModelService private readonly _textModelService: ITextModelService, - @ICSEventsService private readonly _csEventsService: ICSEventsService, - ) { - super(); - - this.registerActiveEditor(); - this._register(this.editorService.onDidActiveEditorChange(() => { - this.registerActiveEditor(); - })); - } - - private registerActiveEditor() { - const activeEditor = this.editorService.activeTextEditorControl; - if (isCodeEditor(activeEditor)) { - this.activeEditor = activeEditor; - const uri = activeEditor.getModel()?.uri; - if (uri && this._workingSet.has(uri.toString())) { - const resourceEdits = this._codeEdits.get(uri.toString())!; - this.updateDecorations(activeEditor, resourceEdits); - } - } - } - - private updateDecorations(editor: ICodeEditor, fileEdits: IAideAgentEdits) { - editor.changeDecorations(decorationsAccessor => { - const keysNow = new Set(this._hunkDisplayData.keys()); - for (const hunkData of fileEdits.hunkData.getInfo()) { - keysNow.delete(hunkData); - - const hunkRanges = hunkData.getRangesN(); - let data = this._hunkDisplayData.get(hunkData); - if (!data) { - const decorationIds: string[] = []; - for (let i = 0; i < hunkRanges.length; i++) { - decorationIds.push(decorationsAccessor.addDecoration(hunkRanges[i], i === 0 - ? editLineDecorationOptions - : editDecorationOptions - )); - } - - const remove = () => { - editor.changeDecorations(decorationsAccessor => { - if (data) { - for (const decorationId of data.decorationIds) { - decorationsAccessor.removeDecoration(decorationId); - } - data.decorationIds = []; - } - }); - }; - - data = { - decorationIds, - hunk: hunkData, - position: hunkRanges[0].getStartPosition().delta(-1), - remove - }; - this._hunkDisplayData.set(hunkData, data); - } else if (hunkData.getState() !== HunkState.Pending) { - data.remove(); - } else { - const modifiedRangeNow = hunkRanges[0]; - data.position = modifiedRangeNow.getStartPosition().delta(-1); - } - } - - for (const key of keysNow) { - const data = this._hunkDisplayData.get(key); - if (data) { - this._hunkDisplayData.delete(key); - data.remove(); - } - } - }); - } - - async apply(codeEdit: IWorkspaceTextEdit): Promise { - await this._progressiveEditsQueue.queue(async () => { - await this.processWorkspaceEdit(codeEdit); - }); - } - - private async processWorkspaceEdit(workspaceEdit: IWorkspaceTextEdit) { - const resource = workspaceEdit.resource; - const mapKey = resource.toString(); - - let codeEdits: IAideAgentEdits; - let firstEdit = false; - if (this._codeEdits.has(mapKey)) { - codeEdits = this._codeEdits.get(mapKey)!; - } else { - firstEdit = true; - let textModel = this._modelService.getModel(resource); - if (!textModel) { - const ref = await this._textModelService.createModelReference(resource); - textModel = ref.object.textEditorModel; - ref.dispose(); - } - const textModelN = textModel; - this._register(textModelN.onDidChangeContent(e => { - if (e.isUndoing) { - this.handleUndoEditEvent(resource, e.changes); - } - })); - - const id = generateUuid(); - const textModel0 = this._register(this._modelService.createModel( - createTextBufferFactoryFromSnapshot(textModel.createSnapshot()), - { languageId: textModel.getLanguageId(), onDidChange: Event.None }, - resource.with({ scheme: Schemas.vscode, authority: 'aide-agent-edits', path: '', query: new URLSearchParams({ id, 'textModel0': '' }).toString() }), true - )); - - codeEdits = { - targetUri: resource.toString(), - textModel0, - textModelN, - hunkData: this._register(new HunkData(this._editorWorkerService, textModel0, textModelN)), - }; - this._codeEdits.set(mapKey, codeEdits); - this._workingSet.add(resource.toString()); - } - - if (firstEdit) { - codeEdits.textModelN.pushStackElement(); - } - - codeEdits.hunkData.ignoreTextModelNChanges = true; - codeEdits.textModelN.pushEditOperations(null, [workspaceEdit.textEdit], () => null); - - this.updateView(resource, codeEdits); - this._onDidChange.fire(); - } - - private async updateView(resource: URI, codeEdits: IAideAgentEdits) { - const { editState, diff } = await this.calculateDiff(codeEdits.textModel0, codeEdits.textModelN); - await codeEdits.hunkData.recompute(editState, diff); - codeEdits.hunkData.ignoreTextModelNChanges = false; - - if (this.activeEditor?.getModel()?.uri.toString() === resource.toString()) { - this.updateDecorations(this.activeEditor, codeEdits); - } - } - - private async calculateDiff(textModel0: ITextModel, textModelN: ITextModel) { - const sha1 = new DefaultModelSHA1Computer(); - const textModel0Sha1 = sha1.canComputeSHA1(textModel0) - ? sha1.computeSHA1(textModel0) - : generateUuid(); - const editState: IChatTextEditGroupState = { sha1: textModel0Sha1, applied: 0 }; - const diff = await this._editorWorkerService.computeDiff(textModel0.uri, textModelN.uri, { computeMoves: true, maxComputationTimeMs: Number.MAX_SAFE_INTEGER, ignoreTrimWhitespace: false }, 'advanced'); - return { editState, diff }; - } - - private async handleUndoEditEvent(resource: URI, changes: IModelContentChange[]) { - const resourceEdits = this._codeEdits.get(resource.toString()); - if (!resourceEdits) { - return; - } - - if (!this.activeEditor || this.activeEditor.getModel()?.uri.toString() !== resource.toString()) { - return; - } - - this.activeEditor.changeDecorations(decorationsAccessor => { - for (const change of changes) { - const changeRange = change.range; - // Remove the corresponding hunk from hunkData - const hunkData = resourceEdits.hunkData.getInfo().find(hunk => hunk.getRangesN().some(range => range.equalsRange(changeRange))); - if (hunkData) { - const data = this._hunkDisplayData.get(hunkData); - if (data) { - this._hunkDisplayData.delete(hunkData); - data.remove(); - } - hunkData.discardChanges(); - } - - // Remove all decorations that intersect with the range of the change - const intersected = this.activeEditor?.getDecorationsInRange(Range.lift(changeRange)); - for (const decoration of intersected ?? []) { - decorationsAccessor.removeDecoration(decoration.id); - } - } - }); - } - - complete(): void { - const editedModels = new Set(Array.from(this._codeEdits.values()).map(edit => edit.textModelN)); - for (const model of editedModels) { - model.pushStackElement(); - } - } - - private removeDecorations(accepted: boolean) { - // Calculate the number of changes being accepted - const edits = Array.from(this._hunkDisplayData.keys()); - const changes = calculateChanges(edits); - this._csEventsService.reportAgentCodeEdit({ accepted, ...changes }); - - for (const data of this._hunkDisplayData.values()) { - data.remove(); - } - } - - accept(): void { - this.removeDecorations(true); - this._onDidComplete.fire(); - } - - reject(): void { - for (const edit of this._codeEdits.values()) { - edit.hunkData.discardAll(); - } - - this.removeDecorations(false); - this._onDidComplete.fire(); - } - - stop(): Promise { - throw new Error('Method not implemented.'); - } - - override dispose(): void { - this._hunkDisplayData.forEach(data => data.remove()); - this._hunkDisplayData.clear(); - for (const edit of this._codeEdits.values()) { - edit.hunkData.dispose(); - } - this._codeEdits.clear(); - this._workingSet.clear(); - - this._onDidDispose.fire(); - super.dispose(); - } -} - -export class AideAgentCodeEditingService extends Disposable implements IAideAgentCodeEditingService { - _serviceBrand: undefined; - - private readonly _onDidComplete = this._register(new Emitter()); - readonly onDidComplete = this._onDidComplete.event; - - private _editingSessions = new DisposableMap(); - - constructor( - @IInstantiationService private readonly instantiationService: IInstantiationService - ) { - super(); - } - - getOrStartCodeEditingSession(sessionId: string): IAideAgentCodeEditingSession { - if (this._editingSessions.get(sessionId)) { - return this._editingSessions.get(sessionId)!; - } - - const editingSession = this.instantiationService.createInstance(AideAgentCodeEditingSession, sessionId); - this._register(editingSession.onDidComplete(() => { - editingSession.dispose(); - this._editingSessions.deleteAndDispose(sessionId); - this._onDidComplete.fire(); - })); - - this._editingSessions.set(sessionId, editingSession); - return editingSession; - } - - getExistingCodeEditingSession(sessionId: string): IAideAgentCodeEditingSession | undefined { - return this._editingSessions.get(sessionId); - } -} diff --git a/src/vs/workbench/contrib/aideAgent/browser/aideAgentContentParts/aideAgentAttachmentsContentPart.ts b/src/vs/workbench/contrib/aideAgent/browser/aideAgentContentParts/aideAgentAttachmentsContentPart.ts index be782d71156..5acb6aada6e 100644 --- a/src/vs/workbench/contrib/aideAgent/browser/aideAgentContentParts/aideAgentAttachmentsContentPart.ts +++ b/src/vs/workbench/contrib/aideAgent/browser/aideAgentContentParts/aideAgentAttachmentsContentPart.ts @@ -4,255 +4,372 @@ *--------------------------------------------------------------------------------------------*/ import * as dom from '../../../../../base/browser/dom.js'; -import { Button } from '../../../../../base/browser/ui/button/button.js'; -import { IListRenderer, IListVirtualDelegate } from '../../../../../base/browser/ui/list/list.js'; +import { StandardMouseEvent } from '../../../../../base/browser/mouseEvent.js'; +import { createInstantHoverDelegate } from '../../../../../base/browser/ui/hover/hoverDelegateFactory.js'; +import { Promises } from '../../../../../base/common/async.js'; import { Codicon } from '../../../../../base/common/codicons.js'; import { Emitter } from '../../../../../base/common/event.js'; import { Disposable, DisposableStore, IDisposable } from '../../../../../base/common/lifecycle.js'; import { basename, dirname } from '../../../../../base/common/path.js'; -import { ThemeIcon } from '../../../../../base/common/themables.js'; import { URI } from '../../../../../base/common/uri.js'; -import { Range } from '../../../../../editor/common/core/range.js'; -import { isLocation } from '../../../../../editor/common/languages.js'; +import { ServicesAccessor } from '../../../../../editor/browser/editorExtensions.js'; +import { IRange, Range } from '../../../../../editor/common/core/range.js'; +import { EditorContextKeys } from '../../../../../editor/common/editorContextKeys.js'; +import { LanguageFeatureRegistry } from '../../../../../editor/common/languageFeatureRegistry.js'; +import { Location, SymbolKind } from '../../../../../editor/common/languages.js'; import { ILanguageService } from '../../../../../editor/common/languages/language.js'; -import { getIconClasses } from '../../../../../editor/common/services/getIconClasses.js'; +import { ILanguageFeaturesService } from '../../../../../editor/common/services/languageFeatures.js'; import { IModelService } from '../../../../../editor/common/services/model.js'; +import { ITextModelService } from '../../../../../editor/common/services/resolverService.js'; import { localize } from '../../../../../nls.js'; -import { FileKind } from '../../../../../platform/files/common/files.js'; +import { getFlatContextMenuActions } from '../../../../../platform/actions/browser/menuEntryActionViewItem.js'; +import { IMenuService, MenuId } from '../../../../../platform/actions/common/actions.js'; +import { ICommandService } from '../../../../../platform/commands/common/commands.js'; +import { IContextKey, IContextKeyService, IScopedContextKeyService, RawContextKey } from '../../../../../platform/contextkey/common/contextkey.js'; +import { IContextMenuService } from '../../../../../platform/contextview/browser/contextView.js'; +import { fillInSymbolsDragData } from '../../../../../platform/dnd/browser/dnd.js'; +import { ITextEditorOptions } from '../../../../../platform/editor/common/editor.js'; +import { FileKind, IFileService } from '../../../../../platform/files/common/files.js'; +import { IHoverService } from '../../../../../platform/hover/browser/hover.js'; import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; -import { WorkbenchList } from '../../../../../platform/list/browser/listService.js'; -import { IOpenerService } from '../../../../../platform/opener/common/opener.js'; -import { IThemeService } from '../../../../../platform/theme/common/themeService.js'; -import { IResourceLabel, ResourceLabels } from '../../../../browser/labels.js'; -import { createFileIconThemableTreeContainerScope } from '../../../files/browser/views/explorerView.js'; -import { IChatRequestVariableEntry } from '../../common/aideAgentModel.js'; +import { IOpenerService, OpenInternalOptions } from '../../../../../platform/opener/common/opener.js'; +import { FolderThemeIcon, IThemeService } from '../../../../../platform/theme/common/themeService.js'; +import { fillEditorsDragData } from '../../../../browser/dnd.js'; +import { ResourceLabels } from '../../../../browser/labels.js'; +import { ResourceContextKey } from '../../../../common/contextkeys.js'; +import { revealInSideBarCommand } from '../../../files/browser/fileActions.contribution.js'; +import { IChatRequestVariableEntry, isLinkVariableEntry } from '../../common/aideAgentModel.js'; import { ChatResponseReferencePartStatusKind, IChatContentReference } from '../../common/aideAgentService.js'; -import { IChatRequestVariableValue } from '../../common/aideAgentVariables.js'; -const $ = dom.$; +export const chatAttachmentResourceContextKey = new RawContextKey('chatAttachmentResource', undefined, { type: 'URI', description: localize('resource', "The full value of the chat attachment resource, including scheme and path") }); -export class ChatAttachmentsContentPart extends Disposable { - public readonly domNode: HTMLElement; +export class ChatAttachmentsContentPart extends Disposable { private readonly attachedContextDisposables = this._register(new DisposableStore()); - - private readonly _onDidChangeHeight = this._register(new Emitter()); - public readonly onDidChangeHeight = this._onDidChangeHeight.event; - private readonly _onDidChangeVisibility = this._register(new Emitter()); - public readonly onDidChangeVisibility = this._onDidChangeVisibility.event; + private readonly _contextResourceLabels = this.instantiationService.createInstance(ResourceLabels, { onDidChangeVisibility: this._onDidChangeVisibility.event }); constructor( private readonly variables: IChatRequestVariableEntry[], - private readonly contentReferences: readonly IChatContentReference[] = [], + private readonly contentReferences: ReadonlyArray = [], + private readonly workingSet: ReadonlyArray = [], + public readonly domNode: HTMLElement | undefined = dom.$('.chat-attached-context'), + @IContextKeyService private readonly contextKeyService: IContextKeyService, @IInstantiationService private readonly instantiationService: IInstantiationService, - @IModelService private readonly modelService: IModelService, - @ILanguageService private readonly languageService: ILanguageService, + @IOpenerService private readonly openerService: IOpenerService, + @IHoverService private readonly hoverService: IHoverService, + @IFileService private readonly fileService: IFileService, + @ICommandService private readonly commandService: ICommandService, @IThemeService private readonly themeService: IThemeService, ) { super(); - this.domNode = dom.$('.aideagent-attached-context'); - this.initAttachedContext(this.domNode); + this.initAttachedContext(domNode); + if (!domNode.childElementCount) { + this.domNode = undefined; + } } private initAttachedContext(container: HTMLElement) { dom.clearNode(container); this.attachedContextDisposables.clear(); - dom.setVisibility(Boolean(this.variables.length), this.domNode); - - if (this.variables.length) { - const attachmentsLabel = this.variables.length > 1 ? - localize('attachmentsPlural', "{0} attachments", this.variables.length) : - localize('attachmentsSingular', "1 attachment"); - const iconsContainer = $('.aideagent-attachment-icons'); - // Only process up to 3 items for icons - const maxIcons = 3; - const itemsToShow = this.variables.slice(0, maxIcons); - for (const item of itemsToShow) { - const reference = this.getReferenceUri(item.value); - if (reference) { - const iconElement = $('span.icon'); - iconElement.classList.add(...getIconClasses(this.modelService, this.languageService, reference, FileKind.FILE)); - iconsContainer.appendChild(iconElement); + const hoverDelegate = this.attachedContextDisposables.add(createInstantHoverDelegate()); + + const attachmentInitPromises: Promise[] = []; + this.variables.forEach(async (attachment) => { + const resource = URI.isUri(attachment.value) ? attachment.value : attachment.value && typeof attachment.value === 'object' && 'uri' in attachment.value && URI.isUri(attachment.value.uri) ? attachment.value.uri : undefined; + const range = attachment.value && typeof attachment.value === 'object' && 'range' in attachment.value && Range.isIRange(attachment.value.range) ? attachment.value.range : undefined; + if (resource && attachment.isFile && this.workingSet.find(entry => entry.toString() === resource?.toString())) { + // Don't render attachment if it's in the working set + return; + } + + const widget = dom.append(container, dom.$('.chat-attached-context-attachment.show-file-icons')); + const label = this._contextResourceLabels.create(widget, { supportIcons: true, hoverDelegate, hoverTargetOverride: widget }); + + const correspondingContentReference = this.contentReferences.find((ref) => typeof ref.reference === 'object' && 'variableName' in ref.reference && ref.reference.variableName === attachment.name); + const isAttachmentOmitted = correspondingContentReference?.options?.status?.kind === ChatResponseReferencePartStatusKind.Omitted; + const isAttachmentPartialOrOmitted = isAttachmentOmitted || correspondingContentReference?.options?.status?.kind === ChatResponseReferencePartStatusKind.Partial; + + let ariaLabel: string | undefined; + + if (resource && (attachment.isFile || attachment.isDirectory)) { + const fileBasename = basename(resource.path); + const fileDirname = dirname(resource.path); + const friendlyName = `${fileBasename} ${fileDirname}`; + + if (isAttachmentOmitted) { + ariaLabel = range ? localize('chat.omittedFileAttachmentWithRange', "Omitted: {0}, line {1} to line {2}.", friendlyName, range.startLineNumber, range.endLineNumber) : localize('chat.omittedFileAttachment', "Omitted: {0}.", friendlyName); + } else if (isAttachmentPartialOrOmitted) { + ariaLabel = range ? localize('chat.partialFileAttachmentWithRange', "Partially attached: {0}, line {1} to line {2}.", friendlyName, range.startLineNumber, range.endLineNumber) : localize('chat.partialFileAttachment', "Partially attached: {0}.", friendlyName); + } else { + ariaLabel = range ? localize('chat.fileAttachmentWithRange3', "Attached: {0}, line {1} to line {2}.", friendlyName, range.startLineNumber, range.endLineNumber) : localize('chat.fileAttachment3', "Attached: {0}.", friendlyName); + } + + const fileOptions = { + hidePath: true, + title: correspondingContentReference?.options?.status?.description + }; + label.setFile(resource, attachment.isFile ? { + ...fileOptions, + fileKind: FileKind.FILE, + range, + } : { + ...fileOptions, + fileKind: FileKind.FOLDER, + icon: !this.themeService.getFileIconTheme().hasFolderIcons ? FolderThemeIcon : undefined + }); + + this.instantiationService.invokeFunction(accessor => { + if (resource) { + this.attachedContextDisposables.add(hookUpResourceAttachmentDragAndContextMenu(accessor, widget, resource)); + } + }); + } else if (attachment.isImage) { + ariaLabel = localize('chat.imageAttachment', "Attached image, {0}", attachment.name); + const hoverElement = dom.$('div.chat-attached-context-hover'); + hoverElement.setAttribute('aria-label', ariaLabel); + + // Custom label + const pillIcon = dom.$('div.chat-attached-context-pill', {}, dom.$(isAttachmentOmitted ? 'span.codicon.codicon-warning' : 'span.codicon.codicon-file-media')); + const textLabel = dom.$('span.chat-attached-context-custom-text', {}, attachment.name); + widget.appendChild(pillIcon); + widget.appendChild(textLabel); + + if (attachment.references) { + widget.style.cursor = 'pointer'; + const clickHandler = () => { + if (attachment.references && URI.isUri(attachment.references[0].reference)) { + this.openResource(attachment.references[0].reference, false, undefined); + } + }; + this.attachedContextDisposables.add(dom.addDisposableListener(widget, 'click', clickHandler)); } + + if (isAttachmentPartialOrOmitted) { + hoverElement.textContent = localize('chat.imageAttachmentHover', "Image was not sent to the model."); + textLabel.style.textDecoration = 'line-through'; + this.attachedContextDisposables.add(this.hoverService.setupManagedHover(hoverDelegate, widget, hoverElement, { trapFocus: true })); + } else { + attachmentInitPromises.push(Promises.withAsyncBody(async (resolve) => { + let buffer: Uint8Array; + try { + if (attachment.value instanceof URI) { + const readFile = await this.fileService.readFile(attachment.value); + if (this.attachedContextDisposables.isDisposed) { + return; + } + buffer = readFile.value.buffer; + } else { + buffer = attachment.value as Uint8Array; + } + this.createImageElements(buffer, widget, hoverElement); + } catch (error) { + console.error('Error processing attachment:', error); + } + this.attachedContextDisposables.add(this.hoverService.setupManagedHover(hoverDelegate, widget, hoverElement, { trapFocus: false })); + resolve(); + })); + } + widget.style.position = 'relative'; + } else if (isLinkVariableEntry(attachment)) { + ariaLabel = localize('chat.attachment.link', "Attached link, {0}", attachment.name); + + label.setResource({ resource: attachment.value, name: attachment.name }, { icon: Codicon.link, title: attachment.value.toString() }); + } else { + const attachmentLabel = attachment.fullName ?? attachment.name; + const withIcon = attachment.icon?.id ? `$(${attachment.icon.id}) ${attachmentLabel}` : attachmentLabel; + label.setLabel(withIcon, correspondingContentReference?.options?.status?.description); + + ariaLabel = localize('chat.attachment3', "Attached context: {0}.", attachment.name); } - // Add ellipsis icon if there are more than 3 items - if (this.variables.length > maxIcons) { - const iconElement = $('span.icon'); - iconElement.classList.add(...ThemeIcon.asClassNameArray(Codicon.more)); - iconsContainer.appendChild(iconElement); + if (attachment.kind === 'symbol') { + const scopedContextKeyService = this.attachedContextDisposables.add(this.contextKeyService.createScoped(widget)); + this.attachedContextDisposables.add(this.instantiationService.invokeFunction(accessor => hookUpSymbolAttachmentDragAndContextMenu(accessor, widget, scopedContextKeyService, { ...attachment, kind: attachment.symbolKind }, MenuId.ChatInputSymbolAttachmentContext))); } - const buttonElement = $('.aideagent-attachments-label.show-file-icons', undefined); - let listExpanded = false; - const collapseButton = this._register(new Button(buttonElement, { - buttonBackground: undefined, - buttonBorder: undefined, - buttonForeground: undefined, - buttonHoverBackground: undefined, - buttonSecondaryBackground: undefined, - buttonSecondaryForeground: undefined, - buttonSecondaryHoverBackground: undefined, - buttonSeparator: undefined - })); - container.appendChild(buttonElement); - collapseButton.element.replaceChildren(iconsContainer, dom.$('span.icon-label', {}, attachmentsLabel)); - this.updateAriaLabel(collapseButton.element, attachmentsLabel, listExpanded); - this.domNode.classList.toggle('aideagent-attachments-list-collapsed', !listExpanded); - this._register(collapseButton.onDidClick(() => { - listExpanded = !listExpanded; - this.domNode.classList.toggle('aideagent-attachments-list-collapsed', !listExpanded); - this._onDidChangeHeight.fire(); - this.updateAriaLabel(collapseButton.element, attachmentsLabel, listExpanded); - })); - - const resourceLabels = this._register(this.instantiationService.createInstance(ResourceLabels, { onDidChangeVisibility: this.onDidChangeVisibility })); - const listContainer = $('.aideagent-attachments-list'); - this._register(createFileIconThemableTreeContainerScope(listContainer, this.themeService)); - const list = this.instantiationService.createInstance( - WorkbenchList, - 'ChatAttachmentsListRenderer', - listContainer, - new CollapsibleListDelegate(), - [this.instantiationService.createInstance(CollapsibleListRenderer, resourceLabels, this.contentReferences)], - { - alwaysConsumeMouseWheel: false, + if (isAttachmentPartialOrOmitted) { + widget.classList.add('warning'); + } + const description = correspondingContentReference?.options?.status?.description; + if (isAttachmentPartialOrOmitted) { + ariaLabel = `${ariaLabel}${description ? ` ${description}` : ''}`; + for (const selector of ['.monaco-icon-suffix-container', '.monaco-icon-name-container']) { + const element = label.element.querySelector(selector); + if (element) { + element.classList.add('warning'); + } } - ); - this.domNode.appendChild(listContainer); - - const maxItemsShown = 6; - const itemsShown = Math.min(this.variables.length, maxItemsShown); - const height = itemsShown * 22; - list.layout(height); - list.getHTMLElement().style.height = `${height}px`; - list.splice(0, list.length, this.variables); - } - } + } + + await Promise.all(attachmentInitPromises); + if (this.attachedContextDisposables.isDisposed) { + return; + } - private updateAriaLabel(element: HTMLElement, label: string, expanded: boolean): void { - element.ariaLabel = expanded ? localize('attachmentsExpanded', "{0}, expanded", label) : localize('attachmentsCollapsed', "{0}, collapsed", label); + if (resource) { + widget.style.cursor = 'pointer'; + if (!this.attachedContextDisposables.isDisposed) { + this.attachedContextDisposables.add(dom.addDisposableListener(widget, dom.EventType.CLICK, async (e: MouseEvent) => { + dom.EventHelper.stop(e, true); + if (attachment.isDirectory) { + this.openResource(resource, true); + } else { + this.openResource(resource, false, range); + } + })); + } + } + + widget.ariaLabel = ariaLabel; + widget.tabIndex = 0; + }); } - private getReferenceUri(value: IChatRequestVariableValue): URI | undefined { - if (typeof value === 'string' || URI.isUri(value)) { - return value as URI; - } else if (isLocation(value)) { - return value.uri; + private openResource(resource: URI, isDirectory: true): void; + private openResource(resource: URI, isDirectory: false, range: IRange | undefined): void; + private openResource(resource: URI, isDirectory?: boolean, range?: IRange): void { + if (isDirectory) { + // Reveal Directory in explorer + this.commandService.executeCommand(revealInSideBarCommand.id, resource); + return; } - return undefined; + // Open file in editor + const openTextEditorOptions: ITextEditorOptions | undefined = range ? { selection: range } : undefined; + const options: OpenInternalOptions = { + fromUserGesture: true, + editorOptions: openTextEditorOptions, + }; + this.openerService.open(resource, options); } - addDisposable(disposable: IDisposable): void { - this._register(disposable); + // Helper function to create and replace image + private async createImageElements(buffer: ArrayBuffer | Uint8Array, widget: HTMLElement, hoverElement: HTMLElement) { + const blob = new Blob([buffer], { type: 'image/png' }); + const url = URL.createObjectURL(blob); + const img = dom.$('img.chat-attached-context-image', { src: url, alt: '' }); + const pillImg = dom.$('img.chat-attached-context-pill-image', { src: url, alt: '' }); + const pill = dom.$('div.chat-attached-context-pill', {}, pillImg); + + const existingPill = widget.querySelector('.chat-attached-context-pill'); + if (existingPill) { + existingPill.replaceWith(pill); + } + + // Update hover image + hoverElement.appendChild(img); } } -class CollapsibleListDelegate implements IListVirtualDelegate { - getHeight(element: IChatRequestVariableEntry): number { - return 22; - } +export function hookUpResourceAttachmentDragAndContextMenu(accessor: ServicesAccessor, widget: HTMLElement, resource: URI): IDisposable { + const contextKeyService = accessor.get(IContextKeyService); + const instantiationService = accessor.get(IInstantiationService); - getTemplateId(element: IChatRequestVariableEntry): string { - return CollapsibleListRenderer.TEMPLATE_ID; - } -} + const store = new DisposableStore(); -interface ICollapsibleListTemplate { - label: IResourceLabel; - templateDisposables: DisposableStore; -} + // Context + const scopedContextKeyService = store.add(contextKeyService.createScoped(widget)); + store.add(setResourceContext(accessor, scopedContextKeyService, resource)); -class CollapsibleListRenderer implements IListRenderer { - static TEMPLATE_ID = 'chatCollapsibleListRenderer'; - readonly templateId: string = CollapsibleListRenderer.TEMPLATE_ID; + // Drag and drop + widget.draggable = true; + store.add(dom.addDisposableListener(widget, 'dragstart', e => { + instantiationService.invokeFunction(accessor => fillEditorsDragData(accessor, [resource], e)); + e.dataTransfer?.setDragImage(widget, 0, 0); + })); - constructor( - private readonly labels: ResourceLabels, - private readonly contentReferences: readonly IChatContentReference[], - @IOpenerService private readonly openerService: IOpenerService, - ) { } + // Context menu + store.add(addBasicContextMenu(accessor, widget, scopedContextKeyService, MenuId.ChatInputResourceAttachmentContext, resource)); - renderTemplate(container: HTMLElement): ICollapsibleListTemplate { - const templateDisposables = new DisposableStore(); - const label = templateDisposables.add(this.labels.create(container, { supportHighlights: true, supportIcons: true })); - return { templateDisposables, label }; - } + return store; +} - renderElement(element: IChatRequestVariableEntry, index: number, templateData: ICollapsibleListTemplate, height: number | undefined): void { - const { label } = templateData; - const file = URI.isUri(element.value) ? element.value : element.value && typeof element.value === 'object' && 'uri' in element.value && URI.isUri(element.value.uri) ? element.value.uri : undefined; - const range = element.value && typeof element.value === 'object' && 'range' in element.value && Range.isIRange(element.value.range) ? element.value.range : undefined; - - const correspondingContentReference = this.contentReferences.find((ref) => typeof ref.reference === 'object' && 'variableName' in ref.reference && ref.reference.variableName === element.name); - const isAttachmentOmitted = correspondingContentReference?.options?.status?.kind === ChatResponseReferencePartStatusKind.Omitted; - const isAttachmentPartialOrOmitted = isAttachmentOmitted || correspondingContentReference?.options?.status?.kind === ChatResponseReferencePartStatusKind.Partial; - - if (file) { - const fileBasename = basename(file.path); - const fileDirname = dirname(file.path); - const friendlyName = `${fileBasename} ${fileDirname}`; - let ariaLabel; - if (isAttachmentOmitted) { - ariaLabel = range ? localize('chat.omittedFileAttachmentWithRange', "Omitted: {0}, line {1} to line {2}.", friendlyName, range.startLineNumber, range.endLineNumber) : localize('chat.omittedFileAttachment', "Omitted: {0}.", friendlyName); - } else if (isAttachmentPartialOrOmitted) { - ariaLabel = range ? localize('chat.partialFileAttachmentWithRange', "Partially attached: {0}, line {1} to line {2}.", friendlyName, range.startLineNumber, range.endLineNumber) : localize('chat.partialFileAttachment', "Partially attached: {0}.", friendlyName); - } else { - ariaLabel = range ? localize('chat.fileAttachmentWithRange3', "Attached: {0}, line {1} to line {2}.", friendlyName, range.startLineNumber, range.endLineNumber) : localize('chat.fileAttachment3', "Attached: {0}.", friendlyName); +export function hookUpSymbolAttachmentDragAndContextMenu(accessor: ServicesAccessor, widget: HTMLElement, scopedContextKeyService: IScopedContextKeyService, attachment: { name: string; value: Location; kind: SymbolKind }, contextMenuId: MenuId): IDisposable { + const instantiationService = accessor.get(IInstantiationService); + const languageFeaturesService = accessor.get(ILanguageFeaturesService); + const textModelService = accessor.get(ITextModelService); + + const store = new DisposableStore(); + + // Context + store.add(setResourceContext(accessor, scopedContextKeyService, attachment.value.uri)); + + const chatResourceContext = chatAttachmentResourceContextKey.bindTo(scopedContextKeyService); + chatResourceContext.set(attachment.value.uri.toString()); + + // Drag and drop + widget.draggable = true; + store.add(dom.addDisposableListener(widget, 'dragstart', e => { + instantiationService.invokeFunction(accessor => fillEditorsDragData(accessor, [{ resource: attachment.value.uri, selection: attachment.value.range }], e)); + + fillInSymbolsDragData([{ + fsPath: attachment.value.uri.fsPath, + range: attachment.value.range, + name: attachment.name, + kind: attachment.kind, + }], e); + + e.dataTransfer?.setDragImage(widget, 0, 0); + })); + + // Context menu + const providerContexts: ReadonlyArray<[IContextKey, LanguageFeatureRegistry]> = [ + [EditorContextKeys.hasDefinitionProvider.bindTo(scopedContextKeyService), languageFeaturesService.definitionProvider], + [EditorContextKeys.hasReferenceProvider.bindTo(scopedContextKeyService), languageFeaturesService.referenceProvider], + [EditorContextKeys.hasImplementationProvider.bindTo(scopedContextKeyService), languageFeaturesService.implementationProvider], + [EditorContextKeys.hasTypeDefinitionProvider.bindTo(scopedContextKeyService), languageFeaturesService.typeDefinitionProvider], + ]; + + const updateContextKeys = async () => { + const modelRef = await textModelService.createModelReference(attachment.value.uri); + try { + const model = modelRef.object.textEditorModel; + for (const [contextKey, registry] of providerContexts) { + contextKey.set(registry.has(model)); } - - label.setFile(file, { - fileKind: FileKind.FILE, - hidePath: true, - range, - title: correspondingContentReference?.options?.status?.description - }); - label.element.ariaLabel = ariaLabel; - label.element.tabIndex = 0; - label.element.style.cursor = 'pointer'; - - templateData.templateDisposables.add(dom.addDisposableListener(label.element, dom.EventType.CLICK, async (e: MouseEvent) => { - dom.EventHelper.stop(e, true); - if (file) { - this.openerService.open( - file, - { - fromUserGesture: true, - editorOptions: { - selection: range, - } as any - }); - } - })); - } else { - const attachmentLabel = element.fullName ?? element.name; - const withIcon = element.icon?.id ? `$(${element.icon.id}) ${attachmentLabel}` : attachmentLabel; - label.setLabel(withIcon, correspondingContentReference?.options?.status?.description); - - label.element.ariaLabel = localize('chat.attachment3', "Attached context: {0}.", element.name); - label.element.tabIndex = 0; + } finally { + modelRef.dispose(); } + }; + store.add(addBasicContextMenu(accessor, widget, scopedContextKeyService, contextMenuId, attachment.value, updateContextKeys)); - if (isAttachmentPartialOrOmitted) { - label.element.classList.add('warning'); - } - const description = correspondingContentReference?.options?.status?.description; - if (isAttachmentPartialOrOmitted) { - label.element.ariaLabel = `${label.element.ariaLabel}${description ? ` ${description}` : ''}`; - for (const selector of ['.monaco-icon-suffix-container', '.monaco-icon-name-container']) { - const element = label.element.querySelector(selector); - if (element) { - element.classList.add('warning'); - } - } + return store; +} + +function setResourceContext(accessor: ServicesAccessor, scopedContextKeyService: IScopedContextKeyService, resource: URI) { + const fileService = accessor.get(IFileService); + const languageService = accessor.get(ILanguageService); + const modelService = accessor.get(IModelService); + + const resourceContextKey = new ResourceContextKey(scopedContextKeyService, fileService, languageService, modelService); + resourceContextKey.set(resource); + return resourceContextKey; +} + +function addBasicContextMenu(accessor: ServicesAccessor, widget: HTMLElement, scopedContextKeyService: IScopedContextKeyService, menuId: MenuId, arg: any, updateContextKeys?: () => Promise): IDisposable { + const contextMenuService = accessor.get(IContextMenuService); + const menuService = accessor.get(IMenuService); + + return dom.addDisposableListener(widget, dom.EventType.CONTEXT_MENU, async domEvent => { + const event = new StandardMouseEvent(dom.getWindow(domEvent), domEvent); + dom.EventHelper.stop(domEvent, true); + + try { + await updateContextKeys?.(); + } catch (e) { + console.error(e); } - } - disposeTemplate(templateData: ICollapsibleListTemplate): void { - templateData.templateDisposables.dispose(); - } + contextMenuService.showContextMenu({ + contextKeyService: scopedContextKeyService, + getAnchor: () => event, + getActions: () => { + const menu = menuService.getMenuActions(menuId, scopedContextKeyService, { arg }); + return getFlatContextMenuActions(menu); + }, + }); + }); } diff --git a/src/vs/workbench/contrib/aideAgent/browser/aideAgentContentParts/aideAgentContentParts.ts b/src/vs/workbench/contrib/aideAgent/browser/aideAgentContentParts/aideAgentContentParts.ts index 847fcad75e6..39d5ee99398 100644 --- a/src/vs/workbench/contrib/aideAgent/browser/aideAgentContentParts/aideAgentContentParts.ts +++ b/src/vs/workbench/contrib/aideAgent/browser/aideAgentContentParts/aideAgentContentParts.ts @@ -20,7 +20,7 @@ export interface IChatContentPart extends IDisposable { export interface IChatContentPartRenderContext { element: ChatTreeItem; - index: number; content: ReadonlyArray; + contentIndex: number; preceedingContentParts: ReadonlyArray; } diff --git a/src/vs/workbench/contrib/aideAgent/browser/aideAgentContentParts/aideAgentMarkdownContentPart.ts b/src/vs/workbench/contrib/aideAgent/browser/aideAgentContentParts/aideAgentMarkdownContentPart.ts index 639ec9eb3bc..376482ba91f 100644 --- a/src/vs/workbench/contrib/aideAgent/browser/aideAgentContentParts/aideAgentMarkdownContentPart.ts +++ b/src/vs/workbench/contrib/aideAgent/browser/aideAgentContentParts/aideAgentMarkdownContentPart.ts @@ -4,25 +4,32 @@ *--------------------------------------------------------------------------------------------*/ import * as dom from '../../../../../base/browser/dom.js'; +import { StandardMouseEvent } from '../../../../../base/browser/mouseEvent.js'; import { Codicon } from '../../../../../base/common/codicons.js'; import { Emitter } from '../../../../../base/common/event.js'; import { IMarkdownString } from '../../../../../base/common/htmlContent.js'; -import { Disposable, IDisposable } from '../../../../../base/common/lifecycle.js'; +import { Disposable, DisposableStore, IDisposable } from '../../../../../base/common/lifecycle.js'; +import { autorun } from '../../../../../base/common/observable.js'; import { equalsIgnoreCase } from '../../../../../base/common/strings.js'; import { ThemeIcon } from '../../../../../base/common/themables.js'; import { URI } from '../../../../../base/common/uri.js'; import { MarkdownRenderer } from '../../../../../editor/browser/widget/markdownRenderer/browser/markdownRenderer.js'; import { Range } from '../../../../../editor/common/core/range.js'; import { ILanguageService } from '../../../../../editor/common/languages/language.js'; +import { ITextModel } from '../../../../../editor/common/model.js'; import { getIconClasses } from '../../../../../editor/common/services/getIconClasses.js'; import { IModelService } from '../../../../../editor/common/services/model.js'; -import { IResolvedTextEditorModel, ITextModelService } from '../../../../../editor/common/services/resolverService.js'; -import { MenuId } from '../../../../../platform/actions/common/actions.js'; +import { ITextModelService } from '../../../../../editor/common/services/resolverService.js'; +import { localize } from '../../../../../nls.js'; +import { getFlatContextMenuActions } from '../../../../../platform/actions/browser/menuEntryActionViewItem.js'; +import { IMenuService, MenuId } from '../../../../../platform/actions/common/actions.js'; import { IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js'; +import { IContextMenuService } from '../../../../../platform/contextview/browser/contextView.js'; import { FileKind } from '../../../../../platform/files/common/files.js'; import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; import { ILabelService } from '../../../../../platform/label/common/label.js'; import { IEditorService } from '../../../../services/editor/common/editorService.js'; +import { IAideAgentEditingService } from '../../common/aideAgentEditingService.js'; import { IChatProgressRenderableResponseContent } from '../../common/aideAgentModel.js'; import { IChatMarkdownContent } from '../../common/aideAgentService.js'; import { isRequestVM, isResponseVM } from '../../common/aideAgentViewModel.js'; @@ -40,10 +47,6 @@ import { IChatContentPart, IChatContentPartRenderContext } from './aideAgentCont const $ = dom.$; -const defaultRendererOptions: IChatListItemRendererOptions = { - editableCodeBlock: false -}; - export class ChatMarkdownContentPart extends Disposable implements IChatContentPart { private static idPool = 0; public readonly id = String(++ChatMarkdownContentPart.idPool); @@ -133,7 +136,7 @@ export class ChatMarkdownContentPart extends Disposable implements IChatContentP renderer: MarkdownRenderer, currentWidth: number, private readonly codeBlockModelCollection: CodeBlockModelCollection, - private readonly rendererOptions = defaultRendererOptions, + private readonly rendererOptions: IChatListItemRendererOptions, @IContextKeyService contextKeyService: IContextKeyService, @ITextModelService private readonly textModelService: ITextModelService, @IInstantiationService private readonly instantiationService: IInstantiationService, @@ -150,7 +153,11 @@ export class ChatMarkdownContentPart extends Disposable implements IChatContentP // We release editors in order so that it's more likely that the same editor will be assigned if this element is re-rendered right away, like it often is during progressive rendering const orderedDisposablesList: IDisposable[] = []; - let codeBlockIndex = codeBlockStartIndex; + + // Need to track the index of the codeblock within the response so it can have a unique ID, + // and within this part to find it within the codeblocks array + let globalCodeBlockIndexStart = codeBlockStartIndex; + let thisPartCodeBlockIndexStart = 0; const result = this._register(renderer.render(cleanMarkdown, { fillInIncompleteTokens, codeBlockRendererSync: (languageId, text, raw) => { @@ -165,7 +172,7 @@ export class ChatMarkdownContentPart extends Disposable implements IChatContentP } } - const isCodeBlockComplete = !isResponseVM(context.element) || context.element.isComplete || !raw || raw?.endsWith('```'); + const isCodeBlockComplete = !isResponseVM(context.element) || context.element.isComplete || !raw || codeblockHasClosingBackticks(raw); if ((!text || (text.startsWith('') && !text.includes('\n'))) && !isCodeBlockComplete && rendererOptions.renderCodeBlockPills) { const hideEmptyCodeblock = $('div'); hideEmptyCodeblock.style.display = 'none'; @@ -175,19 +182,20 @@ export class ChatMarkdownContentPart extends Disposable implements IChatContentP const editPreviewBlock = this.parseEditPreviewBlock(text); if (editPreviewBlock) { const sessionId = isResponseVM(element) || isRequestVM(element) ? element.sessionId : ''; - const originalIndex = codeBlockIndex++; + const originalIndex = globalCodeBlockIndexStart++; const original = this.codeBlockModelCollection.getOrCreate(sessionId, element, originalIndex).model; - const modifiedIndex = codeBlockIndex++; + const modifiedIndex = globalCodeBlockIndexStart++; const modified = this.codeBlockModelCollection.getOrCreate(sessionId, element, modifiedIndex).model; - const ref = this.renderEditPreviewBlock({ + const codeBlockInfo: IEditPreviewBlockData = { uri: extractedUris[currentUriIndex++] || URI.parse(''), // Use the current URI element, languageId, parentContextKeyService: contextKeyService, original: { model: original, text: editPreviewBlock.original, codeBlockIndex: originalIndex }, - modified: { model: modified, text: editPreviewBlock.modified, codeBlockIndex: modifiedIndex } - }, currentWidth); + modified: { model: modified, text: editPreviewBlock.modified, codeBlockIndex: modifiedIndex }, + }; + const ref = this.renderEditPreviewBlock(codeBlockInfo, isCodeBlockComplete, currentWidth); this.allEditPreviewRefs.push(ref); this._register(ref.object.onDidChangeContentHeight(() => this._onDidChangeHeight.fire())); @@ -203,8 +211,9 @@ export class ChatMarkdownContentPart extends Disposable implements IChatContentP return ref.object.element; } - const index = codeBlockIndex++; - let textModel: Promise; + const globalIndex = globalCodeBlockIndexStart++; + const thisPartIndex = thisPartCodeBlockIndexStart++; + let textModel: Promise; let range: Range | undefined; let vulns: readonly IMarkdownVulnerability[] | undefined; let codemapperUri: URI | undefined; @@ -212,24 +221,24 @@ export class ChatMarkdownContentPart extends Disposable implements IChatContentP try { const parsedBody = parseLocalFileData(text); range = parsedBody.range && Range.lift(parsedBody.range); - textModel = this.textModelService.createModelReference(parsedBody.uri).then(ref => ref.object); + textModel = this.textModelService.createModelReference(parsedBody.uri).then(ref => ref.object.textEditorModel); } catch (e) { return $('div'); } } else { const sessionId = isResponseVM(element) || isRequestVM(element) ? element.sessionId : ''; - const modelEntry = this.codeBlockModelCollection.getOrCreate(sessionId, element, index); - const fastUpdateModelEntry = this.codeBlockModelCollection.updateSync(sessionId, element, index, { text, languageId }); + const modelEntry = this.codeBlockModelCollection.getOrCreate(sessionId, element, globalIndex); + const fastUpdateModelEntry = this.codeBlockModelCollection.updateSync(sessionId, element, globalIndex, { text, languageId, isComplete: isCodeBlockComplete }); vulns = modelEntry.vulns; codemapperUri = fastUpdateModelEntry.codemapperUri; textModel = modelEntry.model; } const hideToolbar = isResponseVM(element) && element.errorDetails?.responseIsFiltered; - const codeBlockInfo = { languageId, textModel, codeBlockIndex: index, element, range, hideToolbar, parentContextKeyService: contextKeyService, vulns, codemapperUri }; + const codeBlockInfo: ICodeBlockData = { languageId, textModel, codeBlockIndex: globalIndex, codeBlockPartIndex: thisPartIndex, element, range, hideToolbar, parentContextKeyService: contextKeyService, vulns, codemapperUri }; - if (!rendererOptions.renderCodeBlockPills || !codemapperUri) { - const ref = this.renderCodeBlock(codeBlockInfo, text, currentWidth, rendererOptions.editableCodeBlock); + if (!rendererOptions.renderCodeBlockPills || element.isCompleteAddedRequest || !codemapperUri) { + const ref = this.renderCodeBlock(codeBlockInfo, text, isCodeBlockComplete, currentWidth); this.allRefs.push(ref); // Attach this after updating text/layout of the editor, so it should only be fired when the size updates later (horizontal scrollbar, wrapping) @@ -239,7 +248,7 @@ export class ChatMarkdownContentPart extends Disposable implements IChatContentP const ownerMarkdownPartId = this.id; const info: IChatCodeBlockInfo = new class { readonly ownerMarkdownPartId = ownerMarkdownPartId; - readonly codeBlockIndex = index; + readonly codeBlockIndex = globalIndex; readonly element = element; readonly isStreaming = !rendererOptions.renderCodeBlockPills; codemapperUri = undefined; // will be set async @@ -248,6 +257,7 @@ export class ChatMarkdownContentPart extends Disposable implements IChatContentP // async and the uri might be undefined when it's read immediately return ref.object.uri; } + readonly uriPromise = textModel.then(model => model.uri); public focus() { ref.object.focus(); } @@ -259,15 +269,12 @@ export class ChatMarkdownContentPart extends Disposable implements IChatContentP orderedDisposablesList.push(ref); return ref.object.element; } else { - // TODO(@ghostwriternr): This check is not the best, because it's hit far before we're done making edits. But I'm unable to get the isComplete - // condition to work properly here. Come back and fix this. - const isStreaming = /* isResponseVM(element) ? !element.isComplete : */ !isCodeBlockComplete; - const ref = this.renderCodeBlockPill(codeBlockInfo.codemapperUri, !isStreaming); + const ref = this.renderCodeBlockPill(element.sessionId, element.id, codeBlockInfo.codemapperUri, !isCodeBlockComplete); if (isResponseVM(codeBlockInfo.element)) { // TODO@joyceerhl: remove this code when we change the codeblockUri API to make the URI available synchronously - this.codeBlockModelCollection.update(codeBlockInfo.element.sessionId, codeBlockInfo.element, codeBlockInfo.codeBlockIndex, { text, languageId: codeBlockInfo.languageId }).then((e) => { + this.codeBlockModelCollection.update(codeBlockInfo.element.sessionId, codeBlockInfo.element, codeBlockInfo.codeBlockIndex, { text, languageId: codeBlockInfo.languageId, isComplete: isCodeBlockComplete }).then((e) => { // Update the existing object's codemapperUri - this.codeblocks[codeBlockInfo.codeBlockIndex].codemapperUri = e.codemapperUri; + this.codeblocks[codeBlockInfo.codeBlockPartIndex].codemapperUri = e.codemapperUri; this._onDidChangeHeight.fire(); }); } @@ -275,13 +282,14 @@ export class ChatMarkdownContentPart extends Disposable implements IChatContentP const ownerMarkdownPartId = this.id; const info: IChatCodeBlockInfo = new class { readonly ownerMarkdownPartId = ownerMarkdownPartId; - readonly codeBlockIndex = index; + readonly codeBlockIndex = globalIndex; readonly element = element; - readonly isStreaming = isStreaming; + readonly isStreaming = !isCodeBlockComplete; readonly codemapperUri = codemapperUri; public get uri() { return undefined; } + readonly uriPromise = Promise.resolve(undefined); public focus() { return ref.object.element.focus(); } @@ -337,12 +345,11 @@ export class ChatMarkdownContentPart extends Disposable implements IChatContentP return { original, modified }; } - private renderCodeBlockPill(uri: URI | undefined, isCodeBlockComplete?: boolean): IDisposableReference { - const codeBlock = this.instantiationService.createInstance(CollapsedCodeBlock); - if (uri) { - codeBlock.render(uri, !isCodeBlockComplete); + private renderCodeBlockPill(sessionId: string, exchangeId: string, codemapperUri: URI | undefined, isStreaming: boolean): IDisposableReference { + const codeBlock = this.instantiationService.createInstance(CollapsedCodeBlock, sessionId, exchangeId); + if (codemapperUri) { + codeBlock.render(codemapperUri, isStreaming); } - return { object: codeBlock, isStale: () => false, @@ -350,27 +357,31 @@ export class ChatMarkdownContentPart extends Disposable implements IChatContentP }; } - private renderCodeBlock(data: ICodeBlockData, text: string, currentWidth: number, editableCodeBlock: boolean | undefined): IDisposableReference { + private renderCodeBlock(data: ICodeBlockData, text: string, isComplete: boolean, currentWidth: number): IDisposableReference { const ref = this.editorPool.get(); const editorInfo = ref.object; if (isResponseVM(data.element)) { - this.codeBlockModelCollection.update(data.element.sessionId, data.element, data.codeBlockIndex, { text, languageId: data.languageId }).then((e) => { + this.codeBlockModelCollection.update(data.element.sessionId, data.element, data.codeBlockIndex, { text, languageId: data.languageId, isComplete }).then((e) => { // Update the existing object's codemapperUri - this.codeblocks[data.codeBlockIndex].codemapperUri = e.codemapperUri; + this.codeblocks[data.codeBlockPartIndex].codemapperUri = e.codemapperUri; + this._onDidChangeHeight.fire(); }); } - editorInfo.render(data, currentWidth, editableCodeBlock); - + editorInfo.render(data, currentWidth); return ref; } - private renderEditPreviewBlock(data: IEditPreviewBlockData, currentWidth: number): IDisposableReference { + private renderEditPreviewBlock(data: IEditPreviewBlockData, isComplete: boolean, currentWidth: number): IDisposableReference { const ref = this.editPreviewEditorPool.get(); const editPreviewEditorInfo = ref.object; if (isResponseVM(data.element)) { - this.codeBlockModelCollection.update(data.element.sessionId, data.element, data.original.codeBlockIndex, { text: data.original.text, languageId: data.languageId }); - this.codeBlockModelCollection.update(data.element.sessionId, data.element, data.modified.codeBlockIndex, { text: data.modified.text, languageId: data.languageId }); + this.codeBlockModelCollection.update(data.element.sessionId, data.element, data.original.codeBlockIndex, { text: data.original.text, languageId: data.languageId, isComplete }).then((e) => { + this._onDidChangeHeight.fire(); + }); + this.codeBlockModelCollection.update(data.element.sessionId, data.element, data.modified.codeBlockIndex, { text: data.modified.text, languageId: data.languageId, isComplete }).then((e) => { + this._onDidChangeHeight.fire(); + }); } editPreviewEditorInfo.render(data, currentWidth); @@ -436,6 +447,11 @@ export class EditorPool extends Disposable { } } +export function codeblockHasClosingBackticks(str: string): boolean { + str = str.trim(); + return !!str.match(/\n```+$/); +} + export class EditPreviewEditorPool extends Disposable { private readonly _pool: ResourcePool; @@ -471,6 +487,7 @@ export class EditPreviewEditorPool extends Disposable { } class CollapsedCodeBlock extends Disposable { + public readonly element: HTMLElement; private _uri: URI | undefined; @@ -478,13 +495,19 @@ class CollapsedCodeBlock extends Disposable { return this._uri; } - private isStreaming: boolean | undefined; + private readonly _progressStore = this._store.add(new DisposableStore()); constructor( + sessionId: string, + exchangeId: string, @ILabelService private readonly labelService: ILabelService, @IEditorService private readonly editorService: IEditorService, @IModelService private readonly modelService: IModelService, @ILanguageService private readonly languageService: ILanguageService, + @IContextMenuService private readonly contextMenuService: IContextMenuService, + @IContextKeyService private readonly contextKeyService: IContextKeyService, + @IMenuService private readonly menuService: IMenuService, + @IAideAgentEditingService private readonly chatEditingService: IAideAgentEditingService, ) { super(); this.element = $('.aideagent-codeblock-pill-widget'); @@ -494,20 +517,32 @@ class CollapsedCodeBlock extends Disposable { this.editorService.openEditor({ resource: this.uri }); } })); + this._register(dom.addDisposableListener(this.element, dom.EventType.CONTEXT_MENU, domEvent => { + const event = new StandardMouseEvent(dom.getWindow(domEvent), domEvent); + dom.EventHelper.stop(domEvent, true); + + this.contextMenuService.showContextMenu({ + contextKeyService: this.contextKeyService, + getAnchor: () => event, + getActions: () => { + const menu = this.menuService.getMenuActions(MenuId.AideAgentEditingCodeBlockContext, this.contextKeyService, { arg: { sessionId, exchangeId, uri: this.uri } }); + return getFlatContextMenuActions(menu); + }, + }); + })); } - render(uri: URI, isStreaming?: boolean) { - if (this.uri?.toString() === uri.toString() && this.isStreaming === isStreaming) { - return; - } + render(uri: URI, isStreaming?: boolean): void { + this._progressStore.clear(); this._uri = uri; - this.isStreaming = isStreaming; const iconText = this.labelService.getUriBasenameLabel(uri); + const modifiedEntry = this.chatEditingService.currentEditingSession?.getEntry(uri); + const isComplete = !modifiedEntry?.isCurrentlyBeingModified.get(); let iconClasses: string[] = []; - if (isStreaming) { + if (isStreaming || !isComplete) { const codicon = ThemeIcon.modify(Codicon.loading, 'spin'); iconClasses = ThemeIcon.asClassNameArray(codicon); } else { @@ -517,6 +552,51 @@ class CollapsedCodeBlock extends Disposable { const iconEl = dom.$('span.icon'); iconEl.classList.add(...iconClasses); - this.element.replaceChildren(iconEl, dom.$('span.icon-label', {}, iconText)); + + const children = [dom.$('span.icon-label', {}, iconText)]; + if (isStreaming) { + children.push(dom.$('span.label-detail', {}, localize('chat.codeblock.generating', "Generating edits..."))); + } else if (!isComplete) { + children.push(dom.$('span.label-detail', {}, '')); + } + this.element.replaceChildren(iconEl, ...children); + this.element.title = this.labelService.getUriLabel(uri, { relative: false }); + + // Show a percentage progress that is driven by the rewrite + + this._progressStore.add(autorun(r => { + const rewriteRatio = modifiedEntry?.rewriteRatio.read(r); + + const labelDetail = this.element.querySelector('.label-detail'); + const isComplete = !modifiedEntry?.isCurrentlyBeingModified.read(r); + if (labelDetail && !isStreaming && !isComplete) { + const value = rewriteRatio; + labelDetail.textContent = value === 0 || !value ? localize('chat.codeblock.applying', "Applying edits...") : localize('chat.codeblock.applyingPercentage', "Applying edits ({0}%)...", Math.round(value * 100)); + } else if (labelDetail && !isStreaming && isComplete) { + iconEl.classList.remove(...iconClasses); + const fileKind = uri.path.endsWith('/') ? FileKind.FOLDER : FileKind.FILE; + iconEl.classList.add(...getIconClasses(this.modelService, this.languageService, uri, fileKind)); + labelDetail.textContent = ''; + } + + if (!isStreaming && isComplete) { + const labelAdded = this.element.querySelector('.label-added') ?? this.element.appendChild(dom.$('span.label-added')); + const labelRemoved = this.element.querySelector('.label-removed') ?? this.element.appendChild(dom.$('span.label-removed')); + const changes = modifiedEntry?.diffInfo.read(r); + if (changes && !changes?.identical && !changes?.quitEarly) { + let removedLines = 0; + let addedLines = 0; + for (const change of changes.changes) { + removedLines += change.original.endLineNumberExclusive - change.original.startLineNumber; + addedLines += change.modified.endLineNumberExclusive - change.modified.startLineNumber; + } + labelAdded.textContent = `+${addedLines}`; + labelRemoved.textContent = `-${removedLines}`; + const insertionsFragment = addedLines === 1 ? localize('chat.codeblock.insertions.one', "1 insertion") : localize('chat.codeblock.insertions', "{0} insertions", addedLines); + const deletionsFragment = removedLines === 1 ? localize('chat.codeblock.deletions.one', "1 deletion") : localize('chat.codeblock.deletions', "{0} deletions", removedLines); + this.element.ariaLabel = this.element.title = localize('summary', 'Edited {0}, {1}, {2}', iconText, insertionsFragment, deletionsFragment); + } + } + })); } } diff --git a/src/vs/workbench/contrib/aideAgent/browser/aideAgentContentParts/aideAgentProgressContentPart.ts b/src/vs/workbench/contrib/aideAgent/browser/aideAgentContentParts/aideAgentProgressContentPart.ts index a6dd933d3f1..c4e010d801b 100644 --- a/src/vs/workbench/contrib/aideAgent/browser/aideAgentContentParts/aideAgentProgressContentPart.ts +++ b/src/vs/workbench/contrib/aideAgent/browser/aideAgentContentParts/aideAgentProgressContentPart.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { $ } from '../../../../../base/browser/dom.js'; +import { $, append } from '../../../../../base/browser/dom.js'; import { alert } from '../../../../../base/browser/ui/aria/aria.js'; import { Codicon } from '../../../../../base/common/codicons.js'; import { MarkdownString } from '../../../../../base/common/htmlContent.js'; @@ -19,20 +19,22 @@ export class ChatProgressContentPart extends Disposable implements IChatContentP public readonly domNode: HTMLElement; private readonly showSpinner: boolean; + private readonly isHidden: boolean; constructor( progress: IChatProgressMessage | IChatTask, renderer: MarkdownRenderer, context: IChatContentPartRenderContext, forceShowSpinner?: boolean, - forceShowMessage?: boolean + forceShowMessage?: boolean, + icon?: ThemeIcon ) { super(); - const followingContent = context.content.slice(context.index + 1); + const followingContent = context.content.slice(context.contentIndex + 1); this.showSpinner = forceShowSpinner ?? shouldShowSpinner(followingContent, context.element); - const hideMessage = forceShowMessage !== true && followingContent.some(part => part.kind !== 'progressMessage'); - if (hideMessage) { + this.isHidden = forceShowMessage !== true && followingContent.some(part => part.kind !== 'progressMessage'); + if (this.isHidden) { // Placeholder, don't show the progress message this.domNode = $(''); return; @@ -43,17 +45,27 @@ export class ChatProgressContentPart extends Disposable implements IChatContentP // this step is in progress, communicate it to SR users alert(progress.content.value); } - const codicon = this.showSpinner ? ThemeIcon.modify(Codicon.loading, 'spin').id : Codicon.check.id; - const markdown = new MarkdownString(`$(${codicon}) ${progress.content.value}`, { + const codicon = icon ? icon : this.showSpinner ? ThemeIcon.modify(Codicon.loading, 'spin') : Codicon.check; + const markdown = new MarkdownString(progress.content.value, { supportThemeIcons: true }); const result = this._register(renderer.render(markdown)); result.element.classList.add('progress-step'); - this.domNode = result.element; + this.domNode = $('.progress-container'); + const iconElement = $('div'); + iconElement.classList.add(...ThemeIcon.asClassNameArray(codicon)); + append(this.domNode, iconElement); + append(this.domNode, result.element); } hasSameContent(other: IChatRendererContent, followingContent: IChatRendererContent[], element: ChatTreeItem): boolean { + // Progress parts render render until some other content shows up, then they hide. + // When some other content shows up, need to signal to be rerendered as hidden. + if (followingContent.some(part => part.kind !== 'progressMessage') && !this.isHidden) { + return false; + } + // Needs rerender when spinner state changes const showSpinner = shouldShowSpinner(followingContent, element); return other.kind === 'progressMessage' && this.showSpinner === showSpinner; diff --git a/src/vs/workbench/contrib/aideAgent/browser/aideAgentContentParts/aideAgentReferencesContentPart.ts b/src/vs/workbench/contrib/aideAgent/browser/aideAgentContentParts/aideAgentReferencesContentPart.ts index f577ad0ea5f..9df2a8f5a24 100644 --- a/src/vs/workbench/contrib/aideAgent/browser/aideAgentContentParts/aideAgentReferencesContentPart.ts +++ b/src/vs/workbench/contrib/aideAgent/browser/aideAgentContentParts/aideAgentReferencesContentPart.ts @@ -15,15 +15,15 @@ import { basename } from '../../../../../base/common/path.js'; import { basenameOrAuthority, isEqualAuthority } from '../../../../../base/common/resources.js'; import { ThemeIcon } from '../../../../../base/common/themables.js'; import { URI } from '../../../../../base/common/uri.js'; -import { Location } from '../../../../../editor/common/languages.js'; -import { ILanguageService } from '../../../../../editor/common/languages/language.js'; -import { getIconClasses } from '../../../../../editor/common/services/getIconClasses.js'; -import { IModelService } from '../../../../../editor/common/services/model.js'; import { localize } from '../../../../../nls.js'; -import { IClipboardService } from '../../../../../platform/clipboard/common/clipboardService.js'; +import { getFlatContextMenuActions } from '../../../../../platform/actions/browser/menuEntryActionViewItem.js'; +import { MenuWorkbenchToolBar } from '../../../../../platform/actions/browser/toolbar.js'; +import { IMenuService, MenuId } from '../../../../../platform/actions/common/actions.js'; +import { IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js'; import { IContextMenuService } from '../../../../../platform/contextview/browser/contextView.js'; import { FileKind } from '../../../../../platform/files/common/files.js'; import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; +import { ServiceCollection } from '../../../../../platform/instantiation/common/serviceCollection.js'; import { ILabelService } from '../../../../../platform/label/common/label.js'; import { WorkbenchList } from '../../../../../platform/list/browser/listService.js'; import { IOpenerService } from '../../../../../platform/opener/common/opener.js'; @@ -32,41 +32,48 @@ import { IThemeService } from '../../../../../platform/theme/common/themeService import { fillEditorsDragData } from '../../../../browser/dnd.js'; import { IResourceLabel, ResourceLabels } from '../../../../browser/labels.js'; import { ColorScheme } from '../../../../browser/web.api.js'; +import { ResourceContextKey } from '../../../../common/contextkeys.js'; import { SETTINGS_AUTHORITY } from '../../../../services/preferences/common/preferences.js'; import { createFileIconThemableTreeContainerScope } from '../../../files/browser/views/explorerView.js'; -import { chatVariableLeader } from '../../common/aideAgentParserTypes.js'; -import { ChatResponseReferencePartStatusKind, IChatContentReference, IChatContentVariableReference, IChatWarningMessage } from '../../common/aideAgentService.js'; +import { chatEditingWidgetFileReadonlyContextKey, chatEditingWidgetFileStateContextKey, WorkingSetEntryState } from '../../common/aideAgentEditingService.js'; +import { ChatResponseReferencePartStatusKind, IChatContentReference, IChatWarningMessage } from '../../common/aideAgentService.js'; import { IAideAgentVariablesService } from '../../common/aideAgentVariables.js'; import { IChatRendererContent, IChatResponseViewModel } from '../../common/aideAgentViewModel.js'; import { ChatTreeItem } from '../aideAgent.js'; import { IDisposableReference, ResourcePool } from './aideAgentCollections.js'; -import { IChatContentPart } from './aideAgentContentParts.js'; +import { IChatContentPart, IChatContentPartRenderContext } from './aideAgentContentParts.js'; const $ = dom.$; export interface IChatReferenceListItem extends IChatContentReference { title?: string; + description?: string; + state?: WorkingSetEntryState; + excluded?: boolean; + isMarkedReadonly?: boolean; } export type IChatCollapsibleListItem = IChatReferenceListItem | IChatWarningMessage; export class ChatCollapsibleListContentPart extends Disposable implements IChatContentPart { public readonly domNode: HTMLElement; - private readonly data: ReadonlyArray; private readonly _onDidChangeHeight = this._register(new Emitter()); public readonly onDidChangeHeight = this._onDidChangeHeight.event; + private readonly hasFollowingContent: boolean; + + private readonly data: ReadonlyArray; + constructor( items: ReadonlyArray, labelOverride: string | undefined, - element: IChatResponseViewModel, + context: IChatContentPartRenderContext, contentReferencesListPool: CollapsibleListPool, @IOpenerService openerService: IOpenerService, - @IModelService modelService: IModelService, - @ILanguageService languageService: ILanguageService, + @IMenuService menuService: IMenuService, + @IInstantiationService private readonly instantiationService: IInstantiationService, @IContextMenuService private readonly contextMenuService: IContextMenuService, - @IClipboardService private readonly clipboardService: IClipboardService, ) { super(); @@ -80,32 +87,15 @@ export class ChatCollapsibleListContentPart extends Disposable implements IChatC return false; }); + const element = context.element as IChatResponseViewModel; + this.hasFollowingContent = context.contentIndex + 1 < context.content.length; const referencesLabel = labelOverride ?? (data.length > 1 ? - localize('usedReferencesPlural', "used {0} references", data.length) : - localize('usedReferencesSingular', "used {0} reference", 1)); - const iconsContainer = $('.aideagent-attachment-icons'); - // Only process up to 3 items for icons - const maxIcons = 3; - const itemsToShow = data.slice(0, maxIcons); - for (const item of itemsToShow) { - if (item.kind === 'reference') { - const iconElement = $('span.icon'); - const reference = this.getReferenceUri(item.reference); - if (reference) { - iconElement.classList.add(...getIconClasses(modelService, languageService, reference, FileKind.FILE)); - } - iconsContainer.appendChild(iconElement); - } - } - - // Add ellipsis icon if there are more than 3 items - if (data.length > maxIcons) { - const iconElement = $('span.icon'); - iconElement.classList.add(...ThemeIcon.asClassNameArray(Codicon.more)); - iconsContainer.appendChild(iconElement); - } - - const buttonElement = $('.aideagent-used-context-label.show-file-icons', undefined); + localize('usedReferencesPlural', "Used {0} references", data.length) : + localize('usedReferencesSingular', "Used {0} reference", 1)); + const iconElement = $('.chat-used-context-icon'); + const icon = (element: IChatResponseViewModel) => element.usedReferencesExpanded ? Codicon.chevronDown : Codicon.chevronRight; + iconElement.classList.add(...ThemeIcon.asClassNameArray(icon(element))); + const buttonElement = $('.chat-used-context-label', undefined); const collapseButton = this._register(new Button(buttonElement, { buttonBackground: undefined, @@ -118,11 +108,14 @@ export class ChatCollapsibleListContentPart extends Disposable implements IChatC buttonSeparator: undefined })); this.domNode = $('.chat-used-context', undefined, buttonElement); - collapseButton.element.replaceChildren(iconsContainer, dom.$('span.icon-label', {}, referencesLabel)); + collapseButton.label = referencesLabel; + collapseButton.element.prepend(iconElement); this.updateAriaLabel(collapseButton.element, referencesLabel, element.usedReferencesExpanded); this.domNode.classList.toggle('chat-used-context-collapsed', !element.usedReferencesExpanded); this._register(collapseButton.onDidClick(() => { + iconElement.classList.remove(...ThemeIcon.asClassNameArray(icon(element))); element.usedReferencesExpanded = !element.usedReferencesExpanded; + iconElement.classList.add(...ThemeIcon.asClassNameArray(icon(element))); this.domNode.classList.toggle('chat-used-context-collapsed', !element.usedReferencesExpanded); this._onDidChangeHeight.fire(); this.updateAriaLabel(collapseButton.element, referencesLabel, element.usedReferencesExpanded); @@ -152,34 +145,30 @@ export class ChatCollapsibleListContentPart extends Disposable implements IChatC } } })); - this._register(list.onContextMenu((e) => { - e.browserEvent.preventDefault(); - e.browserEvent.stopPropagation(); - if (e.element && 'reference' in e.element && typeof e.element.reference === 'object') { - const uriOrLocation = 'variableName' in e.element.reference ? e.element.reference.value : e.element.reference; - const uri = URI.isUri(uriOrLocation) ? uriOrLocation : - uriOrLocation?.uri; - if (uri) { - this.contextMenuService.showContextMenu({ - getAnchor: () => e.anchor, - getActions: () => { - return [{ - id: 'workbench.action.chat.copyReference', - title: localize('copyReference', "Copy"), - label: localize('copyReference', "Copy"), - tooltip: localize('copyReference', "Copy"), - enabled: e.element?.kind === 'reference', - class: undefined, - run: () => { - void this.clipboardService.writeResources([uri]); - } - }]; - } - }); - } + this._register(list.onContextMenu(e => { + dom.EventHelper.stop(e.browserEvent, true); + + const uri = e.element && getResourceForElement(e.element); + if (!uri) { + return; } + this.contextMenuService.showContextMenu({ + getAnchor: () => e.anchor, + getActions: () => { + const menu = menuService.getMenuActions(MenuId.ChatAttachmentsContext, list.contextKeyService, { shouldForwardArgs: true, arg: uri }); + return getFlatContextMenuActions(menu); + } + }); + })); + + const resourceContextKey = this._register(this.instantiationService.createInstance(ResourceContextKey)); + this._register(list.onDidChangeFocus(e => { + resourceContextKey.reset(); + const element = e.elements.length ? e.elements[0] : undefined; + const uri = element && getResourceForElement(element); + resourceContextKey.set(uri ?? null); })); const maxItemsShown = 6; @@ -191,8 +180,7 @@ export class ChatCollapsibleListContentPart extends Disposable implements IChatC } hasSameContent(other: IChatRendererContent, followingContent: IChatRendererContent[], element: ChatTreeItem): boolean { - return other.kind === 'references' && other.references.length === this.data.length || - other.kind === 'codeCitations' && other.citations.length === this.data.length; + return other.kind === 'references' && other.references.length === this.data.length && (!!followingContent.length === this.hasFollowingContent); } private updateAriaLabel(element: HTMLElement, label: string, expanded?: boolean): void { @@ -202,18 +190,6 @@ export class ChatCollapsibleListContentPart extends Disposable implements IChatC addDisposable(disposable: IDisposable): void { this._register(disposable); } - - private getReferenceUri(reference: string | URI | Location | IChatContentVariableReference): URI | undefined { - if (typeof reference === 'string') { - return undefined; - } else if (URI.isUri(reference)) { - return reference; - } else if ('uri' in reference) { - return reference.uri; - } else { - return undefined; - } - } } export class CollapsibleListPool extends Disposable { @@ -225,6 +201,7 @@ export class CollapsibleListPool extends Disposable { constructor( private _onDidChangeVisibility: Event, + private readonly menuId: MenuId | undefined, @IInstantiationService private readonly instantiationService: IInstantiationService, @IThemeService private readonly themeService: IThemeService, @ILabelService private readonly labelService: ILabelService, @@ -239,26 +216,12 @@ export class CollapsibleListPool extends Disposable { const container = $('.chat-used-context-list'); this._register(createFileIconThemableTreeContainerScope(container, this.themeService)); - const getDragURI = (element: IChatCollapsibleListItem): URI | null => { - if (element.kind === 'warning') { - return null; - } - const { reference } = element; - if (typeof reference === 'string' || 'variableName' in reference) { - return null; - } else if (URI.isUri(reference)) { - return reference; - } else { - return reference.uri; - } - }; - const list = this.instantiationService.createInstance( WorkbenchList, 'ChatListRenderer', container, new CollapsibleListDelegate(), - [this.instantiationService.createInstance(CollapsibleListRenderer, resourceLabels)], + [this.instantiationService.createInstance(CollapsibleListRenderer, resourceLabels, this.menuId)], { alwaysConsumeMouseWheel: false, accessibilityProvider: { @@ -281,9 +244,9 @@ export class CollapsibleListPool extends Disposable { getWidgetAriaLabel: () => localize('chatCollapsibleList', "Collapsible Chat List") }, dnd: { - getDragURI: (element: IChatCollapsibleListItem) => getDragURI(element)?.toString() ?? null, + getDragURI: (element: IChatCollapsibleListItem) => getResourceForElement(element)?.toString() ?? null, getDragLabel: (elements, originalEvent) => { - const uris: URI[] = coalesce(elements.map(getDragURI)); + const uris: URI[] = coalesce(elements.map(getResourceForElement)); if (!uris.length) { return undefined; } else if (uris.length === 1) { @@ -298,7 +261,7 @@ export class CollapsibleListPool extends Disposable { onDragStart: (data, originalEvent) => { try { const elements = data.getData() as IChatCollapsibleListItem[]; - const uris: URI[] = coalesce(elements.map(getDragURI)); + const uris: URI[] = coalesce(elements.map(getResourceForElement)); this.instantiationService.invokeFunction(accessor => fillEditorsDragData(accessor, uris, originalEvent)); } catch { // noop @@ -335,8 +298,11 @@ class CollapsibleListDelegate implements IListVirtualDelegate { @@ -345,15 +311,30 @@ class CollapsibleListRenderer implements IListRenderer { + const existing = this.modelService.getModel(resource); + if (existing) { + return existing; + } + return this.modelService.createModel('', this.languageService.createById('aideagentinput'), resource); + } +} diff --git a/src/vs/workbench/contrib/aideAgent/browser/aideAgentEditPreviewWidget.ts b/src/vs/workbench/contrib/aideAgent/browser/aideAgentEditPreviewWidget.ts deleted file mode 100644 index 8514dbd042b..00000000000 --- a/src/vs/workbench/contrib/aideAgent/browser/aideAgentEditPreviewWidget.ts +++ /dev/null @@ -1,131 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { h } from '../../../../base/browser/dom.js'; -import { Codicon } from '../../../../base/common/codicons.js'; -import { Emitter } from '../../../../base/common/event.js'; -import { Disposable } from '../../../../base/common/lifecycle.js'; -import { ThemeIcon } from '../../../../base/common/themables.js'; -import { URI } from '../../../../base/common/uri.js'; -import { Range } from '../../../../editor/common/core/range.js'; -import { MenuWorkbenchToolBar } from '../../../../platform/actions/browser/toolbar.js'; -import { MenuId } from '../../../../platform/actions/common/actions.js'; -import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; -import { AideAgentCodeEditsContentPart, CodeEditsPool } from './aideAgentCodeEditPart.js'; -import './media/aideAgentEditPreviewWidget.css'; - -const defaultIconClasses = ThemeIcon.asClassNameArray(Codicon.symbolEvent); -const cancelledIconClasses = ThemeIcon.asClassNameArray(Codicon.close); -const errorIconClasses = ThemeIcon.asClassNameArray(Codicon.error); -const questionIconClasses = ThemeIcon.asClassNameArray(Codicon.question); -const progressIconClasses = ThemeIcon.asClassNameArray(ThemeIcon.modify(Codicon.sync, 'spin')); - -export class AideAgentEditPreviewWidget extends Disposable { - protected readonly _elements = h( - 'div.aideagent-edit-preview@root', - [ - h('div.header@header', [ - h('div.title@title', [ - h('div.icon@icon'), - h('div.title@titleText'), - ]), - h('div.actions-toolbar@toolbar'), - ]), - h('div.code-edits@codeEdits') - ] - ); - - private readonly _onDidChangeHeight = this._register(new Emitter()); - public readonly onDidChangeHeight = this._onDidChangeHeight.event; - - private readonly _onDidChangeVisibility = this._register(new Emitter()); - public readonly onDidChangeVisibility = this._onDidChangeVisibility.event; - - private codeEditsPool: CodeEditsPool; - private editsList!: AideAgentCodeEditsContentPart; - - private _visible = false; - get visible() { - return this._visible; - } - - set visible(value: boolean) { - this._visible = value; - this._elements.root.classList.toggle('hidden', !value); - this._onDidChangeHeight.fire(); - } - - private isProgressing = false; - - constructor( - parent: HTMLElement, - @IInstantiationService private readonly instantiationService: IInstantiationService, - ) { - super(); - - this.codeEditsPool = this.instantiationService.createInstance(CodeEditsPool, this.onDidChangeVisibility); - - this.visible = false; - parent.appendChild(this._elements.root); - this.render(); - } - - private render() { - const iconElement = this._elements.icon; - iconElement.classList.add(...defaultIconClasses); - - const titleElement = this._elements.titleText; - titleElement.textContent = ''; - - const toolbarContainer = this._elements.toolbar; - this._register(this.instantiationService.createInstance(MenuWorkbenchToolBar, toolbarContainer, MenuId.AideAgentEditPreviewWidget, { - menuOptions: { - shouldForwardArgs: true - } - })); - - this.editsList = this.instantiationService.createInstance( - AideAgentCodeEditsContentPart, - this.codeEditsPool - ); - this._elements.codeEdits.appendChild(this.editsList.domNode); - } - - - - updateProgress(message: string) { - this.visible = Boolean(message) ? true : false; // Hide if empty string - this._elements.icon.removeAttribute('class'); // Clear existing classes - if (message === 'Complete') { - this._elements.icon.classList.add(...defaultIconClasses); - this.isProgressing = false; - } else if (message === 'Cancelled') { - this._elements.icon.classList.add(...cancelledIconClasses); - this.isProgressing = false; - } else if (message === 'Error') { - this._elements.icon.classList.add(...errorIconClasses); - this.isProgressing = false; - } else if (message === 'Question') { - this._elements.icon.classList.add(...questionIconClasses); - this.isProgressing = false; - } else if (!this.isProgressing) { - this._elements.icon.classList.add(...progressIconClasses); - this.isProgressing = true; - } - - const titleElement = this._elements.titleText; - titleElement.textContent = message; - } - - setCodeEdits(codeEdits: Map) { - this.editsList.setInput({ edits: codeEdits }); - this._onDidChangeHeight.fire(); - } - - clear() { - this.editsList.setInput({ edits: new Map() }); - this._onDidChangeHeight.fire(); - } -} diff --git a/src/vs/workbench/contrib/aideAgent/browser/aideAgentEditing/aideAgentEditing.ts b/src/vs/workbench/contrib/aideAgent/browser/aideAgentEditing/aideAgentEditing.ts new file mode 100644 index 00000000000..decedf50504 --- /dev/null +++ b/src/vs/workbench/contrib/aideAgent/browser/aideAgentEditing/aideAgentEditing.ts @@ -0,0 +1,20 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { isEqual } from '../../../../../base/common/resources.js'; +import { ICodeEditor } from '../../../../../editor/browser/editorBrowser.js'; +import { findDiffEditorContainingCodeEditor } from '../../../../../editor/browser/widget/diffEditor/commands.js'; +import { ServicesAccessor } from '../../../../../platform/instantiation/common/instantiation.js'; +import { IModifiedFileEntry } from '../../common/aideAgentEditingService.js'; + +export function isDiffEditorForEntry(accessor: ServicesAccessor, entry: IModifiedFileEntry, editor: ICodeEditor) { + const diffEditor = findDiffEditorContainingCodeEditor(accessor, editor); + if (!diffEditor) { + return false; + } + const originalModel = diffEditor.getOriginalEditor().getModel(); + const modifiedModel = diffEditor.getModifiedEditor().getModel(); + return isEqual(originalModel?.uri, entry.originalURI) && isEqual(modifiedModel?.uri, entry.modifiedURI); +} diff --git a/src/vs/workbench/contrib/aideAgent/browser/aideAgentEditing/aideAgentEditingActions.ts b/src/vs/workbench/contrib/aideAgent/browser/aideAgentEditing/aideAgentEditingActions.ts new file mode 100644 index 00000000000..a1f57ec214a --- /dev/null +++ b/src/vs/workbench/contrib/aideAgent/browser/aideAgentEditing/aideAgentEditingActions.ts @@ -0,0 +1,382 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Codicon } from '../../../../../base/common/codicons.js'; +import { KeyCode, KeyMod } from '../../../../../base/common/keyCodes.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { ServicesAccessor } from '../../../../../editor/browser/editorExtensions.js'; +import { localize, localize2 } from '../../../../../nls.js'; +import { Action2, MenuId, registerAction2 } from '../../../../../platform/actions/common/actions.js'; +import { ContextKeyExpr } from '../../../../../platform/contextkey/common/contextkey.js'; +import { KeybindingWeight } from '../../../../../platform/keybinding/common/keybindingsRegistry.js'; +import { IEditorService } from '../../../../services/editor/common/editorService.js'; +import { ChatAgentLocation } from '../../common/aideAgentAgents.js'; +import { CONTEXT_CHAT_CAN_REVERT_EXCHANGE, CONTEXT_CHAT_HAS_HIDDEN_EXCHANGES, CONTEXT_CHAT_INPUT_HAS_TEXT, CONTEXT_CHAT_LOCATION, CONTEXT_CHAT_REQUEST_IN_PROGRESS, CONTEXT_IN_CHAT_INPUT } from '../../common/aideAgentContextKeys.js'; +import { applyingChatEditsFailedContextKey, CHAT_EDITING_MULTI_DIFF_SOURCE_RESOLVER_SCHEME, chatEditingResourceContextKey, chatEditingWidgetFileStateContextKey, decidedChatEditingResourceContextKey, hasAppliedChatEditsContextKey, hasUndecidedChatEditingResourceContextKey, IAideAgentEditingService, IChatEditingSession, WorkingSetEntryState } from '../../common/aideAgentEditingService.js'; +import { IAideAgentService } from '../../common/aideAgentService.js'; +import { isRequestVM, isResponseVM } from '../../common/aideAgentViewModel.js'; +import { CHAT_CATEGORY } from '../actions/aideAgentActions.js'; +import { ChatTreeItem, IAideAgentWidgetService, IChatWidget } from '../aideAgent.js'; + +abstract class WorkingSetAction extends Action2 { + run(accessor: ServicesAccessor, ...args: any[]) { + const chatEditingService = accessor.get(IAideAgentEditingService); + const currentEditingSession = chatEditingService.currentEditingSession; + if (!currentEditingSession) { + return; + } + + const chatWidget = accessor.get(IAideAgentWidgetService).lastFocusedWidget; + + const uris: URI[] = []; + if (URI.isUri(args[0])) { + uris.push(args[0]); + } else if (chatWidget) { + uris.push(...chatWidget.input.selectedElements); + } + if (!uris.length) { + return; + } + + return this.runWorkingSetAction(accessor, currentEditingSession, chatWidget, ...uris); + } + + abstract runWorkingSetAction(accessor: ServicesAccessor, currentEditingSession: IChatEditingSession, chatWidget: IChatWidget | undefined, ...uris: URI[]): any; +} + +registerAction2(class OpenFileInDiffAction extends WorkingSetAction { + constructor() { + super({ + id: 'aideAgentEditing.openFileInDiff', + title: localize2('open.fileInDiff', 'Open Changes in Diff Editor'), + icon: Codicon.diffSingle, + menu: [{ + id: MenuId.AideAgentEditingWidgetModifiedFilesToolbar, + when: ContextKeyExpr.equals(chatEditingWidgetFileStateContextKey.key, WorkingSetEntryState.Modified), + order: 2, + group: 'navigation' + }], + }); + } + + async runWorkingSetAction(accessor: ServicesAccessor, currentEditingSession: IChatEditingSession, _chatWidget: IChatWidget, ...uris: URI[]): Promise { + const editorService = accessor.get(IEditorService); + for (const uri of uris) { + const editedFile = currentEditingSession.getEntry(uri); + if (editedFile?.state.get() === WorkingSetEntryState.Modified) { + await editorService.openEditor({ + original: { resource: URI.from(editedFile.originalURI, true) }, + modified: { resource: URI.from(editedFile.modifiedURI, true) }, + }); + } else { + await editorService.openEditor({ resource: uri }); + } + } + } +}); + +registerAction2(class AcceptAction extends WorkingSetAction { + constructor() { + super({ + id: 'aideAgentEditing.acceptFile', + title: localize2('accept.file', 'Accept'), + icon: Codicon.check, + menu: [{ + when: ContextKeyExpr.and(ContextKeyExpr.equals('resourceScheme', CHAT_EDITING_MULTI_DIFF_SOURCE_RESOLVER_SCHEME), ContextKeyExpr.notIn(chatEditingResourceContextKey.key, decidedChatEditingResourceContextKey.key)), + id: MenuId.MultiDiffEditorFileToolbar, + order: 0, + group: 'navigation', + }, { + id: MenuId.AideAgentEditingWidgetModifiedFilesToolbar, + when: ContextKeyExpr.equals(chatEditingWidgetFileStateContextKey.key, WorkingSetEntryState.Modified), + order: 0, + group: 'navigation' + }], + }); + } + + async runWorkingSetAction(accessor: ServicesAccessor, currentEditingSession: IChatEditingSession, chatWidget: IChatWidget, ...uris: URI[]): Promise { + await currentEditingSession.accept(...uris); + } +}); + +registerAction2(class DiscardAction extends WorkingSetAction { + constructor() { + super({ + id: 'aideAgentEditing.discardFile', + title: localize2('discard.file', 'Discard'), + icon: Codicon.discard, + menu: [{ + when: ContextKeyExpr.and(ContextKeyExpr.equals('resourceScheme', CHAT_EDITING_MULTI_DIFF_SOURCE_RESOLVER_SCHEME), ContextKeyExpr.notIn(chatEditingResourceContextKey.key, decidedChatEditingResourceContextKey.key)), + id: MenuId.MultiDiffEditorFileToolbar, + order: 2, + group: 'navigation', + }, { + id: MenuId.AideAgentEditingWidgetModifiedFilesToolbar, + when: ContextKeyExpr.equals(chatEditingWidgetFileStateContextKey.key, WorkingSetEntryState.Modified), + order: 1, + group: 'navigation' + }], + }); + } + + async runWorkingSetAction(accessor: ServicesAccessor, currentEditingSession: IChatEditingSession, chatWidget: IChatWidget, ...uris: URI[]): Promise { + await currentEditingSession.reject(...uris); + } +}); + +export class ChatEditingAcceptAllAction extends Action2 { + + constructor() { + super({ + id: 'aideAgentEditing.acceptAllFiles', + title: localize('accept', 'Accept'), + icon: Codicon.check, + tooltip: localize('acceptAllEdits', 'Accept All Edits'), + precondition: ContextKeyExpr.and(CONTEXT_CHAT_REQUEST_IN_PROGRESS.negate(), hasUndecidedChatEditingResourceContextKey), + keybinding: { + primary: KeyMod.CtrlCmd | KeyCode.Enter, + when: ContextKeyExpr.and(CONTEXT_CHAT_REQUEST_IN_PROGRESS.negate(), hasUndecidedChatEditingResourceContextKey, CONTEXT_CHAT_LOCATION.isEqualTo(ChatAgentLocation.Panel), CONTEXT_IN_CHAT_INPUT), + weight: KeybindingWeight.WorkbenchContrib, + }, + menu: [ + { + when: ContextKeyExpr.equals('resourceScheme', CHAT_EDITING_MULTI_DIFF_SOURCE_RESOLVER_SCHEME), + id: MenuId.EditorTitle, + order: 0, + group: 'navigation', + }, + { + id: MenuId.AideAgentEditingWidgetToolbar, + group: 'navigation', + order: 0, + when: ContextKeyExpr.and(applyingChatEditsFailedContextKey.negate(), ContextKeyExpr.and(hasUndecidedChatEditingResourceContextKey, ContextKeyExpr.and(CONTEXT_CHAT_LOCATION.isEqualTo(ChatAgentLocation.Panel)))) + } + ] + }); + } + + async run(accessor: ServicesAccessor, ...args: any[]): Promise { + const chatEditingService = accessor.get(IAideAgentEditingService); + const currentEditingSession = chatEditingService.currentEditingSession; + if (!currentEditingSession) { + return; + } + await currentEditingSession.accept(); + } +} +registerAction2(ChatEditingAcceptAllAction); + +export class ChatEditingDiscardAllAction extends Action2 { + + constructor() { + super({ + id: 'aideAgentEditing.discardAllFiles', + title: localize('discard', 'Discard'), + icon: Codicon.discard, + tooltip: localize('discardAllEdits', 'Discard All Edits'), + precondition: ContextKeyExpr.and(CONTEXT_CHAT_REQUEST_IN_PROGRESS.negate(), hasUndecidedChatEditingResourceContextKey), + menu: [ + { + when: ContextKeyExpr.equals('resourceScheme', CHAT_EDITING_MULTI_DIFF_SOURCE_RESOLVER_SCHEME), + id: MenuId.EditorTitle, + order: 1, + group: 'navigation', + }, + { + id: MenuId.AideAgentEditingWidgetToolbar, + group: 'navigation', + order: 1, + when: ContextKeyExpr.and(applyingChatEditsFailedContextKey.negate(), ContextKeyExpr.and(CONTEXT_CHAT_LOCATION.isEqualTo(ChatAgentLocation.Panel), hasUndecidedChatEditingResourceContextKey)) + } + ], + keybinding: { + when: ContextKeyExpr.and(CONTEXT_CHAT_REQUEST_IN_PROGRESS.negate(), hasUndecidedChatEditingResourceContextKey, CONTEXT_CHAT_LOCATION.isEqualTo(ChatAgentLocation.Panel), CONTEXT_IN_CHAT_INPUT, CONTEXT_CHAT_INPUT_HAS_TEXT.negate()), + weight: KeybindingWeight.WorkbenchContrib, + primary: KeyMod.CtrlCmd | KeyCode.Backspace, + }, + }); + } + + async run(accessor: ServicesAccessor, ...args: any[]): Promise { + await discardAllEditsWithConfirmation(accessor); + } +} +registerAction2(ChatEditingDiscardAllAction); + +export async function discardAllEditsWithConfirmation(accessor: ServicesAccessor): Promise { + const chatEditingService = accessor.get(IAideAgentEditingService); + const currentEditingSession = chatEditingService.currentEditingSession; + if (!currentEditingSession) { + return false; + } + + /* + // Ask for confirmation if there are any edits + const entries = currentEditingSession.entries.get(); + if (entries.length > 0) { + const confirmation = await dialogService.confirm({ + title: localize('aideAgent.editing.discardAll.confirmation.title', "Discard all edits?"), + message: entries.length === 1 + ? localize('aideAgent.editing.discardAll.confirmation.oneFile', "This will undo changes made by {0} in {1}. Do you want to proceed?", 'Aide', basename(entries[0].modifiedURI)) + : localize('aideAgent.editing.discardAll.confirmation.manyFiles', "This will undo changes made by {0} in {1} files. Do you want to proceed?", 'Aide', entries.length), + primaryButton: localize('aideAgent.editing.discardAll.confirmation.primaryButton', "Yes"), + type: 'info' + }); + if (!confirmation.confirmed) { + return false; + } + } + */ + + await currentEditingSession.reject(); + return true; +} + +export class ChatEditingShowChangesAction extends Action2 { + static readonly ID = 'aideAgentEditing.viewChanges'; + static readonly LABEL = localize('aideAgentEditing.viewChanges', 'View All Edits'); + + constructor() { + super({ + id: ChatEditingShowChangesAction.ID, + title: ChatEditingShowChangesAction.LABEL, + tooltip: ChatEditingShowChangesAction.LABEL, + f1: false, + icon: Codicon.diffMultiple, + precondition: hasUndecidedChatEditingResourceContextKey, + menu: [ + { + id: MenuId.AideAgentEditingWidgetToolbar, + group: 'navigation', + order: 4, + when: ContextKeyExpr.and(applyingChatEditsFailedContextKey.negate(), ContextKeyExpr.and(hasAppliedChatEditsContextKey, hasUndecidedChatEditingResourceContextKey, CONTEXT_CHAT_LOCATION.isEqualTo(ChatAgentLocation.Panel))) + } + ], + }); + } + + async run(accessor: ServicesAccessor, ...args: any[]): Promise { + const chatEditingService = accessor.get(IAideAgentEditingService); + const currentEditingSession = chatEditingService.currentEditingSession; + if (!currentEditingSession) { + return; + } + await currentEditingSession.show(); + } +} +registerAction2(ChatEditingShowChangesAction); + +registerAction2(class RemoveAction extends Action2 { + constructor() { + super({ + id: 'workbench.action.aideAgent.undoEdits', + title: localize2('aideAgent.undoEdits.label', "Revert until here"), + f1: false, + category: CHAT_CATEGORY, + icon: Codicon.discard, + precondition: CONTEXT_CHAT_REQUEST_IN_PROGRESS.negate(), + menu: [ + { + id: MenuId.AideAgentMessageTitle, + group: 'navigation', + order: 2, + when: ContextKeyExpr.and(CONTEXT_CHAT_LOCATION.isEqualTo(ChatAgentLocation.Panel), CONTEXT_CHAT_CAN_REVERT_EXCHANGE), + } + ] + }); + } + + async run(accessor: ServicesAccessor, ...args: any[]) { + let item: ChatTreeItem | undefined = args[0]; + if (!isResponseVM(item) && !isRequestVM(item)) { + const chatWidgetService = accessor.get(IAideAgentWidgetService); + const widget = chatWidgetService.lastFocusedWidget; + item = widget?.getFocus(); + } + if (!item) { + return; + } + + const chatEditingService = accessor.get(IAideAgentEditingService); + const chatService = accessor.get(IAideAgentService); + const chatModel = chatService.getSession(item.sessionId); + if (chatModel?.initialLocation !== ChatAgentLocation.Panel) { + return; + } + + const session = chatEditingService.currentEditingSession; + if (!session) { + return; + } + + const exchangeId = item.id; + if (exchangeId) { + const chatExchanges = chatModel.getExchanges(); + const itemIndex = chatExchanges.findIndex(exchange => exchange.id === exchangeId); + const exchangesToDisable = chatExchanges.slice(itemIndex); + + // Restore the snapshot to what it was before the exchange(s) that we deleted + const snapshotExchangeId = chatExchanges[itemIndex].id; + await session.restoreSnapshot(snapshotExchangeId); + + // Remove the request and all that come after it + for (const exchange of exchangesToDisable) { + await chatService.disableExchange(item.sessionId, exchange.id); + } + } + } +}); + +registerAction2(class RedoAllAction extends Action2 { + constructor() { + super({ + id: 'workbench.action.aideAgent.redoAllEdits', + title: localize2('aideAgent.redoEdits.label', "Redo all reverted changes"), + f1: false, + category: CHAT_CATEGORY, + icon: Codicon.redo, + precondition: CONTEXT_CHAT_HAS_HIDDEN_EXCHANGES, + menu: [ + { + id: MenuId.AideAgentEditingRevertToolbar, + group: 'navigation', + when: ContextKeyExpr.and(CONTEXT_CHAT_LOCATION.isEqualTo(ChatAgentLocation.Panel), CONTEXT_CHAT_HAS_HIDDEN_EXCHANGES), + } + ] + }); + } + + async run(accessor: ServicesAccessor, ...args: any[]) { + const chatWidgetService = accessor.get(IAideAgentWidgetService); + const widget = chatWidgetService.lastFocusedWidget; + const exchanges = widget?.viewModel?.model.getExchanges(); + const hiddenExchanges = exchanges?.filter(exchange => exchange.shouldBeRemovedOnSend); + const lastHiddenExchange = hiddenExchanges?.slice(-1)[0]; + if (!hiddenExchanges || !lastHiddenExchange) { + return; + } + + const chatEditingService = accessor.get(IAideAgentEditingService); + const chatService = accessor.get(IAideAgentService); + const chatModel = chatService.getSession(lastHiddenExchange.session.sessionId); + if (chatModel?.initialLocation !== ChatAgentLocation.Panel) { + return; + } + + const session = chatEditingService.currentEditingSession; + if (!session) { + return; + } + + const exchangeId = lastHiddenExchange.id; + await session.restoreSnapshot(exchangeId); + + // Restore all the hidden exchanges + for (const exchange of hiddenExchanges) { + await chatService.enableExchange(chatModel.sessionId, exchange.id); + } + } +}); diff --git a/src/vs/workbench/contrib/aideAgent/browser/aideAgentEditing/aideAgentEditingModifiedFileEntry.ts b/src/vs/workbench/contrib/aideAgent/browser/aideAgentEditing/aideAgentEditingModifiedFileEntry.ts new file mode 100644 index 00000000000..254baa987e7 --- /dev/null +++ b/src/vs/workbench/contrib/aideAgent/browser/aideAgentEditing/aideAgentEditingModifiedFileEntry.ts @@ -0,0 +1,618 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { RunOnceScheduler } from '../../../../../base/common/async.js'; +import { Emitter } from '../../../../../base/common/event.js'; +import { Disposable, IReference, toDisposable } from '../../../../../base/common/lifecycle.js'; +import { Schemas } from '../../../../../base/common/network.js'; +import { clamp } from '../../../../../base/common/numbers.js'; +import { autorun, derived, IObservable, ITransaction, observableValue, transaction } from '../../../../../base/common/observable.js'; +import { themeColorFromId } from '../../../../../base/common/themables.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { EditOperation, ISingleEditOperation } from '../../../../../editor/common/core/editOperation.js'; +import { OffsetEdit } from '../../../../../editor/common/core/offsetEdit.js'; +import { Range } from '../../../../../editor/common/core/range.js'; +import { IDocumentDiff, nullDocumentDiff } from '../../../../../editor/common/diff/documentDiffProvider.js'; +import { DetailedLineRangeMapping } from '../../../../../editor/common/diff/rangeMapping.js'; +import { TextEdit } from '../../../../../editor/common/languages.js'; +import { ILanguageService } from '../../../../../editor/common/languages/language.js'; +import { IModelDeltaDecoration, ITextModel, OverviewRulerLane } from '../../../../../editor/common/model.js'; +import { SingleModelEditStackElement } from '../../../../../editor/common/model/editStack.js'; +import { ModelDecorationOptions, createTextBufferFactoryFromSnapshot } from '../../../../../editor/common/model/textModel.js'; +import { OffsetEdits } from '../../../../../editor/common/model/textModelOffsetEdit.js'; +import { IEditorWorkerService } from '../../../../../editor/common/services/editorWorker.js'; +import { IModelService } from '../../../../../editor/common/services/model.js'; +import { IResolvedTextEditorModel, ITextModelService } from '../../../../../editor/common/services/resolverService.js'; +import { IModelContentChangedEvent } from '../../../../../editor/common/textModelEvents.js'; +import { localize } from '../../../../../nls.js'; +import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; +import { IFileService } from '../../../../../platform/files/common/files.js'; +import { observableConfigValue } from '../../../../../platform/observable/common/platformObservableUtils.js'; +import { editorSelectionBackground } from '../../../../../platform/theme/common/colorRegistry.js'; +import { IUndoRedoService } from '../../../../../platform/undoRedo/common/undoRedo.js'; +import { SaveReason } from '../../../../common/editor.js'; +import { IResolvedTextFileEditorModel, stringToSnapshot } from '../../../../services/textfile/common/textfiles.js'; +import { IChatAgentResult } from '../../common/aideAgentAgents.js'; +import { ChatEditKind, IModifiedFileEntry, WorkingSetEntryState } from '../../common/aideAgentEditingService.js'; +import { IAideAgentService } from '../../common/aideAgentService.js'; +import { ChatEditingSnapshotTextModelContentProvider, ChatEditingTextModelContentProvider } from './aideAgentEditingTextModelContentProviders.js'; + +class AutoAcceptControl { + constructor( + readonly total: number, + readonly remaining: number, + readonly cancel: () => void + ) { } +} + +export class ChatEditingModifiedFileEntry extends Disposable implements IModifiedFileEntry { + + public static readonly scheme = 'aide-agent-modified-file-entry'; + private static lastEntryId = 0; + public readonly entryId = `${ChatEditingModifiedFileEntry.scheme}::${++ChatEditingModifiedFileEntry.lastEntryId}`; + + private readonly docSnapshot: ITextModel; + public readonly initialContent: string; + private readonly doc: ITextModel; + private readonly docFileEditorModel: IResolvedTextFileEditorModel; + private _allEditsAreFromUs: boolean = true; + + private readonly _onDidDelete = this._register(new Emitter()); + public get onDidDelete() { + return this._onDidDelete.event; + } + + get originalURI(): URI { + return this.docSnapshot.uri; + } + + get originalModel(): ITextModel { + return this.docSnapshot; + } + + get modifiedURI(): URI { + return this.modifiedModel.uri; + } + + get modifiedModel(): ITextModel { + return this.doc; + } + + private readonly _stateObs = observableValue(this, WorkingSetEntryState.Modified); + public get state(): IObservable { + return this._stateObs; + } + + private readonly _isCurrentlyBeingModifiedObs = observableValue(this, false); + public get isCurrentlyBeingModified(): IObservable { + return this._isCurrentlyBeingModifiedObs; + } + + private readonly _rewriteRatioObs = observableValue(this, 0); + public get rewriteRatio(): IObservable { + return this._rewriteRatioObs; + } + + private readonly _maxLineNumberObs = observableValue(this, 0); + public get maxLineNumber(): IObservable { + return this._maxLineNumberObs; + } + + private readonly _reviewModeTempObs = observableValue(this, undefined); + readonly reviewMode: IObservable; + + private readonly _autoAcceptCtrl = observableValue(this, undefined); + readonly autoAcceptController: IObservable = this._autoAcceptCtrl; + + private _isFirstEditAfterStartOrSnapshot: boolean = true; + private _edit: OffsetEdit = OffsetEdit.empty; + private _isEditFromUs: boolean = false; + private _diffOperation: Promise | undefined; + private _diffOperationIds: number = 0; + + private readonly _diffInfo = observableValue(this, nullDocumentDiff); + get diffInfo(): IObservable { + return this._diffInfo; + } + + private readonly _editDecorationClear = this._register(new RunOnceScheduler(() => { this._editDecorations = this.doc.deltaDecorations(this._editDecorations, []); }, 500)); + private _editDecorations: string[] = []; + + private static readonly _lastEditDecorationOptions = ModelDecorationOptions.register({ + isWholeLine: true, + description: 'chat-last-edit', + className: 'chat-editing-last-edit-line', + marginClassName: 'chat-editing-last-edit', + overviewRuler: { + position: OverviewRulerLane.Full, + color: themeColorFromId(editorSelectionBackground) + }, + }); + + private static readonly _pendingEditDecorationOptions = ModelDecorationOptions.register({ + isWholeLine: true, + description: 'chat-pending-edit', + className: 'chat-editing-pending-edit', + }); + + get telemetryInfo(): IModifiedEntryTelemetryInfo { + return this._telemetryInfo; + } + + readonly createdInRequestId: string | undefined; + + get lastModifyingRequestId() { + return this._telemetryInfo.exchangeId; + } + + private readonly _diffTrimWhitespace: IObservable; + + private _refCounter: number = 1; + + private readonly _autoAcceptTimeout: IObservable; + + constructor( + resourceRef: IReference, + private readonly _multiDiffEntryDelegate: { collapse: (transaction: ITransaction | undefined) => void }, + private _telemetryInfo: IModifiedEntryTelemetryInfo, + kind: ChatEditKind, + initialContent: string | undefined, + @IModelService modelService: IModelService, + @ITextModelService textModelService: ITextModelService, + @ILanguageService languageService: ILanguageService, + @IConfigurationService configService: IConfigurationService, + @IAideAgentService private readonly _chatService: IAideAgentService, + @IEditorWorkerService private readonly _editorWorkerService: IEditorWorkerService, + @IUndoRedoService private readonly _undoRedoService: IUndoRedoService, + @IFileService private readonly _fileService: IFileService, + ) { + super(); + if (kind === ChatEditKind.Created) { + this.createdInRequestId = this._telemetryInfo.exchangeId; + } + this.docFileEditorModel = this._register(resourceRef).object as IResolvedTextFileEditorModel; + this.doc = resourceRef.object.textEditorModel; + + this.initialContent = initialContent ?? this.doc.getValue(); + const docSnapshot = this.docSnapshot = this._register( + modelService.createModel( + createTextBufferFactoryFromSnapshot(initialContent ? stringToSnapshot(initialContent) : this.doc.createSnapshot()), + languageService.createById(this.doc.getLanguageId()), + ChatEditingTextModelContentProvider.getFileURI(this.entryId, this.modifiedURI.path), + false + ) + ); + + // Create a reference to this model to avoid it being disposed from under our nose + (async () => { + const reference = await textModelService.createModelReference(docSnapshot.uri); + if (this._store.isDisposed) { + reference.dispose(); + return; + } + this._register(reference); + })(); + + + this._register(this.doc.onDidChangeContent(e => this._mirrorEdits(e))); + + if (this.modifiedURI.scheme !== Schemas.untitled && this.modifiedURI.scheme !== Schemas.vscodeNotebookCell) { + this._register(this._fileService.watch(this.modifiedURI)); + this._register(this._fileService.onDidFilesChange(e => { + if (e.affects(this.modifiedURI) && kind === ChatEditKind.Created && e.gotDeleted()) { + this._onDidDelete.fire(); + } + })); + } + + this._register(toDisposable(() => { + this._clearCurrentEditLineDecoration(); + })); + + this._diffTrimWhitespace = observableConfigValue('diffEditor.ignoreTrimWhitespace', true, configService); + this._register(autorun(r => { + this._diffTrimWhitespace.read(r); + this._updateDiffInfoSeq(); + })); + + // review mode depends on setting and temporary override + const autoAcceptRaw = observableConfigValue('chat.editing.autoAcceptDelay', 0, configService); + this._autoAcceptTimeout = derived(r => { + const value = autoAcceptRaw.read(r); + return clamp(value, 0, 100); + }); + this.reviewMode = derived(r => { + const configuredValue = this._autoAcceptTimeout.read(r); + const tempValue = this._reviewModeTempObs.read(r); + return tempValue ?? configuredValue === 0; + }); + } + + override dispose(): void { + if (--this._refCounter === 0) { + super.dispose(); + } + } + + acquire() { + this._refCounter++; + return this; + } + + enableReviewModeUntilSettled(): void { + + this._reviewModeTempObs.set(true, undefined); + + const cleanup = autorun(r => { + // reset config when settled + const resetConfig = this.state.read(r) !== WorkingSetEntryState.Modified; + if (resetConfig) { + this._store.delete(cleanup); + this._reviewModeTempObs.set(undefined, undefined); + } + }); + + this._store.add(cleanup); + } + + private _clearCurrentEditLineDecoration() { + this._editDecorations = this.doc.deltaDecorations(this._editDecorations, []); + } + + updateTelemetryInfo(telemetryInfo: IModifiedEntryTelemetryInfo) { + this._telemetryInfo = telemetryInfo; + } + + createSnapshot(requestId: string | undefined): ISnapshotEntry { + this._isFirstEditAfterStartOrSnapshot = true; + return { + resource: this.modifiedURI, + languageId: this.modifiedModel.getLanguageId(), + snapshotUri: ChatEditingSnapshotTextModelContentProvider.getSnapshotFileURI(requestId, this.modifiedURI.path), + original: this.originalModel.getValue(), + current: this.modifiedModel.getValue(), + originalToCurrentEdit: this._edit, + state: this.state.get(), + telemetryInfo: this._telemetryInfo + }; + } + + restoreFromSnapshot(snapshot: ISnapshotEntry) { + this._stateObs.set(snapshot.state, undefined); + this.docSnapshot.setValue(snapshot.original); + this._setDocValue(snapshot.current); + this._edit = snapshot.originalToCurrentEdit; + this._updateDiffInfoSeq(); + } + + resetToInitialValue() { + this._setDocValue(this.initialContent); + } + + acceptStreamingEditsStart(tx: ITransaction) { + this._resetEditsState(tx); + } + + acceptStreamingEditsEnd(tx: ITransaction) { + this._resetEditsState(tx); + } + + private _resetEditsState(tx: ITransaction): void { + this._isCurrentlyBeingModifiedObs.set(false, tx); + this._rewriteRatioObs.set(0, tx); + this._clearCurrentEditLineDecoration(); + + // AUTO accept mode + if (!this.reviewMode.get() && !this._autoAcceptCtrl.get()) { + + const acceptTimeout = this._autoAcceptTimeout.get() * 1000; + const future = Date.now() + acceptTimeout; + const update = () => { + + const reviewMode = this.reviewMode.get(); + if (reviewMode) { + // switched back to review mode + this._autoAcceptCtrl.set(undefined, undefined); + return; + } + + const remain = Math.round(future - Date.now()); + if (remain <= 0) { + this.accept(undefined); + } else { + const handle = setTimeout(update, 100); + this._autoAcceptCtrl.set(new AutoAcceptControl(acceptTimeout, remain, () => { + clearTimeout(handle); + this._autoAcceptCtrl.set(undefined, undefined); + }), undefined); + } + }; + update(); + } + } + + private _mirrorEdits(event: IModelContentChangedEvent) { + const edit = OffsetEdits.fromContentChanges(event.changes); + + if (this._isEditFromUs) { + const e_sum = this._edit; + const e_ai = edit; + this._edit = e_sum.compose(e_ai); + + } else { + + // e_ai + // d0 ---------------> s0 + // | | + // | | + // | e_user_r | e_user + // | | + // | | + // v e_ai_r v + /// d1 ---------------> s1 + // + // d0 - document snapshot + // s0 - document + // e_ai - ai edits + // e_user - user edits + // + + const e_ai = this._edit; + const e_user = edit; + + const e_user_r = e_user.tryRebase(e_ai.inverse(this.docSnapshot.getValue()), true); + + if (e_user_r === undefined) { + // user edits overlaps/conflicts with AI edits + this._edit = e_ai.compose(e_user); + } else { + const edits = OffsetEdits.asEditOperations(e_user_r, this.docSnapshot); + this.docSnapshot.applyEdits(edits); + this._edit = e_ai.tryRebase(e_user_r); + } + + this._allEditsAreFromUs = false; + this._updateDiffInfoSeq(); + } + + if (!this.isCurrentlyBeingModified.get()) { + const didResetToOriginalContent = this.doc.getValue() === this.initialContent; + const currentState = this._stateObs.get(); + switch (currentState) { + case WorkingSetEntryState.Modified: + if (didResetToOriginalContent) { + this._stateObs.set(WorkingSetEntryState.Rejected, undefined); + break; + } + } + } + } + + acceptAgentEdits(textEdits: TextEdit[], isLastEdits: boolean): void { + // push stack element for the first edit + if (this._isFirstEditAfterStartOrSnapshot) { + this._isFirstEditAfterStartOrSnapshot = false; + // TODO(@ghostwriternr): Review this again if the static string doesn't cut it + // const request = this._chatService.getSession(this._telemetryInfo.sessionId)?.getRequests().at(-1); + // const label = request?.message.text ? localize('chatEditing1', "Chat Edit: '{0}'", request.message.text) : localize('chatEditing2', "Chat Edit"); + const label = localize('chatEditing2', "Chat Edit"); + this._undoRedoService.pushElement(new SingleModelEditStackElement(label, 'aideAgent.edit', this.doc, null)); + } + + const ops = textEdits.map(TextEdit.asEditOperation); + const undoEdits = this._applyEdits(ops); + + const maxLineNumber = undoEdits.reduce((max, op) => Math.max(max, op.range.startLineNumber), 0); + + const newDecorations: IModelDeltaDecoration[] = [ + // decorate pending edit (region) + { + options: ChatEditingModifiedFileEntry._pendingEditDecorationOptions, + range: new Range(maxLineNumber + 1, 1, Number.MAX_SAFE_INTEGER, Number.MAX_SAFE_INTEGER) + } + ]; + + if (maxLineNumber > 0) { + // decorate last edit + newDecorations.push({ + options: ChatEditingModifiedFileEntry._lastEditDecorationOptions, + range: new Range(maxLineNumber, 1, maxLineNumber, Number.MAX_SAFE_INTEGER) + }); + } + + this._editDecorations = this.doc.deltaDecorations(this._editDecorations, newDecorations); + + + transaction((tx) => { + if (!isLastEdits) { + this._stateObs.set(WorkingSetEntryState.Modified, tx); + this._isCurrentlyBeingModifiedObs.set(true, tx); + const lineCount = this.doc.getLineCount(); + this._rewriteRatioObs.set(Math.min(1, maxLineNumber / lineCount), tx); + this._maxLineNumberObs.set(maxLineNumber, tx); + } else { + this._resetEditsState(tx); + this._updateDiffInfoSeq(); + this._rewriteRatioObs.set(1, tx); + this._editDecorationClear.schedule(); + } + }); + } + + async acceptHunk(change: DetailedLineRangeMapping): Promise { + if (!this._diffInfo.get().changes.includes(change)) { + // diffInfo should have model version ids and check them (instead of the caller doing that) + return false; + } + const edits: ISingleEditOperation[] = []; + for (const edit of change.innerChanges ?? []) { + const newText = this.modifiedModel.getValueInRange(edit.modifiedRange); + edits.push(EditOperation.replace(edit.originalRange, newText)); + } + this.docSnapshot.pushEditOperations(null, edits, _ => null); + await this._updateDiffInfoSeq(); + if (this.diffInfo.get().identical) { + this._stateObs.set(WorkingSetEntryState.Accepted, undefined); + } + return true; + } + + async rejectHunk(change: DetailedLineRangeMapping): Promise { + if (!this._diffInfo.get().changes.includes(change)) { + return false; + } + const edits: ISingleEditOperation[] = []; + for (const edit of change.innerChanges ?? []) { + const newText = this.docSnapshot.getValueInRange(edit.originalRange); + edits.push(EditOperation.replace(edit.modifiedRange, newText)); + } + this.doc.pushEditOperations(null, edits, _ => null); + await this._updateDiffInfoSeq(); + if (this.diffInfo.get().identical) { + this._stateObs.set(WorkingSetEntryState.Rejected, undefined); + } + return true; + } + + private _applyEdits(edits: ISingleEditOperation[]) { + // make the actual edit + this._isEditFromUs = true; + try { + let result: ISingleEditOperation[] = []; + this.doc.pushEditOperations(null, edits, (undoEdits) => { + result = undoEdits; + return null; + }); + return result; + } finally { + this._isEditFromUs = false; + } + } + + private async _updateDiffInfoSeq() { + const myDiffOperationId = ++this._diffOperationIds; + await Promise.resolve(this._diffOperation); + if (this._diffOperationIds === myDiffOperationId) { + const thisDiffOperation = this._updateDiffInfo(); + this._diffOperation = thisDiffOperation; + await thisDiffOperation; + } + } + + private async _updateDiffInfo(): Promise { + + if (this.docSnapshot.isDisposed() || this.doc.isDisposed()) { + return; + } + + const docVersionNow = this.doc.getVersionId(); + const snapshotVersionNow = this.docSnapshot.getVersionId(); + + const ignoreTrimWhitespace = this._diffTrimWhitespace.get(); + + const diff = await this._editorWorkerService.computeDiff( + this.docSnapshot.uri, + this.doc.uri, + { ignoreTrimWhitespace, computeMoves: false, maxComputationTimeMs: 3000 }, + 'advanced' + ); + + if (this.docSnapshot.isDisposed() || this.doc.isDisposed()) { + return; + } + + // only update the diff if the documents didn't change in the meantime + if (this.doc.getVersionId() === docVersionNow && this.docSnapshot.getVersionId() === snapshotVersionNow) { + const diff2 = diff ?? nullDocumentDiff; + this._diffInfo.set(diff2, undefined); + this._edit = OffsetEdits.fromLineRangeMapping(this.docSnapshot, this.doc, diff2.changes); + } + } + + async accept(transaction: ITransaction | undefined): Promise { + if (this._stateObs.get() !== WorkingSetEntryState.Modified) { + // already accepted or rejected + return; + } + + this.docSnapshot.setValue(this.doc.createSnapshot()); + this._diffInfo.set(nullDocumentDiff, transaction); + this._edit = OffsetEdit.empty; + this._stateObs.set(WorkingSetEntryState.Accepted, transaction); + this._autoAcceptCtrl.set(undefined, transaction); + await this.collapse(transaction); + this._notifyAction('accepted'); + } + + async reject(transaction: ITransaction | undefined): Promise { + if (this._stateObs.get() !== WorkingSetEntryState.Modified) { + // already accepted or rejected + return; + } + + if (this.createdInRequestId === this._telemetryInfo.exchangeId) { + await this.docFileEditorModel.revert({ soft: true }); + await this._fileService.del(this.modifiedURI); + this._onDidDelete.fire(); + } else { + this._setDocValue(this.docSnapshot.getValue()); + if (this._allEditsAreFromUs) { + // save the file after discarding so that the dirty indicator goes away + // and so that an intermediate saved state gets reverted + await this.docFileEditorModel.save({ reason: SaveReason.EXPLICIT, skipSaveParticipants: true }); + } + await this.collapse(transaction); + } + this._stateObs.set(WorkingSetEntryState.Rejected, transaction); + this._autoAcceptCtrl.set(undefined, transaction); + this._notifyAction('rejected'); + } + + private _setDocValue(value: string): void { + if (this.doc.getValue() !== value) { + + this.doc.pushStackElement(); + const edit = EditOperation.replace(this.doc.getFullModelRange(), value); + + this._applyEdits([edit]); + this._updateDiffInfoSeq(); + this.doc.pushStackElement(); + } + } + + async collapse(transaction: ITransaction | undefined): Promise { + this._multiDiffEntryDelegate.collapse(transaction); + } + + private _notifyAction(outcome: 'accepted' | 'rejected') { + this._chatService.notifyUserAction({ + action: { kind: 'chatEditingSessionAction', uri: this.modifiedURI, hasRemainingEdits: false, outcome }, + agentId: this._telemetryInfo.agentId, + command: this._telemetryInfo.command, + sessionId: this._telemetryInfo.sessionId, + requestId: this._telemetryInfo.exchangeId, + result: this._telemetryInfo.result + }); + } +} + +export interface IModifiedEntryTelemetryInfo { + readonly agentId: string | undefined; + readonly command: string | undefined; + readonly sessionId: string; + readonly exchangeId: string; + readonly result: IChatAgentResult | undefined; +} + +export interface ISnapshotEntry { + readonly resource: URI; + readonly languageId: string; + readonly snapshotUri: URI; + readonly original: string; + readonly current: string; + readonly originalToCurrentEdit: OffsetEdit; + readonly state: WorkingSetEntryState; + telemetryInfo: IModifiedEntryTelemetryInfo; +} diff --git a/src/vs/workbench/contrib/aideAgent/browser/aideAgentEditing/aideAgentEditingService.ts b/src/vs/workbench/contrib/aideAgent/browser/aideAgentEditing/aideAgentEditingService.ts new file mode 100644 index 00000000000..3d90ca398e8 --- /dev/null +++ b/src/vs/workbench/contrib/aideAgent/browser/aideAgentEditing/aideAgentEditingService.ts @@ -0,0 +1,528 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { compareBy, delta } from '../../../../../base/common/arrays.js'; +import { AsyncIterableSource } from '../../../../../base/common/async.js'; +import { CancellationToken, CancellationTokenSource } from '../../../../../base/common/cancellation.js'; +import { Codicon } from '../../../../../base/common/codicons.js'; +import { BugIndicatingError, ErrorNoTelemetry } from '../../../../../base/common/errors.js'; +import { Emitter, Event } from '../../../../../base/common/event.js'; +import { Iterable } from '../../../../../base/common/iterator.js'; +import { Disposable, DisposableStore, IDisposable } from '../../../../../base/common/lifecycle.js'; +import { LinkedList } from '../../../../../base/common/linkedList.js'; +import { ResourceMap } from '../../../../../base/common/map.js'; +import { derived, IObservable, observableValue, observableValueOpts, runOnChange, ValueWithChangeEventFromObservable } from '../../../../../base/common/observable.js'; +import { compare } from '../../../../../base/common/strings.js'; +import { ThemeIcon } from '../../../../../base/common/themables.js'; +import { isString } from '../../../../../base/common/types.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { TextEdit } from '../../../../../editor/common/languages.js'; +import { ITextModelService } from '../../../../../editor/common/services/resolverService.js'; +import { localize } from '../../../../../nls.js'; +import { IContextKey, IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js'; +import { IFileService } from '../../../../../platform/files/common/files.js'; +import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; +import { ILogService } from '../../../../../platform/log/common/log.js'; +import { bindContextKey } from '../../../../../platform/observable/common/platformObservableUtils.js'; +import { IProductService } from '../../../../../platform/product/common/productService.js'; +import { IStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js'; +import { IDecorationData, IDecorationsProvider, IDecorationsService } from '../../../../services/decorations/common/decorations.js'; +import { IEditorService } from '../../../../services/editor/common/editorService.js'; +import { IExtensionService } from '../../../../services/extensions/common/extensions.js'; +import { ILifecycleService } from '../../../../services/lifecycle/common/lifecycle.js'; +import { IMultiDiffSourceResolver, IMultiDiffSourceResolverService, IResolvedMultiDiffSource, MultiDiffEditorItem } from '../../../multiDiffEditor/browser/multiDiffSourceResolverService.js'; +import { ChatAgentLocation, IAideAgentAgentService } from '../../common/aideAgentAgents.js'; +import { CONTEXT_CHAT_CAN_REDO, CONTEXT_CHAT_CAN_UNDO } from '../../common/aideAgentContextKeys.js'; +import { applyingChatEditsContextKey, applyingChatEditsFailedContextKey, CHAT_EDITING_MULTI_DIFF_SOURCE_RESOLVER_SCHEME, chatEditingAgentSupportsReadonlyReferencesContextKey, chatEditingResourceContextKey, ChatEditingSessionState, decidedChatEditingResourceContextKey, hasAppliedChatEditsContextKey, hasUndecidedChatEditingResourceContextKey, IAideAgentEditingService, IChatEditingSession, IChatEditingSessionStream, IModifiedFileEntry, inChatEditingSessionContextKey, WorkingSetEntryState } from '../../common/aideAgentEditingService.js'; +import { IChatResponseModel, IChatTextEditGroup } from '../../common/aideAgentModel.js'; +import { IAideAgentService } from '../../common/aideAgentService.js'; +import { ChatEditingModifiedFileEntry } from './aideAgentEditingModifiedFileEntry.js'; +import { ChatEditingSession } from './aideAgentEditingSession.js'; +import { ChatEditingSnapshotTextModelContentProvider, ChatEditingTextModelContentProvider } from './aideAgentEditingTextModelContentProviders.js'; + + +const STORAGE_KEY_EDITING_SESSION = 'aideAgent.editingSession'; + +export class ChatEditingService extends Disposable implements IAideAgentEditingService { + + _serviceBrand: undefined; + + private readonly _currentSessionObs = observableValue(this, null); + private readonly _currentSessionDisposables = this._register(new DisposableStore()); + + private readonly _adhocSessionsObs = observableValueOpts>({ equalsFn: (a, b) => false }, new LinkedList()); + + readonly editingSessionsObs: IObservable = derived(r => { + const result = Array.from(this._adhocSessionsObs.read(r)); + const globalSession = this._currentSessionObs.read(r); + if (globalSession) { + result.push(globalSession); + } + return result; + }); + + private readonly _currentAutoApplyOperationObs = observableValue(this, null); + get currentAutoApplyOperation(): CancellationTokenSource | null { + return this._currentAutoApplyOperationObs.get(); + } + + get currentEditingSession(): IChatEditingSession | null { + return this._currentSessionObs.get(); + } + + get currentEditingSessionObs(): IObservable { + return this._currentSessionObs; + } + + get editingSessionFileLimit() { + return Number.MAX_SAFE_INTEGER; + } + + private _restoringEditingSession: Promise | undefined; + + private _applyingChatEditsFailedContextKey: IContextKey; + + constructor( + @IInstantiationService private readonly _instantiationService: IInstantiationService, + @IMultiDiffSourceResolverService multiDiffSourceResolverService: IMultiDiffSourceResolverService, + @ITextModelService textModelService: ITextModelService, + @IContextKeyService contextKeyService: IContextKeyService, + @IAideAgentService private readonly _chatService: IAideAgentService, + @IEditorService private readonly _editorService: IEditorService, + @IDecorationsService decorationsService: IDecorationsService, + @IFileService private readonly _fileService: IFileService, + @ILifecycleService private readonly lifecycleService: ILifecycleService, + @IStorageService storageService: IStorageService, + @ILogService logService: ILogService, + @IExtensionService extensionService: IExtensionService, + @IProductService productService: IProductService, + ) { + super(); + this._applyingChatEditsFailedContextKey = applyingChatEditsFailedContextKey.bindTo(contextKeyService); + this._applyingChatEditsFailedContextKey.set(false); + this._register(decorationsService.registerDecorationsProvider(_instantiationService.createInstance(ChatDecorationsProvider, this._currentSessionObs))); + this._register(multiDiffSourceResolverService.registerResolver(_instantiationService.createInstance(ChatEditingMultiDiffSourceResolver, this._currentSessionObs))); + this._register(textModelService.registerTextModelContentProvider(ChatEditingTextModelContentProvider.scheme, _instantiationService.createInstance(ChatEditingTextModelContentProvider, this._currentSessionObs))); + this._register(textModelService.registerTextModelContentProvider(ChatEditingSnapshotTextModelContentProvider.scheme, _instantiationService.createInstance(ChatEditingSnapshotTextModelContentProvider, this._currentSessionObs))); + this._register(bindContextKey(decidedChatEditingResourceContextKey, contextKeyService, (reader) => { + const currentSession = this._currentSessionObs.read(reader); + if (!currentSession) { + return; + } + const entries = currentSession.entries.read(reader); + const decidedEntries = entries.filter(entry => entry.state.read(reader) !== WorkingSetEntryState.Modified); + return decidedEntries.map(entry => entry.entryId); + })); + this._register(bindContextKey(hasUndecidedChatEditingResourceContextKey, contextKeyService, (reader) => { + + for (const session of this.editingSessionsObs.read(reader)) { + const entries = session.entries.read(reader); + const decidedEntries = entries.filter(entry => entry.state.read(reader) === WorkingSetEntryState.Modified); + return decidedEntries.length > 0; + } + + return false; + })); + this._register(bindContextKey(hasAppliedChatEditsContextKey, contextKeyService, (reader) => { + const currentSession = this._currentSessionObs.read(reader); + if (!currentSession) { + return false; + } + const entries = currentSession.entries.read(reader); + return entries.length > 0; + })); + this._register(bindContextKey(inChatEditingSessionContextKey, contextKeyService, (reader) => { + return this._currentSessionObs.read(reader) !== null; + })); + this._register(bindContextKey(applyingChatEditsContextKey, contextKeyService, (reader) => { + return this._currentAutoApplyOperationObs.read(reader) !== null; + })); + this._register(bindContextKey(CONTEXT_CHAT_CAN_UNDO, contextKeyService, (r) => { + return this._currentSessionObs.read(r)?.canUndo.read(r) || false; + })); + this._register(bindContextKey(CONTEXT_CHAT_CAN_REDO, contextKeyService, (r) => { + return this._currentSessionObs.read(r)?.canRedo.read(r) || false; + })); + this._register(this._chatService.onDidDisposeSession((e) => { + if (e.reason === 'cleared' && this._currentSessionObs.get()?.chatSessionId === e.sessionId) { + this._applyingChatEditsFailedContextKey.set(false); + void this._currentSessionObs.get()?.stop(); + } + })); + + // todo@connor4312: temporary until chatReadonlyPromptReference proposal is finalized + const readonlyEnabledContextKey = chatEditingAgentSupportsReadonlyReferencesContextKey.bindTo(contextKeyService); + const setReadonlyFilesEnabled = () => { + const enabled = productService.quality !== 'stable'; + readonlyEnabledContextKey.set(enabled); + }; + setReadonlyFilesEnabled(); + this._register(extensionService.onDidRegisterExtensions(setReadonlyFilesEnabled)); + this._register(extensionService.onDidChangeExtensions(setReadonlyFilesEnabled)); + + this._register(this.lifecycleService.onWillShutdown((e) => { + const session = this._currentSessionObs.get(); + if (session) { + storageService.store(STORAGE_KEY_EDITING_SESSION, session.chatSessionId, StorageScope.WORKSPACE, StorageTarget.MACHINE); + e.join(session.storeState(), { id: 'join.chatEditingSession', label: localize('join.chatEditingSession', "Saving chat edits history") }); + } + })); + + const sessionIdToRestore = storageService.get(STORAGE_KEY_EDITING_SESSION, StorageScope.WORKSPACE); + if (isString(sessionIdToRestore)) { + if (this._chatService.getOrRestoreSession(sessionIdToRestore)) { + this._restoringEditingSession = this.startOrContinueEditingSession(sessionIdToRestore); + this._restoringEditingSession.finally(() => { + this._restoringEditingSession = undefined; + }); + } else { + logService.error(`Edit session session to restore is a non-existing chat session: ${sessionIdToRestore}`); + } + storageService.remove(STORAGE_KEY_EDITING_SESSION, StorageScope.WORKSPACE); + } + } + + async getOrRestoreEditingSession(): Promise { + if (this._restoringEditingSession) { + await this._restoringEditingSession; + } + return this.currentEditingSessionObs.get(); + } + + override dispose(): void { + this._currentSessionObs.get()?.dispose(); + super.dispose(); + } + + async startOrContinueEditingSession(chatSessionId: string): Promise { + await this._restoringEditingSession; + + const session = this._currentSessionObs.get(); + if (session) { + if (session.chatSessionId === chatSessionId) { + return session; + } else if (session.chatSessionId !== chatSessionId) { + await session.stop(true); + } + } + return this._createEditingSession(chatSessionId); + } + + + private _lookupEntry(uri: URI): ChatEditingModifiedFileEntry | undefined { + + for (const item of Iterable.concat(this.editingSessionsObs.get())) { + const candidate = item.getEntry(uri); + if (candidate instanceof ChatEditingModifiedFileEntry) { + // make sure to ref-count this object + return candidate.acquire(); + } + } + return undefined; + } + + private async _createEditingSession(chatSessionId: string): Promise { + if (this._currentSessionObs.get()) { + throw new BugIndicatingError('Cannot have more than one active editing session'); + } + + this._currentSessionDisposables.clear(); + + const session = this._instantiationService.createInstance(ChatEditingSession, chatSessionId, true, this._lookupEntry.bind(this)); + await session.init(); + + // listen for completed responses, run the code mapper and apply the edits to this edit session + this._currentSessionDisposables.add(this.installAutoApplyObserver(session)); + + this._currentSessionDisposables.add(session.onDidDispose(() => { + this._currentSessionDisposables.clear(); + this._currentSessionObs.set(null, undefined); + })); + + this._currentSessionObs.set(session, undefined); + return session; + } + + async createAdhocEditingSession(chatSessionId: string): Promise { + const session = this._instantiationService.createInstance(ChatEditingSession, chatSessionId, false, this._lookupEntry.bind(this)); + await session.init(); + + const list = this._adhocSessionsObs.get(); + const removeSession = list.unshift(session); + + const store = new DisposableStore(); + this._store.add(store); + + store.add(this.installAutoApplyObserver(session)); + + store.add(session.onDidDispose(e => { + removeSession(); + this._adhocSessionsObs.set(list, undefined); + this._store.deleteAndLeak(store); + store.dispose(); + })); + + this._adhocSessionsObs.set(list, undefined); + + return session; + } + + private installAutoApplyObserver(session: ChatEditingSession): IDisposable { + + const chatModel = this._chatService.getOrRestoreSession(session.chatSessionId); + if (!chatModel) { + throw new ErrorNoTelemetry(`Edit session was created for a non-existing chat session: ${session.chatSessionId}`); + } + + const observerDisposables = new DisposableStore(); + + let editsSource: AsyncIterableSource | undefined; + let editsPromise: Promise | undefined; + const editsSeen = new ResourceMap<{ seen: number }>(); + const editedFilesExist = new ResourceMap>(); + + const onResponseComplete = (responseModel: IChatResponseModel) => { + if (responseModel.result?.errorDetails && !responseModel.result.errorDetails.responseIsIncomplete) { + // Roll back everything + // TODO(@ghostwriternr): Verify this works - if I'm seeing this after the PR is merged, this TODO is no longer relevant. + session.restoreSnapshot(responseModel.id); + this._applyingChatEditsFailedContextKey.set(true); + } + + editsSource?.resolve(); + editsSource = undefined; + editsSeen.clear(); + editedFilesExist.clear(); + }; + + const handleResponseParts = async (responseModel: IChatResponseModel) => { + for (const part of responseModel.response.value) { + if (part.kind === 'codeblockUri' || part.kind === 'textEditGroup') { + // ensure editor is open asap + if (!editedFilesExist.get(part.uri)) { + const uri = part.uri; + editedFilesExist.set(part.uri, this._fileService.exists(uri).then((e) => { + if (e) { + this._editorService.openEditor({ resource: uri, options: { inactive: true, preserveFocus: true, pinned: true } }); + } + return e; + })); + } + + // get new edits and start editing session + const first = editsSeen.size === 0; + let entry = editsSeen.get(part.uri); + if (!entry) { + entry = { seen: 0 }; + editsSeen.set(part.uri, entry); + } + + const allEdits: TextEdit[][] = part.kind === 'textEditGroup' ? part.edits : []; + const newEdits = allEdits.slice(entry.seen); + entry.seen += newEdits.length; + + if (newEdits.length > 0 || entry.seen === 0) { + // only allow empty edits when having just started, ignore otherwise to avoid unneccessary work + editsSource ??= new AsyncIterableSource(); + editsSource.emitOne({ uri: part.uri, edits: newEdits, kind: 'textEditGroup', done: part.kind === 'textEditGroup' && part.done }); + } + + if (first) { + await editsPromise; + + editsPromise = this._continueEditingSession(session, async (builder, token) => { + for await (const item of editsSource!.asyncIterable) { + if (responseModel.isCanceled) { + break; + } + if (token.isCancellationRequested) { + break; + } + if (item.edits.length === 0) { + // EMPTY edit, just signal via empty edits that work is starting + builder.textEdits(item.uri, [], item.done ?? false, responseModel); + continue; + } + for (let i = 0; i < item.edits.length; i++) { + const group = item.edits[i]; + const isLastGroup = i === item.edits.length - 1; + builder.textEdits(item.uri, group, isLastGroup && (item.done ?? false), responseModel); + } + } + }).finally(() => { + editsPromise = undefined; + }); + } + } + } + }; + + observerDisposables.add(chatModel.onDidChange(async e => { + if (e.kind === 'addRequest') { + session.createSnapshot(e.request.id); + this._applyingChatEditsFailedContextKey.set(false); + } else if (e.kind === 'addResponse') { + const responseModel = e.response; + session.createSnapshot(responseModel.id); + if (responseModel.isComplete) { + await handleResponseParts(responseModel); + onResponseComplete(responseModel); + } else { + const disposable = responseModel.onDidChange(async () => { + await handleResponseParts(responseModel); + if (responseModel.isComplete) { + onResponseComplete(responseModel); + disposable.dispose(); + } + }); + } + } + })); + observerDisposables.add(chatModel.onDidDispose(() => observerDisposables.dispose())); + return observerDisposables; + } + + private async _continueEditingSession(session: ChatEditingSession, builder: (stream: IChatEditingSessionStream, token: CancellationToken) => Promise): Promise { + if (session.state.get() === ChatEditingSessionState.StreamingEdits) { + throw new BugIndicatingError('Cannot continue session that is still streaming'); + } + + const stream: IChatEditingSessionStream = { + textEdits: (resource: URI, textEdits: TextEdit[], isDone: boolean, responseModel: IChatResponseModel) => { + session.acceptTextEdits(resource, textEdits, isDone, responseModel); + } + }; + session.acceptStreamingEditsStart(); + const cancellationTokenSource = new CancellationTokenSource(); + this._currentAutoApplyOperationObs.set(cancellationTokenSource, undefined); + try { + await builder(stream, cancellationTokenSource.token); + } finally { + cancellationTokenSource.dispose(); + this._currentAutoApplyOperationObs.set(null, undefined); + session.resolve(); + } + } +} + +/** + * Emits an event containing the added or removed elements of the observable. + */ +function observeArrayChanges(obs: IObservable, compare: (a: T, b: T) => number, store: DisposableStore): Event { + const emitter = store.add(new Emitter()); + store.add(runOnChange(obs, (newArr, oldArr) => { + const change = delta(oldArr || [], newArr, compare); + const changedElements = ([] as T[]).concat(change.added).concat(change.removed); + emitter.fire(changedElements); + })); + return emitter.event; +} + +class ChatDecorationsProvider extends Disposable implements IDecorationsProvider { + + readonly label: string = localize('aideAgent', "Aide"); + + private readonly _currentEntries = derived(this, (r) => { + const session = this._session.read(r); + if (!session) { + return []; + } + const state = session.state.read(r); + if (state === ChatEditingSessionState.Disposed) { + return []; + } + return session.entries.read(r); + }); + + private readonly _currentlyEditingUris = derived(this, (r) => { + const uri = this._currentEntries.read(r); + return uri.filter(entry => entry.isCurrentlyBeingModified.read(r)).map(entry => entry.modifiedURI); + }); + + private readonly _modifiedUris = derived(this, (r) => { + const uri = this._currentEntries.read(r); + return uri.filter(entry => !entry.isCurrentlyBeingModified.read(r) && entry.state.read(r) === WorkingSetEntryState.Modified).map(entry => entry.modifiedURI); + }); + + public readonly onDidChange = Event.any( + observeArrayChanges(this._currentlyEditingUris, compareBy(uri => uri.toString(), compare), this._store), + observeArrayChanges(this._modifiedUris, compareBy(uri => uri.toString(), compare), this._store), + ); + + constructor( + private readonly _session: IObservable, + @IAideAgentAgentService private readonly _chatAgentService: IAideAgentAgentService + ) { + super(); + } + + provideDecorations(uri: URI, _token: CancellationToken): IDecorationData | undefined { + const isCurrentlyBeingModified = this._currentlyEditingUris.get().some(e => e.toString() === uri.toString()); + if (isCurrentlyBeingModified) { + return { + weight: 1000, + letter: ThemeIcon.modify(Codicon.loading, 'spin'), + bubble: false + }; + } + const isModified = this._modifiedUris.get().some(e => e.toString() === uri.toString()); + if (isModified) { + const defaultAgentName = this._chatAgentService.getDefaultAgent(ChatAgentLocation.Panel)?.fullName; + return { + weight: 1000, + letter: Codicon.diffModified, + tooltip: defaultAgentName ? localize('aideAgentEditing.modified', "Pending changes from {0}", defaultAgentName) : localize('aideAgentEditing.modified2', "Pending changes from chat"), + bubble: true + }; + } + return undefined; + } +} + +export class ChatEditingMultiDiffSourceResolver implements IMultiDiffSourceResolver { + + constructor( + private readonly _currentSession: IObservable, + @IInstantiationService private readonly _instantiationService: IInstantiationService, + ) { } + + canHandleUri(uri: URI): boolean { + return uri.scheme === CHAT_EDITING_MULTI_DIFF_SOURCE_RESOLVER_SCHEME; + } + + async resolveDiffSource(uri: URI): Promise { + return this._instantiationService.createInstance(ChatEditingMultiDiffSource, this._currentSession); + } +} + +class ChatEditingMultiDiffSource implements IResolvedMultiDiffSource { + private readonly _resources = derived(this, (reader) => { + const currentSession = this._currentSession.read(reader); + if (!currentSession) { + return []; + } + const entries = currentSession.entries.read(reader); + return entries.map((entry) => { + return new MultiDiffEditorItem( + entry.originalURI, + entry.modifiedURI, + undefined, + { + [chatEditingResourceContextKey.key]: entry.entryId, + // [inChatEditingSessionContextKey.key]: true + }, + ); + }); + }); + readonly resources = new ValueWithChangeEventFromObservable(this._resources); + + readonly contextKeys = { + [inChatEditingSessionContextKey.key]: true + }; + + constructor( + private readonly _currentSession: IObservable + ) { } +} diff --git a/src/vs/workbench/contrib/aideAgent/browser/aideAgentEditing/aideAgentEditingSession.ts b/src/vs/workbench/contrib/aideAgent/browser/aideAgentEditing/aideAgentEditingSession.ts new file mode 100644 index 00000000000..592543a5d62 --- /dev/null +++ b/src/vs/workbench/contrib/aideAgent/browser/aideAgentEditing/aideAgentEditingSession.ts @@ -0,0 +1,1032 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { ITask, Sequencer, timeout } from '../../../../../base/common/async.js'; +import { VSBuffer } from '../../../../../base/common/buffer.js'; +import { BugIndicatingError } from '../../../../../base/common/errors.js'; +import { Emitter } from '../../../../../base/common/event.js'; +import { StringSHA1 } from '../../../../../base/common/hash.js'; +import { Disposable, DisposableMap, DisposableStore, dispose } from '../../../../../base/common/lifecycle.js'; +import { ResourceMap, ResourceSet } from '../../../../../base/common/map.js'; +import { Schemas } from '../../../../../base/common/network.js'; +import { autorun, derived, IObservable, IReader, ITransaction, observableValue, transaction } from '../../../../../base/common/observable.js'; +import { autorunDelta, autorunIterableDelta } from '../../../../../base/common/observableInternal/autorun.js'; +import { isEqual, joinPath } from '../../../../../base/common/resources.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { isCodeEditor, isDiffEditor } from '../../../../../editor/browser/editorBrowser.js'; +import { IBulkEditService } from '../../../../../editor/browser/services/bulkEditService.js'; +import { IOffsetEdit, ISingleOffsetEdit, OffsetEdit } from '../../../../../editor/common/core/offsetEdit.js'; +import { TextEdit } from '../../../../../editor/common/languages.js'; +import { ILanguageService } from '../../../../../editor/common/languages/language.js'; +import { ITextModel } from '../../../../../editor/common/model.js'; +import { IModelService } from '../../../../../editor/common/services/model.js'; +import { ITextModelService } from '../../../../../editor/common/services/resolverService.js'; +import { localize } from '../../../../../nls.js'; +import { EditorActivation } from '../../../../../platform/editor/common/editor.js'; +import { IEnvironmentService } from '../../../../../platform/environment/common/environment.js'; +import { IFileService } from '../../../../../platform/files/common/files.js'; +import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; +import { ILogService } from '../../../../../platform/log/common/log.js'; +import { IWorkspaceContextService } from '../../../../../platform/workspace/common/workspace.js'; +import { IEditorCloseEvent, SaveReason } from '../../../../common/editor.js'; +import { DiffEditorInput } from '../../../../common/editor/diffEditorInput.js'; +import { IEditorGroupsService } from '../../../../services/editor/common/editorGroupsService.js'; +import { IEditorService } from '../../../../services/editor/common/editorService.js'; +import { ITextFileService } from '../../../../services/textfile/common/textfiles.js'; +import { MultiDiffEditor } from '../../../multiDiffEditor/browser/multiDiffEditor.js'; +import { MultiDiffEditorInput } from '../../../multiDiffEditor/browser/multiDiffEditorInput.js'; +import { isNotebookEditorInput } from '../../../notebook/common/notebookEditorInput.js'; +import { ChatEditingSessionChangeType, ChatEditingSessionState, ChatEditKind, getMultiDiffSourceUri, IChatEditingSession, IModifiedFileEntry, WorkingSetDisplayMetadata, WorkingSetEntryRemovalReason, WorkingSetEntryState } from '../../common/aideAgentEditingService.js'; +import { IChatResponseModel } from '../../common/aideAgentModel.js'; +import { IAideAgentService } from '../../common/aideAgentService.js'; +import { ChatEditingModifiedFileEntry, IModifiedEntryTelemetryInfo, ISnapshotEntry } from './aideAgentEditingModifiedFileEntry.js'; +import { ChatEditingTextModelContentProvider } from './aideAgentEditingTextModelContentProviders.js'; + +const STORAGE_CONTENTS_FOLDER = 'contents'; +const STORAGE_STATE_FILE = 'state.json'; + + +class ThrottledSequencer extends Sequencer { + + private _size = 0; + + constructor( + private readonly _minDuration: number, + private readonly _maxOverallDelay: number + ) { + super(); + } + + override queue(promiseTask: ITask>): Promise { + + this._size += 1; + + const noDelay = this._size * this._minDuration > this._maxOverallDelay; + + return super.queue(async () => { + try { + const p1 = promiseTask(); + const p2 = noDelay + ? Promise.resolve(undefined) + : timeout(this._minDuration); + + const [result] = await Promise.all([p1, p2]); + return result; + + } finally { + this._size -= 1; + } + }); + } +} + +export class ChatEditingSession extends Disposable implements IChatEditingSession { + + private readonly _state = observableValue(this, ChatEditingSessionState.Initial); + private readonly _linearHistory = observableValue(this, []); + private readonly _linearHistoryIndex = observableValue(this, 0); + + /** + * Contains the contents of a file when the AI first began doing edits to it. + */ + private readonly _initialFileContents = new ResourceMap(); + + private readonly _entriesObs = observableValue(this, []); + public get entries(): IObservable { + this._assertNotDisposed(); + return this._entriesObs; + } + private readonly _sequencer = new ThrottledSequencer(15, 1000); + + private _workingSet = new ResourceMap(); + get workingSet() { + this._assertNotDisposed(); + + // Return here a reunion between the AI modified entries and the user built working set + const result = new ResourceMap(this._workingSet); + for (const entry of this._entriesObs.get()) { + result.set(entry.modifiedURI, { state: entry.state.get() }); + } + + return result; + } + + private _removedTransientEntries = new ResourceSet(); + + private _editorPane: MultiDiffEditor | undefined; + + get state(): IObservable { + return this._state; + } + + public readonly canUndo = derived((r) => { + if (this.state.read(r) !== ChatEditingSessionState.Idle) { + return false; + } + const linearHistoryIndex = this._linearHistoryIndex.read(r); + return linearHistoryIndex > 0; + }); + + public readonly canRedo = derived((r) => { + if (this.state.read(r) !== ChatEditingSessionState.Idle) { + return false; + } + const linearHistory = this._linearHistory.read(r); + const linearHistoryIndex = this._linearHistoryIndex.read(r); + return linearHistoryIndex < linearHistory.length; + }); + + public hiddenExchangeIds = derived((r) => { + const linearHistory = this._linearHistory.read(r); + const linearHistoryIndex = this._linearHistoryIndex.read(r); + return linearHistory.slice(linearHistoryIndex).map(s => s.exchangeId).filter((e): e is string => !!e); + }); + + private readonly _onDidChange = this._register(new Emitter()); + get onDidChange() { + this._assertNotDisposed(); + return this._onDidChange.event; + } + + private readonly _onDidDispose = new Emitter(); + get onDidDispose() { + this._assertNotDisposed(); + return this._onDidDispose.event; + } + + constructor( + readonly chatSessionId: string, + readonly isGlobalEditingSession: boolean, + private _lookupExternalEntry: (uri: URI) => ChatEditingModifiedFileEntry | undefined, + @IInstantiationService private readonly _instantiationService: IInstantiationService, + @IModelService private readonly _modelService: IModelService, + @ILanguageService private readonly _languageService: ILanguageService, + @ITextModelService private readonly _textModelService: ITextModelService, + @IBulkEditService public readonly _bulkEditService: IBulkEditService, + @IEditorGroupsService private readonly _editorGroupsService: IEditorGroupsService, + @IEditorService private readonly _editorService: IEditorService, + @IAideAgentService private readonly _chatService: IAideAgentService, + @ITextFileService private readonly _textFileService: ITextFileService, + ) { + super(); + } + + public async init(): Promise { + const restoredSessionState = await this._instantiationService.createInstance(ChatEditingSessionStorage, this.chatSessionId).restoreState(); + if (restoredSessionState) { + for (const [uri, content] of restoredSessionState.initialFileContents) { + this._initialFileContents.set(uri, content); + } + this._pendingSnapshot = restoredSessionState.pendingSnapshot; + await this._restoreSnapshot(restoredSessionState.recentSnapshot); + this._linearHistoryIndex.set(restoredSessionState.linearHistoryIndex, undefined); + this._linearHistory.set(restoredSessionState.linearHistory, undefined); + this._state.set(ChatEditingSessionState.Idle, undefined); + } + + // Add the currently active editors to the working set + this._trackCurrentEditorsInWorkingSet(); + this._triggerSaveParticipantsOnAccept(); + this._register(this._editorService.onDidVisibleEditorsChange(() => { + this._trackCurrentEditorsInWorkingSet(); + })); + this._register(autorun(reader => { + const entries = this.entries.read(reader); + entries.forEach(entry => { + entry.state.read(reader); + }); + this._onDidChange.fire(ChatEditingSessionChangeType.WorkingSet); + })); + } + + public getEntry(uri: URI): IModifiedFileEntry | undefined { + return this._entriesObs.get().find(e => isEqual(e.modifiedURI, uri)); + } + + public readEntry(uri: URI, reader: IReader | undefined): IModifiedFileEntry | undefined { + return this._entriesObs.read(reader).find(e => isEqual(e.modifiedURI, uri)); + } + + public storeState(): Promise { + const storage = this._instantiationService.createInstance(ChatEditingSessionStorage, this.chatSessionId); + const state: StoredSessionState = { + initialFileContents: this._initialFileContents, + pendingSnapshot: this._pendingSnapshot, + recentSnapshot: this._createSnapshot(undefined), + linearHistoryIndex: this._linearHistoryIndex.get(), + linearHistory: this._linearHistory.get(), + }; + return storage.storeState(state); + } + + private _triggerSaveParticipantsOnAccept() { + const im = this._register(new DisposableMap()); + const attachToEntry = (entry: ChatEditingModifiedFileEntry) => { + return autorunDelta(entry.state, ({ lastValue, newValue }) => { + if (newValue === WorkingSetEntryState.Accepted && lastValue === WorkingSetEntryState.Modified) { + // Don't save a file if there's still pending changes. If there's not (e.g. + // the agentic flow with autosave) then save again to trigger participants. + if (!this._textFileService.isDirty(entry.modifiedURI)) { + this._textFileService.save(entry.modifiedURI, { + reason: SaveReason.EXPLICIT, + force: true, + ignoreErrorHandler: true, + }).catch(() => { + // ignored + }); + } + } + }); + }; + + this._register(autorunIterableDelta( + reader => this._entriesObs.read(reader), + ({ addedValues, removedValues }) => { + for (const entry of addedValues) { + im.set(entry, attachToEntry(entry)); + } + for (const entry of removedValues) { + im.deleteAndDispose(entry); + } + } + )); + } + + private _trackCurrentEditorsInWorkingSet(e?: IEditorCloseEvent) { + const existingTransientEntries = new ResourceSet(); + for (const file of this._workingSet.keys()) { + if (this._workingSet.get(file)?.state === WorkingSetEntryState.Transient) { + existingTransientEntries.add(file); + } + } + + const activeEditors = new ResourceSet(); + this._editorGroupsService.groups.forEach((group) => { + if (!group.activeEditorPane) { + return; + } + let uri; + if (isNotebookEditorInput(group.activeEditorPane.input)) { + uri = group.activeEditorPane.input.resource; + } else { + let activeEditorControl = group.activeEditorPane.getControl(); + if (isDiffEditor(activeEditorControl)) { + activeEditorControl = activeEditorControl.getOriginalEditor().hasTextFocus() ? activeEditorControl.getOriginalEditor() : activeEditorControl.getModifiedEditor(); + } + if ((isCodeEditor(activeEditorControl)) && activeEditorControl.hasModel()) { + uri = activeEditorControl.getModel().uri; + } + } + if (!uri) { + return; + } + if (existingTransientEntries.has(uri)) { + existingTransientEntries.delete(uri); + } else if ((!this._workingSet.has(uri) || this._workingSet.get(uri)?.state === WorkingSetEntryState.Suggested) && !this._removedTransientEntries.has(uri)) { + // Don't add as a transient entry if it's already a confirmed part of the working set + // or if the user has intentionally removed it from the working set + activeEditors.add(uri); + } + }); + + let didChange = false; + for (const entry of existingTransientEntries) { + didChange = this._workingSet.delete(entry) || didChange; + } + + for (const entry of activeEditors) { + this._workingSet.set(entry, { state: WorkingSetEntryState.Transient, description: localize('aideAgentEditing.transient', "Open Editor") }); + didChange = true; + } + + if (didChange) { + this._onDidChange.fire(ChatEditingSessionChangeType.WorkingSet); + } + } + + private _findSnapshot(exchangeId: string): IChatEditingSessionSnapshot | undefined { + return this._linearHistory.get().find(s => s.exchangeId === exchangeId); + } + + public createSnapshot(exchangeId: string | undefined): void { + const snapshot = this._createSnapshot(exchangeId); + if (exchangeId) { + for (const [uri, data] of this._workingSet) { + if (data.state !== WorkingSetEntryState.Suggested) { + this._workingSet.set(uri, { state: WorkingSetEntryState.Sent, isMarkedReadonly: data.isMarkedReadonly }); + } + } + const linearHistory = this._linearHistory.get(); + const linearHistoryIndex = this._linearHistoryIndex.get(); + const newLinearHistory = linearHistory.slice(0, linearHistoryIndex); + newLinearHistory.push(snapshot); + transaction((tx) => { + this._linearHistory.set(newLinearHistory, tx); + this._linearHistoryIndex.set(newLinearHistory.length, tx); + }); + } else { + this._pendingSnapshot = snapshot; + } + } + + private _createSnapshot(exchangeId: string | undefined): IChatEditingSessionSnapshot { + const workingSet = new ResourceMap(); + for (const [file, state] of this._workingSet) { + workingSet.set(file, state); + } + const entries = new ResourceMap(); + for (const entry of this._entriesObs.get()) { + entries.set(entry.modifiedURI, entry.createSnapshot(exchangeId)); + } + return { + exchangeId, + workingSet, + entries + }; + } + + public async getSnapshotModel(exchangeId: string, snapshotUri: URI): Promise { + const entries = this._findSnapshot(exchangeId)?.entries; + if (!entries) { + return null; + } + + const snapshotEntry = [...entries.values()].find((e) => isEqual(e.snapshotUri, snapshotUri)); + if (!snapshotEntry) { + return null; + } + + return this._modelService.createModel(snapshotEntry.current, this._languageService.createById(snapshotEntry.languageId), snapshotUri, false); + } + + public getSnapshot(exchangeId: string, uri: URI): ISnapshotEntry | undefined { + const snapshot = this._findSnapshot(exchangeId); + const snapshotEntries = snapshot?.entries; + return snapshotEntries?.get(uri); + } + + public getSnapshotUri(exchangeId: string, uri: URI): URI | undefined { + return this.getSnapshot(exchangeId, uri)?.snapshotUri; + } + + /** + * A snapshot representing the state of the working set before a new request has been sent + */ + private _pendingSnapshot: IChatEditingSessionSnapshot | undefined; + public async restoreSnapshot(exchangeId: string | undefined): Promise { + if (exchangeId !== undefined) { + const snapshot = this._findSnapshot(exchangeId); + if (snapshot) { + if (!this._pendingSnapshot) { + // Create and save a pending snapshot + this.createSnapshot(undefined); + } + await this._restoreSnapshot(snapshot); + } + } else { + if (!this._pendingSnapshot) { + return; // We don't have a pending snapshot that we can restore + } + const snapshot = this._pendingSnapshot; + this._pendingSnapshot = undefined; + await this._restoreSnapshot(snapshot); + } + } + + + private async _restoreSnapshot(snapshot: IChatEditingSessionSnapshot): Promise { + this._workingSet = new ResourceMap(); + snapshot.workingSet.forEach((state, uri) => this._workingSet.set(uri, state)); + + // Reset all the files which are modified in this session state + // but which are not found in the snapshot + for (const entry of this._entriesObs.get()) { + const snapshotEntry = snapshot.entries.get(entry.modifiedURI); + if (!snapshotEntry) { + entry.resetToInitialValue(); + entry.dispose(); + } + } + + const entriesArr: ChatEditingModifiedFileEntry[] = []; + // Restore all entries from the snapshot + for (const snapshotEntry of snapshot.entries.values()) { + const entry = await this._getOrCreateModifiedFileEntry(snapshotEntry.resource, snapshotEntry.telemetryInfo); + entry.restoreFromSnapshot(snapshotEntry); + entriesArr.push(entry); + } + + this._entriesObs.set(entriesArr, undefined); + } + + remove(reason: WorkingSetEntryRemovalReason, ...uris: URI[]): void { + this._assertNotDisposed(); + + let didRemoveUris = false; + for (const uri of uris) { + + const entry = this._entriesObs.get().find(e => isEqual(e.modifiedURI, uri)); + if (entry) { + entry.dispose(); + const newEntries = this._entriesObs.get().filter(e => !isEqual(e.modifiedURI, uri)); + this._entriesObs.set(newEntries, undefined); + didRemoveUris = true; + } + + const state = this._workingSet.get(uri); + if (state !== undefined) { + didRemoveUris = this._workingSet.delete(uri) || didRemoveUris; + if (reason === WorkingSetEntryRemovalReason.User && (state.state === WorkingSetEntryState.Transient || state.state === WorkingSetEntryState.Suggested)) { + this._removedTransientEntries.add(uri); + } + } + } + + if (!didRemoveUris) { + return; // noop + } + + this._onDidChange.fire(ChatEditingSessionChangeType.WorkingSet); + } + + markIsReadonly(resource: URI, isReadonly?: boolean): void { + const entry = this._workingSet.get(resource); + if (entry) { + if (entry.state === WorkingSetEntryState.Transient || entry.state === WorkingSetEntryState.Suggested) { + entry.state = WorkingSetEntryState.Attached; + } + entry.isMarkedReadonly = isReadonly ?? !entry.isMarkedReadonly; + } else { + this._workingSet.set(resource, { + state: WorkingSetEntryState.Attached, + isMarkedReadonly: isReadonly ?? true + }); + } + this._onDidChange.fire(ChatEditingSessionChangeType.WorkingSet); + } + + private _assertNotDisposed(): void { + if (this._state.get() === ChatEditingSessionState.Disposed) { + throw new BugIndicatingError(`Cannot access a disposed editing session`); + } + } + + async accept(...uris: URI[]): Promise { + this._assertNotDisposed(); + + if (uris.length === 0) { + await Promise.all(this._entriesObs.get().map(entry => entry.accept(undefined))); + } + + for (const uri of uris) { + const entry = this._entriesObs.get().find(e => isEqual(e.modifiedURI, uri)); + if (entry) { + await entry.accept(undefined); + } + } + + this._onDidChange.fire(ChatEditingSessionChangeType.Other); + } + + async reject(...uris: URI[]): Promise { + this._assertNotDisposed(); + + if (uris.length === 0) { + await Promise.all(this._entriesObs.get().map(entry => entry.reject(undefined))); + } + + for (const uri of uris) { + const entry = this._entriesObs.get().find(e => isEqual(e.modifiedURI, uri)); + if (entry) { + await entry.reject(undefined); + } + } + + this._onDidChange.fire(ChatEditingSessionChangeType.Other); + } + + async show(): Promise { + this._assertNotDisposed(); + if (this._editorPane) { + if (this._editorPane.isVisible()) { + return; + } else if (this._editorPane.input) { + await this._editorGroupsService.activeGroup.openEditor(this._editorPane.input, { pinned: true, activation: EditorActivation.ACTIVATE }); + return; + } + } + const input = MultiDiffEditorInput.fromResourceMultiDiffEditorInput({ + multiDiffSource: getMultiDiffSourceUri(), + label: localize('multiDiffEditorInput.name', "Suggested Edits") + }, this._instantiationService); + + this._editorPane = await this._editorGroupsService.activeGroup.openEditor(input, { pinned: true, activation: EditorActivation.ACTIVATE }) as MultiDiffEditor | undefined; + } + + private stopPromise: Promise | undefined; + + async stop(clearState = false): Promise { + if (!this.stopPromise) { + this.stopPromise = this._performStop(); + } + await this.stopPromise; + if (clearState) { + await this._instantiationService.createInstance(ChatEditingSessionStorage, this.chatSessionId).clearState(); + } + } + + async _performStop(): Promise { + // Close out all open files + const schemes = [ChatEditingModifiedFileEntry.scheme, ChatEditingTextModelContentProvider.scheme]; + await Promise.allSettled(this._editorGroupsService.groups.flatMap(async (g) => { + return g.editors.map(async (e) => { + if ((e instanceof MultiDiffEditorInput && e.initialResources?.some(r => r.originalUri && schemes.indexOf(r.originalUri.scheme) !== -1)) + || (e instanceof DiffEditorInput && e.original.resource && schemes.indexOf(e.original.resource.scheme) !== -1)) { + await g.closeEditor(e); + } + }); + })); + + if (this._state.get() !== ChatEditingSessionState.Disposed) { + // session got disposed while we were closing editors and clearing state + this.dispose(); + } + } + + override dispose() { + this._assertNotDisposed(); + + this._chatService.cancelCurrentRequestForSession(this.chatSessionId); + + dispose(this._entriesObs.get()); + super.dispose(); + this._state.set(ChatEditingSessionState.Disposed, undefined); + this._onDidDispose.fire(); + this._onDidDispose.dispose(); + } + + getVirtualModel(documentId: string): ITextModel | null { + this._assertNotDisposed(); + + const entry = this._entriesObs.get().find(e => e.entryId === documentId); + return entry?.originalModel ?? null; + } + + acceptStreamingEditsStart(): void { + if (this._state.get() === ChatEditingSessionState.Disposed) { + // we don't throw in this case because there could be a builder still connected to a disposed session + return; + } + + // ensure that the edits are processed sequentially + this._sequencer.queue(() => this._acceptStreamingEditsStart()); + } + + acceptTextEdits(resource: URI, textEdits: TextEdit[], isLastEdits: boolean, responseModel: IChatResponseModel): void { + if (this._state.get() === ChatEditingSessionState.Disposed) { + // we don't throw in this case because there could be a builder still connected to a disposed session + return; + } + + // ensure that the edits are processed sequentially + this._sequencer.queue(() => this._acceptTextEdits(resource, textEdits, isLastEdits, responseModel)); + } + + resolve(): void { + if (this._state.get() === ChatEditingSessionState.Disposed) { + // we don't throw in this case because there could be a builder still connected to a disposed session + return; + } + + // ensure that the edits are processed sequentially + this._sequencer.queue(() => this._resolve()); + } + + private _trackUntitledWorkingSetEntry(resource: URI) { + if (resource.scheme !== Schemas.untitled) { + return; + } + const untitled = this._textFileService.untitled.get(resource); + if (!untitled) { // Shouldn't happen + return; + } + + // Track this file until + // 1. it is removed from the working set + // 2. it is closed + // 3. we are disposed + const store = new DisposableStore(); + store.add(this.onDidChange(e => { + if (e === ChatEditingSessionChangeType.WorkingSet && !this._workingSet.get(resource)) { + // The user has removed the file from the working set + store.dispose(); + } + })); + store.add(this._textFileService.untitled.onDidSave(e => { + const existing = this._workingSet.get(resource); + if (isEqual(e.source, resource) && existing) { + this._workingSet.delete(resource); + this._workingSet.set(e.target, existing); + store.dispose(); + this._onDidChange.fire(ChatEditingSessionChangeType.WorkingSet); + } + })); + store.add(this._editorService.onDidCloseEditor((e) => { + if (isEqual(e.editor.resource, resource)) { + this._workingSet.delete(resource); + store.dispose(); + this._onDidChange.fire(ChatEditingSessionChangeType.WorkingSet); + } + })); + this._store.add(store); + } + + addFileToWorkingSet(resource: URI, description?: string, proposedState?: WorkingSetEntryState.Suggested): void { + const state = this._workingSet.get(resource); + if (proposedState === WorkingSetEntryState.Suggested) { + if (state !== undefined || this._removedTransientEntries.has(resource)) { + return; + } + this._workingSet.set(resource, { description, state: WorkingSetEntryState.Suggested }); + this._trackUntitledWorkingSetEntry(resource); + this._onDidChange.fire(ChatEditingSessionChangeType.WorkingSet); + } else if (state === undefined || state.state === WorkingSetEntryState.Transient || state.state === WorkingSetEntryState.Suggested) { + this._workingSet.set(resource, { description, state: WorkingSetEntryState.Attached }); + this._trackUntitledWorkingSetEntry(resource); + this._onDidChange.fire(ChatEditingSessionChangeType.WorkingSet); + } + } + + async undoInteraction(): Promise { + const linearHistory = this._linearHistory.get(); + const newIndex = this._linearHistoryIndex.get() - 1; + if (newIndex < 0) { + return; + } + const previousSnapshot = linearHistory[newIndex]; + await this.restoreSnapshot(previousSnapshot.exchangeId); + this._linearHistoryIndex.set(newIndex, undefined); + this._updateRequestHiddenState(); + + } + + async redoInteraction(): Promise { + const linearHistory = this._linearHistory.get(); + const newIndex = this._linearHistoryIndex.get() + 1; + if (newIndex > linearHistory.length) { + return; + } + const nextSnapshot = newIndex < linearHistory.length ? linearHistory[newIndex] : this._pendingSnapshot; + if (!nextSnapshot) { + return; + } + await this.restoreSnapshot(nextSnapshot.exchangeId); + this._linearHistoryIndex.set(newIndex, undefined); + this._updateRequestHiddenState(); + } + + private _updateRequestHiddenState() { + const hiddenExchangeIds = this._linearHistory.get().slice(this._linearHistoryIndex.get()).map(s => s.exchangeId).filter((e): e is string => !!e); + this._chatService.getSession(this.chatSessionId)?.disableRequests(hiddenExchangeIds); + } + + private async _acceptStreamingEditsStart(): Promise { + transaction((tx) => { + this._state.set(ChatEditingSessionState.StreamingEdits, tx); + for (const entry of this._entriesObs.get()) { + entry.acceptStreamingEditsStart(tx); + } + }); + } + + private async _acceptTextEdits(resource: URI, textEdits: TextEdit[], isLastEdits: boolean, responseModel: IChatResponseModel): Promise { + // Make these getters because the response result is not available when the file first starts to be edited + const telemetryInfo = new class { + get agentId() { return responseModel.agent?.id; } + get command() { return responseModel.slashCommand?.name; } + get sessionId() { return responseModel.session.sessionId; } + get exchangeId() { return responseModel.id; } + get result() { return responseModel.result; } + }; + const entry = await this._getOrCreateModifiedFileEntry(resource, telemetryInfo); + entry.acceptAgentEdits(textEdits, isLastEdits); + } + + private async _resolve(): Promise { + transaction((tx) => { + for (const entry of this._entriesObs.get()) { + entry.acceptStreamingEditsEnd(tx); + } + this._state.set(ChatEditingSessionState.Idle, tx); + }); + this._onDidChange.fire(ChatEditingSessionChangeType.Other); + } + + /** + * Retrieves or creates a modified file entry. + * + * @returns The modified file entry. + */ + private async _getOrCreateModifiedFileEntry(resource: URI, responseModel: IModifiedEntryTelemetryInfo): Promise { + const existingEntry = this._entriesObs.get().find(e => isEqual(e.modifiedURI, resource)); + if (existingEntry) { + if (responseModel.exchangeId !== existingEntry.telemetryInfo.exchangeId) { + existingEntry.updateTelemetryInfo(responseModel); + } + return existingEntry; + } + + let entry: ChatEditingModifiedFileEntry; + const existingExternalEntry = this._lookupExternalEntry(resource); + if (existingExternalEntry) { + entry = existingExternalEntry; + } else { + const initialContent = this._initialFileContents.get(resource); + // This gets manually disposed in .dispose() or in .restoreSnapshot() + entry = await this._createModifiedFileEntry(resource, responseModel, false, initialContent); + if (!initialContent) { + this._initialFileContents.set(resource, entry.initialContent); + } + } + + // If an entry is deleted e.g. reverting a created file, + // remove it from the entries and don't show it in the working set anymore + // so that it can be recreated e.g. through retry + const listener = entry.onDidDelete(() => { + const newEntries = this._entriesObs.get().filter(e => !isEqual(e.modifiedURI, entry.modifiedURI)); + this._entriesObs.set(newEntries, undefined); + this._workingSet.delete(entry.modifiedURI); + this._editorService.closeEditors(this._editorService.findEditors(entry.modifiedURI)); + + if (!existingExternalEntry) { + // don't dispose entries that are not yours! + entry.dispose(); + } + + this._store.delete(listener); + this._onDidChange.fire(ChatEditingSessionChangeType.WorkingSet); + }); + this._store.add(listener); + + const entriesArr = [...this._entriesObs.get(), entry]; + this._entriesObs.set(entriesArr, undefined); + this._onDidChange.fire(ChatEditingSessionChangeType.WorkingSet); + + return entry; + } + + private async _createModifiedFileEntry(resource: URI, responseModel: IModifiedEntryTelemetryInfo, mustExist = false, initialContent: string | undefined): Promise { + try { + const ref = await this._textModelService.createModelReference(resource); + return this._instantiationService.createInstance(ChatEditingModifiedFileEntry, ref, { collapse: (transaction: ITransaction | undefined) => this._collapse(resource, transaction) }, responseModel, mustExist ? ChatEditKind.Created : ChatEditKind.Modified, initialContent); + } catch (err) { + if (mustExist) { + throw err; + } + // this file does not exist yet, create it and try again + await this._bulkEditService.apply({ edits: [{ newResource: resource }] }); + this._editorService.openEditor({ resource, options: { inactive: true, preserveFocus: true, pinned: true } }); + return this._createModifiedFileEntry(resource, responseModel, true, initialContent); + } + } + + private _collapse(resource: URI, transaction: ITransaction | undefined) { + const multiDiffItem = this._editorPane?.findDocumentDiffItem(resource); + if (multiDiffItem) { + this._editorPane?.viewModel?.items.get().find((documentDiffItem) => + isEqual(documentDiffItem.originalUri, multiDiffItem.originalUri) && + isEqual(documentDiffItem.modifiedUri, multiDiffItem.modifiedUri)) + ?.collapsed.set(true, transaction); + } + } +} + +interface StoredSessionState { + readonly initialFileContents: ResourceMap; + readonly pendingSnapshot?: IChatEditingSessionSnapshot; + readonly recentSnapshot: IChatEditingSessionSnapshot; + readonly linearHistoryIndex: number; + readonly linearHistory: IChatEditingSessionSnapshot[]; +} + +class ChatEditingSessionStorage { + constructor( + private readonly chatSessionId: string, + @IFileService private readonly _fileService: IFileService, + @IEnvironmentService private readonly _environmentService: IEnvironmentService, + @ILogService private readonly _logService: ILogService, + @IWorkspaceContextService private readonly _workspaceContextService: IWorkspaceContextService, + ) { } + + private _getStorageLocation(): URI { + const workspaceId = this._workspaceContextService.getWorkspace().id; + return joinPath(this._environmentService.workspaceStorageHome, workspaceId, 'aideAgentEditingSessions', this.chatSessionId); + } + + public async restoreState(): Promise { + const storageLocation = this._getStorageLocation(); + const getFileContent = (hash: string) => { + return this._fileService.readFile(joinPath(storageLocation, STORAGE_CONTENTS_FOLDER, hash)).then(content => content.value.toString()); + }; + const deserializeResourceMap = (resourceMap: ResourceMapDTO, deserialize: (value: any) => T, result: ResourceMap): ResourceMap => { + resourceMap.forEach(([resourceURI, value]) => { + result.set(URI.parse(resourceURI), deserialize(value)); + }); + return result; + }; + const deserializeChatEditingSessionSnapshot = async (snapshot: IChatEditingSessionSnapshotDTO) => { + const entriesMap = new ResourceMap(); + for (const entryDTO of snapshot.entries) { + const entry = await deserializeSnapshotEntry(entryDTO); + entriesMap.set(entry.resource, entry); + } + return ({ + exchangeId: snapshot.exchangeId, + workingSet: deserializeResourceMap(snapshot.workingSet, (value) => value, new ResourceMap()), + entries: entriesMap + } satisfies IChatEditingSessionSnapshot); + }; + const deserializeSnapshotEntry = async (entry: ISnapshotEntryDTO) => { + return { + resource: URI.parse(entry.resource), + languageId: entry.languageId, + original: await getFileContent(entry.originalHash), + current: await getFileContent(entry.currentHash), + originalToCurrentEdit: OffsetEdit.fromJson(entry.originalToCurrentEdit), + state: entry.state, + snapshotUri: URI.parse(entry.snapshotUri), + telemetryInfo: { exchangeId: entry.telemetryInfo.exchangeId, agentId: entry.telemetryInfo.agentId, command: entry.telemetryInfo.command, sessionId: this.chatSessionId, result: undefined } + } satisfies ISnapshotEntry; + }; + try { + const stateFilePath = joinPath(storageLocation, STORAGE_STATE_FILE); + if (! await this._fileService.exists(stateFilePath)) { + this._logService.debug(`chatEditingSession: No editing session state found at ${stateFilePath.toString()}`); + return undefined; + } + this._logService.debug(`chatEditingSession: Restoring editing session at ${stateFilePath.toString()}`); + const stateFileContent = await this._fileService.readFile(stateFilePath); + const data = JSON.parse(stateFileContent.value.toString()) as IChatEditingSessionDTO; + if (data.version !== STORAGE_VERSION) { + return undefined; + } + + const linearHistory = await Promise.all(data.linearHistory.map(deserializeChatEditingSessionSnapshot)); + + const initialFileContents = new ResourceMap(); + for (const fileContentDTO of data.initialFileContents) { + initialFileContents.set(URI.parse(fileContentDTO[0]), await getFileContent(fileContentDTO[1])); + } + const pendingSnapshot = data.pendingSnapshot ? await deserializeChatEditingSessionSnapshot(data.pendingSnapshot) : undefined; + const recentSnapshot = await deserializeChatEditingSessionSnapshot(data.recentSnapshot); + + return { + initialFileContents, + pendingSnapshot, + recentSnapshot, + linearHistoryIndex: data.linearHistoryIndex, + linearHistory + }; + } catch (e) { + this._logService.error(`Error restoring chat editing session from ${storageLocation.toString()}`, e); + } + return undefined; + } + + public async storeState(state: StoredSessionState): Promise { + const storageFolder = this._getStorageLocation(); + const contentsFolder = URI.joinPath(storageFolder, STORAGE_CONTENTS_FOLDER); + + // prepare the content folder + const existingContents = new Set(); + try { + const stat = await this._fileService.resolve(contentsFolder); + stat.children?.forEach(child => { + if (child.isDirectory) { + existingContents.add(child.name); + } + }); + } catch (e) { + try { + // does not exist, create + await this._fileService.createFolder(contentsFolder); + } catch (e) { + this._logService.error(`Error creating chat editing session content folder ${contentsFolder.toString()}`, e); + return; + } + } + + const fileContents = new Map(); + const addFileContent = (content: string): string => { + const shaComputer = new StringSHA1(); + shaComputer.update(content); + const sha = shaComputer.digest().substring(0, 7); + if (!existingContents.has(sha)) { + fileContents.set(sha, content); + } + return sha; + }; + const serializeResourceMap = (resourceMap: ResourceMap, serialize: (value: T) => any): ResourceMapDTO => { + return Array.from(resourceMap.entries()).map(([resourceURI, value]) => [resourceURI.toString(), serialize(value)]); + }; + const serializeChatEditingSessionSnapshot = (snapshot: IChatEditingSessionSnapshot) => { + return ({ + exchangeId: snapshot.exchangeId, + workingSet: serializeResourceMap(snapshot.workingSet, value => value), + entries: Array.from(snapshot.entries.values()).map(serializeSnapshotEntry) + } satisfies IChatEditingSessionSnapshotDTO); + }; + const serializeSnapshotEntry = (entry: ISnapshotEntry) => { + return { + resource: entry.resource.toString(), + languageId: entry.languageId, + originalHash: addFileContent(entry.original), + currentHash: addFileContent(entry.current), + originalToCurrentEdit: entry.originalToCurrentEdit.edits.map(edit => ({ pos: edit.replaceRange.start, len: edit.replaceRange.length, txt: edit.newText } satisfies ISingleOffsetEdit)), + state: entry.state, + snapshotUri: entry.snapshotUri.toString(), + telemetryInfo: { exchangeId: entry.telemetryInfo.exchangeId, agentId: entry.telemetryInfo.agentId, command: entry.telemetryInfo.command } + } satisfies ISnapshotEntryDTO; + }; + + try { + const data = { + version: STORAGE_VERSION, + sessionId: this.chatSessionId, + linearHistory: state.linearHistory.map(serializeChatEditingSessionSnapshot), + linearHistoryIndex: state.linearHistoryIndex, + initialFileContents: serializeResourceMap(state.initialFileContents, value => addFileContent(value)), + pendingSnapshot: state.pendingSnapshot ? serializeChatEditingSessionSnapshot(state.pendingSnapshot) : undefined, + recentSnapshot: serializeChatEditingSessionSnapshot(state.recentSnapshot), + } satisfies IChatEditingSessionDTO; + + this._logService.debug(`chatEditingSession: Storing editing session at ${storageFolder.toString()}: ${fileContents.size} files`); + + for (const [hash, content] of fileContents) { + await this._fileService.writeFile(joinPath(contentsFolder, hash), VSBuffer.fromString(content)); + } + + await this._fileService.writeFile(joinPath(storageFolder, STORAGE_STATE_FILE), VSBuffer.fromString(JSON.stringify(data, undefined, 2))); + } catch (e) { + this._logService.debug(`Error storing chat editing session to ${storageFolder.toString()}`, e); + } + } + + public async clearState(): Promise { + const storageFolder = this._getStorageLocation(); + if (await this._fileService.exists(storageFolder)) { + this._logService.debug(`chatEditingSession: Clearing editing session at ${storageFolder.toString()}`); + try { + await this._fileService.del(storageFolder, { recursive: true }); + } catch (e) { + this._logService.debug(`Error clearing chat editing session from ${storageFolder.toString()}`, e); + } + } + } + +} + +export interface IChatEditingSessionSnapshot { + readonly exchangeId: string | undefined; + readonly workingSet: ResourceMap; + readonly entries: ResourceMap; +} + +interface IChatEditingSessionSnapshotDTO { + readonly exchangeId: string | undefined; + readonly workingSet: ResourceMapDTO; + readonly entries: ISnapshotEntryDTO[]; +} + +interface ISnapshotEntryDTO { + readonly resource: string; + readonly languageId: string; + readonly originalHash: string; + readonly currentHash: string; + readonly originalToCurrentEdit: IOffsetEdit; + readonly state: WorkingSetEntryState; + readonly snapshotUri: string; + readonly telemetryInfo: IModifiedEntryTelemetryInfoDTO; +} + +interface IModifiedEntryTelemetryInfoDTO { + readonly exchangeId: string; + readonly agentId?: string; + readonly command?: string; +} + +type ResourceMapDTO = [string, T][]; + +const STORAGE_VERSION = 1; + +interface IChatEditingSessionDTO { + readonly version: number; + readonly sessionId: string; + readonly recentSnapshot: IChatEditingSessionSnapshotDTO; + readonly linearHistory: IChatEditingSessionSnapshotDTO[]; + readonly linearHistoryIndex: number; + readonly pendingSnapshot: IChatEditingSessionSnapshotDTO | undefined; + readonly initialFileContents: ResourceMapDTO; +} diff --git a/src/vs/workbench/contrib/aideAgent/browser/aideAgentEditing/aideAgentEditingTextModelContentProviders.ts b/src/vs/workbench/contrib/aideAgent/browser/aideAgentEditing/aideAgentEditingTextModelContentProviders.ts new file mode 100644 index 00000000000..32f31cb068e --- /dev/null +++ b/src/vs/workbench/contrib/aideAgent/browser/aideAgentEditing/aideAgentEditingTextModelContentProviders.ts @@ -0,0 +1,91 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IObservable } from '../../../../../base/common/observable.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { ITextModel } from '../../../../../editor/common/model.js'; +import { IModelService } from '../../../../../editor/common/services/model.js'; +import { ITextModelContentProvider } from '../../../../../editor/common/services/resolverService.js'; +import { ChatEditingSession } from './aideAgentEditingSession.js'; + +type ChatEditingTextModelContentQueryData = { kind: 'empty' } | { kind: 'doc'; documentId: string }; + +export class ChatEditingTextModelContentProvider implements ITextModelContentProvider { + public static readonly scheme = 'aide-agent-editing-text-model'; + + public static getEmptyFileURI(): URI { + return URI.from({ + scheme: ChatEditingTextModelContentProvider.scheme, + query: JSON.stringify({ kind: 'empty' }), + }); + } + + public static getFileURI(documentId: string, path: string): URI { + return URI.from({ + scheme: ChatEditingTextModelContentProvider.scheme, + path, + query: JSON.stringify({ kind: 'doc', documentId }), + }); + } + + constructor( + private readonly _currentSessionObs: IObservable, + @IModelService private readonly _modelService: IModelService + ) { } + + async provideTextContent(resource: URI): Promise { + const existing = this._modelService.getModel(resource); + if (existing && !existing.isDisposed()) { + return existing; + } + + const data: ChatEditingTextModelContentQueryData = JSON.parse(resource.query); + if (data.kind === 'empty') { + return this._modelService.createModel('', null, resource, false); + } + + const session = this._currentSessionObs.get(); + if (!session) { + return null; + } + + return session.getVirtualModel(data.documentId); + } +} + +type ChatEditingSnapshotTextModelContentQueryData = { requestId: string | undefined }; + +export class ChatEditingSnapshotTextModelContentProvider implements ITextModelContentProvider { + public static readonly scheme = 'aide-agent-editing-snapshot-text-model'; + + public static getSnapshotFileURI(requestId: string | undefined, path: string): URI { + return URI.from({ + scheme: ChatEditingSnapshotTextModelContentProvider.scheme, + path, + query: JSON.stringify({ requestId: requestId ?? '' }), + }); + } + + constructor( + private readonly _currentSessionObs: IObservable, + @IModelService private readonly _modelService: IModelService + ) { } + + async provideTextContent(resource: URI): Promise { + const existing = this._modelService.getModel(resource); + if (existing && !existing.isDisposed()) { + return existing; + } + + const data: ChatEditingSnapshotTextModelContentQueryData = JSON.parse(resource.query); + + const session = this._currentSessionObs.get(); + if (!session || !data.requestId) { + return null; + } + + return session.getSnapshotModel(data.requestId, resource); + } +} diff --git a/src/vs/workbench/contrib/aideAgent/browser/aideAgentEditor.ts b/src/vs/workbench/contrib/aideAgent/browser/aideAgentEditor.ts index 7e36f5b3a48..9eefd71571a 100644 --- a/src/vs/workbench/contrib/aideAgent/browser/aideAgentEditor.ts +++ b/src/vs/workbench/contrib/aideAgent/browser/aideAgentEditor.ts @@ -66,7 +66,12 @@ export class ChatEditor extends EditorPane { ChatWidget, ChatAgentLocation.Panel, undefined, - { supportsFileReferences: true }, + { + supportsFileReferences: true, + rendererOptions: { + renderTextEditsAsSummary: () => true + } + }, { listForeground: editorForeground, listBackground: editorBackground, diff --git a/src/vs/workbench/contrib/aideAgent/browser/aideAgentEditorActions.ts b/src/vs/workbench/contrib/aideAgent/browser/aideAgentEditorActions.ts new file mode 100644 index 00000000000..36b0eb23418 --- /dev/null +++ b/src/vs/workbench/contrib/aideAgent/browser/aideAgentEditorActions.ts @@ -0,0 +1,342 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Codicon } from '../../../../base/common/codicons.js'; +import { KeyCode, KeyMod } from '../../../../base/common/keyCodes.js'; +import { isEqual } from '../../../../base/common/resources.js'; +import { ICodeEditor, isCodeEditor, isDiffEditor } from '../../../../editor/browser/editorBrowser.js'; +import { EditorAction2, ServicesAccessor } from '../../../../editor/browser/editorExtensions.js'; +import { Range } from '../../../../editor/common/core/range.js'; +import { EditorContextKeys } from '../../../../editor/common/editorContextKeys.js'; +import { localize, localize2 } from '../../../../nls.js'; +import { Action2, MenuId, MenuRegistry, registerAction2 } from '../../../../platform/actions/common/actions.js'; +import { ContextKeyExpr } from '../../../../platform/contextkey/common/contextkey.js'; +import { KeybindingWeight } from '../../../../platform/keybinding/common/keybindingsRegistry.js'; +import { ACTIVE_GROUP, IEditorService } from '../../../services/editor/common/editorService.js'; +import { getNotebookEditorFromEditorPane } from '../../notebook/browser/notebookBrowser.js'; +import { CONTEXT_CHAT_REQUEST_IN_PROGRESS } from '../common/aideAgentContextKeys.js'; +import { hasUndecidedChatEditingResourceContextKey, IAideAgentEditingService } from '../common/aideAgentEditingService.js'; +import { CHAT_CATEGORY } from './actions/aideAgentActions.js'; +import { ChatEditorController, ctxHasEditorModification, ctxReviewModeEnabled } from './aideAgentEditorController.js'; + +abstract class NavigateAction extends Action2 { + + constructor(readonly next: boolean) { + super({ + id: next + ? 'aideAgentEditor.action.navigateNext' + : 'aideAgentEditor.action.navigatePrevious', + title: next + ? localize2('next', 'Go to Next Edit') + : localize2('prev', 'Go to Previous Edit'), + category: CHAT_CATEGORY, + icon: next ? Codicon.arrowDown : Codicon.arrowUp, + keybinding: { + primary: next + ? KeyMod.Alt | KeyCode.F5 + : KeyMod.Alt | KeyMod.Shift | KeyCode.F5, + weight: KeybindingWeight.EditorContrib, + when: ContextKeyExpr.and(ctxHasEditorModification, EditorContextKeys.focus), + }, + f1: true, + menu: { + id: MenuId.AideAgentEditingEditorContent, + group: 'navigate', + order: !next ? 2 : 3, + when: ctxReviewModeEnabled + } + }); + } + + override async run(accessor: ServicesAccessor) { + + const chatEditingService = accessor.get(IAideAgentEditingService); + const editorService = accessor.get(IEditorService); + + let editor = editorService.activeTextEditorControl; + if (isDiffEditor(editor)) { + editor = editor.getModifiedEditor(); + } + if (!isCodeEditor(editor) || !editor.hasModel()) { + return; + } + const ctrl = ChatEditorController.get(editor); + if (!ctrl) { + return; + } + + const session = chatEditingService.editingSessionsObs.get() + .find(candidate => candidate.getEntry(editor.getModel().uri)); + + if (!session) { + return; + } + + const done = this.next + ? ctrl.revealNext(true) + : ctrl.revealPrevious(true); + + if (done) { + return; + } + + const entries = session.entries.get(); + const idx = entries.findIndex(e => isEqual(e.modifiedURI, editor.getModel().uri)); + if (idx < 0) { + return; + } + + const newIdx = (idx + (this.next ? 1 : -1) + entries.length) % entries.length; + if (idx === newIdx) { + // wrap inside the same file + if (this.next) { + ctrl.revealNext(false); + } else { + ctrl.revealPrevious(false); + } + return; + } + + const entry = entries[newIdx]; + const change = entry.diffInfo.get().changes.at(this.next ? 0 : -1); + + const newEditorPane = await editorService.openEditor({ + resource: entry.modifiedURI, + options: { + selection: change && Range.fromPositions({ lineNumber: change.modified.startLineNumber, column: 1 }), + revealIfOpened: false, + revealIfVisible: false, + } + }, ACTIVE_GROUP); + + + const newEditor = newEditorPane?.getControl(); + if (isCodeEditor(newEditor)) { + ChatEditorController.get(newEditor)?.initNavigation(); + } + } +} + +abstract class AcceptDiscardAction extends Action2 { + + constructor(id: string, readonly accept: boolean) { + super({ + id, + title: accept + ? localize2('accept', 'Accept Chat Edit') + : localize2('discard', 'Discard Chat Edit'), + shortTitle: accept + ? localize2('accept2', 'Accept') + : localize2('discard2', 'Discard'), + category: CHAT_CATEGORY, + precondition: ContextKeyExpr.and(ctxHasEditorModification, hasUndecidedChatEditingResourceContextKey), + icon: accept + ? Codicon.check + : Codicon.discard, + f1: true, + keybinding: { + when: EditorContextKeys.focus, + weight: KeybindingWeight.WorkbenchContrib, + primary: accept + ? KeyMod.CtrlCmd | KeyCode.Enter + : KeyMod.CtrlCmd | KeyCode.Backspace + }, + menu: { + id: MenuId.AideAgentEditingEditorContent, + group: 'a_resolve', + order: accept ? 0 : 1, + when: !accept ? ctxReviewModeEnabled : undefined + } + }); + } + + override run(accessor: ServicesAccessor) { + const chatEditingService = accessor.get(IAideAgentEditingService); + const editorService = accessor.get(IEditorService); + + let uri = getNotebookEditorFromEditorPane(editorService.activeEditorPane)?.textModel?.uri; + if (!uri) { + let editor = editorService.activeTextEditorControl; + if (isDiffEditor(editor)) { + editor = editor.getModifiedEditor(); + } + uri = isCodeEditor(editor) && editor.hasModel() + ? editor.getModel().uri + : undefined; + } + if (!uri) { + return; + } + + const session = chatEditingService.editingSessionsObs.get() + .find(candidate => candidate.getEntry(uri)); + + if (!session) { + return; + } + + if (this.accept) { + session.accept(uri); + } else { + session.reject(uri); + } + } +} + +export class AcceptAction extends AcceptDiscardAction { + + static readonly ID = 'aideAgentEditor.action.accept'; + + constructor() { + super(AcceptAction.ID, true); + } +} + +export class RejectAction extends AcceptDiscardAction { + + static readonly ID = 'aideAgentEditor.action.reject'; + + constructor() { + super(RejectAction.ID, false); + } +} + +class RejectHunkAction extends EditorAction2 { + constructor() { + super({ + id: 'aideAgentEditor.action.undoHunk', + title: localize2('undo', 'Discard this Change'), + category: CHAT_CATEGORY, + precondition: ContextKeyExpr.and(ctxHasEditorModification, CONTEXT_CHAT_REQUEST_IN_PROGRESS.negate(), hasUndecidedChatEditingResourceContextKey), + icon: Codicon.discard, + f1: true, + keybinding: { + when: EditorContextKeys.focus, + weight: KeybindingWeight.WorkbenchContrib, + primary: KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.Backspace + }, + menu: { + id: MenuId.AideAgentEditingEditorHunk, + order: 1 + } + }); + } + + override runEditorCommand(_accessor: ServicesAccessor, editor: ICodeEditor, ...args: any[]) { + ChatEditorController.get(editor)?.rejectNearestChange(args[0]); + } +} + +class AcceptHunkAction extends EditorAction2 { + constructor() { + super({ + id: 'aideAgentEditor.action.acceptHunk', + title: localize2('acceptHunk', 'Accept this Change'), + category: CHAT_CATEGORY, + precondition: ContextKeyExpr.and(ctxHasEditorModification, CONTEXT_CHAT_REQUEST_IN_PROGRESS.negate(), hasUndecidedChatEditingResourceContextKey), + icon: Codicon.check, + f1: true, + keybinding: { + when: EditorContextKeys.focus, + weight: KeybindingWeight.WorkbenchContrib, + primary: KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.Enter + }, + menu: { + id: MenuId.AideAgentEditingEditorHunk, + order: 0 + } + }); + } + + override runEditorCommand(_accessor: ServicesAccessor, editor: ICodeEditor, ...args: any[]) { + ChatEditorController.get(editor)?.acceptNearestChange(args[0]); + } +} + +class OpenDiffAction extends EditorAction2 { + constructor() { + super({ + id: 'aideAgentEditor.action.diffHunk', + title: localize2('diff', 'Toggle Diff Editor'), + category: CHAT_CATEGORY, + toggled: { + condition: EditorContextKeys.inDiffEditor, + icon: Codicon.goToFile, + }, + precondition: ContextKeyExpr.and(ctxHasEditorModification, CONTEXT_CHAT_REQUEST_IN_PROGRESS.negate(), hasUndecidedChatEditingResourceContextKey), + icon: Codicon.diffSingle, + keybinding: { + when: EditorContextKeys.focus, + weight: KeybindingWeight.WorkbenchContrib, + primary: KeyMod.Alt | KeyMod.Shift | KeyCode.F7, + }, + menu: [{ + id: MenuId.AideAgentEditingEditorHunk, + order: 10 + }, { + id: MenuId.AideAgentEditingEditorContent, + group: 'a_resolve', + order: 2, + when: ctxReviewModeEnabled + }] + }); + } + + override runEditorCommand(_accessor: ServicesAccessor, editor: ICodeEditor, ...args: any[]) { + ChatEditorController.get(editor)?.toggleDiff(args[0]); + } +} + +export class ReviewChangesAction extends EditorAction2 { + + constructor() { + super({ + id: 'aideAgentEditor.action.reviewChanges', + title: localize2('review', "Review"), + menu: [{ + id: MenuId.AideAgentEditingEditorContent, + group: 'a_resolve', + order: 3, + when: ctxReviewModeEnabled.negate(), + }] + }); + } + + override runEditorCommand(accessor: ServicesAccessor, editor: ICodeEditor) { + const chatEditingService = accessor.get(IAideAgentEditingService); + + if (!editor.hasModel()) { + return; + } + + const session = chatEditingService.editingSessionsObs.get().find(session => session.getEntry(editor.getModel().uri)); + const entry = session?.getEntry(editor.getModel().uri); + entry?.enableReviewModeUntilSettled(); + } +} + +export function registerChatEditorActions() { + registerAction2(class NextAction extends NavigateAction { constructor() { super(true); } }); + registerAction2(class PrevAction extends NavigateAction { constructor() { super(false); } }); + registerAction2(ReviewChangesAction); + registerAction2(AcceptAction); + registerAction2(AcceptHunkAction); + registerAction2(RejectAction); + registerAction2(RejectHunkAction); + registerAction2(OpenDiffAction); + + MenuRegistry.appendMenuItem(MenuId.AideAgentEditingEditorContent, { + command: { + id: navigationBearingFakeActionId, + title: localize('label', "Navigation Status"), + precondition: ContextKeyExpr.false(), + }, + group: 'navigate', + order: -1, + when: ctxReviewModeEnabled, + }); +} + +export const navigationBearingFakeActionId = 'aideAgentEditor.navigation.bearings'; diff --git a/src/vs/workbench/contrib/aideAgent/browser/aideAgentEditorController.ts b/src/vs/workbench/contrib/aideAgent/browser/aideAgentEditorController.ts new file mode 100644 index 00000000000..a836d095a15 --- /dev/null +++ b/src/vs/workbench/contrib/aideAgent/browser/aideAgentEditorController.ts @@ -0,0 +1,785 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import './media/aideAgentEditorController.css'; +import { addStandardDisposableListener, getTotalWidth } from '../../../../base/browser/dom.js'; +import { Disposable, DisposableStore, dispose, toDisposable } from '../../../../base/common/lifecycle.js'; +import { autorun, autorunWithStore, derived, IObservable, observableFromEvent, observableValue } from '../../../../base/common/observable.js'; +import { themeColorFromId } from '../../../../base/common/themables.js'; +import { ICodeEditor, IOverlayWidget, IOverlayWidgetPosition, IOverlayWidgetPositionCoordinates, IViewZone, MouseTargetType } from '../../../../editor/browser/editorBrowser.js'; +import { LineSource, renderLines, RenderOptions } from '../../../../editor/browser/widget/diffEditor/components/diffEditorViewZones/renderLines.js'; +import { diffAddDecoration, diffDeleteDecoration, diffWholeLineAddDecoration } from '../../../../editor/browser/widget/diffEditor/registrations.contribution.js'; +import { EditorOption, IEditorOptions } from '../../../../editor/common/config/editorOptions.js'; +import { Range } from '../../../../editor/common/core/range.js'; +import { IDocumentDiff } from '../../../../editor/common/diff/documentDiffProvider.js'; +import { IEditorContribution, ScrollType } from '../../../../editor/common/editorCommon.js'; +import { IModelDeltaDecoration, MinimapPosition, OverviewRulerLane, TrackedRangeStickiness } from '../../../../editor/common/model.js'; +import { ModelDecorationOptions } from '../../../../editor/common/model/textModel.js'; +import { InlineDecoration, InlineDecorationType } from '../../../../editor/common/viewModel.js'; +import { localize } from '../../../../nls.js'; +import { IContextKey, IContextKeyService, RawContextKey } from '../../../../platform/contextkey/common/contextkey.js'; +import { ChatEditingSessionState, IAideAgentEditingService, IModifiedFileEntry, WorkingSetEntryState } from '../common/aideAgentEditingService.js'; +import { Event } from '../../../../base/common/event.js'; +import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; +import { MenuId } from '../../../../platform/actions/common/actions.js'; +import { IEditorService } from '../../../services/editor/common/editorService.js'; +import { Position } from '../../../../editor/common/core/position.js'; +import { Selection } from '../../../../editor/common/core/selection.js'; +import { HiddenItemStrategy, MenuWorkbenchToolBar } from '../../../../platform/actions/browser/toolbar.js'; +import { observableCodeEditor } from '../../../../editor/browser/observableCodeEditor.js'; +import { minimapGutterAddedBackground, minimapGutterDeletedBackground, minimapGutterModifiedBackground, overviewRulerAddedForeground, overviewRulerDeletedForeground, overviewRulerModifiedForeground } from '../../scm/common/quickDiff.js'; +import { DetailedLineRangeMapping } from '../../../../editor/common/diff/rangeMapping.js'; +import { isDiffEditorForEntry } from './aideAgentEditing/aideAgentEditing.js'; +import { basename, isEqual } from '../../../../base/common/resources.js'; +import { ChatAgentLocation, IAideAgentAgentService } from '../common/aideAgentAgents.js'; +import { EditorsOrder, IEditorIdentifier, isDiffEditorInput } from '../../../common/editor.js'; +import { ChatEditorOverlayController } from './aideAgentEditorOverlay.js'; +import { IAideAgentService } from '../common/aideAgentService.js'; + +export const ctxIsGlobalEditingSession = new RawContextKey('aideAgent.isGlobalEditingSession', undefined, localize('chat.ctxEditSessionIsGlobal', "The current editor is part of the global edit session")); +export const ctxHasEditorModification = new RawContextKey('aideAgent.hasEditorModifications', undefined, localize('chat.hasEditorModifications', "The current editor contains chat modifications")); +export const ctxHasRequestInProgress = new RawContextKey('aideAgent.ctxHasRequestInProgress', false, localize('chat.ctxHasRequestInProgress', "The current editor shows a file from an edit session which is still in progress")); +export const ctxReviewModeEnabled = new RawContextKey('aideAgent.ctxReviewModeEnabled', true, localize('chat.ctxReviewModeEnabled', "Review mode for chat changes is enabled")); + +export class ChatEditorController extends Disposable implements IEditorContribution { + + public static readonly ID = 'editor.contrib.aideAgentEditorController'; + + private static _diffLineDecorationData = ModelDecorationOptions.register({ description: 'diff-line-decoration' }); + + private readonly _diffLineDecorations = this._editor.createDecorationsCollection(); // tracks the line range w/o visuals (used for navigate) + private readonly _diffVisualDecorations = this._editor.createDecorationsCollection(); // tracks the real diff with character level inserts + private readonly _diffHunksRenderStore = this._register(new DisposableStore()); + private readonly _diffHunkWidgets: DiffHunkWidget[] = []; + + private _viewZones: string[] = []; + + private readonly _overlayCtrl: ChatEditorOverlayController; + + private readonly _ctxIsGlobalEditsSession: IContextKey; + private readonly _ctxHasEditorModification: IContextKey; + private readonly _ctxRequestInProgress: IContextKey; + private readonly _ctxReviewModelEnabled: IContextKey; + + static get(editor: ICodeEditor): ChatEditorController | null { + const controller = editor.getContribution(ChatEditorController.ID); + return controller; + } + + private readonly _currentEntryIndex = observableValue(this, undefined); + readonly currentEntryIndex: IObservable = this._currentEntryIndex; + + private readonly _currentChangeIndex = observableValue(this, undefined); + readonly currentChangeIndex: IObservable = this._currentChangeIndex; + + private _scrollLock: boolean = false; + + constructor( + private readonly _editor: ICodeEditor, + @IAideAgentEditingService private readonly _chatEditingService: IAideAgentEditingService, + @IInstantiationService private readonly _instantiationService: IInstantiationService, + @IAideAgentAgentService private readonly _chatAgentService: IAideAgentAgentService, + @IEditorService private readonly _editorService: IEditorService, + @IContextKeyService contextKeyService: IContextKeyService, + @IAideAgentService chatService: IAideAgentService, + ) { + super(); + + this._overlayCtrl = ChatEditorOverlayController.get(_editor)!; + this._ctxIsGlobalEditsSession = ctxIsGlobalEditingSession.bindTo(contextKeyService); + this._ctxHasEditorModification = ctxHasEditorModification.bindTo(contextKeyService); + this._ctxRequestInProgress = ctxHasRequestInProgress.bindTo(contextKeyService); + this._ctxReviewModelEnabled = ctxReviewModeEnabled.bindTo(contextKeyService); + + const editorObs = observableCodeEditor(this._editor); + const fontInfoObs = editorObs.getOption(EditorOption.fontInfo); + const lineHeightObs = editorObs.getOption(EditorOption.lineHeight); + const modelObs = editorObs.model; + + this._store.add(autorun(r => { + let isStreamingEdits = false; + for (const session of _chatEditingService.editingSessionsObs.read(r)) { + isStreamingEdits ||= session.state.read(r) === ChatEditingSessionState.StreamingEdits; + } + this._ctxRequestInProgress.set(isStreamingEdits); + })); + + const entryForEditor = derived(r => { + const model = modelObs.read(r); + if (!model) { + return; + } + + for (const session of _chatEditingService.editingSessionsObs.read(r)) { + const entries = session.entries.read(r); + const idx = model?.uri + ? entries.findIndex(e => isEqual(e.modifiedURI, model.uri)) + : -1; + + const chatModel = chatService.getSession(session.chatSessionId); + + if (idx >= 0 && chatModel) { + return { session, chatModel, entry: entries[idx], entries, idx }; + } + } + + return undefined; + }); + + + let didReval = false; + + this._register(autorunWithStore((r, store) => { + + const currentEditorEntry = entryForEditor.read(r); + + if (!currentEditorEntry) { + this._ctxIsGlobalEditsSession.reset(); + this._clear(); + didReval = false; + return; + } + + if (this._editor.getOption(EditorOption.inDiffEditor) && !_instantiationService.invokeFunction(isDiffEditorForEntry, currentEditorEntry.entry, this._editor)) { + this._clear(); + return; + } + + const { session, chatModel, entries, idx, entry } = currentEditorEntry; + + store.add(chatModel.onDidChange(e => { + if (e.kind === 'addRequest') { + didReval = false; + } + })); + + this._ctxIsGlobalEditsSession.set(session.isGlobalEditingSession); + this._ctxReviewModelEnabled.set(entry.reviewMode.read(r)); + + // context + this._currentEntryIndex.set(idx, undefined); + + // overlay widget + if (entry.state.read(r) !== WorkingSetEntryState.Modified) { + this._overlayCtrl.hide(); + } else { + this._overlayCtrl.showEntry(session, entry, entries[(idx + 1) % entries.length]); + } + + // scrolling logic + if (entry.isCurrentlyBeingModified.read(r)) { + // while modified: scroll along unless locked + if (!this._scrollLock) { + const maxLineNumber = entry.maxLineNumber.read(r); + this._editor.revealLineNearTop(maxLineNumber, ScrollType.Smooth); + } + const domNode = this._editor.getDomNode(); + if (domNode) { + store.add(addStandardDisposableListener(domNode, 'wheel', () => { + this._scrollLock = true; + })); + } + } else { + // done: render diff + fontInfoObs.read(r); + lineHeightObs.read(r); + + const diff = entry?.diffInfo.read(r); + + // Add line decorations (just markers, no UI) for diff navigation + this._updateDiffLineDecorations(diff); + + const reviewMode = entry.reviewMode.read(r); + + // Add diff decoration to the UI (unless in diff editor) + if (!this._editor.getOption(EditorOption.inDiffEditor)) { + this._updateDiffRendering(entry, diff, reviewMode); + } else { + this._clearDiffRendering(); + } + + if (!didReval && !diff.identical) { + didReval = true; + this._reveal(true, false, ScrollType.Immediate); + } + } + })); + + // ---- readonly while streaming + + const shouldBeReadOnly = derived(this, r => { + + const model = modelObs.read(r); + if (!model) { + return undefined; + } + for (const session of _chatEditingService.editingSessionsObs.read(r)) { + if (session.readEntry(model.uri, r) && session.state.read(r) === ChatEditingSessionState.StreamingEdits) { + return true; + } + } + return false; + }); + + + let actualOptions: IEditorOptions | undefined; + + this._register(autorun(r => { + const value = shouldBeReadOnly.read(r); + if (value) { + + actualOptions ??= { + readOnly: this._editor.getOption(EditorOption.readOnly), + renderValidationDecorations: this._editor.getOption(EditorOption.renderValidationDecorations), + stickyScroll: this._editor.getOption(EditorOption.stickyScroll) + }; + + this._editor.updateOptions({ + readOnly: true, + renderValidationDecorations: 'off', + stickyScroll: { enabled: false } + }); + } else { + if (actualOptions !== undefined) { + this._editor.updateOptions(actualOptions); + actualOptions = undefined; + } + } + })); + } + + override dispose(): void { + this._clear(); + super.dispose(); + } + + private _clear() { + this._clearDiffRendering(); + this._overlayCtrl.hide(); + this._diffLineDecorations.clear(); + this._currentChangeIndex.set(undefined, undefined); + this._currentEntryIndex.set(undefined, undefined); + this._ctxHasEditorModification.reset(); + this._ctxReviewModelEnabled.reset(); + } + + private _clearDiffRendering() { + this._editor.changeViewZones((viewZoneChangeAccessor) => { + for (const id of this._viewZones) { + viewZoneChangeAccessor.removeZone(id); + } + }); + this._viewZones = []; + this._diffHunksRenderStore.clear(); + this._diffVisualDecorations.clear(); + this._scrollLock = false; + } + + private _updateDiffRendering(entry: IModifiedFileEntry, diff: IDocumentDiff, reviewMode: boolean): void { + + const originalModel = entry.originalModel; + + const chatDiffAddDecoration = ModelDecorationOptions.createDynamic({ + ...diffAddDecoration, + stickiness: TrackedRangeStickiness.NeverGrowsWhenTypingAtEdges + }); + const chatDiffWholeLineAddDecoration = ModelDecorationOptions.createDynamic({ + ...diffWholeLineAddDecoration, + stickiness: TrackedRangeStickiness.NeverGrowsWhenTypingAtEdges, + }); + const createOverviewDecoration = (overviewRulerColor: string, minimapColor: string) => { + return ModelDecorationOptions.createDynamic({ + description: 'chat-editing-decoration', + overviewRuler: { color: themeColorFromId(overviewRulerColor), position: OverviewRulerLane.Left }, + minimap: { color: themeColorFromId(minimapColor), position: MinimapPosition.Gutter }, + }); + }; + const modifiedDecoration = createOverviewDecoration(overviewRulerModifiedForeground, minimapGutterModifiedBackground); + const addedDecoration = createOverviewDecoration(overviewRulerAddedForeground, minimapGutterAddedBackground); + const deletedDecoration = createOverviewDecoration(overviewRulerDeletedForeground, minimapGutterDeletedBackground); + + this._diffHunksRenderStore.clear(); + this._diffHunkWidgets.length = 0; + const diffHunkDecorations: IModelDeltaDecoration[] = []; + + this._editor.changeViewZones((viewZoneChangeAccessor) => { + for (const id of this._viewZones) { + viewZoneChangeAccessor.removeZone(id); + } + this._viewZones = []; + const modifiedVisualDecorations: IModelDeltaDecoration[] = []; + const mightContainNonBasicASCII = originalModel.mightContainNonBasicASCII(); + const mightContainRTL = originalModel.mightContainRTL(); + const renderOptions = RenderOptions.fromEditor(this._editor); + const editorLineCount = this._editor.getModel()?.getLineCount(); + + for (const diffEntry of diff.changes) { + + const originalRange = diffEntry.original; + originalModel.tokenization.forceTokenization(Math.max(1, originalRange.endLineNumberExclusive - 1)); + const source = new LineSource( + originalRange.mapToLineArray(l => originalModel.tokenization.getLineTokens(l)), + [], + mightContainNonBasicASCII, + mightContainRTL, + ); + const decorations: InlineDecoration[] = []; + + if (reviewMode) { + for (const i of diffEntry.innerChanges || []) { + decorations.push(new InlineDecoration( + i.originalRange.delta(-(diffEntry.original.startLineNumber - 1)), + diffDeleteDecoration.className!, + InlineDecorationType.Regular + )); + + // If the original range is empty, the start line number is 1 and the new range spans the entire file, don't draw an Added decoration + if (!(i.originalRange.isEmpty() && i.originalRange.startLineNumber === 1 && i.modifiedRange.endLineNumber === editorLineCount) && !i.modifiedRange.isEmpty()) { + modifiedVisualDecorations.push({ + range: i.modifiedRange, options: chatDiffAddDecoration + }); + } + } + } + + // Render an added decoration but don't also render a deleted decoration for newly inserted content at the start of the file + // Note, this is a workaround for the `LineRange.isEmpty()` in diffEntry.original being `false` for newly inserted content + const isCreatedContent = decorations.length === 1 && decorations[0].range.isEmpty() && diffEntry.original.startLineNumber === 1; + + if (!diffEntry.modified.isEmpty && !(isCreatedContent && (diffEntry.modified.endLineNumberExclusive - 1) === editorLineCount)) { + modifiedVisualDecorations.push({ + range: diffEntry.modified.toInclusiveRange()!, + options: chatDiffWholeLineAddDecoration + }); + } + + if (diffEntry.original.isEmpty) { + // insertion + modifiedVisualDecorations.push({ + range: diffEntry.modified.toInclusiveRange()!, + options: addedDecoration + }); + } else if (diffEntry.modified.isEmpty) { + // deletion + modifiedVisualDecorations.push({ + range: new Range(diffEntry.modified.startLineNumber - 1, 1, diffEntry.modified.startLineNumber, 1), + options: deletedDecoration + }); + } else { + // modification + modifiedVisualDecorations.push({ + range: diffEntry.modified.toInclusiveRange()!, + options: modifiedDecoration + }); + } + + if (reviewMode) { + const domNode = document.createElement('div'); + domNode.className = 'chat-editing-original-zone view-lines line-delete monaco-mouse-cursor-text'; + const result = renderLines(source, renderOptions, decorations, domNode); + + if (!isCreatedContent) { + + const viewZoneData: IViewZone = { + afterLineNumber: diffEntry.modified.startLineNumber - 1, + heightInLines: result.heightInLines, + domNode, + ordinal: 50000 + 2 // more than https://github.com/microsoft/vscode/blob/bf52a5cfb2c75a7327c9adeaefbddc06d529dcad/src/vs/workbench/contrib/inlineChat/browser/inlineChatZoneWidget.ts#L42 + }; + + this._viewZones.push(viewZoneChangeAccessor.addZone(viewZoneData)); + } + + + // Add content widget for each diff change + const widget = this._instantiationService.createInstance(DiffHunkWidget, entry, diffEntry, this._editor.getModel()!.getVersionId(), this._editor, isCreatedContent ? 0 : result.heightInLines); + widget.layout(diffEntry.modified.startLineNumber); + + this._diffHunkWidgets.push(widget); + diffHunkDecorations.push({ + range: diffEntry.modified.toInclusiveRange() ?? new Range(diffEntry.modified.startLineNumber, 1, diffEntry.modified.startLineNumber, Number.MAX_SAFE_INTEGER), + options: { + description: 'diff-hunk-widget', + stickiness: TrackedRangeStickiness.AlwaysGrowsWhenTypingAtEdges + } + }); + } + } + + this._diffVisualDecorations.set(modifiedVisualDecorations); + }); + + const diffHunkDecoCollection = this._editor.createDecorationsCollection(diffHunkDecorations); + + this._diffHunksRenderStore.add(toDisposable(() => { + dispose(this._diffHunkWidgets); + this._diffHunkWidgets.length = 0; + diffHunkDecoCollection.clear(); + })); + + + + const positionObs = observableFromEvent(this._editor.onDidChangeCursorPosition, _ => this._editor.getPosition()); + + const activeWidgetIdx = derived(r => { + const position = positionObs.read(r); + if (!position) { + return -1; + } + const idx = diffHunkDecoCollection.getRanges().findIndex(r => r.containsPosition(position)); + return idx; + }); + const toggleWidget = (activeWidget: DiffHunkWidget | undefined) => { + const positionIdx = activeWidgetIdx.get(); + for (let i = 0; i < this._diffHunkWidgets.length; i++) { + const widget = this._diffHunkWidgets[i]; + widget.toggle(widget === activeWidget || i === positionIdx); + } + }; + + this._diffHunksRenderStore.add(autorun(r => { + // reveal when cursor inside + const idx = activeWidgetIdx.read(r); + const widget = this._diffHunkWidgets[idx]; + toggleWidget(widget); + })); + + + this._diffHunksRenderStore.add(this._editor.onMouseMove(e => { + + // reveal when hovering over + if (e.target.type === MouseTargetType.OVERLAY_WIDGET) { + const id = e.target.detail; + const widget = this._diffHunkWidgets.find(w => w.getId() === id); + toggleWidget(widget); + + } else if (e.target.type === MouseTargetType.CONTENT_VIEW_ZONE) { + const zone = e.target.detail; + const idx = this._viewZones.findIndex(id => id === zone.viewZoneId); + toggleWidget(this._diffHunkWidgets[idx]); + + } else if (e.target.position) { + const { position } = e.target; + const idx = diffHunkDecoCollection.getRanges().findIndex(r => r.containsPosition(position)); + toggleWidget(this._diffHunkWidgets[idx]); + + } else { + toggleWidget(undefined); + } + })); + + this._diffHunksRenderStore.add(Event.any(this._editor.onDidScrollChange, this._editor.onDidLayoutChange)(() => { + for (let i = 0; i < this._diffHunkWidgets.length; i++) { + const widget = this._diffHunkWidgets[i]; + const range = diffHunkDecoCollection.getRange(i); + if (range) { + widget.layout(range?.startLineNumber); + } else { + widget.dispose(); + } + } + })); + } + + private _updateDiffLineDecorations(diff: IDocumentDiff): void { + this._ctxHasEditorModification.set(!diff.identical); + + const modifiedLineDecorations: IModelDeltaDecoration[] = []; + + for (const diffEntry of diff.changes) { + modifiedLineDecorations.push({ + range: diffEntry.modified.toInclusiveRange() ?? new Range(diffEntry.modified.startLineNumber, 1, diffEntry.modified.startLineNumber, Number.MAX_SAFE_INTEGER), + options: ChatEditorController._diffLineDecorationData + }); + } + this._diffLineDecorations.set(modifiedLineDecorations); + } + + unlockScroll(): void { + this._scrollLock = false; + } + + initNavigation(): void { + const position = this._editor.getPosition(); + const target = position ? this._diffLineDecorations.getRanges().findIndex(r => r.containsPosition(position)) : -1; + this._currentChangeIndex.set(target >= 0 ? target : undefined, undefined); + } + + revealNext(strict = false): boolean { + return this._reveal(true, strict); + } + + revealPrevious(strict = false): boolean { + return this._reveal(false, strict); + } + + private _reveal(next: boolean, strict: boolean, scrollType = ScrollType.Smooth): boolean { + const position = this._editor.getPosition(); + if (!position) { + this._currentChangeIndex.set(undefined, undefined); + return false; + } + + const decorations = this._diffLineDecorations + .getRanges() + .sort((a, b) => Range.compareRangesUsingStarts(a, b)); + + if (decorations.length === 0) { + this._currentChangeIndex.set(undefined, undefined); + return false; + } + + let target: number = -1; + for (let i = 0; i < decorations.length; i++) { + const range = decorations[i]; + if (range.containsPosition(position)) { + target = i + (next ? 1 : -1); + break; + } else if (Position.isBefore(position, range.getStartPosition())) { + target = next ? i : i - 1; + break; + } + } + + if (strict && (target < 0 || target >= decorations.length)) { + this._currentChangeIndex.set(undefined, undefined); + return false; + } + + target = (target + decorations.length) % decorations.length; + + this._currentChangeIndex.set(target, undefined); + + const targetPosition = next ? decorations[target].getStartPosition() : decorations[target].getEndPosition(); + this._editor.setPosition(targetPosition); + this._editor.revealPositionInCenter(targetPosition, scrollType); + this._editor.focus(); + + return true; + } + + private _findClosestWidget(): DiffHunkWidget | undefined { + if (!this._editor.hasModel()) { + return undefined; + } + const lineRelativeTop = this._editor.getTopForLineNumber(this._editor.getPosition().lineNumber) - this._editor.getScrollTop(); + let closestWidget: DiffHunkWidget | undefined; + let closestDistance = Number.MAX_VALUE; + + for (const widget of this._diffHunkWidgets) { + const widgetTop = (widget.getPosition()?.preference)?.top; + if (widgetTop !== undefined) { + const distance = Math.abs(widgetTop - lineRelativeTop); + if (distance < closestDistance) { + closestDistance = distance; + closestWidget = widget; + } + } + } + + return closestWidget; + } + + rejectNearestChange(closestWidget: DiffHunkWidget | undefined): void { + closestWidget = closestWidget ?? this._findClosestWidget(); + if (closestWidget instanceof DiffHunkWidget) { + closestWidget.reject(); + this.revealNext(); + } + } + + acceptNearestChange(closestWidget: DiffHunkWidget | undefined): void { + closestWidget = closestWidget ?? this._findClosestWidget(); + if (closestWidget instanceof DiffHunkWidget) { + closestWidget.accept(); + this.revealNext(); + } + } + + async toggleDiff(widget: DiffHunkWidget | undefined): Promise { + if (!this._editor.hasModel()) { + return; + } + + let entry: IModifiedFileEntry | undefined; + for (const session of this._chatEditingService.editingSessionsObs.get()) { + entry = session.getEntry(this._editor.getModel().uri); + if (entry) { + break; + } + } + + if (!entry) { + return; + } + + const lineRelativeTop = this._editor.getTopForLineNumber(this._editor.getPosition().lineNumber) - this._editor.getScrollTop(); + let closestDistance = Number.MAX_VALUE; + + if (!(widget instanceof DiffHunkWidget)) { + for (const candidate of this._diffHunkWidgets) { + const widgetTop = (candidate.getPosition()?.preference)?.top; + if (widgetTop !== undefined) { + const distance = Math.abs(widgetTop - lineRelativeTop); + if (distance < closestDistance) { + closestDistance = distance; + widget = candidate; + } + } + } + } + + let selection = this._editor.getSelection(); + if (widget instanceof DiffHunkWidget) { + const lineNumber = widget.getStartLineNumber(); + const position = lineNumber ? new Position(lineNumber, 1) : undefined; + if (position && !selection.containsPosition(position)) { + selection = Selection.fromPositions(position); + } + } + + const isDiffEditor = this._editor.getOption(EditorOption.inDiffEditor); + + if (isDiffEditor) { + // normal EDITOR + await this._editorService.openEditor({ resource: entry.modifiedURI }); + + } else { + // DIFF editor + const defaultAgentName = this._chatAgentService.getDefaultAgent(ChatAgentLocation.Panel)?.fullName; + const diffEditor = await this._editorService.openEditor({ + original: { resource: entry.originalURI, options: { selection: undefined } }, + modified: { resource: entry.modifiedURI, options: { selection } }, + label: defaultAgentName + ? localize('diff.agent', '{0} (changes from {1})', basename(entry.modifiedURI), defaultAgentName) + : localize('diff.generic', '{0} (changes from chat)', basename(entry.modifiedURI)) + }); + + if (diffEditor && diffEditor.input) { + + // this is needed, passing the selection doesn't seem to work + diffEditor.getControl()?.setSelection(selection); + + // close diff editor when entry is decided + const d = autorun(r => { + const state = entry.state.read(r); + if (state === WorkingSetEntryState.Accepted || state === WorkingSetEntryState.Rejected) { + d.dispose(); + + const editorIdents: IEditorIdentifier[] = []; + for (const candidate of this._editorService.getEditors(EditorsOrder.MOST_RECENTLY_ACTIVE)) { + if (isDiffEditorInput(candidate.editor) + && isEqual(candidate.editor.original.resource, entry.originalURI) + && isEqual(candidate.editor.modified.resource, entry.modifiedURI) + ) { + editorIdents.push(candidate); + } + } + + this._editorService.closeEditors(editorIdents); + } + }); + } + } + } +} + +class DiffHunkWidget implements IOverlayWidget { + + private static _idPool = 0; + private readonly _id: string = `diff-change-widget-${DiffHunkWidget._idPool++}`; + + private readonly _domNode: HTMLElement; + private readonly _store = new DisposableStore(); + private _position: IOverlayWidgetPosition | undefined; + private _lastStartLineNumber: number | undefined; + + + constructor( + readonly entry: IModifiedFileEntry, + private readonly _change: DetailedLineRangeMapping, + private readonly _versionId: number, + private readonly _editor: ICodeEditor, + private readonly _lineDelta: number, + @IInstantiationService instaService: IInstantiationService, + ) { + this._domNode = document.createElement('div'); + this._domNode.className = 'chat-diff-change-content-widget'; + + const toolbar = instaService.createInstance(MenuWorkbenchToolBar, this._domNode, MenuId.AideAgentEditingEditorHunk, { + telemetrySource: 'aideAgentEditingEditorHunk', + hiddenItemStrategy: HiddenItemStrategy.NoHide, + toolbarOptions: { primaryGroup: () => true, }, + menuOptions: { + renderShortTitle: true, + arg: this, + }, + }); + + this._store.add(toolbar); + this._store.add(toolbar.actionRunner.onWillRun(_ => _editor.focus())); + this._editor.addOverlayWidget(this); + } + + dispose(): void { + this._store.dispose(); + this._editor.removeOverlayWidget(this); + } + + getId(): string { + return this._id; + } + + layout(startLineNumber: number): void { + + const lineHeight = this._editor.getOption(EditorOption.lineHeight); + const { contentLeft, contentWidth, verticalScrollbarWidth } = this._editor.getLayoutInfo(); + const scrollTop = this._editor.getScrollTop(); + + this._position = { + stackOridinal: 1, + preference: { + top: this._editor.getTopForLineNumber(startLineNumber) - scrollTop - (lineHeight * this._lineDelta), + left: contentLeft + contentWidth - (2 * verticalScrollbarWidth + getTotalWidth(this._domNode)) + } + }; + + this._editor.layoutOverlayWidget(this); + this._lastStartLineNumber = startLineNumber; + } + + toggle(show: boolean) { + this._domNode.classList.toggle('hover', show); + if (this._lastStartLineNumber) { + this.layout(this._lastStartLineNumber); + } + } + + getDomNode(): HTMLElement { + return this._domNode; + } + + getPosition(): IOverlayWidgetPosition | null { + return this._position ?? null; + } + + getStartLineNumber(): number | undefined { + return this._lastStartLineNumber; + } + + // --- + + reject(): void { + if (this._versionId === this._editor.getModel()?.getVersionId()) { + this.entry.rejectHunk(this._change); + } + } + + accept(): void { + if (this._versionId === this._editor.getModel()?.getVersionId()) { + this.entry.acceptHunk(this._change); + } + } +} diff --git a/src/vs/workbench/contrib/aideAgent/browser/aideAgentEditorOverlay.ts b/src/vs/workbench/contrib/aideAgent/browser/aideAgentEditorOverlay.ts new file mode 100644 index 00000000000..99df80e7c49 --- /dev/null +++ b/src/vs/workbench/contrib/aideAgent/browser/aideAgentEditorOverlay.ts @@ -0,0 +1,357 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { $, addDisposableGenericMouseMoveListener, append, EventLike, reset } from '../../../../base/browser/dom.js'; +import { ActionViewItem } from '../../../../base/browser/ui/actionbar/actionViewItems.js'; +import { renderIcon } from '../../../../base/browser/ui/iconLabel/iconLabels.js'; +import { IActionRunner } from '../../../../base/common/actions.js'; +import { Codicon } from '../../../../base/common/codicons.js'; +import { DisposableStore, MutableDisposable } from '../../../../base/common/lifecycle.js'; +import { autorun, observableFromEvent, observableValue, transaction } from '../../../../base/common/observable.js'; +import { ThemeIcon } from '../../../../base/common/themables.js'; +import { assertType } from '../../../../base/common/types.js'; +import { ICodeEditor, IOverlayWidget, IOverlayWidgetPosition, OverlayWidgetPositionPreference } from '../../../../editor/browser/editorBrowser.js'; +import { findDiffEditorContainingCodeEditor } from '../../../../editor/browser/widget/diffEditor/commands.js'; +import { Range } from '../../../../editor/common/core/range.js'; +import { IEditorContribution } from '../../../../editor/common/editorCommon.js'; +import { localize } from '../../../../nls.js'; +import { HiddenItemStrategy, MenuWorkbenchToolBar, WorkbenchToolBar } from '../../../../platform/actions/browser/toolbar.js'; +import { MenuId } from '../../../../platform/actions/common/actions.js'; +import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; +import { ACTIVE_GROUP, IEditorService } from '../../../services/editor/common/editorService.js'; +import { IChatEditingSession, IModifiedFileEntry } from '../common/aideAgentEditingService.js'; +import { AcceptAction, navigationBearingFakeActionId, RejectAction } from './aideAgentEditorActions.js'; +import { ChatEditorController } from './aideAgentEditorController.js'; +import './media/aideAgentEditorOverlay.css'; + +class ChatEditorOverlayWidget implements IOverlayWidget { + + readonly allowEditorOverflow = true; + + private readonly _domNode: HTMLElement; + private readonly _progressNode: HTMLElement; + private readonly _toolbar: WorkbenchToolBar; + + private _isAdded: boolean = false; + private readonly _showStore = new DisposableStore(); + + private readonly _entry = observableValue<{ entry: IModifiedFileEntry; next: IModifiedFileEntry } | undefined>(this, undefined); + + private readonly _navigationBearings = observableValue<{ changeCount: number; activeIdx: number; entriesCount: number }>(this, { changeCount: -1, activeIdx: -1, entriesCount: -1 }); + + constructor( + private readonly _editor: ICodeEditor, + @IEditorService editorService: IEditorService, + @IInstantiationService private readonly _instaService: IInstantiationService, + ) { + this._domNode = document.createElement('div'); + this._domNode.classList.add('chat-editor-overlay-widget'); + + const progressNode = document.createElement('div'); + progressNode.classList.add('chat-editor-overlay-progress'); + append(progressNode, renderIcon(ThemeIcon.modify(Codicon.loading, 'spin'))); + this._progressNode = append(progressNode, $('SPAN.busy-label')); + this._domNode.appendChild(progressNode); + + const toolbarNode = document.createElement('div'); + toolbarNode.classList.add('chat-editor-overlay-toolbar'); + this._domNode.appendChild(toolbarNode); + + this._toolbar = _instaService.createInstance(MenuWorkbenchToolBar, toolbarNode, MenuId.AideAgentEditingEditorContent, { + telemetrySource: 'chatEditor.overlayToolbar', + hiddenItemStrategy: HiddenItemStrategy.Ignore, + toolbarOptions: { + primaryGroup: () => true, + useSeparatorsInPrimaryActions: true + }, + menuOptions: { renderShortTitle: true }, + actionViewItemProvider: (action, options) => { + const that = this; + + if (action.id === navigationBearingFakeActionId) { + return new class extends ActionViewItem { + + constructor() { + super(undefined, action, { ...options, icon: false, label: true, keybindingNotRenderedWithLabel: true }); + } + + override render(container: HTMLElement) { + super.render(container); + + container.classList.add('label-item'); + + this._store.add(autorun(r => { + assertType(this.label); + + const { changeCount, activeIdx } = that._navigationBearings.read(r); + const n = activeIdx === -1 ? '?' : `${activeIdx + 1}`; + const m = changeCount === -1 ? '?' : `${changeCount}`; + this.label.innerText = localize('nOfM', "{0} of {1}", n, m); + + this.updateTooltip(); + })); + } + + protected override getTooltip(): string | undefined { + const { changeCount, entriesCount } = that._navigationBearings.get(); + if (changeCount === -1 || entriesCount === -1) { + return undefined; + } else if (changeCount === 1 && entriesCount === 1) { + return localize('tooltip_11', "1 change in 1 file"); + } else if (changeCount === 1) { + return localize('tooltip_1n', "1 change in {0} files", entriesCount); + } else if (entriesCount === 1) { + return localize('tooltip_n1', "{0} changes in 1 file", changeCount); + } else { + return localize('tooltip_nm', "{0} changes in {1} files", changeCount, entriesCount); + } + } + + override onClick(event: EventLike, preserveFocus?: boolean): void { + ChatEditorController.get(that._editor)?.unlockScroll(); + } + }; + } + + if (action.id === AcceptAction.ID || action.id === RejectAction.ID) { + return new class extends ActionViewItem { + + private readonly _reveal = this._store.add(new MutableDisposable()); + + constructor() { + super(undefined, action, { ...options, icon: false, label: true, keybindingNotRenderedWithLabel: true }); + } + + override render(container: HTMLElement): void { + super.render(container); + + if (action.id === AcceptAction.ID) { + + const listener = this._store.add(new MutableDisposable()); + + this._store.add(autorun(r => { + + assertType(this.label); + assertType(this.element); + + const ctrl = that._entry.read(r)?.entry.autoAcceptController.read(r); + if (ctrl) { + + const r = -100 * (ctrl.remaining / ctrl.total); + + this.element.style.setProperty('--vscode-action-item-auto-timeout', `${r}%`); + + this.element.classList.toggle('auto', true); + listener.value = addDisposableGenericMouseMoveListener(this.element, () => ctrl.cancel()); + } else { + this.element.classList.toggle('auto', false); + listener.clear(); + } + })); + } + } + + override set actionRunner(actionRunner: IActionRunner) { + super.actionRunner = actionRunner; + + const store = new DisposableStore(); + + store.add(actionRunner.onWillRun(_e => { + that._editor.focus(); + })); + + store.add(actionRunner.onDidRun(e => { + if (e.action !== this.action) { + return; + } + const d = that._entry.get(); + if (!d || d.entry === d.next) { + return; + } + const change = d.next.diffInfo.get().changes.at(0); + return editorService.openEditor({ + resource: d.next.modifiedURI, + options: { + selection: change && Range.fromPositions({ lineNumber: change.original.startLineNumber, column: 1 }), + revealIfOpened: false, + revealIfVisible: false, + } + }, ACTIVE_GROUP); + })); + + this._reveal.value = store; + } + override get actionRunner(): IActionRunner { + return super.actionRunner; + } + }; + } + return undefined; + } + }); + } + + dispose() { + this.hide(); + this._showStore.dispose(); + this._toolbar.dispose(); + } + + getId(): string { + return 'aideAgentEditorOverlayWidget'; + } + + getDomNode(): HTMLElement { + return this._domNode; + } + + getPosition(): IOverlayWidgetPosition | null { + return { preference: OverlayWidgetPositionPreference.BOTTOM_RIGHT_CORNER }; + } + + showRequest(session: IChatEditingSession) { + /* TODO(@ghostwriternr): Review this + this._showStore.clear(); + + const chatModel = this._chatService.getSession(session.chatSessionId); + const chatRequest = chatModel?.getRequests().at(-1); + + if (!chatRequest || !chatRequest.response) { + this.hide(); + return; + } + + this._domNode.classList.toggle('busy', true); + + const message = rcut(chatRequest.message.text, 47); + reset(this._progressNode, message); + + this._showStore.add(this._hoverService.setupDelayedHover(this._progressNode, { + content: chatRequest.message.text, + appearance: { showPointer: true } + })); + + this._show(); + */ + } + + showEntry(session: IChatEditingSession, activeEntry: IModifiedFileEntry, next: IModifiedFileEntry) { + + this._showStore.clear(); + + this._entry.set({ entry: activeEntry, next }, undefined); + + this._showStore.add(autorun(r => { + const busy = activeEntry.isCurrentlyBeingModified.read(r); + this._domNode.classList.toggle('busy', busy); + })); + + this._showStore.add(autorun(r => { + const value = activeEntry.rewriteRatio.read(r); + reset(this._progressNode, (value === 0 + ? localize('generating', "Generating Edits") + : localize('applyingPercentage', "{0}% Applying Edits", Math.round(value * 100)))); + })); + + this._showStore.add(autorun(r => { + + const ctrl = ChatEditorController.get(this._editor); + + const entryIndex = ctrl?.currentEntryIndex.read(r); + const changeIndex = ctrl?.currentChangeIndex.read(r); + + const entries = session.entries.read(r); + + let activeIdx = entryIndex !== undefined && changeIndex !== undefined + ? changeIndex + : -1; + + let changes = 0; + for (let i = 0; i < entries.length; i++) { + const diffInfo = entries[i].diffInfo.read(r); + changes += diffInfo.changes.length; + + if (entryIndex !== undefined && i < entryIndex) { + activeIdx += diffInfo.changes.length; + } + } + + this._navigationBearings.set({ changeCount: changes, activeIdx, entriesCount: entries.length }, undefined); + })); + + this._show(); + } + + private _show(): void { + + const editorWidthObs = observableFromEvent(this._editor.onDidLayoutChange, () => { + const diffEditor = this._instaService.invokeFunction(findDiffEditorContainingCodeEditor, this._editor); + return diffEditor + ? diffEditor.getOriginalEditor().getLayoutInfo().contentWidth + diffEditor.getModifiedEditor().getLayoutInfo().contentWidth + : this._editor.getLayoutInfo().contentWidth; + }); + + this._showStore.add(autorun(r => { + const width = editorWidthObs.read(r); + this._domNode.style.maxWidth = `${width - 20}px`; + })); + + if (!this._isAdded) { + this._editor.addOverlayWidget(this); + this._isAdded = true; + } + } + + hide() { + + transaction(tx => { + this._entry.set(undefined, tx); + this._navigationBearings.set({ changeCount: -1, activeIdx: -1, entriesCount: -1 }, tx); + }); + + if (this._isAdded) { + this._editor.removeOverlayWidget(this); + this._isAdded = false; + this._showStore.clear(); + } + } +} + + +export class ChatEditorOverlayController implements IEditorContribution { + + static readonly ID = 'editor.contrib.aideAgentEditorOverlayController'; + + static get(editor: ICodeEditor): ChatEditorOverlayController | undefined { + return editor.getContribution(ChatEditorOverlayController.ID) ?? undefined; + } + + private readonly _overlayWidget: ChatEditorOverlayWidget; + + constructor( + private readonly _editor: ICodeEditor, + @IInstantiationService private readonly _instaService: IInstantiationService, + ) { + this._overlayWidget = this._instaService.createInstance(ChatEditorOverlayWidget, this._editor); + + } + + dispose(): void { + this.hide(); + this._overlayWidget.dispose(); + } + + showRequest(session: IChatEditingSession) { + this._overlayWidget.showRequest(session); + } + + showEntry(session: IChatEditingSession, activeEntry: IModifiedFileEntry, next: IModifiedFileEntry) { + this._overlayWidget.showEntry(session, activeEntry, next); + } + + hide() { + this._overlayWidget.hide(); + } +} diff --git a/src/vs/workbench/contrib/aideAgent/browser/aideAgentInputPart.ts b/src/vs/workbench/contrib/aideAgent/browser/aideAgentInputPart.ts index 2254fda8c0d..47fc7730277 100644 --- a/src/vs/workbench/contrib/aideAgent/browser/aideAgentInputPart.ts +++ b/src/vs/workbench/contrib/aideAgent/browser/aideAgentInputPart.ts @@ -14,14 +14,17 @@ import { IHoverDelegate } from '../../../../base/browser/ui/hover/hoverDelegate. import { getBaseLayerHoverDelegate } from '../../../../base/browser/ui/hover/hoverDelegate2.js'; import { createInstantHoverDelegate, getDefaultHoverDelegate } from '../../../../base/browser/ui/hover/hoverDelegateFactory.js'; import { renderLabelWithIcons } from '../../../../base/browser/ui/iconLabel/iconLabels.js'; +import { ProgressBar } from '../../../../base/browser/ui/progressbar/progressbar.js'; import { Switch } from '../../../../base/browser/ui/switch/switch.js'; import { IAction } from '../../../../base/common/actions.js'; +import { coalesce } from '../../../../base/common/arrays.js'; import { Promises } from '../../../../base/common/async.js'; import { Codicon } from '../../../../base/common/codicons.js'; import { Emitter, Event } from '../../../../base/common/event.js'; import { HistoryNavigator2 } from '../../../../base/common/history.js'; import { KeyCode } from '../../../../base/common/keyCodes.js'; import { Disposable, DisposableStore, MutableDisposable } from '../../../../base/common/lifecycle.js'; +import { ResourceSet } from '../../../../base/common/map.js'; import { isMacintosh } from '../../../../base/common/platform.js'; import { ThemeIcon } from '../../../../base/common/themables.js'; import { URI } from '../../../../base/common/uri.js'; @@ -34,16 +37,21 @@ import { IPosition } from '../../../../editor/common/core/position.js'; import { IRange, Range } from '../../../../editor/common/core/range.js'; import { ITextModel } from '../../../../editor/common/model.js'; import { IModelService } from '../../../../editor/common/services/model.js'; +import { ITextModelService } from '../../../../editor/common/services/resolverService.js'; import { CopyPasteController } from '../../../../editor/contrib/dropOrPasteInto/browser/copyPasteController.js'; +import { DropIntoEditorController } from '../../../../editor/contrib/dropOrPasteInto/browser/dropIntoEditorController.js'; import { ContentHoverController } from '../../../../editor/contrib/hover/browser/contentHoverController.js'; import { GlyphHoverController } from '../../../../editor/contrib/hover/browser/glyphHoverController.js'; +import { LinkDetector } from '../../../../editor/contrib/links/browser/links.js'; import { SuggestController } from '../../../../editor/contrib/suggest/browser/suggestController.js'; import { localize } from '../../../../nls.js'; import { IAccessibilityService } from '../../../../platform/accessibility/common/accessibility.js'; import { ActionViewItemWithKb } from '../../../../platform/actionbarWithKeybindings/browser/actionViewItemWithKb.js'; +import { MenuWorkbenchButtonBar } from '../../../../platform/actions/browser/buttonbar.js'; import { MenuEntryActionViewItem } from '../../../../platform/actions/browser/menuEntryActionViewItem.js'; import { HiddenItemStrategy, MenuWorkbenchToolBar } from '../../../../platform/actions/browser/toolbar.js'; import { MenuId, MenuItemAction } from '../../../../platform/actions/common/actions.js'; +import { AgentMode } from '../../../../platform/aideAgent/common/model.js'; import { IAIModelSelectionService } from '../../../../platform/aiModel/common/aiModels.js'; import { ICommandService } from '../../../../platform/commands/common/commands.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; @@ -56,12 +64,13 @@ import { IHoverService } from '../../../../platform/hover/browser/hover.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { ServiceCollection } from '../../../../platform/instantiation/common/serviceCollection.js'; import { IKeybindingService } from '../../../../platform/keybinding/common/keybinding.js'; +import { WorkbenchList } from '../../../../platform/list/browser/listService.js'; import { ILogService } from '../../../../platform/log/common/log.js'; import { INotificationService } from '../../../../platform/notification/common/notification.js'; import { IOpenerService, OpenInternalOptions } from '../../../../platform/opener/common/opener.js'; import { IThemeService } from '../../../../platform/theme/common/themeService.js'; import { ResourceLabels } from '../../../browser/labels.js'; -import { AgentMode } from '../../../../platform/aideAgent/common/model.js'; +import { ACTIVE_GROUP, IEditorService, SIDE_GROUP } from '../../../services/editor/common/editorService.js'; import { AccessibilityVerbositySettingId } from '../../accessibility/browser/accessibilityConfiguration.js'; import { AccessibilityCommandId } from '../../accessibility/common/accessibilityCommands.js'; import { getSimpleCodeEditorWidgetOptions, getSimpleEditorOptions, setupSimpleEditorSelectionStyling } from '../../codeEditor/browser/simpleEditorOptions.js'; @@ -69,24 +78,35 @@ import { revealInSideBarCommand } from '../../files/browser/fileActions.contribu import { ModelSelectionIndicator } from '../../preferences/browser/modelSelectionIndicator.js'; import { ChatAgentLocation } from '../common/aideAgentAgents.js'; import { CONTEXT_CHAT_HAS_FILE_ATTACHMENTS, CONTEXT_CHAT_INPUT_CURSOR_AT_TOP, CONTEXT_CHAT_INPUT_HAS_FOCUS, CONTEXT_CHAT_INPUT_HAS_TEXT, CONTEXT_CHAT_MODE, CONTEXT_IN_CHAT_INPUT } from '../common/aideAgentContextKeys.js'; +import { ChatEditingSessionState, IChatEditingSession, WorkingSetEntryState } from '../common/aideAgentEditingService.js'; import { AgentScope, IChatRequestVariableEntry } from '../common/aideAgentModel.js'; import { IChatFollowup } from '../common/aideAgentService.js'; import { IChatResponseViewModel } from '../common/aideAgentViewModel.js'; -import { IAideAgentWidgetHistoryService, IChatHistoryEntry } from '../common/aideAgentWidgetHistoryService.js'; +import { IAideAgentWidgetHistoryService, IChatHistoryEntry, IChatInputState } from '../common/aideAgentWidgetHistoryService.js'; import { IAideAgentLMService } from '../common/languageModels.js'; import { ISidecarService, SidecarDownloadStatus, SidecarRunningStatus } from '../common/sidecarService.js'; import { AgentScopePickerActionId, CancelAction, ExecuteChatAction, IChatExecuteActionContext, ToggleEditModeAction } from './actions/aideAgentExecuteActions.js'; import { IChatWidget } from './aideAgent.js'; import { AideAgentAttachmentModel } from './aideAgentAttachmentModel.js'; +import { IDisposableReference } from './aideAgentContentParts/aideAgentCollections.js'; +import { CollapsibleListPool, IChatCollapsibleListItem } from './aideAgentContentParts/aideAgentReferencesContentPart.js'; +import { ChatEditingShowChangesAction } from './aideAgentEditing/aideAgentEditingActions.js'; import { ChatFollowups } from './aideAgentFollowups.js'; +import { IChatViewState } from './aideAgentWidget.js'; const $ = dom.$; const INPUT_EDITOR_MAX_HEIGHT = 250; +export interface IChatInputStyles { + overlayBackground: string; + listForeground: string; + listBackground: string; +} + interface IChatInputPartOptions { renderFollowups: boolean; - renderStyle?: 'default' | 'compact'; + renderStyle?: 'compact'; menus: { executeToolbar: MenuId; inputSideToolbar?: MenuId; @@ -96,6 +116,11 @@ interface IChatInputPartOptions { preventChatEditToggle?: boolean; } +export interface IWorkingSetEntry { + uri: URI; + isMarkedReadonly?: boolean; +} + export class ChatInputPart extends Disposable implements IHistoryNavigationWidget { static readonly INPUT_SCHEME = 'aideAgentSessionInput'; private static _counter = 0; @@ -142,6 +167,8 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge private attachedContextContainer!: HTMLElement; private readonly attachedContextDisposables = this._register(new MutableDisposable()); + private chatEditingSessionWidgetContainer!: HTMLElement; + private statusClickable = false; private statusMessageContainer!: HTMLElement; @@ -164,7 +191,6 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge private history: HistoryNavigator2; private historyNavigationBackwardsEnablement!: IContextKey; private historyNavigationForewardsEnablement!: IContextKey; - private inHistoryNavigation = false; private inputModel: ITextModel | undefined; private inputEditorHasText: IContextKey; private chatCursorAtTop: IContextKey; @@ -226,11 +252,36 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge readonly inputUri = URI.parse(`${ChatInputPart.INPUT_SCHEME}:input-${ChatInputPart._counter++}`); + private readonly _chatEditsActionsDisposables = this._register(new DisposableStore()); + private readonly _chatEditsDisposables = this._register(new DisposableStore()); + private _chatEditsProgress: ProgressBar | undefined; + private _chatEditsListPool: CollapsibleListPool; + private _chatEditList: IDisposableReference> | undefined; + get selectedElements(): URI[] { + const edits = []; + const editsList = this._chatEditList?.object; + const selectedElements = editsList?.getSelectedElements() ?? []; + for (const element of selectedElements) { + if (element.kind === 'reference' && URI.isUri(element.reference)) { + edits.push(element.reference); + } + } + return edits; + } + + private _combinedChatEditWorkingSetEntries: IWorkingSetEntry[] = []; + public get chatEditWorkingSetFiles() { + return this._combinedChatEditWorkingSetEntries; + } + + private readonly getInputState: () => IChatInputState; + constructor( // private readonly editorOptions: ChatEditorOptions, // TODO this should be used private readonly location: ChatAgentLocation, private readonly options: IChatInputPartOptions, - private readonly getInputState: () => any, + styles: IChatInputStyles, + getContribsInputState: () => any, @IAccessibilityService private readonly accessibilityService: IAccessibilityService, @IAideAgentLMService private readonly languageModelsService: IAideAgentLMService, @IAideAgentWidgetHistoryService private readonly historyService: IAideAgentWidgetHistoryService, @@ -238,6 +289,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge @ICommandService private readonly commandService: ICommandService, @IConfigurationService private readonly configurationService: IConfigurationService, @IContextKeyService private readonly contextKeyService: IContextKeyService, + @IEditorService private readonly editorService: IEditorService, @IFileService private readonly fileService: IFileService, @IHoverService private readonly hoverService: IHoverService, @IInstantiationService private readonly instantiationService: IInstantiationService, @@ -246,11 +298,19 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge @IModelService private readonly modelService: IModelService, @IOpenerService private readonly openerService: IOpenerService, @ISidecarService private readonly sidecarService: ISidecarService, + @ITextModelService private readonly textModelResolverService: ITextModelService, @IThemeService private readonly themeService: IThemeService, ) { super(); this._attachmentModel = this._register(this.instantiationService.createInstance(AideAgentAttachmentModel)); + + this.getInputState = (): IChatInputState => { + return { + ...getContribsInputState(), + chatContextAttachments: this._attachmentModel.attachments, + }; + }; this.inputEditorMaxHeight = this.options.renderStyle === 'compact' ? INPUT_EDITOR_MAX_HEIGHT / 3 : INPUT_EDITOR_MAX_HEIGHT; this.inputEditorHasText = CONTEXT_CHAT_INPUT_HAS_TEXT.bindTo(contextKeyService); @@ -268,16 +328,9 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge } })); - this._hasFileAttachmentContextKey = CONTEXT_CHAT_HAS_FILE_ATTACHMENTS.bindTo(contextKeyService); - } + this._chatEditsListPool = this._register(this.instantiationService.createInstance(CollapsibleListPool, this._onDidChangeVisibility.event, MenuId.AideAgentEditingWidgetModifiedFilesToolbar)); - private resetCurrentLanguageModel() { - const defaultLanguageModel = this.languageModelsService.getLanguageModelIds().find(id => this.languageModelsService.lookupLanguageModel(id)?.isDefault); - const hasUserSelectableLanguageModels = this.languageModelsService.getLanguageModelIds().find(id => { - const model = this.languageModelsService.lookupLanguageModel(id); - return model?.isUserSelectable && !model.isDefault; - }); - this._currentLanguageModel = hasUserSelectableLanguageModels ? defaultLanguageModel : undefined; + this._hasFileAttachmentContextKey = CONTEXT_CHAT_HAS_FILE_ATTACHMENTS.bindTo(contextKeyService); } private loadHistory(): HistoryNavigator2 { @@ -298,29 +351,17 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge return localize('chatInput', "Chat Input"); } - updateState(inputState: Object): void { - if (this.inHistoryNavigation) { - return; - } - - const newEntry = { text: this._inputEditor.getValue(), state: inputState }; - - if (this.history.isAtEnd()) { - // The last history entry should always be the current input value - this.history.replaceLast(newEntry); - } else { - // Added a reference while in the middle of history navigation, it's a new entry - this.history.replaceLast(newEntry); - this.history.resetCursor(); - } - } - - initForNewChatModel(inputValue: string | undefined, inputState: Object): void { + initForNewChatModel(state: IChatViewState): void { this.history = this.loadHistory(); - this.history.add({ text: inputValue ?? this.history.current().text, state: inputState }); + this.history.add({ + text: state.inputValue ?? this.history.current().text, + state: state.inputState ?? this.getInputState() + }); + const attachments = state.inputState?.chatContextAttachments ?? []; + this._attachmentModel.clearAndSetContext(...attachments); - if (inputValue) { - this.setValue(inputValue, false); + if (state.inputValue) { + this.setValue(state.inputValue, false); } } @@ -369,21 +410,31 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge const historyEntry = previous ? this.history.previous() : this.history.next(); - aria.status(historyEntry.text); + const historyAttachments = historyEntry.state?.chatContextAttachments ?? []; + this._attachmentModel.clearAndSetContext(...historyAttachments); - this.inHistoryNavigation = true; + aria.status(historyEntry.text); this.setValue(historyEntry.text, true); - this.inHistoryNavigation = false; this._onDidLoadInputState.fire(historyEntry.state); + + const model = this._inputEditor.getModel(); + if (!model) { + return; + } + if (previous) { - this._inputEditor.setPosition({ lineNumber: 1, column: 1 }); - } else { - const model = this._inputEditor.getModel(); - if (!model) { - return; + const endOfFirstViewLine = this._inputEditor._getViewModel()?.getLineLength(1) ?? 1; + const endOfFirstModelLine = model.getLineLength(1); + if (endOfFirstViewLine === endOfFirstModelLine) { + // Not wrapped - set cursor to the end of the first line + this._inputEditor.setPosition({ lineNumber: 1, column: endOfFirstViewLine + 1 }); + } else { + // Wrapped - set cursor one char short of the end of the first view line. + // If it's after the next character, the cursor shows on the second line. + this._inputEditor.setPosition({ lineNumber: 1, column: endOfFirstViewLine }); } - + } else { this._inputEditor.setPosition(getLastPosition(model)); } } @@ -398,7 +449,8 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge } } - private saveCurrentValue(inputState: any): void { + private saveCurrentValue(inputState: IChatInputState): void { + inputState.chatContextAttachments = inputState.chatContextAttachments?.filter(attachment => !attachment.isImage); const newEntry = { text: this._inputEditor.getValue(), state: inputState }; this.history.replaceLast(newEntry); } @@ -421,11 +473,10 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge const entry: IChatHistoryEntry = { text: userQuery, state: this.getInputState() }; this.history.replaceLast(entry); this.history.add({ text: '' }); - this.resetCurrentLanguageModel(); } // Clear attached context, fire event to clear input state, and clear the input editor - this._attachmentModel.clear(); + this.attachmentModel.clear(); this._onDidLoadInputState.fire({}); if (this.accessibilityService.isScreenReaderOptimized() && isMacintosh) { this._acceptInputForVoiceover(); @@ -457,13 +508,14 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge let elements; if (this.options.renderStyle === 'compact') { elements = dom.h('.interactive-input-part', [ + dom.h('.chat-editing-session@chatEditingSessionWidgetContainer'), dom.h('.interactive-input-and-side-toolbar@inputAndSideToolbar', [ dom.h('.chat-input-container@inputContainer', [ dom.h('.chat-editor-container@editorContainer'), dom.h('.aideagent-input-toolbars@inputToolbars'), ]), ]), - dom.h('.aideagent-attached-context@attachedContextContainer'), + dom.h('.chat-attached-context@attachedContextContainer'), dom.h('.interactive-input-followups@followupsContainer'), dom.h('.interactive-input-status-message@statusMessageContainer', [ dom.h('.model-config@modelConfig'), @@ -474,9 +526,10 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge } else { elements = dom.h('.interactive-input-part', [ dom.h('.interactive-input-followups@followupsContainer'), + dom.h('.chat-editing-session@chatEditingSessionWidgetContainer'), dom.h('.interactive-input-and-side-toolbar@inputAndSideToolbar', [ dom.h('.chat-input-container@inputContainer', [ - dom.h('.aideagent-attached-context@attachedContextContainer'), + dom.h('.chat-attached-context@attachedContextContainer'), dom.h('.chat-editor-container@editorContainer'), dom.h('.aideagent-input-toolbars@inputToolbars'), ]), @@ -497,8 +550,10 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge const editorContainer = elements.editorContainer; this.attachedContextContainer = elements.attachedContextContainer; const toolbarsContainer = elements.inputToolbars; + this.chatEditingSessionWidgetContainer = elements.chatEditingSessionWidgetContainer; this.renderAttachedContext(); this._register(this._attachmentModel.onDidChangeContext(() => this._handleAttachedContextChange())); + this.renderChatEditingSessionState(null, widget); this.statusMessageContainer = elements.statusMessageContainer; const modelConfig = elements.modelConfig; @@ -548,7 +603,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge this._inputEditorElement = dom.append(editorContainer!, $(chatInputEditorContainerSelector)); const editorOptions = getSimpleCodeEditorWidgetOptions(); - editorOptions.contributions?.push(...EditorExtensionsRegistry.getSomeEditorContributions([ContentHoverController.ID, GlyphHoverController.ID, CopyPasteController.ID])); + editorOptions.contributions?.push(...EditorExtensionsRegistry.getSomeEditorContributions([ContentHoverController.ID, GlyphHoverController.ID, CopyPasteController.ID, LinkDetector.ID])); this._inputEditor = this._register(scopedInstantiationService.createInstance(CodeEditorWidget, this._inputEditorElement, options, editorOptions)); if (!isFloatingWidget) { @@ -567,6 +622,12 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge const inputHasText = !!model && model.getValue().trim().length > 0; this.inputEditorHasText.set(inputHasText); })); + this._register(this._inputEditor.onDidContentSizeChange(e => { + if (e.contentHeightChanged) { + this.inputEditorHeight = e.contentHeight; + this._onDidChangeHeight.fire(); + } + })); this._register(this._inputEditor.onDidFocusEditorText(() => { this.inputEditorHasFocus.set(true); this._onDidFocus.fire(); @@ -580,13 +641,17 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge })); this._register(this._inputEditor.onDidBlurEditorWidget(() => { CopyPasteController.get(this._inputEditor)?.clearWidgets(); + DropIntoEditorController.get(this._inputEditor)?.clearWidgets(); })); + const hoverDelegate = this._register(createInstantHoverDelegate()); + this._register(dom.addStandardDisposableListener(toolbarsContainer, dom.EventType.CLICK, e => this.inputEditor.focus())); this.inputActionsToolbar = this._register(this.instantiationService.createInstance(MenuWorkbenchToolBar, toolbarsContainer, MenuId.AideAgentInput, { telemetrySource: this.options.menus.telemetrySource, menuOptions: { shouldForwardArgs: true }, hiddenItemStrategy: HiddenItemStrategy.Ignore, + hoverDelegate, actionViewItemProvider: (action, options) => { if (action.id === AgentScopePickerActionId && action instanceof MenuItemAction) { const scopeDelegate: AgentScopeSetterDelegate = { @@ -632,6 +697,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge menuOptions: { shouldForwardArgs: true }, + hoverDelegate, hiddenItemStrategy: HiddenItemStrategy.Ignore, // keep it lean when hiding items and avoid a "..." overflow menu actionViewItemProvider: (action, options) => { if ((action.id === ExecuteChatAction.ID || action.id === CancelAction.ID) && action instanceof MenuItemAction) { @@ -641,6 +707,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge return undefined; } })); + this.executeToolbar.getElement().classList.add('chat-execute-toolbar'); this.executeToolbar.context = { widget } satisfies IChatExecuteActionContext; this._register(this.executeToolbar.onDidChangeMenuItems(() => { if (this.cachedDimensions && typeof this.cachedExecuteToolbarWidth === 'number' && this.cachedExecuteToolbarWidth !== this.executeToolbar.getItemsWidth()) { @@ -652,7 +719,8 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge telemetrySource: this.options.menus.telemetrySource, menuOptions: { shouldForwardArgs: true - } + }, + hoverDelegate })); this.inputSideToolbarContainer = toolbarSide.getElement(); toolbarSide.getElement().classList.add('chat-side-toolbar'); @@ -683,9 +751,17 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge let inputModel = this.modelService.getModel(this.inputUri); if (!inputModel) { inputModel = this.modelService.createModel('', null, this.inputUri, true); - this._register(inputModel); } + this.textModelResolverService.createModelReference(this.inputUri).then(ref => { + // make sure to hold a reference so that the model doesn't get disposed by the text model service + if (this._store.isDisposed) { + ref.dispose(); + return; + } + this._register(ref); + }); + this.inputModel = inputModel; this.inputModel.updateOptions({ bracketColorizationOptions: { enabled: false, independentColorPoolPerBracketType: false } }); this._inputEditor.setModel(this.inputModel); @@ -706,7 +782,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge return; } - const atTop = position.column === 1 && position.lineNumber === 1; + const atTop = position.lineNumber === 1 && position.column - 1 <= (this._inputEditor._getViewModel()?.getLineLength(1) ?? 0); this.chatCursorAtTop.set(atTop); this.historyNavigationBackwardsEnablement.set(atTop); @@ -826,7 +902,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge const attachmentInitPromises: Promise[] = []; for (const [index, attachment] of attachments) { - const widget = dom.append(container, $('.aideagent-attached-context-attachment.show-file-icons')); + const widget = dom.append(container, $('.chat-attached-context-attachment.show-file-icons')); const label = this._contextResourceLabels.create(widget, { supportIcons: true, hoverDelegate, hoverTargetOverride: widget }); let ariaLabel: string | undefined; @@ -863,10 +939,10 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge console.error('Error processing attachment:', error); } - widget.style.position = 'relative'; store.add(this.hoverService.setupManagedHover(hoverDelegate, widget, hoverElement, { trapFocus: false })); resolve(); })); + widget.style.position = 'relative'; } else { const attachmentLabel = attachment.fullName ?? attachment.name; const withIcon = attachment.icon?.id ? `$(${attachment.icon.id}) ${attachmentLabel}` : attachmentLabel; @@ -1027,6 +1103,142 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge }; } + async renderChatEditingSessionState(chatEditingSession: IChatEditingSession | null, chatWidget?: IChatWidget) { + dom.setVisibility(Boolean(chatEditingSession), this.chatEditingSessionWidgetContainer); + + if (!chatEditingSession) { + dom.clearNode(this.chatEditingSessionWidgetContainer); + this._chatEditsDisposables.clear(); + this._chatEditList = undefined; + this._combinedChatEditWorkingSetEntries = []; + this._chatEditsProgress?.dispose(); + return; + } + + const currentChatEditingState = chatEditingSession.state.get(); + if (this._chatEditList && !chatWidget?.viewModel?.requestInProgress && (currentChatEditingState === ChatEditingSessionState.Idle || currentChatEditingState === ChatEditingSessionState.Initial)) { + this._chatEditsProgress?.stop(); + } + + // Summary of number of files changed + const innerContainer = this.chatEditingSessionWidgetContainer.querySelector('.chat-editing-session-container.show-file-icons') as HTMLElement ?? dom.append(this.chatEditingSessionWidgetContainer, $('.chat-editing-session-container.show-file-icons')); + const seenEntries = new ResourceSet(); + const entries: IChatCollapsibleListItem[] = chatEditingSession?.entries.get().map((entry) => { + seenEntries.add(entry.modifiedURI); + return { + reference: entry.modifiedURI, + state: entry.state.get(), + kind: 'reference', + }; + }) ?? []; + for (const [file, metadata] of chatEditingSession.workingSet.entries()) { + if (!seenEntries.has(file) && metadata.state !== WorkingSetEntryState.Suggested) { + entries.unshift({ + reference: file, + state: metadata.state, + description: metadata.description, + kind: 'reference', + isMarkedReadonly: metadata.isMarkedReadonly, + }); + seenEntries.add(file); + } + } + entries.sort((a, b) => { + if (a.kind === 'reference' && b.kind === 'reference') { + if (a.state === b.state || a.state === undefined || b.state === undefined) { + return a.reference.toString().localeCompare(b.reference.toString()); + } + return a.state - b.state; + } + return 0; + }); + const overviewRegion = innerContainer.querySelector('.chat-editing-session-overview') as HTMLElement ?? dom.append(innerContainer, $('.chat-editing-session-overview')); + const overviewTitle = overviewRegion.querySelector('.working-set-title') as HTMLElement ?? dom.append(overviewRegion, $('.working-set-title')); + const overviewWorkingSet = overviewTitle.querySelector('span') ?? dom.append(overviewTitle, $('span')); + const overviewFileCount = overviewTitle.querySelector('span.working-set-count') ?? dom.append(overviewTitle, $('span.working-set-count')); + + overviewWorkingSet.textContent = localize('chatEditingSession.workingSet', 'Session files'); + overviewTitle.ariaLabel = overviewTitle.textContent; + overviewTitle.tabIndex = 0; + + if (entries.length > 1) { + const fileCount = entries.length; + overviewFileCount.textContent = ' ' + (fileCount === 1 ? localize('chatEditingSession.oneFile', '(1 file)') : localize('chatEditingSession.manyFiles', '({0} files)', fileCount)); + } + + // Clear out the previous actions (if any) + this._chatEditsActionsDisposables.clear(); + + // Chat editing session actions + const actionsContainer = overviewRegion.querySelector('.chat-editing-session-actions') as HTMLElement ?? dom.append(overviewRegion, $('.chat-editing-session-actions')); + + this._chatEditsActionsDisposables.add(this.instantiationService.createInstance(MenuWorkbenchButtonBar, actionsContainer, MenuId.AideAgentEditingWidgetToolbar, { + telemetrySource: this.options.menus.telemetrySource, + menuOptions: { + arg: { sessionId: chatEditingSession.chatSessionId }, + }, + buttonConfigProvider: (action) => { + if (action.id === ChatEditingShowChangesAction.ID) { + return { showIcon: true, showLabel: false, isSecondary: true }; + } + return undefined; + } + })); + + if (!chatEditingSession) { + return; + } + + if (currentChatEditingState === ChatEditingSessionState.StreamingEdits || chatWidget?.viewModel?.requestInProgress) { + // this._chatEditsProgress ??= new ProgressBar(innerContainer); + this._chatEditsProgress?.infinite().show(500); + } + + // Working set + const workingSetContainer = innerContainer.querySelector('.chat-editing-session-list') as HTMLElement ?? dom.append(innerContainer, $('.chat-editing-session-list')); + if (!this._chatEditList) { + this._chatEditList = this._chatEditsListPool.get(); + const list = this._chatEditList.object; + this._chatEditsDisposables.add(this._chatEditList); + this._chatEditsDisposables.add(list.onDidFocus(() => { + this._onDidFocus.fire(); + })); + this._chatEditsDisposables.add(list.onDidOpen((e) => { + if (e.element?.kind === 'reference' && URI.isUri(e.element.reference)) { + const modifiedFileUri = e.element.reference; + + const entry = chatEditingSession.getEntry(modifiedFileUri); + const diffInfo = entry?.diffInfo.get(); + const range = diffInfo?.changes.at(0)?.modified.toExclusiveRange(); + + this.editorService.openEditor({ + resource: modifiedFileUri, + options: { + ...e.editorOptions, + selection: range, + } + }, e.sideBySide ? SIDE_GROUP : ACTIVE_GROUP); + } + })); + this._chatEditsDisposables.add(dom.addDisposableListener(list.getHTMLElement(), 'click', e => { + if (!this.hasFocus()) { + this._onDidFocus.fire(); + } + }, true)); + dom.append(workingSetContainer, list.getHTMLElement()); + dom.append(innerContainer, workingSetContainer); + } + + const maxItemsShown = 6; + const itemsShown = Math.min(entries.length, maxItemsShown); + const height = itemsShown * 22; + const list = this._chatEditList.object; + list.layout(height); + list.getHTMLElement().style.height = `${height}px`; + list.splice(0, list.length, entries); + this._combinedChatEditWorkingSetEntries = coalesce(entries.map((e) => e.kind === 'reference' && URI.isUri(e.reference) ? ({ uri: e.reference, isMarkedReadonly: e.isMarkedReadonly }) : undefined)); + } + async renderFollowups(items: IChatFollowup[] | undefined, response: IChatResponseViewModel | undefined): Promise { if (!this.options.renderFollowups) { return; @@ -1042,7 +1254,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge get contentHeight(): number { const data = this.getLayoutData(); - return data.followupsHeight + data.inputPartEditorHeight + data.inputPartVerticalPadding + data.inputEditorBorder + data.attachmentsHeight + data.toolbarsHeight + data.statusMessageHeight; + return data.followupsHeight + data.inputPartEditorHeight + data.inputPartVerticalPadding + data.inputEditorBorder + data.attachmentsHeight + data.toolbarsHeight + data.chatEditingStateHeight + data.statusMessageHeight; } layout(height: number, width: number) { @@ -1059,7 +1271,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge const followupsWidth = width - data.inputPartHorizontalPadding; this.followupsContainer.style.width = `${followupsWidth}px`; - this._inputPartHeight = data.inputPartVerticalPadding + data.followupsHeight + inputEditorHeight + data.inputEditorBorder + data.attachmentsHeight + data.toolbarsHeight + data.statusMessageHeight; + this._inputPartHeight = data.inputPartVerticalPadding + data.followupsHeight + inputEditorHeight + data.inputEditorBorder + data.attachmentsHeight + data.toolbarsHeight + data.chatEditingStateHeight + data.statusMessageHeight; const initialEditorScrollWidth = this._inputEditor.getScrollWidth(); const newEditorWidth = width - data.inputPartHorizontalPadding - data.editorBorder - data.inputPartHorizontalPaddingInside - data.toolbarsWidth - data.sideToolbarWidth; @@ -1081,7 +1293,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge const executeToolbarWidth = this.cachedExecuteToolbarWidth = this.executeToolbar.getItemsWidth(); const inputToolbarWidth = this.cachedInputToolbarWidth = this.inputActionsToolbar.getItemsWidth(); const executeToolbarPadding = (this.executeToolbar.getItemsLength() - 1) * 4; - const inputToolbarPadding = (this.inputActionsToolbar.getItemsLength() - 1) * 4; + const inputToolbarPadding = this.inputActionsToolbar.getItemsLength() ? (this.inputActionsToolbar.getItemsLength() - 1) * 4 : 0; return { inputEditorBorder: 2, followupsHeight: this.followupsContainer.offsetHeight, @@ -1093,11 +1305,16 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge inputPartHorizontalPaddingInside: 12, toolbarsWidth: this.options.renderStyle === 'compact' ? executeToolbarWidth + executeToolbarPadding + inputToolbarWidth + inputToolbarPadding : 0, toolbarsHeight: this.options.renderStyle === 'compact' ? 0 : 22, + chatEditingStateHeight: this.chatEditingSessionWidgetContainer.offsetHeight, sideToolbarWidth: this.inputSideToolbarContainer ? dom.getTotalWidth(this.inputSideToolbarContainer) + 4 /*gap*/ : 0, statusMessageHeight: this.statusMessageContainer ? this.statusMessageContainer.offsetHeight : 0 }; } + getViewState(): IChatInputState { + return this.getInputState(); + } + saveState(): void { this.saveCurrentValue(this.getInputState()); const inputHistory = [...this.history]; @@ -1126,7 +1343,7 @@ export class AgentScopeActionViewItem extends MenuEntryActionViewItem { @IContextKeyService contextKeyService: IContextKeyService, @IThemeService themeService: IThemeService, @IContextMenuService contextMenuService: IContextMenuService, - @IAccessibilityService _accessibilityService: IAccessibilityService + @IAccessibilityService _accessibilityService: IAccessibilityService, ) { super(action, undefined, keybindingService, notificationService, contextKeyService, themeService, contextMenuService, _accessibilityService); diff --git a/src/vs/workbench/contrib/aideAgent/browser/aideAgentListRenderer.ts b/src/vs/workbench/contrib/aideAgent/browser/aideAgentListRenderer.ts index ef81b65440f..79728246a53 100644 --- a/src/vs/workbench/contrib/aideAgent/browser/aideAgentListRenderer.ts +++ b/src/vs/workbench/contrib/aideAgent/browser/aideAgentListRenderer.ts @@ -6,41 +6,34 @@ import * as dom from '../../../../base/browser/dom.js'; import { renderFormattedText } from '../../../../base/browser/formattedTextRenderer.js'; import { IActionViewItemOptions } from '../../../../base/browser/ui/actionbar/actionViewItems.js'; -import { DropdownMenuActionViewItem, IDropdownMenuActionViewItemOptions } from '../../../../base/browser/ui/dropdown/dropdownActionViewItem.js'; import { IListVirtualDelegate } from '../../../../base/browser/ui/list/list.js'; import { ITreeNode, ITreeRenderer } from '../../../../base/browser/ui/tree/tree.js'; import { IAction } from '../../../../base/common/actions.js'; import { coalesce, distinct } from '../../../../base/common/arrays.js'; -import { Codicon } from '../../../../base/common/codicons.js'; import { Emitter, Event } from '../../../../base/common/event.js'; import { FuzzyScore } from '../../../../base/common/filters.js'; import { MarkdownString } from '../../../../base/common/htmlContent.js'; import { Disposable, DisposableStore, IDisposable, dispose, toDisposable } from '../../../../base/common/lifecycle.js'; import { ResourceMap } from '../../../../base/common/map.js'; import { autorun } from '../../../../base/common/observable.js'; -import { ThemeIcon } from '../../../../base/common/themables.js'; import { URI } from '../../../../base/common/uri.js'; import { MarkdownRenderer } from '../../../../editor/browser/widget/markdownRenderer/browser/markdownRenderer.js'; import { localize } from '../../../../nls.js'; -import { IMenuEntryActionViewItemOptions, createActionViewItem } from '../../../../platform/actions/browser/menuEntryActionViewItem.js'; +import { createActionViewItem } from '../../../../platform/actions/browser/menuEntryActionViewItem.js'; import { MenuWorkbenchToolBar } from '../../../../platform/actions/browser/toolbar.js'; -import { MenuId, MenuItemAction } from '../../../../platform/actions/common/actions.js'; -import { ICommandService } from '../../../../platform/commands/common/commands.js'; +import { MenuId } from '../../../../platform/actions/common/actions.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; -import { IContextMenuService } from '../../../../platform/contextview/browser/contextView.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { ServiceCollection } from '../../../../platform/instantiation/common/serviceCollection.js'; import { ILogService } from '../../../../platform/log/common/log.js'; -import { IWorkbenchIssueService } from '../../issue/common/issue.js'; -import { CONTEXT_CHAT_RESPONSE_SUPPORT_ISSUE_REPORTING, CONTEXT_REQUEST, CONTEXT_RESPONSE, CONTEXT_RESPONSE_DETECTED_AGENT_COMMAND, CONTEXT_RESPONSE_ERROR, CONTEXT_RESPONSE_FILTERED, CONTEXT_RESPONSE_VOTE } from '../common/aideAgentContextKeys.js'; +import { CONTEXT_CHAT_CAN_REVERT_EXCHANGE, CONTEXT_CHAT_RESPONSE_SUPPORT_ISSUE_REPORTING, CONTEXT_REQUEST, CONTEXT_RESPONSE, CONTEXT_RESPONSE_DETECTED_AGENT_COMMAND, CONTEXT_RESPONSE_ERROR, CONTEXT_RESPONSE_FILTERED, CONTEXT_RESPONSE_VOTE } from '../common/aideAgentContextKeys.js'; import { IChatRequestVariableEntry, IChatTextEditGroup } from '../common/aideAgentModel.js'; import { chatSubcommandLeader } from '../common/aideAgentParserTypes.js'; -import { ChatAgentVoteDirection, ChatAgentVoteDownReason, IAideAgentToolTypeError, IChatConfirmation, IChatContentReference, IChatFollowup, IChatMarkdownContent, IChatTask, IChatTreeData } from '../common/aideAgentService.js'; +import { ChatAgentVoteDirection, IAideAgentToolTypeError, IChatConfirmation, IChatContentReference, IChatFollowup, IChatMarkdownContent, IChatTask, IChatTreeData } from '../common/aideAgentService.js'; import { IChatCodeCitations, IChatReferences, IChatRendererContent, IChatRequestViewModel, IChatResponseViewModel, isRequestVM, isResponseVM } from '../common/aideAgentViewModel.js'; import { annotateSpecialMarkdownContent } from '../common/annotations.js'; import { CodeBlockModelCollection } from '../common/codeBlockModelCollection.js'; -import { MarkUnhelpfulActionId } from './actions/aideAgentTitleActions.js'; import { ChatTreeItem, IChatCodeBlockInfo, IChatFileTreeInfo, IChatListItemRendererOptions, IEditPreviewCodeBlockInfo } from './aideAgent.js'; import { ChatAttachmentsContentPart } from './aideAgentContentParts/aideAgentAttachmentsContentPart.js'; import { ChatCodeCitationContentPart } from './aideAgentContentParts/aideAgentCodeCitationContentPart.js'; @@ -65,6 +58,7 @@ interface IChatListItemTemplate { currentElement?: ChatTreeItem; renderedParts?: IChatContentPart[]; readonly rowContainer: HTMLElement; + readonly header: HTMLElement; readonly titleToolbar?: MenuWorkbenchToolBar; readonly avatarContainer: HTMLElement; readonly username: HTMLElement; @@ -86,6 +80,7 @@ const forceVerboseLayoutTracing = false ; export interface IChatRendererDelegate { + container: HTMLElement; getListLength(): number; readonly onDidScroll?: Event; @@ -142,7 +137,7 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer submenu.actions.length <= 1 }, actionViewItemProvider: (action: IAction, options: IActionViewItemOptions) => { - if (action instanceof MenuItemAction && action.item.id === MarkUnhelpfulActionId) { - return scopedInstantiationService.createInstance(ChatVoteDownButton, action, options as IMenuEntryActionViewItemOptions); - } return createActionViewItem(scopedInstantiationService, action, options); } })); } - const template: IChatListItemTemplate = { avatarContainer, username, detail, value, rowContainer, elementDisposables, templateDisposables, contextKeyService, instantiationService: scopedInstantiationService, titleToolbar }; + const template: IChatListItemTemplate = { header, avatarContainer, username, detail, value, rowContainer, elementDisposables, templateDisposables, contextKeyService, instantiationService: scopedInstantiationService, titleToolbar }; return template; } @@ -306,8 +298,28 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer { const context: IChatContentPartRenderContext = { element, - index, + contentIndex: index, content: value, preceedingContentParts: parts, }; @@ -466,9 +478,13 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer { this.updateItemHeight(templateData); })); @@ -724,12 +740,8 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer | undefined, templateData: IChatListItemTemplate) { - const attachmentPart = this.instantiationService.createInstance(ChatAttachmentsContentPart, variables, contentReferences); - attachmentPart.addDisposable(attachmentPart.onDidChangeHeight(() => { - this.updateItemHeight(templateData); - })); - return attachmentPart; + private renderAttachments(variables: IChatRequestVariableEntry[], contentReferences: ReadonlyArray | undefined, workingSet: ReadonlyArray | undefined, templateData: IChatListItemTemplate) { + return this.instantiationService.createInstance(ChatAttachmentsContentPart, variables, contentReferences, workingSet, undefined); } private renderTextEdit(context: IChatContentPartRenderContext, chatTextEdit: IChatTextEditGroup, templateData: IChatListItemTemplate): IChatContentPart { @@ -859,89 +871,3 @@ export class ChatListDelegate implements IListVirtualDelegate { return true; } } - -const voteDownDetailLabels: Record = { - [ChatAgentVoteDownReason.IncorrectCode]: localize('incorrectCode', "Suggested incorrect code"), - [ChatAgentVoteDownReason.DidNotFollowInstructions]: localize('didNotFollowInstructions', "Didn't follow instructions"), - [ChatAgentVoteDownReason.MissingContext]: localize('missingContext', "Missing context"), - [ChatAgentVoteDownReason.OffensiveOrUnsafe]: localize('offensiveOrUnsafe', "Offensive or unsafe"), - [ChatAgentVoteDownReason.PoorlyWrittenOrFormatted]: localize('poorlyWrittenOrFormatted', "Poorly written or formatted"), - [ChatAgentVoteDownReason.RefusedAValidRequest]: localize('refusedAValidRequest', "Refused a valid request"), - [ChatAgentVoteDownReason.IncompleteCode]: localize('incompleteCode', "Incomplete code"), - [ChatAgentVoteDownReason.WillReportIssue]: localize('reportIssue', "Report an issue"), - [ChatAgentVoteDownReason.Other]: localize('other', "Other"), -}; - -export class ChatVoteDownButton extends DropdownMenuActionViewItem { - constructor( - action: IAction, - options: IDropdownMenuActionViewItemOptions | undefined, - @ICommandService private readonly commandService: ICommandService, - @IWorkbenchIssueService private readonly issueService: IWorkbenchIssueService, - @ILogService private readonly logService: ILogService, - @IContextMenuService contextMenuService: IContextMenuService, - ) { - super(action, - { getActions: () => this.getActions(), }, - contextMenuService, - { - ...options, - classNames: ThemeIcon.asClassNameArray(Codicon.thumbsdown), - }); - } - - getActions(): readonly IAction[] { - return [ - this.getVoteDownDetailAction(ChatAgentVoteDownReason.IncorrectCode), - this.getVoteDownDetailAction(ChatAgentVoteDownReason.DidNotFollowInstructions), - this.getVoteDownDetailAction(ChatAgentVoteDownReason.IncompleteCode), - this.getVoteDownDetailAction(ChatAgentVoteDownReason.MissingContext), - this.getVoteDownDetailAction(ChatAgentVoteDownReason.PoorlyWrittenOrFormatted), - this.getVoteDownDetailAction(ChatAgentVoteDownReason.RefusedAValidRequest), - this.getVoteDownDetailAction(ChatAgentVoteDownReason.OffensiveOrUnsafe), - this.getVoteDownDetailAction(ChatAgentVoteDownReason.Other), - { - id: 'reportIssue', - label: voteDownDetailLabels[ChatAgentVoteDownReason.WillReportIssue], - tooltip: '', - enabled: true, - class: undefined, - run: async (context: IChatResponseViewModel) => { - if (!isResponseVM(context)) { - this.logService.error('ChatVoteDownButton#run: invalid context'); - return; - } - - await this.commandService.executeCommand(MarkUnhelpfulActionId, context, ChatAgentVoteDownReason.WillReportIssue); - await this.issueService.openReporter({ extensionId: context.agent?.extensionId.value }); - } - } - ]; - } - - override render(container: HTMLElement): void { - super.render(container); - - this.element?.classList.toggle('checked', this.action.checked); - } - - private getVoteDownDetailAction(reason: ChatAgentVoteDownReason): IAction { - const label = voteDownDetailLabels[reason]; - return { - id: MarkUnhelpfulActionId, - label, - tooltip: '', - enabled: true, - checked: (this._context as IChatResponseViewModel).voteDownReason === reason, - class: undefined, - run: async (context: IChatResponseViewModel) => { - if (!isResponseVM(context)) { - this.logService.error('ChatVoteDownButton#getVoteDownDetailAction: invalid context'); - return; - } - - await this.commandService.executeCommand(MarkUnhelpfulActionId, context, reason); - } - }; - } -} diff --git a/src/vs/workbench/contrib/aideAgent/browser/aideAgentPlanContentParts/aideAgentPlanMarkdownContentPart.ts b/src/vs/workbench/contrib/aideAgent/browser/aideAgentPlanContentParts/aideAgentPlanMarkdownContentPart.ts index 429a4d7f97c..51f301249b8 100644 --- a/src/vs/workbench/contrib/aideAgent/browser/aideAgentPlanContentParts/aideAgentPlanMarkdownContentPart.ts +++ b/src/vs/workbench/contrib/aideAgent/browser/aideAgentPlanContentParts/aideAgentPlanMarkdownContentPart.ts @@ -10,7 +10,8 @@ import { equalsIgnoreCase } from '../../../../../base/common/strings.js'; import { URI } from '../../../../../base/common/uri.js'; import { MarkdownRenderer } from '../../../../../editor/browser/widget/markdownRenderer/browser/markdownRenderer.js'; import { Range } from '../../../../../editor/common/core/range.js'; -import { IResolvedTextEditorModel, ITextModelService } from '../../../../../editor/common/services/resolverService.js'; +import { ITextModel } from '../../../../../editor/common/model.js'; +import { ITextModelService } from '../../../../../editor/common/services/resolverService.js'; import { IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js'; import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; import { IAideAgentPlanStepViewModel, isAideAgentPlanStepVM } from '../../common/aideAgentPlanViewModel.js'; @@ -20,7 +21,7 @@ import { IMarkdownVulnerability } from '../../common/annotations.js'; import { CodeBlockModelCollection } from '../../common/codeBlockModelCollection.js'; import { IChatCodeBlockInfo } from '../aideAgent.js'; import { IDisposableReference } from '../aideAgentContentParts/aideAgentCollections.js'; -import { EditorPool } from '../aideAgentContentParts/aideAgentMarkdownContentPart.js'; +import { codeblockHasClosingBackticks, EditorPool } from '../aideAgentContentParts/aideAgentMarkdownContentPart.js'; import { ChatMarkdownDecorationsRenderer } from '../aideAgentMarkdownDecorationsRenderer.js'; import { CodeBlockPart, ICodeBlockData, localFileLanguageId, parseLocalFileData } from '../codeBlockPart.js'; import { IAideAgentPlanContentPart, IAideAgentPlanContentPartRenderContext } from './aideAgentPlanContentParts.js'; @@ -57,35 +58,39 @@ export class AideAgentPlanMarkdownContentPart extends Disposable implements IAid // We release editors in order so that it's more likely that the same editor will be assigned if this element is re-rendered right away, like it often is during progressive rendering const orderedDisposablesList: IDisposable[] = []; - let codeBlockIndex = codeBlockStartIndex; + let globalCodeBlockIndexStart = codeBlockStartIndex; + let thisPartCodeBlockIndexStart = 0; const result = this._register(renderer.render(markdown.content, { fillInIncompleteTokens, - codeBlockRendererSync: (languageId, text) => { - const index = codeBlockIndex++; - let textModel: Promise; + codeBlockRendererSync: (languageId, text, raw) => { + const globalIndex = globalCodeBlockIndexStart++; + const thisPartIndex = thisPartCodeBlockIndexStart++; + let textModel: Promise; let range: Range | undefined; let vulns: readonly IMarkdownVulnerability[] | undefined; let codemapperUri: URI | undefined; + + const isCodeBlockComplete = context.element.isComplete || !raw || codeblockHasClosingBackticks(raw); if (equalsIgnoreCase(languageId, localFileLanguageId)) { try { const parsedBody = parseLocalFileData(text); range = parsedBody.range && Range.lift(parsedBody.range); - textModel = this.textModelService.createModelReference(parsedBody.uri).then(ref => ref.object); + textModel = this.textModelService.createModelReference(parsedBody.uri).then(ref => ref.object.textEditorModel); } catch (e) { return $('div'); } } else { const sessionId = element.sessionId; - const modelEntry = this.codeBlockModelCollection.getOrCreate(sessionId, element, index); - const fastUpdateModelEntry = this.codeBlockModelCollection.updateSync(sessionId, element, index, { text, languageId }); + const modelEntry = this.codeBlockModelCollection.getOrCreate(sessionId, element, globalIndex); + const fastUpdateModelEntry = this.codeBlockModelCollection.updateSync(sessionId, element, globalIndex, { text, languageId, isComplete: isCodeBlockComplete }); vulns = modelEntry.vulns; codemapperUri = fastUpdateModelEntry.codemapperUri; textModel = modelEntry.model; } - const codeBlockInfo = { languageId, textModel, codeBlockIndex: index, element, range, parentContextKeyService: contextKeyService, vulns, codemapperUri }; + const codeBlockInfo: ICodeBlockData = { languageId, textModel, codeBlockIndex: globalIndex, codeBlockPartIndex: thisPartIndex, element, range, parentContextKeyService: contextKeyService, vulns, codemapperUri }; - const ref = this.renderCodeBlock(codeBlockInfo, text, currentWidth, false); + const ref = this.renderCodeBlock(codeBlockInfo, text, isCodeBlockComplete, currentWidth); this.allRefs.push(ref); // Attach this after updating text/layout of the editor, so it should only be fired when the size updates later (horizontal scrollbar, wrapping) @@ -95,7 +100,7 @@ export class AideAgentPlanMarkdownContentPart extends Disposable implements IAid const ownerMarkdownPartId = this.id; const info: IChatCodeBlockInfo = new class { readonly ownerMarkdownPartId = ownerMarkdownPartId; - readonly codeBlockIndex = index; + readonly codeBlockIndex = globalIndex; readonly element = element; readonly isStreaming = true; codemapperUri = undefined; // will be set async @@ -126,17 +131,18 @@ export class AideAgentPlanMarkdownContentPart extends Disposable implements IAid this.domNode = result.element; } - private renderCodeBlock(data: ICodeBlockData, text: string, currentWidth: number, editableCodeBlock: boolean | undefined): IDisposableReference { + private renderCodeBlock(data: ICodeBlockData, text: string, isComplete: boolean, currentWidth: number): IDisposableReference { const ref = this.editorPool.get(); const editorInfo = ref.object; if (isAideAgentPlanStepVM(data.element)) { - this.codeBlockModelCollection.update(data.element.sessionId, data.element, data.codeBlockIndex, { text, languageId: data.languageId }).then((e) => { + this.codeBlockModelCollection.update(data.element.sessionId, data.element, data.codeBlockIndex, { text, languageId: data.languageId, isComplete }).then((e) => { // Update the existing object's codemapperUri this.codeblocks[data.codeBlockIndex].codemapperUri = e.codemapperUri; + this._onDidChangeHeight.fire(); }); } - editorInfo.render(data, currentWidth, editableCodeBlock); + editorInfo.render(data, currentWidth); return ref; } diff --git a/src/vs/workbench/contrib/aideAgent/browser/aideAgentPlanWidget.ts b/src/vs/workbench/contrib/aideAgent/browser/aideAgentPlanWidget.ts index 6e5663a16ce..1e2d738a6bb 100644 --- a/src/vs/workbench/contrib/aideAgent/browser/aideAgentPlanWidget.ts +++ b/src/vs/workbench/contrib/aideAgent/browser/aideAgentPlanWidget.ts @@ -125,6 +125,7 @@ export class AideAgentPlanWidget extends Disposable { const scopedInstantiationService = this._register(this._register(this.instantiationService.createChild(new ServiceCollection([IContextKeyService, this.contextKeyService])))); const delegate = scopedInstantiationService.createInstance(AideAgentPlanListDelegate); const rendererDelegate: IChatRendererDelegate = { + container: listContainer, getListLength: () => this.tree.getNode(null).visibleChildrenCount, onDidScroll: this.onDidScroll, }; diff --git a/src/vs/workbench/contrib/aideAgent/browser/aideAgentVariables.ts b/src/vs/workbench/contrib/aideAgent/browser/aideAgentVariables.ts index 2e5a7d478ad..be597775c29 100644 --- a/src/vs/workbench/contrib/aideAgent/browser/aideAgentVariables.ts +++ b/src/vs/workbench/contrib/aideAgent/browser/aideAgentVariables.ts @@ -24,7 +24,6 @@ import { AgentScope, IChatModel, IChatRequestVariableData, IChatRequestVariableE import { ChatRequestDynamicVariablePart, ChatRequestToolPart, ChatRequestVariablePart, IParsedChatRequest } from '../common/aideAgentParserTypes.js'; import { IChatContentReference, IChatSendRequestOptions } from '../common/aideAgentService.js'; import { IAideAgentVariablesService, IChatRequestVariableValue, IChatVariableData, IChatVariableResolver, IChatVariableResolverProgress, IDynamicVariable } from '../common/aideAgentVariables.js'; -import { IAideAgentLMToolsService } from '../common/languageModelToolsService.js'; import { IAideAgentWidgetService, showChatView } from './aideAgent.js'; import { ChatDynamicVariableModel } from './contrib/aideAgentDynamicVariables.js'; @@ -39,12 +38,11 @@ export class ChatVariablesService implements IAideAgentVariablesService { private _resolver = new Map(); constructor( - @IAideAgentLMToolsService private readonly toolsService: IAideAgentLMToolsService, @IAideAgentWidgetService private readonly chatWidgetService: IAideAgentWidgetService, @IEditorService private readonly editorService: IEditorService, @IModelService private readonly modelService: IModelService, - @ITextModelService private readonly textModelService: ITextModelService, @IPinnedContextService private readonly pinnedContextService: IPinnedContextService, + @ITextModelService private readonly textModelService: ITextModelService, @IViewsService private readonly viewsService: IViewsService, ) { } @@ -73,12 +71,9 @@ export class ChatVariablesService implements IAideAgentVariablesService { }).catch(onUnexpectedExternalError)); } } else if (part instanceof ChatRequestDynamicVariablePart) { - resolvedVariables[i] = { id: part.id, name: part.referenceText, range: part.range, value: part.data, }; + resolvedVariables[i] = { id: part.id, name: part.referenceText, range: part.range, value: part.data, fullName: part.fullName, icon: part.icon, isFile: part.isFile }; } else if (part instanceof ChatRequestToolPart) { - const tool = this.toolsService.getTool(part.toolId); - if (tool) { - resolvedVariables[i] = { id: part.toolId, name: part.toolName, range: part.range, value: undefined, isTool: true, icon: ThemeIcon.isThemeIcon(tool.icon) ? tool.icon : undefined, fullName: tool.displayName }; - } + resolvedVariables[i] = { id: part.toolId, name: part.toolName, range: part.range, value: undefined, isTool: true, icon: ThemeIcon.isThemeIcon(part.icon) ? part.icon : undefined, fullName: part.displayName }; } }); @@ -101,7 +96,7 @@ export class ChatVariablesService implements IAideAgentVariablesService { } }).catch(onUnexpectedExternalError)); } else if (attachment.isDynamic || attachment.isTool) { - resolvedAttachedContext[i] = { ...attachment }; + resolvedAttachedContext[i] = attachment; } }); @@ -129,6 +124,8 @@ export class ChatVariablesService implements IAideAgentVariablesService { id: 'vscode.editor.selection', name: basename(model.uri.fsPath), value: { uri: model.uri, range }, + isFile: true, + isDynamic: true, }); } } @@ -151,7 +148,9 @@ export class ChatVariablesService implements IAideAgentVariablesService { return { id: 'vscode.file.pinnedContext', name: basename(model.uri.fsPath), - value: { uri: model.uri, range } + value: { uri: model.uri, range }, + isFile: true, + isDynamic: true, }; }); @@ -169,7 +168,9 @@ export class ChatVariablesService implements IAideAgentVariablesService { resolvedAttachedContext.push({ id: 'vscode.file', name: basename(model.uri.fsPath), - value: { uri: model.uri, range } + value: { uri: model.uri, range }, + isFile: true, + isDynamic: true, }); } } diff --git a/src/vs/workbench/contrib/aideAgent/browser/aideAgentViewPane.ts b/src/vs/workbench/contrib/aideAgent/browser/aideAgentViewPane.ts index edfc905cf33..827befb015c 100644 --- a/src/vs/workbench/contrib/aideAgent/browser/aideAgentViewPane.ts +++ b/src/vs/workbench/contrib/aideAgent/browser/aideAgentViewPane.ts @@ -3,6 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { $, getWindow } from '../../../../base/browser/dom.js'; import { CancellationToken } from '../../../../base/common/cancellation.js'; import { DisposableStore } from '../../../../base/common/lifecycle.js'; import { MarshalledId } from '../../../../base/common/marshallingIds.js'; @@ -14,6 +15,7 @@ import { IHoverService } from '../../../../platform/hover/browser/hover.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { ServiceCollection } from '../../../../platform/instantiation/common/serviceCollection.js'; import { IKeybindingService } from '../../../../platform/keybinding/common/keybinding.js'; +import { ILayoutService } from '../../../../platform/layout/browser/layoutService.js'; import { ILogService } from '../../../../platform/log/common/log.js'; import { IOpenerService } from '../../../../platform/opener/common/opener.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; @@ -68,7 +70,8 @@ export class ChatViewPane extends ViewPane { @IAideAgentService private readonly chatService: IAideAgentService, @IAideAgentAgentService private readonly chatAgentService: IAideAgentAgentService, @ILogService private readonly logService: ILogService, - @IDevtoolsService devoolsService: IDevtoolsService + @IDevtoolsService devoolsService: IDevtoolsService, + @ILayoutService private readonly layoutService: ILayoutService, ) { super(options, keybindingService, contextMenuService, configurationService, contextKeyService, viewDescriptorService, instantiationService, openerService, themeService, telemetryService, hoverService); // Temporary performance fix @g-danna @@ -167,11 +170,21 @@ export class ChatViewPane extends ViewPane { const scopedInstantiationService = this._register(this.instantiationService.createChild(new ServiceCollection([IContextKeyService, this.scopedContextKeyService]))); const locationBasedColors = this.getLocationBasedColors(); + const editorOverflowNode = this.layoutService.getContainer(getWindow(parent)).appendChild($('.chat-editor-overflow.monaco-editor')); + this._register({ dispose: () => editorOverflowNode.remove() }); + this._widget = this._register(scopedInstantiationService.createInstance( ChatWidget, ChatAgentLocation.Panel, { viewId: this.id }, - { supportsFileReferences: true }, + { + autoScroll: true, + supportsFileReferences: true, + editorOverflowWidgetsDomNode: editorOverflowNode, + rendererOptions: { + renderTextEditsAsSummary: () => true, + } + }, { listForeground: SIDE_BAR_FOREGROUND, listBackground: locationBasedColors.background, diff --git a/src/vs/workbench/contrib/aideAgent/browser/aideAgentWidget.ts b/src/vs/workbench/contrib/aideAgent/browser/aideAgentWidget.ts index 2ac92c6c07f..df08792bb39 100644 --- a/src/vs/workbench/contrib/aideAgent/browser/aideAgentWidget.ts +++ b/src/vs/workbench/contrib/aideAgent/browser/aideAgentWidget.ts @@ -4,19 +4,24 @@ *--------------------------------------------------------------------------------------------*/ import * as dom from '../../../../base/browser/dom.js'; +import { Button } from '../../../../base/browser/ui/button/button.js'; import { ITreeContextMenuEvent, ITreeElement } from '../../../../base/browser/ui/tree/tree.js'; import { disposableTimeout, timeout } from '../../../../base/common/async.js'; +import { Codicon } from '../../../../base/common/codicons.js'; import { toErrorMessage } from '../../../../base/common/errorMessage.js'; import { Emitter, Event } from '../../../../base/common/event.js'; +import { FuzzyScore } from '../../../../base/common/filters.js'; import { MarkdownString } from '../../../../base/common/htmlContent.js'; import { Disposable, DisposableStore, IDisposable, MutableDisposable, combinedDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; import { Schemas } from '../../../../base/common/network.js'; +import { autorunWithStore, observableFromEvent } from '../../../../base/common/observable.js'; import { extUri, isEqual } from '../../../../base/common/resources.js'; import { isDefined } from '../../../../base/common/types.js'; import { URI } from '../../../../base/common/uri.js'; import { ICodeEditor } from '../../../../editor/browser/editorBrowser.js'; import { ICodeEditorService } from '../../../../editor/browser/services/codeEditorService.js'; import { localize } from '../../../../nls.js'; +import { MenuWorkbenchToolBar } from '../../../../platform/actions/browser/toolbar.js'; import { MenuId } from '../../../../platform/actions/common/actions.js'; import { AgentMode } from '../../../../platform/aideAgent/common/model.js'; import { IAIModelSelectionService } from '../../../../platform/aiModel/common/aiModels.js'; @@ -28,22 +33,24 @@ import { ServiceCollection } from '../../../../platform/instantiation/common/ser import { WorkbenchObjectTree } from '../../../../platform/list/browser/listService.js'; import { ILogService } from '../../../../platform/log/common/log.js'; import { INotificationService } from '../../../../platform/notification/common/notification.js'; +import { buttonSecondaryBackground, buttonSecondaryForeground, buttonSecondaryHoverBackground } from '../../../../platform/theme/common/colorRegistry.js'; +import { asCssVariable } from '../../../../platform/theme/common/colorUtils.js'; import { IThemeService } from '../../../../platform/theme/common/themeService.js'; import { IViewsService } from '../../../services/views/common/viewsService.js'; import { ChatAgentLocation, IAideAgentAgentService, IChatAgentCommand, IChatAgentData, IChatWelcomeMessageContent } from '../common/aideAgentAgents.js'; -import { IAideAgentCodeEditingService } from '../common/aideAgentCodeEditingService.js'; -import { CONTEXT_CHAT_INPUT_HAS_AGENT, CONTEXT_CHAT_IN_PASSTHROUGH_WIDGET, CONTEXT_CHAT_LAST_ITEM_ID, CONTEXT_CHAT_LOCATION, CONTEXT_CHAT_REQUEST_IN_PROGRESS, CONTEXT_CHAT_SESSION_WITH_EDITS, CONTEXT_IN_CHAT_SESSION, CONTEXT_PARTICIPANT_SUPPORTS_MODEL_PICKER, CONTEXT_RESPONSE_FILTERED } from '../common/aideAgentContextKeys.js'; -import { AgentScope, IChatModel, IChatProgressResponseContent, IChatRequestVariableEntry, IChatResponseModel } from '../common/aideAgentModel.js'; +import { CONTEXT_CHAT_HAS_HIDDEN_EXCHANGES, CONTEXT_CHAT_INPUT_HAS_AGENT, CONTEXT_CHAT_IN_PASSTHROUGH_WIDGET, CONTEXT_CHAT_LAST_ITEM_ID, CONTEXT_CHAT_LOCATION, CONTEXT_CHAT_REQUEST_IN_PROGRESS, CONTEXT_IN_CHAT_SESSION, CONTEXT_PARTICIPANT_SUPPORTS_MODEL_PICKER, CONTEXT_RESPONSE_FILTERED } from '../common/aideAgentContextKeys.js'; +import { IAideAgentEditingService, IChatEditingSession } from '../common/aideAgentEditingService.js'; +import { AgentScope, IChatModel, IChatRequestVariableEntry, IChatResponseModel } from '../common/aideAgentModel.js'; import { ChatRequestAgentPart, IParsedChatRequest, formatChatQuestion } from '../common/aideAgentParserTypes.js'; import { ChatRequestParser } from '../common/aideAgentRequestParser.js'; import { IAideAgentService, IChatLocationData } from '../common/aideAgentService.js'; import { ChatViewModel, IChatResponseViewModel, isRequestVM, isResponseVM } from '../common/aideAgentViewModel.js'; +import { IChatInputState } from '../common/aideAgentWidgetHistoryService.js'; import { CodeBlockModelCollection } from '../common/codeBlockModelCollection.js'; import { ChatTreeItem, IAideAgentAccessibilityService, IAideAgentWidgetService, IChatCodeBlockInfo, IChatFileTreeInfo, IChatListItemRendererOptions, IChatWidget, IChatWidgetViewContext, IChatWidgetViewOptions, showChatView } from './aideAgent.js'; import { ChatAccessibilityProvider } from './aideAgentAccessibilityProvider.js'; import { AideAgentAttachmentModel } from './aideAgentAttachmentModel.js'; -import { AideAgentEditPreviewWidget } from './aideAgentEditPreviewWidget.js'; -import { ChatInputPart } from './aideAgentInputPart.js'; +import { ChatInputPart, IChatInputStyles } from './aideAgentInputPart.js'; import { ChatListDelegate, ChatListItemRenderer, IChatRendererDelegate } from './aideAgentListRenderer.js'; import { ChatEditorOptions } from './aideAgentOptions.js'; import { invokePlanView } from './aideAgentPlan.js'; @@ -53,16 +60,12 @@ import { ChatViewWelcomePart } from './viewsWelcome/chatViewWelcomeController.js const $ = dom.$; -export type IChatInputState = Record; export interface IChatViewState { inputValue?: string; inputState?: IChatInputState; } -export interface IChatWidgetStyles { - listForeground: string; - listBackground: string; - overlayBackground: string; +export interface IChatWidgetStyles extends IChatInputStyles { inputEditorBackground: string; resultEditorBackground: string; } @@ -132,7 +135,7 @@ export class ChatWidget extends Disposable implements IChatWidget { private contribs: ReadonlyArray = []; - private tree!: WorkbenchObjectTree; + private tree!: WorkbenchObjectTree; private renderer!: ChatListItemRenderer; private readonly _codeBlockModelCollection: CodeBlockModelCollection; private lastItem: ChatTreeItem | undefined; @@ -145,15 +148,15 @@ export class ChatWidget extends Disposable implements IChatWidget { private welcomeMessageContainer!: HTMLElement; private persistedWelcomeMessage: IChatWelcomeMessageContent | undefined; - private editPreviewContainer!: HTMLElement; - private editPreviewWidget: AideAgentEditPreviewWidget | undefined; + private hiddenExchangesMessageContainer!: HTMLElement; + private hiddenExchangesMessage!: HTMLElement; private bodyDimension: dom.Dimension | undefined; private visibleChangeCount = 0; private requestInProgress: IContextKey; private agentInInput: IContextKey; private agentSupportsModelPicker: IContextKey; - private sessionHasEdits: IContextKey; + private hasHiddenExchanges: IContextKey; private _visible = false; public get visible() { @@ -162,6 +165,12 @@ export class ChatWidget extends Disposable implements IChatWidget { private previousTreeScrollHeight: number = 0; + /** + * Whether the list is scroll-locked to the bottom. Initialize to true so that we can scroll to the bottom on first render. + * The initial render leads to a lot of `onDidChangeTreeContentHeight` as the renderer works out the real heights of rows. + */ + private scrollLock = true; + private readonly viewModelDisposables = this._register(new DisposableStore()); private _viewModel: ChatViewModel | undefined; private set viewModel(viewModel: ChatViewModel | undefined) { @@ -183,9 +192,15 @@ export class ChatWidget extends Disposable implements IChatWidget { return this._viewModel; } + private _editingSession: IChatEditingSession | undefined; + private parsedChatRequest: IParsedChatRequest | undefined; - get parsedInput() { + get parsedInput(): IParsedChatRequest { if (this.parsedChatRequest === undefined) { + if (!this.viewModel) { + return { text: '', parts: [] }; + } + this.parsedChatRequest = this.instantiationService.createInstance(ChatRequestParser).parseChatRequest(this.viewModel!.sessionId, this.getInput(), this.location, { selectedAgent: this._lastSelectedAgent }); } @@ -197,7 +212,6 @@ export class ChatWidget extends Disposable implements IChatWidget { } private readonly _location: IChatWidgetLocationOptions; - get location() { return this._location.location; } @@ -209,20 +223,20 @@ export class ChatWidget extends Disposable implements IChatWidget { _viewContext: IChatWidgetViewContext | undefined, private readonly viewOptions: IChatWidgetViewOptions, private readonly styles: IChatWidgetStyles, + @IAideAgentAccessibilityService private readonly chatAccessibilityService: IAideAgentAccessibilityService, + @IAideAgentAgentService private readonly chatAgentService: IAideAgentAgentService, + @IAideAgentEditingService chatEditingService: IAideAgentEditingService, + @IAideAgentService private readonly chatService: IAideAgentService, + @IAideAgentWidgetService private readonly chatWidgetService: IAideAgentWidgetService, + @IAIModelSelectionService private readonly modelSelectionService: IAIModelSelectionService, @ICodeEditorService codeEditorService: ICodeEditorService, @IContextKeyService private readonly contextKeyService: IContextKeyService, - @IInstantiationService private readonly instantiationService: IInstantiationService, - @IAideAgentService private readonly chatService: IAideAgentService, - @IAideAgentAgentService private readonly chatAgentService: IAideAgentAgentService, - @IAideAgentWidgetService chatWidgetService: IAideAgentWidgetService, - @IAideAgentCodeEditingService private readonly codeEditingService: IAideAgentCodeEditingService, @IContextMenuService private readonly contextMenuService: IContextMenuService, - @IAideAgentAccessibilityService private readonly chatAccessibilityService: IAideAgentAccessibilityService, + @IInstantiationService private readonly instantiationService: IInstantiationService, @ILogService private readonly logService: ILogService, + @INotificationService private readonly notificationService: INotificationService, @IThemeService private readonly themeService: IThemeService, @IViewsService private readonly viewsService: IViewsService, - @IAIModelSelectionService private readonly modelSelectionService: IAIModelSelectionService, - @INotificationService private readonly notificationService: INotificationService, ) { super(); @@ -240,12 +254,61 @@ export class ChatWidget extends Disposable implements IChatWidget { this.agentInInput = CONTEXT_CHAT_INPUT_HAS_AGENT.bindTo(contextKeyService); this.agentSupportsModelPicker = CONTEXT_PARTICIPANT_SUPPORTS_MODEL_PICKER.bindTo(contextKeyService); this.requestInProgress = CONTEXT_CHAT_REQUEST_IN_PROGRESS.bindTo(contextKeyService); - this.sessionHasEdits = CONTEXT_CHAT_SESSION_WITH_EDITS.bindTo(contextKeyService); - - this._register((chatWidgetService as ChatWidgetService).register(this)); + this.hasHiddenExchanges = CONTEXT_CHAT_HAS_HIDDEN_EXCHANGES.bindTo(contextKeyService); this._codeBlockModelCollection = this._register(instantiationService.createInstance(CodeBlockModelCollection)); + const viewModelObs = observableFromEvent(this, this.onDidChangeViewModel, () => this.viewModel); + this._register(autorunWithStore((r, store) => { + const viewModel = viewModelObs.read(r); + const sessions = chatEditingService.editingSessionsObs.read(r); + + const session = sessions.find(candidate => candidate.chatSessionId === viewModel?.sessionId); + this._editingSession = undefined; + this.renderChatEditingSessionState(); // this is necessary to make sure we dispose previous buttons, etc. + + if (!session) { + // none or for a different chat widget + return; + } + + this._editingSession = session; + + store.add(session.onDidChange(() => { + this.renderChatEditingSessionState(); + })); + store.add(session.onDidDispose(() => { + this._editingSession = undefined; + this.renderChatEditingSessionState(); + })); + store.add(this.onDidChangeParsedInput(() => { + this.renderChatEditingSessionState(); + })); + store.add(this.inputEditor.onDidChangeModelContent(() => { + if (this.getInput() === '') { + this.refreshParsedInput(); + this.renderChatEditingSessionState(); + } + })); + this.renderChatEditingSessionState(); + })); + + let currentEditSession: IChatEditingSession | undefined = undefined; + this._register(this.onDidChangeViewModel(async () => { + const sessionId = this._viewModel?.sessionId; + if (sessionId) { + if (sessionId !== currentEditSession?.chatSessionId) { + currentEditSession = await chatEditingService.startOrContinueEditingSession(sessionId); + } + } else { + if (currentEditSession) { + const session = currentEditSession; + currentEditSession = undefined; + await session.stop(); + } + } + })); + this._register(codeEditorService.registerCodeEditorOpenHandler(async (input: ITextResourceEditorInput, _source: ICodeEditor | null, _sideBySide?: boolean): Promise => { const resource = input.resource; if (resource.scheme !== Schemas.vscodeAideAgentCodeBlock) { @@ -302,21 +365,7 @@ export class ChatWidget extends Disposable implements IChatWidget { return null; })); - this._register(this.chatService.onDidDisposeSession(() => { - this.hideEditPreviewWidget(); - })); - - this._register(this.codeEditingService.onDidComplete(() => { - this.hideEditPreviewWidget(); - this.inputPart.inputEditor.focus(); - })); - } - - private hideEditPreviewWidget() { - if (this.editPreviewWidget) { - this.editPreviewWidget.clear(); - this.editPreviewWidget.visible = false; - } + this._register(this.onDidChangeParsedInput(() => this.updateChatInputContext())); } private _lastSelectedAgent: IChatAgentData | undefined; @@ -371,17 +420,27 @@ export class ChatWidget extends Disposable implements IChatWidget { if (renderInputOnTop) { this.createInput(this.container, { renderFollowups, renderStyle }); this.listContainer = dom.append(this.container, $(`.interactive-list`)); - this.editPreviewContainer = dom.append(this.container, $(`.edit-preview`)); + this.createHiddenExchangesHandler(); } else { this.listContainer = dom.append(this.container, $(`.interactive-list`)); - this.editPreviewContainer = dom.append(this.container, $(`.edit-preview`)); + this.createHiddenExchangesHandler(); this.createInput(this.container, { renderFollowups, renderStyle }); } this.createList(this.listContainer, { ...this.viewOptions.rendererOptions, renderStyle }); - if (!('isPassthrough' in this.viewContext) || !this.viewContext.isPassthrough) { - this.createEditPreviewWidget(this.editPreviewContainer); - } + const scrollDownButton = this._register(new Button(this.listContainer, { + supportIcons: true, + buttonBackground: asCssVariable(buttonSecondaryBackground), + buttonForeground: asCssVariable(buttonSecondaryForeground), + buttonHoverBackground: asCssVariable(buttonSecondaryHoverBackground), + })); + scrollDownButton.element.classList.add('chat-scroll-down'); + scrollDownButton.label = `$(${Codicon.chevronDown.id})`; + scrollDownButton.setTitle(localize('scrollDownButtonLabel', "Scroll down")); + this._register(scrollDownButton.onDidClick(() => { + this.scrollLock = true; + this.scrollToEnd(); + })); this._register(this.editorOptions.onDidChange(() => this.onDidStyleChange())); this.onDidStyleChange(); @@ -400,6 +459,9 @@ export class ChatWidget extends Disposable implements IChatWidget { return undefined; } }).filter(isDefined); + + this._register((this.chatWidgetService as ChatWidgetService).register(this)); + } private scrollToEnd() { @@ -421,6 +483,14 @@ export class ChatWidget extends Disposable implements IChatWidget { return this.inputPart.hasFocus(); } + refreshParsedInput() { + if (!this.viewModel) { + return; + } + this.parsedChatRequest = this.instantiationService.createInstance(ChatRequestParser).parseChatRequest(this.viewModel.sessionId, this.getInput(), this.location, { selectedAgent: this._lastSelectedAgent }); + this._onDidChangeParsedInput.fire(); + } + getSibling(item: ChatTreeItem, type: 'next' | 'previous'): ChatTreeItem | undefined { if (!isResponseVM(item)) { return; @@ -449,8 +519,8 @@ export class ChatWidget extends Disposable implements IChatWidget { } private onDidChangeItems(skipDynamicLayout?: boolean) { - const vmItems = this.viewModel?.getItems() ?? []; - if (this.tree && this._visible) { + if (this._visible || !this.viewModel) { + const vmItems = this.viewModel?.getItems() ?? []; const treeItems = vmItems .map((item): ITreeElement => { return { @@ -461,6 +531,7 @@ export class ChatWidget extends Disposable implements IChatWidget { }); this.renderWelcomeViewContentIfNeeded(); + this.renderHiddenExchangesIfNeeded(); this._onWillMaybeChangeHeight.fire(); @@ -472,12 +543,15 @@ export class ChatWidget extends Disposable implements IChatWidget { return element.dataId + // Ensure re-rendering an element once slash commands are loaded, so the colorization can be applied. `${(isRequestVM(element)) /* && !!this.lastSlashCommands ? '_scLoaded' : '' */}` + + `${(isResponseVM(element)) && element.isFirst ? `_first` : ''}` + `${(isResponseVM(element)) && element.isLast ? `_last` : ''}` + // If a response is in the process of progressive rendering, we need to ensure that it will // be re-rendered so progressive rendering is restarted, even if the model wasn't updated. `${isResponseVM(element) && element.renderData ? `_${this.visibleChangeCount}` : ''}` + // Re-render once content references are loaded (isResponseVM(element) ? `_${element.contentReferences.length}` : '') + + // Re-render if element becomes hidden due to undo/redo + `_${element.shouldBeRemovedOnSend ? '1' : '0'}` + // Rerender request if we got new content references in the response // since this may change how we render the corresponding attachments in the request (isRequestVM(element) && element.contentReferences ? `_${element.contentReferences?.length}` : ''); @@ -489,21 +563,34 @@ export class ChatWidget extends Disposable implements IChatWidget { this.layoutDynamicChatTreeItemMode(); } } + } - if (this.editPreviewWidget) { - const progressStages = vmItems - .filter(i => isResponseVM(i)) - .flatMap(i => i.response.value.map(progress => ({ progress, exchangeId: i.id }))) - .filter(i => i.progress.kind === 'stage'); - const lastProgressStage = progressStages.pop(); - if (lastProgressStage && lastProgressStage.exchangeId === this.chatService.lastExchangeId) { - const entry = lastProgressStage?.progress as IChatProgressResponseContent & { kind: 'stage' }; - this.editPreviewWidget.updateProgress(entry.message); - } + private renderHiddenExchangesIfNeeded() { + let hiddenCount = 0; + if (this._viewModel?.sessionId) { + const exchanges = this.chatService.getSession(this._viewModel.sessionId)?.getExchanges() ?? []; + hiddenCount = exchanges.filter(exchange => exchange.shouldBeRemovedOnSend).length; + } + + if (hiddenCount > 0) { + this.hiddenExchangesMessage.textContent = localize('hiddenExchanges', "{0} steps reverted", hiddenCount); + this.hiddenExchangesMessageContainer.classList.add('hidden-exchanges-visible'); + dom.show(this.hiddenExchangesMessageContainer); + this.hasHiddenExchanges.set(true); + } else { + this.hiddenExchangesMessage.textContent = ''; + this.hiddenExchangesMessageContainer.classList.remove('hidden-exchanges-visible'); + dom.hide(this.hiddenExchangesMessageContainer); + this.hasHiddenExchanges.set(false); } } private renderWelcomeViewContentIfNeeded() { + if (this.viewOptions.renderStyle === 'compact' || this.viewOptions.renderStyle === 'minimal') { + return; + } + + const numItems = this.viewModel?.getItems().length ?? 0; const welcomeContent = this.viewModel?.model.welcomeMessage ?? this.persistedWelcomeMessage; if (welcomeContent && this.welcomeMessageContainer.children.length === 0 && !this.viewOptions.renderStyle) { const tips = new MarkdownString(localize('chatWidget.tips.withoutParticipants', "{0} or type {1} to attach context", '$(attach)', '@'), { supportThemeIcons: true }); @@ -515,10 +602,20 @@ export class ChatWidget extends Disposable implements IChatWidget { dom.append(this.welcomeMessageContainer, welcomePart.element); } - if (!this.viewOptions.renderStyle && this.viewModel) { - const treeItems = this.viewModel.getItems(); - dom.setVisibility(treeItems.length === 0, this.welcomeMessageContainer); - dom.setVisibility(treeItems.length !== 0, this.listContainer); + if (this.viewModel) { + dom.setVisibility(numItems === 0, this.welcomeMessageContainer); + dom.setVisibility(numItems !== 0, this.listContainer); + } + } + + private async renderChatEditingSessionState() { + if (!this.inputPart) { + return; + } + this.inputPart.renderChatEditingSessionState(this._editingSession ?? null, this); + + if (this.bodyDimension) { + this.layout(this.bodyDimension.height, this.bodyDimension.width); } } @@ -553,11 +650,12 @@ export class ChatWidget extends Disposable implements IChatWidget { } private createList(listContainer: HTMLElement, options: IChatListItemRendererOptions): void { - const scopedInstantiationService = this._register(this._register(this.instantiationService.createChild(new ServiceCollection([IContextKeyService, this.contextKeyService])))); + const scopedInstantiationService = this._register(this.instantiationService.createChild(new ServiceCollection([IContextKeyService, this.contextKeyService]))); const delegate = scopedInstantiationService.createInstance(ChatListDelegate, this.viewOptions.defaultElementHeight ?? 200); const rendererDelegate: IChatRendererDelegate = { getListLength: () => this.tree.getNode(null).visibleChildrenCount, onDidScroll: this.onDidScroll, + container: listContainer, }; // Create a dom element to hold UI from editor widgets embedded in chat messages @@ -575,6 +673,7 @@ export class ChatWidget extends Disposable implements IChatWidget { )); /* TODO(@ghostwriternr): This was used for followups, but not sure if it's needed anymore. this._register(this.renderer.onDidClickFollowup(item => { + // is this used anymore? this.acceptInput(item.message); })); */ @@ -587,8 +686,8 @@ export class ChatWidget extends Disposable implements IChatWidget { */ })); - this.tree = this._register(>scopedInstantiationService.createInstance( - WorkbenchObjectTree, + this.tree = this._register(scopedInstantiationService.createInstance( + WorkbenchObjectTree, 'Chat', listContainer, delegate, @@ -603,6 +702,7 @@ export class ChatWidget extends Disposable implements IChatWidget { keyboardNavigationLabelProvider: { getKeyboardNavigationLabel: (e: ChatTreeItem) => isRequestVM(e) ? e.message : isResponseVM(e) ? e.response.value : '' }, // TODO setRowLineHeight: false, filter: this.viewOptions.filter ? { filter: this.viewOptions.filter.bind(this.viewOptions), } : undefined, + scrollToActiveElement: true, overrideStyles: { listFocusBackground: this.styles.listBackground, listInactiveFocusBackground: this.styles.listBackground, @@ -634,15 +734,9 @@ export class ChatWidget extends Disposable implements IChatWidget { })); this._register(this.tree.onDidScroll(() => { this._onDidScroll.fire(); - })); - } - private createEditPreviewWidget(container: HTMLElement): void { - this.editPreviewWidget = this._register(this.instantiationService.createInstance(AideAgentEditPreviewWidget, container)); - this._register(this.editPreviewWidget.onDidChangeHeight(() => { - if (this.bodyDimension) { - this.layout(this.bodyDimension.height, this.bodyDimension.width); - } + const isScrolledDown = this.tree.scrollTop >= this.tree.scrollHeight - this.tree.renderHeight - 2; + this.container.classList.toggle('show-scroll-down', !isScrolledDown && !this.scrollLock); })); } @@ -664,23 +758,49 @@ export class ChatWidget extends Disposable implements IChatWidget { } private onDidChangeTreeContentHeight(): void { + // If the list was previously scrolled all the way down, ensure it stays scrolled down, if scroll lock is on if (this.tree.scrollHeight !== this.previousTreeScrollHeight) { - // Due to rounding, the scrollTop + renderHeight will not exactly match the scrollHeight. - // Consider the tree to be scrolled all the way down if it is within 2px of the bottom. - const lastElementWasVisible = this.tree.scrollTop + this.tree.renderHeight >= this.previousTreeScrollHeight - 2; - if (lastElementWasVisible) { - dom.scheduleAtNextAnimationFrame(dom.getWindow(this.listContainer), () => { - // Can't set scrollTop during this event listener, the list might overwrite the change - this.scrollToEnd(); - }, 0); + const lastItem = this.viewModel?.getItems().at(-1); + const lastResponseIsRendering = isResponseVM(lastItem) && lastItem.renderData; + if (!lastResponseIsRendering || this.scrollLock) { + // Due to rounding, the scrollTop + renderHeight will not exactly match the scrollHeight. + // Consider the tree to be scrolled all the way down if it is within 2px of the bottom. + const lastElementWasVisible = this.tree.scrollTop + this.tree.renderHeight >= this.previousTreeScrollHeight - 2; + if (lastElementWasVisible) { + dom.scheduleAtNextAnimationFrame(dom.getWindow(this.listContainer), () => { + // Can't set scrollTop during this event listener, the list might overwrite the change + + this.scrollToEnd(); + }, 0); + } } } + // TODO@roblourens add `show-scroll-down` class when button should show + // Show the button when content height changes, the list is not fully scrolled down, and (the latest response is currently rendering OR I haven't yet scrolled all the way down since the last response) + // So for example it would not reappear if I scroll up and delete a message + this.previousTreeScrollHeight = this.tree.scrollHeight; this._onDidChangeContentHeight.fire(); } - private createInput(container: HTMLElement, options?: { renderFollowups: boolean; renderStyle?: 'default' | 'compact' | 'minimal' }): void { + private createHiddenExchangesHandler() { + this.hiddenExchangesMessageContainer = dom.append(this.container, $(`.hidden-exchanges-container`)); + this.hiddenExchangesMessage = dom.append(this.hiddenExchangesMessageContainer, $('.hidden-exchanges-message')); + const hiddenExchangesToolbarContainer = dom.append(this.hiddenExchangesMessageContainer, $('.hidden-exchanges-toolbar')); + this._register(this.instantiationService.createInstance( + MenuWorkbenchToolBar, + hiddenExchangesToolbarContainer, + MenuId.AideAgentEditingRevertToolbar, + { + actionViewItemProvider: (action) => { + return undefined; + } + } + )); + } + + private createInput(container: HTMLElement, options?: { renderFollowups: boolean; renderStyle?: 'compact' | 'minimal' }): void { this.inputPart = this._register(this.instantiationService.createInstance(ChatInputPart, this.location, { @@ -690,6 +810,7 @@ export class ChatWidget extends Disposable implements IChatWidget { editorOverflowWidgetsDomNode: this.viewOptions.editorOverflowWidgetsDomNode, preventChatEditToggle: 'isPassthrough' in this.viewContext && this.viewContext.isPassthrough }, + this.styles, () => this.collectInputState() )); this.inputPart.render(container, '', this); @@ -701,6 +822,7 @@ export class ChatWidget extends Disposable implements IChatWidget { c.setInputState(contribState); } }); + this.refreshParsedInput(); })); this._register(this.inputPart.onDidFocus(() => this._onDidFocus.fire())); this._register(this.inputPart.onDidChangeContext((e) => this._onDidChangeContext.fire(e))); @@ -756,11 +878,22 @@ export class ChatWidget extends Disposable implements IChatWidget { } this._onDidChangeContentHeight.fire(); })); + this._register(this.inputPart.attachmentModel.onDidChangeContext(() => { + if (this._editingSession) { + // TODO still needed? Do this inside input part and fire onDidChangeHeight? + this.renderChatEditingSessionState(); + } + })); this._register(this.inputEditor.onDidChangeModelContent(() => { this.parsedChatRequest = undefined; this.updateChatInputContext(); })); - this._register(this.chatAgentService.onDidChangeAgents(() => this.parsedChatRequest = undefined)); + this._register(this.chatAgentService.onDidChangeAgents(() => { + this.parsedChatRequest = undefined; + + // Tools agent loads -> welcome content changes + this.renderWelcomeViewContentIfNeeded(); + })); } private onDidStyleChange(): void { @@ -793,17 +926,10 @@ export class ChatWidget extends Disposable implements IChatWidget { this.onDidChangeItems(); if (events.some(e => e?.kind === 'addRequest') && this.visible) { this.scrollToEnd(); - this.focusInput(); } - const activeCodeEditingSession = this.codeEditingService.getExistingCodeEditingSession(model.sessionId); - if (this.editPreviewWidget && activeCodeEditingSession) { - const edits = activeCodeEditingSession.codeEdits; - this.editPreviewWidget?.setCodeEdits(edits); - - this.sessionHasEdits.set(edits.size > 0); - } else { - this.sessionHasEdits.set(false); + if (this._editingSession) { + this.renderChatEditingSessionState(); } })); this.viewModelDisposables.add(this.viewModel.onDidDisposeModel(() => { @@ -814,12 +940,13 @@ export class ChatWidget extends Disposable implements IChatWidget { this.viewModel = undefined; this.onDidChangeItems(); })); - this.inputPart.initForNewChatModel(viewState.inputValue, viewState.inputState ?? this.collectInputState()); + this.inputPart.initForNewChatModel(viewState); this.contribs.forEach(c => { if (c.setInputState && viewState.inputState?.[c.id]) { c.setInputState(viewState.inputState?.[c.id]); } }); + this.refreshParsedInput(); this.viewModelDisposables.add(model.onDidChange(async (e) => { if (e.kind === 'setAgent') { this._onDidChangeAgent.fire({ agent: e.agent, slashCommand: e.command }); @@ -832,10 +959,11 @@ export class ChatWidget extends Disposable implements IChatWidget { } })); - if (this.tree) { + if (this.tree && this.visible) { this.onDidChangeItems(); this.scrollToEnd(); } + this.updateChatInputContext(); } @@ -872,6 +1000,7 @@ export class ChatWidget extends Disposable implements IChatWidget { setInput(value = ''): void { this.inputPart.setValue(value, false); + this.refreshParsedInput(); } getInput(): string { @@ -898,7 +1027,6 @@ export class ChatWidget extends Disposable implements IChatWidget { private async _acceptInput(opts: { query: string; mode: AgentMode } | undefined): Promise { if (this.viewModel) { - this.editPreviewWidget?.updateProgress('Thinking...'); const editorValue = this.getInput(); if ('isPassthrough' in this.viewContext && this.viewContext.isPassthrough) { const widget = await showChatView(this.viewsService); @@ -915,6 +1043,10 @@ export class ChatWidget extends Disposable implements IChatWidget { } this._onDidAcceptInput.fire(); + if (!this.viewOptions.autoScroll) { + this.scrollLock = false; + } + const requestId = this.chatAccessibilityService.acceptRequest(); const input = !opts ? editorValue : 'query' in opts ? opts.query : @@ -969,6 +1101,13 @@ export class ChatWidget extends Disposable implements IChatWidget { }).catch(() => { this.requestInProgress.set(false); }); + + const RESPONSE_TIMEOUT = 20000; + setTimeout(() => { + // Stop the signal if the promise is still unresolved + this.chatAccessibilityService.acceptResponse(undefined, requestId); + }, RESPONSE_TIMEOUT); + return result.responseCreatedPromise; } else { this.requestInProgress.set(false); @@ -1044,12 +1183,16 @@ export class ChatWidget extends Disposable implements IChatWidget { width = Math.min(width, 850); this.bodyDimension = new dom.Dimension(width, height); - this.inputPart.layout(height, width); + const inputPartMaxHeight = this._dynamicMessageLayoutData?.enabled ? this._dynamicMessageLayoutData.maxHeight : height; + this.inputPart.layout(inputPartMaxHeight, width); const inputPartHeight = this.inputPart.inputPartHeight; const lastElementVisible = this.tree.scrollTop + this.tree.renderHeight >= this.tree.scrollHeight - 2; - const editPreviewWidgetHeight = this.editPreviewContainer.offsetHeight; + const hiddenExchangesMessageHeight = this.hiddenExchangesMessageContainer.offsetHeight; - const listHeight = Math.max(0, height - inputPartHeight - editPreviewWidgetHeight); + const listHeight = Math.max(0, height - inputPartHeight - hiddenExchangesMessageHeight); + if (!this.viewOptions.autoScroll) { + this.listContainer.style.setProperty('--chat-current-response-min-height', listHeight * .75 + 'px'); + } this.tree.layout(listHeight, width); this.tree.getHTMLElement().style.height = `${listHeight}px`; @@ -1060,7 +1203,7 @@ export class ChatWidget extends Disposable implements IChatWidget { const lastItem = this.viewModel?.getItems().at(-1); const lastResponseIsRendering = isResponseVM(lastItem) && lastItem.renderData; - if (lastElementVisible && (!lastResponseIsRendering)) { + if (lastElementVisible && (!lastResponseIsRendering || this.viewOptions.autoScroll)) { this.scrollToEnd(); } @@ -1164,7 +1307,6 @@ export class ChatWidget extends Disposable implements IChatWidget { ); if (needsRerender || !listHeight) { - // TODO: figure out a better place to reveal the last element this.scrollToEnd(); } } @@ -1174,7 +1316,10 @@ export class ChatWidget extends Disposable implements IChatWidget { } getViewState(): IChatViewState { - return { inputValue: this.getInput(), inputState: this.collectInputState() }; + return { + inputValue: this.getInput(), + inputState: this.inputPart.getViewState() + }; } private updateChatInputContext() { @@ -1184,18 +1329,23 @@ export class ChatWidget extends Disposable implements IChatWidget { } } -export class ChatWidgetService implements IAideAgentWidgetService { +export class ChatWidgetService extends Disposable implements IAideAgentWidgetService { declare readonly _serviceBrand: undefined; private _widgets: ChatWidget[] = []; private _lastFocusedWidget: ChatWidget | undefined = undefined; - get lastFocusedWidget(): ChatWidget | undefined { + private readonly _onDidAddWidget = this._register(new Emitter()); + readonly onDidAddWidget: Event = this._onDidAddWidget.event; + + get lastFocusedWidget(): IChatWidget | undefined { return this._lastFocusedWidget; } - constructor() { } + getWidgetsByLocations(location: ChatAgentLocation): ReadonlyArray { + return this._widgets.filter(w => w.location === location); + } getWidgetByInputUri(uri: URI): ChatWidget | undefined { return this._widgets.find(w => isEqual(w.inputUri, uri)); @@ -1219,6 +1369,7 @@ export class ChatWidgetService implements IAideAgentWidgetService { } this._widgets.push(newWidget); + this._onDidAddWidget.fire(newWidget); return combinedDisposable( newWidget.onDidFocus(() => this.setLastFocusedWidget(newWidget)), diff --git a/src/vs/workbench/contrib/aideAgent/browser/codeBlockPart.css b/src/vs/workbench/contrib/aideAgent/browser/codeBlockPart.css index 2e984b2bb20..90a632d994c 100644 --- a/src/vs/workbench/contrib/aideAgent/browser/codeBlockPart.css +++ b/src/vs/workbench/contrib/aideAgent/browser/codeBlockPart.css @@ -57,7 +57,7 @@ } .aideagent-item-container .value .rendered-markdown [data-code] { - margin: 16px 0; + margin: 0 0 16px 0; } .interactive-result-code-block { @@ -158,10 +158,6 @@ border-bottom: solid 1px var(--vscode-chat-requestBorder); } -.interactive-result-code-block.compare .interactive-result-header .monaco-icon-label::before { - height: unset !important; -} - .interactive-result-code-block.compare.no-diff .interactive-result-header, .interactive-result-code-block.compare.no-diff .interactive-result-editor { display: none; diff --git a/src/vs/workbench/contrib/aideAgent/browser/codeBlockPart.ts b/src/vs/workbench/contrib/aideAgent/browser/codeBlockPart.ts index 3190c80ee1a..055dcb2bda9 100644 --- a/src/vs/workbench/contrib/aideAgent/browser/codeBlockPart.ts +++ b/src/vs/workbench/contrib/aideAgent/browser/codeBlockPart.ts @@ -11,13 +11,12 @@ import { Button } from '../../../../base/browser/ui/button/button.js'; import { CancellationToken } from '../../../../base/common/cancellation.js'; import { Codicon } from '../../../../base/common/codicons.js'; import { Emitter, Event } from '../../../../base/common/event.js'; -import { combinedDisposable, Disposable, DisposableStore, MutableDisposable } from '../../../../base/common/lifecycle.js'; +import { combinedDisposable, Disposable, MutableDisposable } from '../../../../base/common/lifecycle.js'; import { Schemas } from '../../../../base/common/network.js'; import { isEqual } from '../../../../base/common/resources.js'; import { assertType } from '../../../../base/common/types.js'; import { URI, UriComponents } from '../../../../base/common/uri.js'; import { IEditorConstructionOptions } from '../../../../editor/browser/config/editorConfiguration.js'; -import { TabFocus } from '../../../../editor/browser/config/tabFocus.js'; import { IDiffEditor } from '../../../../editor/browser/editorBrowser.js'; import { EditorExtensionsRegistry } from '../../../../editor/browser/editorExtensions.js'; import { ICodeEditorService } from '../../../../editor/browser/services/codeEditorService.js'; @@ -31,7 +30,7 @@ import { EndOfLinePreference, ITextModel } from '../../../../editor/common/model import { TextModelText } from '../../../../editor/common/model/textModelText.js'; import { IModelService } from '../../../../editor/common/services/model.js'; import { DefaultModelSHA1Computer } from '../../../../editor/common/services/modelService.js'; -import { IResolvedTextEditorModel, ITextModelContentProvider, ITextModelService } from '../../../../editor/common/services/resolverService.js'; +import { ITextModelContentProvider, ITextModelService } from '../../../../editor/common/services/resolverService.js'; import { BracketMatchingController } from '../../../../editor/contrib/bracketMatching/browser/bracketMatching.js'; import { ColorDetector } from '../../../../editor/contrib/colorPicker/browser/colorDetector.js'; import { ContextMenuController } from '../../../../editor/contrib/contextmenu/browser/contextmenu.js'; @@ -55,6 +54,7 @@ import { IInstantiationService } from '../../../../platform/instantiation/common import { ServiceCollection } from '../../../../platform/instantiation/common/serviceCollection.js'; import { ILabelService } from '../../../../platform/label/common/label.js'; import { IOpenerService } from '../../../../platform/opener/common/opener.js'; +import { emptyProgressRunner, IEditorProgressService } from '../../../../platform/progress/common/progress.js'; import { ResourceLabel } from '../../../browser/labels.js'; import { ResourceContextKey } from '../../../common/contextkeys.js'; import { AccessibilityVerbositySettingId } from '../../accessibility/browser/accessibilityConfiguration.js'; @@ -62,10 +62,10 @@ import { InspectEditorTokensController } from '../../codeEditor/browser/inspectE import { MenuPreventer } from '../../codeEditor/browser/menuPreventer.js'; import { SelectionClipboardContributionID } from '../../codeEditor/browser/selectionClipboard.js'; import { getSimpleEditorOptions } from '../../codeEditor/browser/simpleEditorOptions.js'; -import { IMarkdownVulnerability } from '../common/annotations.js'; import { CONTEXT_CHAT_EDIT_APPLIED } from '../common/aideAgentContextKeys.js'; import { IChatResponseModel, IChatTextEditGroup } from '../common/aideAgentModel.js'; import { IChatResponseViewModel, isResponseVM } from '../common/aideAgentViewModel.js'; +import { IMarkdownVulnerability } from '../common/annotations.js'; import { ChatTreeItem } from './aideAgent.js'; import { IChatRendererDelegate } from './aideAgentListRenderer.js'; import { ChatEditorOptions } from './aideAgentOptions.js'; @@ -74,9 +74,10 @@ const $ = dom.$; export interface ICodeBlockData { readonly codeBlockIndex: number; + readonly codeBlockPartIndex: number; readonly element: unknown; - readonly textModel: Promise; + readonly textModel: Promise; readonly languageId: string; readonly codemapperUri?: URI; @@ -151,7 +152,6 @@ export class CodeBlockPart extends Disposable { private currentCodeBlockData: ICodeBlockData | undefined; private currentScrollWidth = 0; - private readonly disposableStore = this._register(new DisposableStore()); private isDisposed = false; private resourceContextKey: ResourceContextKey; @@ -364,7 +364,7 @@ export class CodeBlockPart extends Disposable { return this.editor.getContentHeight(); } - async render(data: ICodeBlockData, width: number, editable: boolean | undefined) { + async render(data: ICodeBlockData, width: number) { this.currentCodeBlockData = data; if (data.parentContextKeyService) { this.contextKeyService.updateParent(data.parentContextKeyService); @@ -382,12 +382,7 @@ export class CodeBlockPart extends Disposable { } this.layout(width); - if (editable) { - this.disposableStore.clear(); - this.disposableStore.add(this.editor.onDidFocusEditorWidget(() => TabFocus.setTabFocusMode(true))); - this.disposableStore.add(this.editor.onDidBlurEditorWidget(() => TabFocus.setTabFocusMode(false))); - } - this.editor.updateOptions({ ariaLabel: localize('chat.codeBlockLabel', "Code block {0}", data.codeBlockIndex + 1), readOnly: !editable }); + this.editor.updateOptions({ ariaLabel: localize('chat.codeBlockLabel', "Code block {0}", data.codeBlockIndex + 1) }); if (data.hideToolbar) { dom.hide(this.toolbar.getElement()); @@ -416,7 +411,7 @@ export class CodeBlockPart extends Disposable { } private async updateEditor(data: ICodeBlockData): Promise { - const textModel = (await data.textModel).textEditorModel; + const textModel = await data.textModel; this.editor.setModel(textModel); if (data.range) { this.editor.setSelection(data.range); @@ -528,7 +523,18 @@ export class CodeCompareBlockPart extends Disposable { this.messageElement.tabIndex = 0; this.contextKeyService = this._register(contextKeyService.createScoped(this.element)); - const scopedInstantiationService = this._register(instantiationService.createChild(new ServiceCollection([IContextKeyService, this.contextKeyService]))); + const scopedInstantiationService = this._register(instantiationService.createChild(new ServiceCollection( + [IContextKeyService, this.contextKeyService], + [IEditorProgressService, new class implements IEditorProgressService { + _serviceBrand: undefined; + show(_total: unknown, _delay?: unknown) { + return emptyProgressRunner; + } + async showWhile(promise: Promise, _delay?: number): Promise { + await promise; + } + }], + ))); const editorHeader = dom.append(this.element, $('.interactive-result-header.show-file-icons')); const editorElement = dom.append(this.element, $('.interactive-result-editor')); this.diffEditor = this.createDiffEditor(scopedInstantiationService, editorElement, { @@ -692,20 +698,20 @@ export class CodeCompareBlockPart extends Disposable { } layout(width: number): void { - const contentHeight = this.getContentHeight(); const editorBorder = 2; - const dimension = { width: width - editorBorder, height: contentHeight }; + + const toolbar = dom.getTotalHeight(this.toolbar.getElement()); + const content = this.diffEditor.getModel() + ? this.diffEditor.getContentHeight() + : dom.getTotalHeight(this.messageElement); + + const dimension = new dom.Dimension(width - editorBorder, toolbar + content); this.element.style.height = `${dimension.height}px`; this.element.style.width = `${dimension.width}px`; - this.diffEditor.layout(dimension); + this.diffEditor.layout(dimension.with(undefined, content - editorBorder)); this.updatePaddingForLayout(); } - private getContentHeight() { - return this.diffEditor.getModel() - ? this.diffEditor.getContentHeight() - : dom.getTotalHeight(this.messageElement); - } async render(data: ICodeCompareBlockData, width: number, token: CancellationToken) { if (data.parentContextKeyService) { diff --git a/src/vs/workbench/contrib/aideAgent/browser/contrib/aideAgentInputCompletions.ts b/src/vs/workbench/contrib/aideAgent/browser/contrib/aideAgentInputCompletions.ts index fc1c55350c4..5d87b439a95 100644 --- a/src/vs/workbench/contrib/aideAgent/browser/contrib/aideAgentInputCompletions.ts +++ b/src/vs/workbench/contrib/aideAgent/browser/contrib/aideAgentInputCompletions.ts @@ -607,7 +607,8 @@ class AddFileCompletionEntryAction extends Action2 { new ReferenceArgument(widget, { id: 'vscode.file', range: replace, - data: { uri: context.resource, range } + data: { uri: context.resource, range }, + isFile: true, }) ); } diff --git a/src/vs/workbench/contrib/aideAgent/browser/devtoolsServiceImpl.ts b/src/vs/workbench/contrib/aideAgent/browser/devtoolsServiceImpl.ts index 71dbf088719..950f42e4314 100644 --- a/src/vs/workbench/contrib/aideAgent/browser/devtoolsServiceImpl.ts +++ b/src/vs/workbench/contrib/aideAgent/browser/devtoolsServiceImpl.ts @@ -311,7 +311,8 @@ export class DevtoolsService extends Disposable implements IDevtoolsService { startColumn: replaceRange.startColumn + (isLeading ? 0 : 1), endColumn: replaceRange.endColumn + displayName.length + (isLeading ? 0 : 1), }, - data: { uri: payload.location.uri, range: payload.location.range } + data: { uri: payload.location.uri, range: payload.location.range }, + isFile: true, }; dynamicVariablesModel.addReference(variable); input.focus(); diff --git a/src/vs/workbench/contrib/aideAgent/browser/editPreviewPart.ts b/src/vs/workbench/contrib/aideAgent/browser/editPreviewPart.ts index 1e343b0b2f9..0b39ff88fb0 100644 --- a/src/vs/workbench/contrib/aideAgent/browser/editPreviewPart.ts +++ b/src/vs/workbench/contrib/aideAgent/browser/editPreviewPart.ts @@ -12,7 +12,7 @@ import { EditorExtensionsRegistry } from '../../../../editor/browser/editorExten import { ICodeEditorWidgetOptions } from '../../../../editor/browser/widget/codeEditor/codeEditorWidget.js'; import { DiffEditorWidget } from '../../../../editor/browser/widget/diffEditor/diffEditorWidget.js'; import { EDITOR_FONT_DEFAULTS, IEditorOptions } from '../../../../editor/common/config/editorOptions.js'; -import { IResolvedTextEditorModel } from '../../../../editor/common/services/resolverService.js'; +import { ITextModel } from '../../../../editor/common/model.js'; import { BracketMatchingController } from '../../../../editor/contrib/bracketMatching/browser/bracketMatching.js'; import { ContextMenuController } from '../../../../editor/contrib/contextmenu/browser/contextmenu.js'; import { ContentHoverController } from '../../../../editor/contrib/hover/browser/contentHoverController.js'; @@ -42,12 +42,12 @@ export interface IEditPreviewBlockData { readonly uri: URI; readonly languageId: string; original: { - model: Promise; + model: Promise; text: string; codeBlockIndex: number; }; modified: { - model: Promise; + model: Promise; text: string; codeBlockIndex: number; }; @@ -222,8 +222,8 @@ export class EditPreviewBlockPart extends Disposable { return; } - const original = (await data.original.model).textEditorModel; - const modified = (await data.modified.model).textEditorModel; + const original = await data.original.model; + const modified = await data.modified.model; const viewModel = this.diffEditor.createViewModel({ original, modified }); await viewModel.waitForDiff(); diff --git a/src/vs/workbench/contrib/aideAgent/browser/media/aideAgent.css b/src/vs/workbench/contrib/aideAgent/browser/media/aideAgent.css index cfd6eadc8f4..20d683b51df 100644 --- a/src/vs/workbench/contrib/aideAgent/browser/media/aideAgent.css +++ b/src/vs/workbench/contrib/aideAgent/browser/media/aideAgent.css @@ -155,15 +155,17 @@ font-size: 14px; } +/* .monaco-list-row:not(.focused) .aideagent-item-container:not(:hover) .header .monaco-toolbar, .monaco-list:not(:focus-within) .monaco-list-row .aideagent-item-container:not(:hover) .header .monaco-toolbar, .monaco-list-row:not(.focused) .aideagent-item-container:not(:hover) .header .monaco-toolbar .action-label, .monaco-list:not(:focus-within) .monaco-list-row .aideagent-item-container:not(:hover) .header .monaco-toolbar .action-label { /* Also apply this rule to the .action-label directly to work around a strange issue- when the toolbar is hidden without that second rule, tabbing from the list container into a list item doesn't work - and the tab key doesn't do anything. */ + and the tab key doesn't do anything. *\/ display: none; } +*/ .aideagent-item-container .header .monaco-toolbar .monaco-action-bar .actions-container { gap: 4px; @@ -204,6 +206,10 @@ margin-bottom: 8px; } +.aideagent-item-container .value .rendered-markdown .codicon { + font-size: inherit; +} + .aideagent-item-container .value .rendered-markdown blockquote { margin: 0px; padding: 0px 16px 0 10px; @@ -265,6 +271,7 @@ overflow-wrap: anywhere; } +.aideagent-item-container .value > :last-child, .aideagent-item-container .value > :last-child.rendered-markdown > :last-child { margin-bottom: 0px; } @@ -296,6 +303,16 @@ margin: 16px 0; } +.aideagent-item-container.editing-session .value .rendered-markdown p:has(+ [data-code] > .chat-codeblock-pill-widget) { + margin-bottom: 8px; +} + +.aideagent-item-container.editing-session .value .rendered-markdown h3 { + font-size: 13px; + margin: 0 0 8px 0; + font-weight: unset; +} + #workbench\.panel\.aideAgentPlan .aideagent-item-container .value .rendered-markdown h1, #workbench\.panel\.aideAgentPlan .aideagent-item-container .value .rendered-markdown h2, #workbench\.panel\.aideAgentPlan .aideagent-item-container .value .rendered-markdown h3 { @@ -341,7 +358,7 @@ top: 0px; } -.aideagent-item-container .value .rendered-markdown p { +.aideagent-item-container .value .rendered-markdown { line-height: 1.5em; } @@ -390,22 +407,19 @@ have to be updated for changes to the rules above, or to support more deeply nes /* #endregion list indent rules */ -.aideagent-item-container .value .rendered-markdown li { - line-height: 1.3rem; -} - .aideagent-item-container .value .rendered-markdown img { max-width: 100%; } -.aideagent-item-container .monaco-tokenized-source, -.aideagent-item-container code { - font-family: var(--monaco-monospace-font); - font-size: 12px; - color: var(--vscode-textPreformat-foreground); - background-color: var(--vscode-textPreformat-background); - padding: 1px 3px; - border-radius: 4px; +.aideagent-item-container { + .monaco-tokenized-source, code { + font-family: var(--monaco-monospace-font); + font-size: 12px; + color: var(--vscode-textPreformat-foreground); + background-color: var(--vscode-textPreformat-background); + padding: 1px 3px; + border-radius: 4px; + } } .aideagent-item-container > .value > .aideagent-codeedit-list { @@ -496,6 +510,10 @@ have to be updated for changes to the rules above, or to support more deeply nes flex-grow: 1; } +.aideagent-item-container.interactive-request.minimal .rendered-markdown .chat-animated-ellipsis { + display: inline-flex; +} + .aideagent-item-container.minimal .user > .username { display: none; } @@ -553,6 +571,223 @@ have to be updated for changes to the rules above, or to support more deeply nes max-width: 100%; } +.interactive-session .chat-editing-session { + margin-bottom: -4px; + width: 100%; + position: relative; +} + +.interactive-session .chat-editing-session .chat-editing-session-container { + margin-bottom: -14px; + padding: 6px 8px 18px 8px; + box-sizing: border-box; + background-color: var(--vscode-editor-background); + border: 1px solid var(--vscode-input-border, transparent); + border-radius: 4px; + display: flex; + flex-direction: column; + gap: 2px; + overflow: hidden; +} + +.interactive-session .chat-editing-session .monaco-list-row .chat-collapsible-list-action-bar { + display: none; + padding-right: 12px; +} + +.interactive-session .chat-editing-session .monaco-list-row:hover .chat-collapsible-list-action-bar, +.interactive-session .chat-editing-session .monaco-list-row.focused .chat-collapsible-list-action-bar, +.interactive-session .chat-editing-session .monaco-list-row.selected .chat-collapsible-list-action-bar { + display: inherit; +} + +.interactive-session .chat-editing-session .monaco-list-row .chat-collapsible-list-action-bar .action-label.checked { + color: var(--vscode-inputOption-activeForeground); + background-color: var(--vscode-inputOption-activeBackground); + box-shadow: inset 0 0 0 1px var(--vscode-inputOption-activeBorder); +} + +.interactive-session .chat-editing-session .chat-editing-session-container.show-file-icons .monaco-scrollable-element .monaco-list-rows .monaco-list-row { + border-radius: 2px; +} + +.interactive-session .chat-editing-session .chat-editing-session-container .monaco-list .monaco-list-row .monaco-icon-name-container.modified { + font-weight: bold; +} + +.interactive-session .chat-editing-session .chat-editing-session-container .chat-editing-session-overview { + display: flex; + flex-direction: row; + justify-content: space-between; + gap: 6px; + padding: 0 4px; +} + +.interactive-session .chat-editing-session .chat-editing-session-container .chat-editing-session-overview > .working-set-title { + color: var(--vscode-descriptionForeground); + font-size: 11px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + align-content: center; +} + +.interactive-session .chat-editing-session .chat-editing-session-container .chat-editing-session-overview > .working-set-title .working-set-count.file-limit-reached { + color: var(--vscode-notificationsWarningIcon-foreground); +} + +.interactive-session .chat-editing-session .chat-editing-session-container .monaco-progress-container { + position: relative; +} + +.interactive-session .chat-editing-session .chat-editing-session-toolbar-actions, +.interactive-session .chat-editing-session .chat-editing-session-actions { + display: flex; + flex-direction: row; + flex-wrap: nowrap; + gap: 6px; + align-items: center; +} + +.interactive-session .chat-editing-session .chat-editing-session-toolbar-actions { + margin: 3px 0px; + overflow: hidden; +} + +.interactive-session .chat-editing-session .monaco-button { + height: 17px; + width: fit-content; + padding: 2px 6px; + font-size: 11px; + background-color: var(--vscode-button-background); + border: 1px solid var(--vscode-button-border); + color: var(--vscode-button-foreground); +} + +.interactive-session .chat-editing-session .chat-editing-session-toolbar-actions .monaco-button:hover { + background-color: var(--vscode-button-hoverBackground); +} + +.interactive-session .chat-editing-session .chat-editing-session-actions-group { + display: flex; + flex-direction: row; + flex-wrap: wrap; + gap: 6px; +} + +.interactive-session .chat-editing-session .chat-editing-session-toolbar-actions .monaco-button.codicon.codicon-close { + width: 17px; + height: 17px; + display: flex; + align-items: center; + justify-content: center; + font-size: 16px; + color: var(--vscode-descriptionForeground); + background-color: transparent; + border: none; + padding: 0; + border-radius: 5px; + cursor: pointer; +} + +.interactive-session .chat-editing-session .chat-editing-session-toolbar-actions .monaco-button.secondary { + color: var(--vscode-foreground); + background-color: transparent; + border: none; + height: 22px; + padding-left: 0px; + cursor: pointer; + display: flex; + justify-content: start; +} + +.interactive-session .chat-editing-session .chat-editing-session-toolbar-actions .monaco-button.secondary:first-child { + margin: 3px 0px 3px 3px; + flex-shrink: 0; +} + +.interactive-session .chat-editing-session .chat-editing-session-toolbar-actions .monaco-button.secondary.monaco-icon-label::before { + display: inline-flex; + align-items: center; +} + +.interactive-session .chat-editing-session .chat-editing-session-toolbar-actions .monaco-button.secondary:only-child { + width: 100%; +} + +.interactive-session .chat-editing-session .chat-editing-session-toolbar-actions .monaco-button.secondary.disabled { + cursor: initial; +} + +.interactive-session .chat-editing-session .chat-editing-session-toolbar-actions .monaco-button.secondary .codicon { + font-size: 12px; + margin-left: 4px; +} + +.interactive-session .chat-editing-session .chat-editing-session-actions .monaco-button.secondary.monaco-text-button.codicon { + background-color: transparent; + border-color: transparent; + color: var(--vscode-foreground); + cursor: pointer; + height: 16px; + padding: 0px; + border-radius: 2px; + display: inline-flex; +} + +.interactive-session .chat-editing-session .chat-editing-session-actions .monaco-button.secondary.monaco-text-button { + background-color: var(--vscode-button-secondaryBackground); + border: 1px solid var(--vscode-button-border); + color: var(--vscode-button-secondaryForeground); +} + +.interactive-session .chat-editing-session .chat-editing-session-actions .monaco-button.secondary:hover { + background-color: var(--vscode-button-secondaryHoverBackground); + color: var(--vscode-button-secondaryForeground); +} + +/* The Add Files button is currently implemented as a secondary button but should not have the secondary button background */ +.interactive-session .chat-editing-session .chat-editing-session-toolbar-actions .monaco-button.secondary:hover { + background-color: var(--vscode-toolbar-hoverBackground); +} + +.interactive-session .chat-editing-session .chat-editing-session-actions .monaco-button.secondary.monaco-text-button.codicon:not(.disabled):hover, +.interactive-session .chat-editing-session .chat-editing-session-toolbar-actions .monaco-button:hover { + background-color: var(--vscode-toolbar-hoverBackground); +} + +.interactive-session .chat-editing-session .chat-editing-session-toolbar-actions .monaco-button, +.interactive-session .chat-editing-session .chat-editing-session-actions .monaco-button { + overflow: hidden; + text-wrap: nowrap; +} + +.interactive-session .chat-editing-session .chat-editing-session-toolbar-actions .monaco-button-dropdown.sidebyside-button { + align-items: center; + border-radius: 2px; +} + +.interactive-session .chat-editing-session .chat-editing-session-toolbar-actions .monaco-button-dropdown.sidebyside-button .monaco-button, +.interactive-session .chat-editing-session .chat-editing-session-toolbar-actions .monaco-button-dropdown.sidebyside-button .monaco-button:hover { + border-right: 1px solid transparent; + background-color: unset; + padding: 0; +} + +.interactive-session .chat-editing-session .chat-editing-session-toolbar-actions .monaco-button-dropdown.sidebyside-button > .separator { + border-right: 1px solid transparent; + padding: 0 1px; + height: 22px; +} + +.interactive-session .chat-editing-session .chat-editing-session-toolbar-actions .monaco-button-dropdown.sidebyside-button:hover > .separator { + border-color: var(--vscode-input-border, transparent); +} + +.interactive-session .chat-editing-session .chat-editing-session-toolbar-actions .monaco-button-dropdown.sidebyside-button:hover { + background-color: var(--vscode-toolbar-hoverBackground); +} + .interactive-session .interactive-input-part.compact .chat-input-container { display: flex; justify-content: space-between; @@ -676,6 +911,46 @@ have to be updated for changes to the rules above, or to support more deeply nes padding-inline-start: unset; } +.monaco-editor .chat-editing-pending-edit { + z-index: 1; + opacity: 0.6; + background-color: var(--vscode-editor-background); +} + +.monaco-editor .chat-editing-last-edit { + background-color: var(--vscode-editor-rangeHighlightBackground); + box-sizing: border-box; + border: 1px solid var(--vscode-editor-rangeHighlightBorder); +} + +@property --chat-editing-last-edit-shift { + syntax: ''; + initial-value: 100%; + inherits: false; +} + +@keyframes kf-chat-editing-last-edit-shift { + 0% { + --chat-editing-last-edit-shift: 100%; + } + + 50% { + --chat-editing-last-edit-shift: 7%; + } + + 100% { + --chat-editing-last-edit-shift: 100%; + } +} + +.monaco-editor .chat-editing-last-edit-line { + --chat-editing-last-edit-shift: 100%; + background: linear-gradient(45deg, var(--vscode-editor-rangeHighlightBackground), var(--chat-editing-last-edit-shift), transparent); + animation: 2.3s kf-chat-editing-last-edit-shift ease-in-out infinite; + animation-delay: 330ms; +} + + .chat-notification-widget .chat-info-codicon, .chat-notification-widget .chat-error-codicon, .chat-notification-widget .chat-warning-codicon { @@ -717,14 +992,14 @@ have to be updated for changes to the rules above, or to support more deeply nes color: var(--vscode-interactive-session-foreground); } -.aideagent-attached-context .aideagent-attached-context-attachment .monaco-icon-name-container.warning, -.aideagent-attached-context .aideagent-attached-context-attachment .monaco-icon-suffix-container.warning, +.chat-attached-context .chat-attached-context-attachment .monaco-icon-name-container.warning, +.chat-attached-context .chat-attached-context-attachment .monaco-icon-suffix-container.warning, .chat-used-context-list .monaco-icon-name-container.warning, .chat-used-context-list .monaco-icon-suffix-container.warning { color: var(--vscode-notificationsWarningIcon-foreground); } -.aideagent-attached-context .aideagent-attached-context-attachment.show-file-icons.warning { +.chat-attached-context .chat-attached-context-attachment.show-file-icons.warning { border-color: var(--vscode-notificationsWarningIcon-foreground); } @@ -757,25 +1032,19 @@ have to be updated for changes to the rules above, or to support more deeply nes padding: 8px 0 0 0 } -.interactive-session .aideagent-attached-context { - display: flex; - flex-direction: column; - gap: 2px; -} - -.interactive-session .aideagent-attached-context .aideagent-attached-context-attachment { +.interactive-session .chat-attached-context .chat-attached-context-attachment { display: flex; gap: 4px; overflow: hidden; font-size: 11px; - padding: 0 4px 0 2px; + padding: 0 4px 0 4px; border: 1px solid var(--vscode-chat-requestBorder, var(--vscode-input-background, transparent)); border-radius: 4px; height: 18px; max-width: 100%; } -.interactive-session .aideagent-attached-context .aideagent-attached-context-attachment .monaco-button { +.interactive-session .chat-attached-context .chat-attached-context-attachment .monaco-button { display: flex; align-items: center; margin-top: -2px; @@ -786,12 +1055,12 @@ have to be updated for changes to the rules above, or to support more deeply nes outline-offset: -4px; } -.interactive-session .aideagent-attached-context .aideagent-attached-context-attachment .monaco-button:hover { +.interactive-session .chat-attached-context .chat-attached-context-attachment .monaco-button:hover { cursor: pointer; background: var(--vscode-toolbar-hoverBackground); } -.interactive-session .aideagent-attached-context .aideagent-attached-context-attachment .monaco-icon-label-container { +.interactive-session .chat-attached-context .chat-attached-context-attachment .monaco-icon-label-container { display: flex; .monaco-icon-suffix-container { @@ -800,32 +1069,31 @@ have to be updated for changes to the rules above, or to support more deeply nes } } -.interactive-session .aideagent-attached-context .aideagent-attached-context-attachment .monaco-icon-label-container .monaco-highlighted-label { +.interactive-session .chat-attached-context .chat-attached-context-attachment .monaco-icon-label-container .monaco-highlighted-label { display: flex !important; align-items: center !important; } -.interactive-session .aideagent-attached-context .aideagent-attached-context-attachment .monaco-icon-label .monaco-button.codicon.codicon-close, -.interactive-session .aideagent-attached-context .aideagent-attached-context-attachment .monaco-button.codicon.codicon-close { +.interactive-session .chat-attached-context .chat-attached-context-attachment .monaco-icon-label .monaco-button.codicon.codicon-close, +.interactive-session .chat-attached-context .chat-attached-context-attachment .monaco-button.codicon.codicon-close { color: var(--vscode-descriptionForeground); cursor: pointer; } -.interactive-session .aideagent-attached-context .aideagent-attached-context-attachment .monaco-icon-label .codicon { +.interactive-session .chat-attached-context .chat-attached-context-attachment .monaco-icon-label .codicon { padding-left: 4px; font-size: 100% !important; } -.interactive-session .aideagent-attached-context { - padding: 4px 0 4px 0; +.interactive-session .chat-attached-context { + padding: 0 0 4px 0; display: flex; gap: 4px; flex-wrap: wrap; cursor: default; } -/* .interactive-session .interactive-input-part.compact .aideagent-attached-context { */ -.interactive-session .interactive-input-part .aideagent-attached-context { +.interactive-session .interactive-input-part.compact .chat-attached-context { padding-top: 8px; padding-bottom: 0px; display: flex; @@ -833,88 +1101,55 @@ have to be updated for changes to the rules above, or to support more deeply nes flex-wrap: wrap; } -.interactive-session .interactive-input-part .aideagent-attached-context .aideagent-attached-context-attachment .monaco-button.codicon.codicon-close { - margin-left: auto; -} - -.interactive-session .aideagent-item-container.aideagent-request .aideagent-attached-context { +.interactive-session .aideagent-item-container.aideagent-request .chat-attached-context { margin-top: -8px; } -.interactive-session .aideagent-item-container.interactive-item-compact.aideagent-request .aideagent-attached-context { +.interactive-session .aideagent-item-container.interactive-item-compact.aideagent-request .chat-attached-context { margin-top: -4px; } -.interactive-session .aideagent-item-container.editing-session.aideagent-request .aideagent-attached-context { +.interactive-session .aideagent-item-container.editing-session.aideagent-request .chat-attached-context { margin-top: 0px; } -.interactive-session .aideagent-attached-context .aideagent-attached-context-attachment.implicit { +.interactive-session .chat-attached-context .chat-attached-context-attachment.implicit { display: flex; gap: 4px; } -.interactive-session .aideagent-attached-context .aideagent-attached-context-attachment.implicit .chat-implicit-hint { +.interactive-session .chat-attached-context .chat-attached-context-attachment.implicit .chat-implicit-hint { opacity: 0.7; font-size: .9em; } -.interactive-session .aideagent-attached-context .aideagent-attached-context-attachment.implicit.disabled .chat-implicit-hint { +.interactive-session .chat-attached-context .chat-attached-context-attachment.implicit.disabled .chat-implicit-hint { font-style: italic; } -.interactive-session .aideagent-attached-context .aideagent-attached-context-attachment.implicit.disabled { +.interactive-session .chat-attached-context .chat-attached-context-attachment.implicit.disabled { border-style: dashed; } -.interactive-session .aideagent-attached-context .aideagent-attached-context-attachment.implicit.disabled:focus { +.interactive-session .chat-attached-context .chat-attached-context-attachment.implicit.disabled:focus { outline: none; border-color: var(--vscode-focusBorder); } -.interactive-session .aideagent-attached-context .aideagent-attached-context-attachment.implicit.disabled .monaco-icon-label .label-name { +.interactive-session .chat-attached-context .chat-attached-context-attachment.implicit.disabled .monaco-icon-label .label-name { text-decoration: line-through; font-style: italic; opacity: 0.8; } -.interactive-session .aideagent-attached-context .aideagent-attached-context-attachment .monaco-icon-label::before { +.interactive-session .chat-attached-context .chat-attached-context-attachment .monaco-icon-label::before { padding: 0 2px 0 0; } -.interactive-session .aideagent-attached-context .aideagent-attached-context-attachment .monaco-icon-label.predefined-file-icon::before { +.interactive-session .chat-attached-context .chat-attached-context-attachment .monaco-icon-label.predefined-file-icon::before { padding: 0 0 0 2px; } -.interactive-session .aideagent-attached-context .aideagent-attachments-list .monaco-list { - border: none; - border-radius: 4px; - width: auto; -} - -.interactive-session .aideagent-item-container.aideagent-request .aideagent-attached-context .aideagent-attached-context-attachment { - padding-right: 6px; -} - -.interactive-session .aideagent-attached-context.aideagent-attachments-list-collapsed .aideagent-attachments-list { - display: none; -} - -.interactive-session .aideagent-attached-context .aideagent-attachments-list .monaco-list .monaco-list-row { - border-radius: 2px; -} - -.interactive-session .aideagent-attached-context .aideagent-attachments-list .monaco-list .monaco-list-row .monaco-icon-label .label-name .monaco-highlighted-label { - display: flex; - align-items: center; -} - -.interactive-session .aideagent-attached-context .aideagent-attachments-list .monaco-list .monaco-list-row .monaco-icon-label .label-name .monaco-highlighted-label .codicon { - font-size: 13px; - padding-left: 3px; - padding-right: 2px; -} - .interactive-session-followups { display: flex; flex-direction: column; @@ -936,9 +1171,9 @@ have to be updated for changes to the rules above, or to support more deeply nes padding: 4px 8px; } -.interactive-session .interactive-input-part .interactive-input-followups .interactive-session-followups { - margin: 8px 0; -} +/* .interactive-session .interactive-input-part .interactive-input-followups .interactive-session-followups { + margin-bottom: 4px; +} */ .interactive-session .interactive-input-part .interactive-input-followups .interactive-session-followups .monaco-button { display: block; @@ -967,10 +1202,6 @@ have to be updated for changes to the rules above, or to support more deeply nes border: none; } -.interactive-welcome .value .interactive-session-followups { - margin-bottom: 16px; -} - .aideagent-item-container .monaco-toolbar .codicon { /* Very aggressive list styles try to apply focus colors to every codicon in a list row. */ color: var(--vscode-icon-foreground) !important; @@ -979,7 +1210,7 @@ have to be updated for changes to the rules above, or to support more deeply nes /* #region Quick Chat */ .quick-input-widget .interactive-session .interactive-input-part { - padding: 8px 6px 6px 6px; + padding: 8px 6px 8px 6px; margin: 0 3px; } @@ -1059,6 +1290,31 @@ have to be updated for changes to the rules above, or to support more deeply nes padding: 1px 3px; } +.aideagent-item-container .chat-agent-command { + background-color: var(--vscode-chat-slashCommandBackground); + color: var(--vscode-chat-slashCommandForeground); + display: inline-flex; + align-items: center; + margin-right: 0.5ch; + border-radius: 4px; + padding: 0 0 0 3px; +} + +.aideagent-item-container .chat-agent-command > .monaco-button { + display: flex; + align-self: stretch; + align-items: center; + cursor: pointer; + padding: 0 2px; + margin-left: 2px; + border-top-right-radius: 4px; + border-bottom-right-radius: 4px; +} + +.aideagent-item-container .chat-agent-command > .monaco-button:hover { + background: var(--vscode-toolbar-hoverBackground); +} + .aideagent-item-container .chat-agent-widget .monaco-text-button { display: inline; border: none; @@ -1077,11 +1333,30 @@ have to be updated for changes to the rules above, or to support more deeply nes .interactive-response-progress-tree, .aideagent-item-container .chat-notification-widget, .interactive-session .chat-used-context-list, -.interactive-session .aideagent-attached-context .aideagent-attachments-list { +.interactive-session .chat-attached-context .aideagent-attachments-list { border: 1px solid var(--vscode-chat-requestBorder); border-radius: 4px; margin-bottom: 8px; - padding: 4px 6px; +} + +.interactive-response-progress-tree, +.interactive-session .chat-used-context-list { + padding: 4px 3px; + + .monaco-icon-label { + padding: 0px 3px; + } +} + +.interactive-session .chat-editing-session-list { + + .monaco-icon-label { + padding: 0px 3px; + } + + .monaco-icon-label.excluded { + color: var(--vscode-notificationsWarningIcon-foreground) + } } .aideagent-item-container .chat-notification-widget { @@ -1139,7 +1414,7 @@ have to be updated for changes to the rules above, or to support more deeply nes } .interactive-session .chat-used-context .aideagent-used-context-label .monaco-button .codicon, -.interactive-session .aideagent-attached-context .aideagent-attachments-label .monaco-button .codicon { +.interactive-session .chat-attached-context .aideagent-attachments-label .monaco-button .codicon { margin: 0 0 0 4px; } @@ -1185,6 +1460,10 @@ have to be updated for changes to the rules above, or to support more deeply nes padding: 4px 8px; } +.aideagent-item-container .chat-confirmation-widget .rendered-markdown [data-code] { + margin-bottom: 8px; +} + .aideagent-item-container .chat-command-button .monaco-button .codicon { margin-left: 0; margin-top: 1px; @@ -1214,7 +1493,7 @@ have to be updated for changes to the rules above, or to support more deeply nes display: block; } -.aideagent-attached-context-attachment .chat-attached-context-pill { +.chat-attached-context-attachment .chat-attached-context-pill { font-size: 12px; display: inline-flex; align-items: center; @@ -1226,24 +1505,44 @@ have to be updated for changes to the rules above, or to support more deeply nes border: none; } -.aideagent-attached-context-attachment .attachment-additional-info { +.chat-attached-context-attachment .attachment-additional-info { opacity: 0.7; font-size: .9em; } -.aideagent-attached-context-attachment .chat-attached-context-pill-image { +.chat-attached-context-attachment .chat-attached-context-pill-image { width: 14px; height: 14px; border-radius: 2px; } -.aideagent-attached-context-attachment .chat-attached-context-custom-text { +.chat-attached-context-attachment .chat-attached-context-custom-text { vertical-align: middle; user-select: none; outline: none; border: none; } +.interactive-session .chat-scroll-down { + display: none; + position: absolute; + bottom: 7px; + right: 12px; + border-radius: 100%; + width: initial; + width: 27px; + height: 27px; + + .codicon { + margin: 0px; + } +} + +.interactive-session.show-scroll-down .chat-scroll-down { + display: initial; +} + + .aideagent-item-container .aideagent-attachment-icons { padding-right: 8px; } @@ -1269,3 +1568,30 @@ have to be updated for changes to the rules above, or to support more deeply nes .aideagent-item-container .aideagent-attachment-icons .icon-label { text-wrap: wrap; } + +.interactive-session .hidden-exchanges-container { + display: flex; + flex-direction: row; + align-items: center; + justify-content: space-between; + gap: 8px; + margin: 0px 16px; + padding: 8px 12px; + background-color: var(--vscode-input-background); + border: 1px solid var(--vscode-input-border, transparent); + border-radius: 4px; +} + +.interactive-session .hidden-exchanges-container.hidden-exchanges-message { + color: var(--vscode-foreground); + font-size: 12px; +} + +.interactive-session .hidden-exchanges-container .hidden-exchanges-toolbar { + display: flex; + align-items: center; +} + +.interactive-session .hidden-exchanges-container .hidden-exchanges-toolbar .monaco-action-bar .actions-container { + gap: 4px; +} diff --git a/src/vs/workbench/contrib/aideAgent/browser/media/aideAgentCodeBlockPill.css b/src/vs/workbench/contrib/aideAgent/browser/media/aideAgentCodeBlockPill.css index 19d16aff413..83cda49ccad 100644 --- a/src/vs/workbench/contrib/aideAgent/browser/media/aideAgentCodeBlockPill.css +++ b/src/vs/workbench/contrib/aideAgent/browser/media/aideAgentCodeBlockPill.css @@ -44,3 +44,21 @@ background-repeat: no-repeat; flex-shrink: 0; } + +span.label-detail { + padding-left: 4px; + font-style: italic; + color: var(--vscode-descriptionForeground); +} + +span.label-added { + font-weight: bold; + padding-left: 4px; + color: var(--vscode-editorGutter-addedBackground); +} + +span.label-removed { + font-weight: bold; + padding-left: 4px; + color: var(--vscode-editorGutter-deletedBackground); +} diff --git a/src/vs/workbench/contrib/aideAgent/browser/media/aideAgentEditPreviewWidget.css b/src/vs/workbench/contrib/aideAgent/browser/media/aideAgentEditPreviewWidget.css deleted file mode 100644 index 70cf0a3923e..00000000000 --- a/src/vs/workbench/contrib/aideAgent/browser/media/aideAgentEditPreviewWidget.css +++ /dev/null @@ -1,39 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -.edit-preview { - margin: 0 16px; -} - -.edit-preview > .aideagent-edit-preview { - display: flex; - flex-direction: column; - padding: 8px; - background-color: var(--vscode-input-background); - border: 1px solid var(--vscode-input-border, transparent); - border-radius: 4px; - transition: transform 0.2s ease; -} - -.edit-preview > .aideagent-edit-preview:has(> .code-edits .monaco-list-row) { - gap: 8px; -} - -.edit-preview > .aideagent-edit-preview.hidden { - display: none; - transform: scale(0.9) translateY(calc(-100% + 10px)); -} - -.edit-preview > .aideagent-edit-preview > .header { - display: flex; - justify-content: space-between; - height: 22px; -} - -.edit-preview > .aideagent-edit-preview > .header > .title { - display: flex; - align-items: center; - gap: 8px; -} diff --git a/src/vs/workbench/contrib/aideAgent/browser/media/aideAgentEditorController.css b/src/vs/workbench/contrib/aideAgent/browser/media/aideAgentEditorController.css new file mode 100644 index 00000000000..ccef0cfb191 --- /dev/null +++ b/src/vs/workbench/contrib/aideAgent/browser/media/aideAgentEditorController.css @@ -0,0 +1,40 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +.chat-diff-change-content-widget { + opacity: 0; + transition: opacity 0.2s ease-in-out; + display: flex; + box-shadow: 0 2px 8px var(--vscode-widget-shadow); +} + +.chat-diff-change-content-widget.hover { + opacity: 1; +} + +.chat-diff-change-content-widget .monaco-action-bar { + padding: 0; + border-radius: 2px; + background-color: var(--vscode-button-background); + color: var(--vscode-button-foreground); +} + +.chat-diff-change-content-widget .monaco-action-bar .action-item .action-label { + height: 14px; + border-radius: 2px; + color: var(--vscode-button-foreground); +} + +.chat-diff-change-content-widget .monaco-action-bar .action-item .action-label.codicon { + width: unset; + padding: 2px; + font-size: 12px; + line-height: 14px; + color: var(--vscode-button-foreground); +} + +.chat-diff-change-content-widget .monaco-action-bar .action-item .action-label.codicon[class*='codicon-'] { + font-size: 12px; +} diff --git a/src/vs/workbench/contrib/aideAgent/browser/media/aideAgentEditorOverlay.css b/src/vs/workbench/contrib/aideAgent/browser/media/aideAgentEditorOverlay.css new file mode 100644 index 00000000000..f33b5a589c9 --- /dev/null +++ b/src/vs/workbench/contrib/aideAgent/browser/media/aideAgentEditorOverlay.css @@ -0,0 +1,136 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +.chat-editor-overlay-widget { + padding: 0px; + color: var(--vscode-button-foreground); + background-color: var(--vscode-button-background); + border-radius: 5px; + border: 1px solid var(--vscode-contrastBorder); + display: flex; + align-items: center; + z-index: 10; + box-shadow: 0 2px 8px var(--vscode-widget-shadow); + overflow: hidden; +} + +@keyframes pulse { + 0% { + box-shadow: 0 2px 8px 0 var(--vscode-widget-shadow); + } + 50% { + box-shadow: 0 2px 8px 4px var(--vscode-widget-shadow); + } + 100% { + box-shadow: 0 2px 8px 0 var(--vscode-widget-shadow); + } +} + +.chat-editor-overlay-widget.busy { + animation: pulse ease-in 2.3s infinite; +} + +.chat-editor-overlay-widget .chat-editor-overlay-progress { + align-items: center; + display: none; + padding: 0px 5px; + font-size: 12px; + font-variant-numeric: tabular-nums; + overflow: hidden; + white-space: nowrap; +} + +.chat-editor-overlay-widget.busy .chat-editor-overlay-progress { + display: inline-flex; +} + +.chat-editor-overlay-widget.busy .chat-editor-overlay-progress .busy-label { + padding: 5px; + /* font-style: italic; */ +} + +@keyframes ellipsis { + 0% { + content: ""; + } + 25% { + content: "."; + } + 50% { + content: ".."; + } + 75% { + content: "..."; + } + 100% { + content: ""; + } +} + +.chat-editor-overlay-widget.busy .chat-editor-overlay-progress .busy-label::after { + content: ""; + display: inline-flex; + white-space: nowrap; + overflow: hidden; + width: 3ch; + animation: ellipsis steps(4, end) 1s infinite; +} + +.chat-editor-overlay-widget.busy .chat-editor-overlay-toolbar { + display: none; +} + +.chat-editor-overlay-widget .action-item > .action-label { + padding: 5px; + font-size: 12px; +} + +.chat-editor-overlay-widget .action-item:first-child > .action-label { + padding-left: 7px; +} + +.chat-editor-overlay-widget .action-item:last-child > .action-label { + padding-right: 7px; +} + +.chat-editor-overlay-widget.busy .chat-editor-overlay-progress .codicon, +.chat-editor-overlay-widget .action-item > .action-label.codicon { + color: var(--vscode-button-foreground); +} + +.chat-editor-overlay-widget .monaco-action-bar .action-item.disabled > .action-label.codicon::before, +.chat-editor-overlay-widget .monaco-action-bar .action-item.disabled > .action-label.codicon, +.chat-editor-overlay-widget .monaco-action-bar .action-item.disabled > .action-label, +.chat-editor-overlay-widget .monaco-action-bar .action-item.disabled > .action-label:hover { + color: var(--vscode-button-foreground); + opacity: 0.7; +} + + +.chat-editor-overlay-widget .action-item.label-item { + font-variant-numeric: tabular-nums; +} + +.chat-editor-overlay-widget .monaco-action-bar .action-item.label-item > .action-label, +.chat-editor-overlay-widget .monaco-action-bar .action-item.label-item > .action-label:hover { + color: var(--vscode-button-foreground); + opacity: 1; +} + +.chat-editor-overlay-widget .action-item.auto { + position: relative; + overflow: hidden; +} + +.chat-editor-overlay-widget .action-item.auto::before { + content: ''; + position: absolute; + top: 0; + left: var(--vscode-action-item-auto-timeout, -100%); + width: 100%; + height: 100%; + background-color: var(--vscode-toolbar-hoverBackground); + transition: left 0.5s linear; +} diff --git a/src/vs/workbench/contrib/aideAgent/common/aideAgentCodeEditingService.ts b/src/vs/workbench/contrib/aideAgent/common/aideAgentCodeEditingService.ts deleted file mode 100644 index 3322a6f79ba..00000000000 --- a/src/vs/workbench/contrib/aideAgent/common/aideAgentCodeEditingService.ts +++ /dev/null @@ -1,44 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { Event } from '../../../../base/common/event.js'; -import { URI } from '../../../../base/common/uri.js'; -import { Range } from '../../../../editor/common/core/range.js'; -import { IWorkspaceTextEdit } from '../../../../editor/common/languages.js'; -import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; - -export const enum AideAgentCodeEditingSessionState { - Initial = 0, - StreamingEdits = 1, - Idle = 2, - Disposed = 3 -} - -export interface IAideAgentCodeEditingSession { - readonly onDidChange: Event; - readonly onDidDispose: Event; - - readonly sessionId: string; - readonly codeEdits: Map; - - apply(edits: IWorkspaceTextEdit): Promise; - complete(): void; - accept(): void; - reject(): void; - /** - * Will lead to this object getting disposed - */ - stop(): Promise; - dispose(): void; -} - -export const IAideAgentCodeEditingService = createDecorator('aideAgentCodeEditingService'); -export interface IAideAgentCodeEditingService { - _serviceBrand: undefined; - - onDidComplete: Event; - getOrStartCodeEditingSession(sessionId: string): IAideAgentCodeEditingSession; - getExistingCodeEditingSession(sessionId: string): IAideAgentCodeEditingSession | undefined; -} diff --git a/src/vs/workbench/contrib/aideAgent/common/aideAgentContextKeys.ts b/src/vs/workbench/contrib/aideAgent/common/aideAgentContextKeys.ts index cdcf03f7711..b1a025db2b5 100644 --- a/src/vs/workbench/contrib/aideAgent/common/aideAgentContextKeys.ts +++ b/src/vs/workbench/contrib/aideAgent/common/aideAgentContextKeys.ts @@ -14,6 +14,8 @@ export const CONTEXT_CHAT_RESPONSE_SUPPORT_ISSUE_REPORTING = new RawContextKey('aideAgentSessionResponseFiltered', false, { type: 'boolean', description: localize('chatResponseFiltered', "True when the chat response was filtered out by the server.") }); export const CONTEXT_RESPONSE_ERROR = new RawContextKey('aideAgentSessionResponseError', false, { type: 'boolean', description: localize('chatResponseErrored', "True when the chat response resulted in an error.") }); export const CONTEXT_CHAT_REQUEST_IN_PROGRESS = new RawContextKey('aideAgentSessionRequestInProgress', false, { type: 'boolean', description: localize('interactiveSessionRequestInProgress', "True when the current request is still in progress.") }); +export const CONTEXT_CHAT_CAN_REVERT_EXCHANGE = new RawContextKey('aideAgentCanRevertExchange', false, { type: 'boolean', description: localize('aideAgentCanRevertExchange', "True when the conversation can be revert until this exchange.") }); +export const CONTEXT_CHAT_HAS_HIDDEN_EXCHANGES = new RawContextKey('aideAgentHasHiddenExchanges', false, { type: 'boolean', description: localize('aideAgentHasHiddenExchanges', "True when there are reverted exchanges that are yet to be discarded.") }); export const CONTEXT_RESPONSE = new RawContextKey('aideAgentResponse', false, { type: 'boolean', description: localize('chatResponse', "The chat item is a response.") }); export const CONTEXT_REQUEST = new RawContextKey('aideAgentRequest', false, { type: 'boolean', description: localize('chatRequest', "The chat item is a request") }); @@ -28,11 +30,12 @@ export const CONTEXT_CHAT_MODE = new RawContextKey('aideAgentMode', 'Pla export const CONTEXT_CHAT_IS_PLAN_VISIBLE = new RawContextKey('aideAgentIsPlanVisible', false, { type: 'boolean', description: localize('chatIsPlanVisible', "True when the plan is visible.") }); export const CONTEXT_CHAT_LAST_ITEM_ID = new RawContextKey('aideAgentLastItemId', [], { type: 'string', description: localize('chatLastItemId', "The id of the last chat item.") }); export const CONTEXT_CHAT_LAST_EXCHANGE_COMPLETE = new RawContextKey('aideAgentLastExchangeComplete', false, { type: 'boolean', description: localize('chatLastExchangeComplete', "True when the last exchange is complete.") }); -export const CONTEXT_CHAT_SESSION_WITH_EDITS = new RawContextKey('aideAgentSessionWithEdits', false, { type: 'boolean', description: localize('chatSessionWithEdits', "True when the chat session has edits.") }); export const CONTEXT_CHAT_HAS_FILE_ATTACHMENTS = new RawContextKey('aideAgentHasFileAttachments', false, { type: 'boolean', description: localize('chatHasFileAttachments', "True when the chat has file attachments.") }); export const CONTEXT_CHAT_ENABLED = new RawContextKey('aideAgentIsEnabled', false, { type: 'boolean', description: localize('chatIsEnabled', "True when chat is enabled because a default chat participant is activated with an implementation.") }); export const CONTEXT_CHAT_PANEL_PARTICIPANT_REGISTERED = new RawContextKey('aideAgentPanelParticipantRegistered', false, { type: 'boolean', description: localize('chatParticipantRegistered', "True when a default chat participant is registered for the panel.") }); +export const CONTEXT_CHAT_CAN_UNDO = new RawContextKey('aideAgentEditingCanUndo', false, { type: 'boolean', description: localize('chatEditingCanUndo', "True when it is possible to undo an interaction in the assistant panel.") }); +export const CONTEXT_CHAT_CAN_REDO = new RawContextKey('aideAgentEditingCanRedo', false, { type: 'boolean', description: localize('chatEditingCanRedo', "True when it is possible to redo an interaction in the assistant panel.") }); export const CONTEXT_CHAT_EXTENSION_INVALID = new RawContextKey('aideAgentExtensionInvalid', false, { type: 'boolean', description: localize('chatExtensionInvalid', "True when the installed chat extension is invalid and needs to be updated.") }); export const CONTEXT_CHAT_INPUT_CURSOR_AT_TOP = new RawContextKey('aideAgentCursorAtTop', false); export const CONTEXT_CHAT_INPUT_HAS_AGENT = new RawContextKey('aideAgentInputHasAgent', false); diff --git a/src/vs/workbench/contrib/aideAgent/common/aideAgentEditingService.ts b/src/vs/workbench/contrib/aideAgent/common/aideAgentEditingService.ts new file mode 100644 index 00000000000..c0f93eab63b --- /dev/null +++ b/src/vs/workbench/contrib/aideAgent/common/aideAgentEditingService.ts @@ -0,0 +1,180 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { CancellationTokenSource } from '../../../../base/common/cancellation.js'; +import { Event } from '../../../../base/common/event.js'; +import { IDisposable } from '../../../../base/common/lifecycle.js'; +import { ResourceMap } from '../../../../base/common/map.js'; +import { IObservable, IReader, ITransaction } from '../../../../base/common/observable.js'; +import { URI } from '../../../../base/common/uri.js'; +import { IDocumentDiff } from '../../../../editor/common/diff/documentDiffProvider.js'; +import { DetailedLineRangeMapping } from '../../../../editor/common/diff/rangeMapping.js'; +import { TextEdit } from '../../../../editor/common/languages.js'; +import { ITextModel } from '../../../../editor/common/model.js'; +import { localize } from '../../../../nls.js'; +import { RawContextKey } from '../../../../platform/contextkey/common/contextkey.js'; +import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; +import { IChatResponseModel } from './aideAgentModel.js'; + +export const IAideAgentEditingService = createDecorator('aideAgentEditingService'); + +export interface IAideAgentEditingService { + + _serviceBrand: undefined; + + readonly currentEditingSessionObs: IObservable; + + readonly currentEditingSession: IChatEditingSession | null; + readonly currentAutoApplyOperation: CancellationTokenSource | null; + + readonly editingSessionFileLimit: number; + + startOrContinueEditingSession(chatSessionId: string): Promise; + getOrRestoreEditingSession(): Promise; + + /** + * All editing sessions, sorted by recency, e.g the last created session comes first. + */ + readonly editingSessionsObs: IObservable; + + /** + * Creates a new short lived editing session + */ + createAdhocEditingSession(chatSessionId: string): Promise; +} + +export interface IChatRequestDraft { + readonly prompt: string; + readonly files: readonly URI[]; +} + +export interface IChatRelatedFileProviderMetadata { + readonly description: string; +} + +export interface IChatRelatedFile { + readonly uri: URI; + readonly description: string; +} + +export interface WorkingSetDisplayMetadata { + state: WorkingSetEntryState; + description?: string; + isMarkedReadonly?: boolean; +} + +export interface IChatEditingSession { + readonly isGlobalEditingSession: boolean; + readonly chatSessionId: string; + readonly onDidChange: Event; + readonly onDidDispose: Event; + readonly state: IObservable; + readonly entries: IObservable; + readonly workingSet: ResourceMap; + addFileToWorkingSet(uri: URI, description?: string, kind?: WorkingSetEntryState.Transient | WorkingSetEntryState.Suggested): void; + show(): Promise; + remove(reason: WorkingSetEntryRemovalReason, ...uris: URI[]): void; + markIsReadonly(uri: URI, isReadonly?: boolean): void; + accept(...uris: URI[]): Promise; + reject(...uris: URI[]): Promise; + getEntry(uri: URI): IModifiedFileEntry | undefined; + readEntry(uri: URI, reader?: IReader): IModifiedFileEntry | undefined; + + restoreSnapshot(requestId: string): Promise; + getSnapshotUri(requestId: string, uri: URI): URI | undefined; + + /** + * Will lead to this object getting disposed + */ + stop(clearState?: boolean): Promise; + + undoInteraction(): Promise; + redoInteraction(): Promise; +} + +export const enum WorkingSetEntryRemovalReason { + User, + Programmatic +} + +export const enum WorkingSetEntryState { + Modified, + Accepted, + Rejected, + Transient, + Attached, + Sent, // TODO@joyceerhl remove this + Suggested, +} + +export const enum ChatEditingSessionChangeType { + WorkingSet, + Other, +} + +export interface IModifiedFileEntry { + readonly originalURI: URI; + readonly originalModel: ITextModel; + readonly modifiedURI: URI; + readonly state: IObservable; + readonly isCurrentlyBeingModified: IObservable; + readonly rewriteRatio: IObservable; + readonly maxLineNumber: IObservable; + readonly diffInfo: IObservable; + acceptHunk(change: DetailedLineRangeMapping): Promise; + rejectHunk(change: DetailedLineRangeMapping): Promise; + readonly lastModifyingRequestId: string; + accept(transaction: ITransaction | undefined): Promise; + reject(transaction: ITransaction | undefined): Promise; + + reviewMode: IObservable; + autoAcceptController: IObservable<{ total: number; remaining: number; cancel(): void } | undefined>; + enableReviewModeUntilSettled(): void; +} + +export interface IChatEditingSessionStream { + textEdits(resource: URI, textEdits: TextEdit[], isLastEdits: boolean, responseModel: IChatResponseModel): void; +} + +export const enum ChatEditingSessionState { + Initial = 0, + StreamingEdits = 1, + Idle = 2, + Disposed = 3 +} + +export const CHAT_EDITING_MULTI_DIFF_SOURCE_RESOLVER_SCHEME = 'aide-agent-editing-multi-diff-source'; + +export const chatEditingWidgetFileStateContextKey = new RawContextKey('aideAgentEditingWidgetFileState', undefined, localize('chatEditingWidgetFileState', "The current state of the file in the chat editing widget")); +export const chatEditingWidgetFileReadonlyContextKey = new RawContextKey('aideAgentEditingWidgetFileReadonly', undefined, localize('chatEditingWidgetFileReadonly', "Whether the file has been marked as read-only in the chat editing widget")); +export const chatEditingAgentSupportsReadonlyReferencesContextKey = new RawContextKey('aideAgentEditingAgentSupportsReadonlyReferences', undefined, localize('chatEditingAgentSupportsReadonlyReferences', "Whether the chat editing agent supports readonly references (temporary)")); +export const decidedChatEditingResourceContextKey = new RawContextKey('decidedAideAgentEditingResource', []); +export const chatEditingResourceContextKey = new RawContextKey('aideAgentEditingResource', undefined); +export const inChatEditingSessionContextKey = new RawContextKey('inAideAgentEditingSession', undefined); +export const applyingChatEditsContextKey = new RawContextKey('isApplyingAideAgentEdits', undefined); +export const hasUndecidedChatEditingResourceContextKey = new RawContextKey('hasUndecidedAideAgentEditingResource', false); +export const hasAppliedChatEditsContextKey = new RawContextKey('hasAppliedAideAgentEdits', false); +export const applyingChatEditsFailedContextKey = new RawContextKey('applyingAideAgentEditsFailed', false); + +export const enum ChatEditKind { + Created, + Modified, +} + +export interface IChatEditingActionContext { + // The chat session ID that this editing session is associated with + sessionId: string; +} + +export function isChatEditingActionContext(thing: unknown): thing is IChatEditingActionContext { + return typeof thing === 'object' && !!thing && 'sessionId' in thing; +} + +export function getMultiDiffSourceUri(): URI { + return URI.from({ + scheme: CHAT_EDITING_MULTI_DIFF_SOURCE_RESOLVER_SCHEME, + path: '', + }); +} diff --git a/src/vs/workbench/contrib/aideAgent/common/aideAgentEditingSession.ts b/src/vs/workbench/contrib/aideAgent/common/aideAgentEditingSession.ts deleted file mode 100644 index ca66dfda871..00000000000 --- a/src/vs/workbench/contrib/aideAgent/common/aideAgentEditingSession.ts +++ /dev/null @@ -1,398 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { coalesceInPlace } from '../../../../base/common/arrays.js'; -import { Iterable } from '../../../../base/common/iterator.js'; -import { DisposableStore } from '../../../../base/common/lifecycle.js'; -import { EditOperation, ISingleEditOperation } from '../../../../editor/common/core/editOperation.js'; -import { LineRange } from '../../../../editor/common/core/lineRange.js'; -import { Position } from '../../../../editor/common/core/position.js'; -import { Range } from '../../../../editor/common/core/range.js'; -import { IDocumentDiff } from '../../../../editor/common/diff/documentDiffProvider.js'; -import { DetailedLineRangeMapping, RangeMapping } from '../../../../editor/common/diff/rangeMapping.js'; -import { IIdentifiedSingleEditOperation, ITextModel, IValidEditOperation, TrackedRangeStickiness } from '../../../../editor/common/model.js'; -import { ModelDecorationOptions } from '../../../../editor/common/model/textModel.js'; -import { IEditorWorkerService } from '../../../../editor/common/services/editorWorker.js'; -import { IModelContentChangedEvent } from '../../../../editor/common/textModelEvents.js'; - -export const enum HunkState { - Pending = 0, - Accepted = 1, - Rejected = 2 -} - -export type HunkDisplayData = { - decorationIds: string[]; - hunk: HunkInformation; - position: Position; - remove(): void; -}; - -class RawHunk { - constructor( - readonly original: LineRange, - readonly modified: LineRange, - readonly changes: RangeMapping[] - ) { } -} - -interface IChatTextEditGroupState { - sha1: string; - applied: number; -} - -type RawHunkData = { - textModelNDecorations: string[]; - textModel0Decorations: string[]; - state: HunkState; - editState: IChatTextEditGroupState; -}; - -export interface HunkInformation { - /** - * The first element [0] is the whole modified range and subsequent elements are word-level changes - */ - getRangesN(): Range[]; - getRanges0(): Range[]; - isInsertion(): boolean; - discardChanges(): void; - /** - * Accept the hunk. Applies the corresponding edits into textModel0 - */ - acceptChanges(): void; - getState(): HunkState; -} - -export class HunkData { - - private static readonly _HUNK_TRACKED_RANGE = ModelDecorationOptions.register({ - description: 'aide-agent-hunk-tracked-range', - stickiness: TrackedRangeStickiness.AlwaysGrowsWhenTypingAtEdges - }); - - private static readonly _HUNK_THRESHOLD = 8; - - private readonly _store = new DisposableStore(); - private readonly _data = new Map(); - private _ignoreChanges: boolean = false; - - constructor( - @IEditorWorkerService private readonly _editorWorkerService: IEditorWorkerService, - private readonly _textModel0: ITextModel, - private readonly _textModelN: ITextModel, - ) { - - this._store.add(_textModelN.onDidChangeContent(e => { - if (!this._ignoreChanges) { - this._mirrorChanges(e); - } - })); - } - - dispose(): void { - if (!this._textModelN.isDisposed()) { - this._textModelN.changeDecorations(accessor => { - for (const { textModelNDecorations } of this._data.values()) { - textModelNDecorations.forEach(accessor.removeDecoration, accessor); - } - }); - } - if (!this._textModel0.isDisposed()) { - this._textModel0.changeDecorations(accessor => { - for (const { textModel0Decorations } of this._data.values()) { - textModel0Decorations.forEach(accessor.removeDecoration, accessor); - } - }); - } - this._data.clear(); - this._store.dispose(); - } - - set ignoreTextModelNChanges(value: boolean) { - this._ignoreChanges = value; - } - - get ignoreTextModelNChanges(): boolean { - return this._ignoreChanges; - } - - private _mirrorChanges(event: IModelContentChangedEvent) { - - // mirror textModelN changes to textModel0 execept for those that - // overlap with a hunk - - type HunkRangePair = { rangeN: Range; range0: Range; markAccepted: () => void }; - const hunkRanges: HunkRangePair[] = []; - - const ranges0: Range[] = []; - - for (const entry of this._data.values()) { - - if (entry.state === HunkState.Pending) { - // pending means the hunk's changes aren't "sync'd" yet - for (let i = 1; i < entry.textModelNDecorations.length; i++) { - const rangeN = this._textModelN.getDecorationRange(entry.textModelNDecorations[i]); - const range0 = this._textModel0.getDecorationRange(entry.textModel0Decorations[i]); - if (rangeN && range0) { - hunkRanges.push({ - rangeN, range0, - markAccepted: () => entry.state = HunkState.Accepted - }); - } - } - - } else if (entry.state === HunkState.Accepted) { - // accepted means the hunk's changes are also in textModel0 - for (let i = 1; i < entry.textModel0Decorations.length; i++) { - const range = this._textModel0.getDecorationRange(entry.textModel0Decorations[i]); - if (range) { - ranges0.push(range); - } - } - } - } - - hunkRanges.sort((a, b) => Range.compareRangesUsingStarts(a.rangeN, b.rangeN)); - ranges0.sort(Range.compareRangesUsingStarts); - - const edits: IIdentifiedSingleEditOperation[] = []; - - for (const change of event.changes) { - - let isOverlapping = false; - - let pendingChangesLen = 0; - - for (const entry of hunkRanges) { - if (entry.rangeN.getEndPosition().isBefore(Range.getStartPosition(change.range))) { - // pending hunk _before_ this change. When projecting into textModel0 we need to - // subtract that. Because diffing is relaxed it might include changes that are not - // actual insertions/deletions. Therefore we need to take the length of the original - // range into account. - pendingChangesLen += this._textModelN.getValueLengthInRange(entry.rangeN); - pendingChangesLen -= this._textModel0.getValueLengthInRange(entry.range0); - - } else if (Range.areIntersectingOrTouching(entry.rangeN, change.range)) { - // an edit overlaps with a (pending) hunk. We take this as a signal - // to mark the hunk as accepted and to ignore the edit. The range of the hunk - // will be up-to-date because of decorations created for them - entry.markAccepted(); - isOverlapping = true; - break; - - } else { - // hunks past this change aren't relevant - break; - } - } - - if (isOverlapping) { - // hunk overlaps, it grew - continue; - } - - const offset0 = change.rangeOffset - pendingChangesLen; - const start0 = this._textModel0.getPositionAt(offset0); - - let acceptedChangesLen = 0; - for (const range of ranges0) { - if (range.getEndPosition().isBefore(start0)) { - // accepted hunk _before_ this projected change. When projecting into textModel0 - // we need to add that - acceptedChangesLen += this._textModel0.getValueLengthInRange(range); - } - } - - const start = this._textModel0.getPositionAt(offset0 + acceptedChangesLen); - const end = this._textModel0.getPositionAt(offset0 + acceptedChangesLen + change.rangeLength); - edits.push(EditOperation.replace(Range.fromPositions(start, end), change.text)); - } - - this._textModel0.pushEditOperations(null, edits, () => null); - } - - async recompute(editState: IChatTextEditGroupState, diff?: IDocumentDiff | null) { - - diff ??= await this._editorWorkerService.computeDiff(this._textModel0.uri, this._textModelN.uri, { ignoreTrimWhitespace: false, maxComputationTimeMs: Number.MAX_SAFE_INTEGER, computeMoves: false }, 'advanced'); - - let mergedChanges: DetailedLineRangeMapping[] = []; - - if (diff && diff.changes.length > 0) { - // merge changes neighboring changes - mergedChanges = [diff.changes[0]]; - for (let i = 1; i < diff.changes.length; i++) { - const lastChange = mergedChanges[mergedChanges.length - 1]; - const thisChange = diff.changes[i]; - if (thisChange.modified.startLineNumber - lastChange.modified.endLineNumberExclusive <= HunkData._HUNK_THRESHOLD) { - mergedChanges[mergedChanges.length - 1] = new DetailedLineRangeMapping( - lastChange.original.join(thisChange.original), - lastChange.modified.join(thisChange.modified), - (lastChange.innerChanges ?? []).concat(thisChange.innerChanges ?? []) - ); - } else { - mergedChanges.push(thisChange); - } - } - } - - const hunks = mergedChanges.map(change => new RawHunk(change.original, change.modified, change.innerChanges ?? [])); - - editState.applied = hunks.length; - - this._textModelN.changeDecorations(accessorN => { - - this._textModel0.changeDecorations(accessor0 => { - - // clean up old decorations - for (const { textModelNDecorations, textModel0Decorations } of this._data.values()) { - textModelNDecorations.forEach(accessorN.removeDecoration, accessorN); - textModel0Decorations.forEach(accessor0.removeDecoration, accessor0); - } - - this._data.clear(); - - // add new decorations - for (const hunk of hunks) { - - const textModelNDecorations: string[] = []; - const textModel0Decorations: string[] = []; - - textModelNDecorations.push(accessorN.addDecoration(lineRangeAsRange(hunk.modified, this._textModelN), HunkData._HUNK_TRACKED_RANGE)); - textModel0Decorations.push(accessor0.addDecoration(lineRangeAsRange(hunk.original, this._textModel0), HunkData._HUNK_TRACKED_RANGE)); - - for (const change of hunk.changes) { - textModelNDecorations.push(accessorN.addDecoration(change.modifiedRange, HunkData._HUNK_TRACKED_RANGE)); - textModel0Decorations.push(accessor0.addDecoration(change.originalRange, HunkData._HUNK_TRACKED_RANGE)); - } - - this._data.set(hunk, { - editState, - textModelNDecorations, - textModel0Decorations, - state: HunkState.Pending - }); - } - }); - }); - } - - get size(): number { - return this._data.size; - } - - get pending(): number { - return Iterable.reduce(this._data.values(), (r, { state }) => r + (state === HunkState.Pending ? 1 : 0), 0); - } - - private _discardEdits(item: HunkInformation): ISingleEditOperation[] { - const edits: ISingleEditOperation[] = []; - const rangesN = item.getRangesN(); - const ranges0 = item.getRanges0(); - for (let i = 1; i < rangesN.length; i++) { - const modifiedRange = rangesN[i]; - - const originalValue = this._textModel0.getValueInRange(ranges0[i]); - edits.push(EditOperation.replace(modifiedRange, originalValue)); - } - return edits; - } - - discardAll() { - const edits: ISingleEditOperation[][] = []; - for (const item of this.getInfo()) { - if (item.getState() === HunkState.Pending) { - edits.push(this._discardEdits(item)); - } - } - const undoEdits: IValidEditOperation[][] = []; - this._textModelN.pushEditOperations(null, edits.flat(), (_undoEdits) => { - undoEdits.push(_undoEdits); - return null; - }); - return undoEdits.flat(); - } - - getInfo(): HunkInformation[] { - - const result: HunkInformation[] = []; - - for (const [hunk, data] of this._data.entries()) { - const item: HunkInformation = { - getState: () => { - return data.state; - }, - isInsertion: () => { - return hunk.original.isEmpty; - }, - getRangesN: () => { - const ranges = data.textModelNDecorations.map(id => this._textModelN.getDecorationRange(id)); - coalesceInPlace(ranges); - return ranges; - }, - getRanges0: () => { - const ranges = data.textModel0Decorations.map(id => this._textModel0.getDecorationRange(id)); - coalesceInPlace(ranges); - return ranges; - }, - discardChanges: () => { - // DISCARD: replace modified range with original value. The modified range is retrieved from a decoration - // which was created above so that typing in the editor keeps discard working. - if (data.state === HunkState.Pending) { - const edits = this._discardEdits(item); - this._textModelN.pushEditOperations(null, edits, () => null); - data.state = HunkState.Rejected; - if (data.editState.applied > 0) { - data.editState.applied -= 1; - } - } - }, - acceptChanges: () => { - // ACCEPT: replace original range with modified value. The modified value is retrieved from the model via - // its decoration and the original range is retrieved from the hunk. - if (data.state === HunkState.Pending) { - const edits: ISingleEditOperation[] = []; - const rangesN = item.getRangesN(); - const ranges0 = item.getRanges0(); - for (let i = 1; i < ranges0.length; i++) { - const originalRange = ranges0[i]; - const modifiedValue = this._textModelN.getValueInRange(rangesN[i]); - edits.push(EditOperation.replace(originalRange, modifiedValue)); - } - this._textModel0.pushEditOperations(null, edits, () => null); - data.state = HunkState.Accepted; - } - } - }; - result.push(item); - } - - return result; - } -} - -function lineRangeAsRange(lineRange: LineRange, model: ITextModel): Range { - return lineRange.isEmpty - ? new Range(lineRange.startLineNumber, 1, lineRange.startLineNumber, model.getLineLength(lineRange.startLineNumber)) - : new Range(lineRange.startLineNumber, 1, lineRange.endLineNumberExclusive - 1, model.getLineLength(lineRange.endLineNumberExclusive - 1)); -} - -export function calculateChanges(edits: HunkInformation[]) { - const changes = edits.reduce((acc, edit) => { - const newRanges = edit.getRangesN() || []; - const oldRanges = edit.getRanges0() || []; - if (edit.isInsertion()) { - const wholeNewRange = newRanges[0]; - acc.added += wholeNewRange.endLineNumber - wholeNewRange.startLineNumber + 1; - } else if (newRanges.length > 0 && oldRanges.length > 0) { - const wholeNewRange = newRanges[0]; - const wholeOldRange = oldRanges[0]; - - acc.added += wholeNewRange.endLineNumber - wholeNewRange.startLineNumber + 1; - acc.removed += wholeOldRange.endLineNumber - wholeOldRange.startLineNumber + 1; - } - return acc; - }, { added: 0, removed: 0 }); - return changes; -} diff --git a/src/vs/workbench/contrib/aideAgent/common/aideAgentModel.ts b/src/vs/workbench/contrib/aideAgent/common/aideAgentModel.ts index f1ecb098a11..13d05a43ff6 100644 --- a/src/vs/workbench/contrib/aideAgent/common/aideAgentModel.ts +++ b/src/vs/workbench/contrib/aideAgent/common/aideAgentModel.ts @@ -9,27 +9,24 @@ import { Emitter, Event } from '../../../../base/common/event.js'; import { IMarkdownString, MarkdownString, isMarkdownString } from '../../../../base/common/htmlContent.js'; import { Disposable } from '../../../../base/common/lifecycle.js'; import { revive } from '../../../../base/common/marshalling.js'; +import { Schemas } from '../../../../base/common/network.js'; import { equals } from '../../../../base/common/objects.js'; import { basename, isEqual } from '../../../../base/common/resources.js'; import { ThemeIcon } from '../../../../base/common/themables.js'; -import { isObject } from '../../../../base/common/types.js'; import { URI, UriComponents, UriDto, isUriComponents } from '../../../../base/common/uri.js'; import { generateUuid } from '../../../../base/common/uuid.js'; import { IOffsetRange, OffsetRange } from '../../../../editor/common/core/offsetRange.js'; import { IRange } from '../../../../editor/common/core/range.js'; -import { IWorkspaceFileEdit, IWorkspaceTextEdit, TextEdit, WorkspaceEdit } from '../../../../editor/common/languages.js'; -import { IModelDeltaDecoration, ITextModel } from '../../../../editor/common/model.js'; +import { Location, SymbolKind, TextEdit } from '../../../../editor/common/languages.js'; import { localize } from '../../../../nls.js'; import { IContextKey, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { ILogService } from '../../../../platform/log/common/log.js'; import { ChatAgentLocation, IAideAgentAgentService, IChatAgentCommand, IChatAgentData, IChatAgentResult, IChatWelcomeMessageContent, reviveSerializedAgent } from './aideAgentAgents.js'; -import { IAideAgentCodeEditingService, IAideAgentCodeEditingSession } from './aideAgentCodeEditingService.js'; import { CONTEXT_CHAT_IS_PLAN_VISIBLE, CONTEXT_CHAT_LAST_EXCHANGE_COMPLETE } from './aideAgentContextKeys.js'; -import { HunkData } from './aideAgentEditingSession.js'; import { ChatRequestTextPart, IParsedChatRequest, reviveParsedChatRequest } from './aideAgentParserTypes.js'; import { AideAgentPlanModel, IAideAgentPlanModel } from './aideAgentPlanModel.js'; -import { ChatAgentVoteDirection, ChatAgentVoteDownReason, IAideAgentPlanProgressContent, IAideAgentPlanStep, IAideAgentToolTypeError, IChatAgentMarkdownContentWithVulnerability, IChatCodeCitation, IChatCodeEdit, IChatCommandButton, IChatConfirmation, IChatContentInlineReference, IChatContentReference, IChatFollowup, IChatLocationData, IChatMarkdownContent, IChatProgress, IChatProgressMessage, IChatResponseCodeblockUriPart, IChatResponseProgressFileTreeData, IChatTask, IChatTextEdit, IChatTreeData, IChatUsedContext, IChatWarningMessage, isIUsedContext } from './aideAgentService.js'; +import { ChatAgentVoteDirection, ChatAgentVoteDownReason, IAideAgentPlanProgressContent, IAideAgentPlanStep, IAideAgentToolTypeError, IChatAgentMarkdownContentWithVulnerability, IChatCodeCitation, IChatCommandButton, IChatConfirmation, IChatContentInlineReference, IChatContentReference, IChatFollowup, IChatLocationData, IChatMarkdownContent, IChatProgress, IChatProgressMessage, IChatResponseCodeblockUriPart, IChatResponseProgressFileTreeData, IChatTask, IChatTextEdit, IChatTreeData, IChatUsedContext, IChatWarningMessage, isIUsedContext } from './aideAgentService.js'; import { IAideAgentTerminalService } from './aideAgentTerminalService.js'; import { IChatRequestVariableValue } from './aideAgentVariables.js'; @@ -38,10 +35,10 @@ export function isRequestModel(item: unknown): item is IChatRequestModel { } export function isResponseModel(item: unknown): item is IChatResponseModel { - return !!item && typeof (item as IChatResponseModel).setVote !== 'undefined'; + return !!item && typeof (item as IChatResponseModel).response !== 'undefined'; } -export interface IChatRequestVariableEntry { +export interface IBaseChatRequestVariableEntry { id: string; fullName?: string; icon?: ThemeIcon; @@ -52,7 +49,11 @@ export interface IChatRequestVariableEntry { references?: IChatContentReference[]; mimeType?: string; - // TODO are these just a 'kind'? + // TODO these represent different kinds, should be extracted to new interfaces with kind tags + kind?: never; + /** + * True if the variable has a value vs being a reference to a variable + */ isDynamic?: boolean; isFile?: boolean; isDirectory?: boolean; @@ -60,6 +61,25 @@ export interface IChatRequestVariableEntry { isImage?: boolean; } +export interface ISymbolVariableEntry extends Omit { + readonly kind: 'symbol'; + readonly isDynamic: true; + readonly value: Location; + readonly symbolKind: SymbolKind; +} + +export interface ILinkVariableEntry extends Omit { + readonly kind: 'link'; + readonly isDynamic: true; + readonly value: URI; +} + +export type IChatRequestVariableEntry = ISymbolVariableEntry | ILinkVariableEntry | IBaseChatRequestVariableEntry; + +export function isLinkVariableEntry(obj: IChatRequestVariableEntry): obj is ILinkVariableEntry { + return obj.kind === 'link'; +} + export interface IChatRequestVariableData { variables: IChatRequestVariableEntry[]; } @@ -75,6 +95,8 @@ export interface IChatRequestModel { readonly confirmation?: string; readonly locationData?: IChatLocationData; readonly attachedContext?: IChatRequestVariableEntry[]; + readonly isCompleteAddedRequest: boolean; + shouldBeRemovedOnSend: boolean; } export type IChatExchangeModel = IChatRequestModel | IChatResponseModel; @@ -89,6 +111,7 @@ export interface IChatTextEditGroup { edits: TextEdit[][]; state?: IChatTextEditGroupState; kind: 'textEditGroup'; + done: boolean | undefined; } export type IChatProgressResponseContent = @@ -115,14 +138,6 @@ export interface IResponse { responseRepr: string; } -export interface IAideAgentEdits { - readonly targetUri: string; - readonly textModelN: ITextModel; - textModel0: ITextModel; - hunkData: HunkData; - textModelNDecorations?: IModelDeltaDecoration[]; -} - export interface IChatResponseModel { readonly onDidChange: Event; readonly id: string; @@ -140,8 +155,11 @@ export interface IChatResponseModel { readonly response: IResponse; readonly isComplete: boolean; readonly isCanceled: boolean; + shouldBeRemovedOnSend: boolean; + isCompleteAddedRequest: boolean; /** A stale response is one that has been persisted and rehydrated, so e.g. Commands that have their arguments stored in the EH are gone. */ readonly isStale: boolean; + readonly hasSideEffects: boolean; readonly vote: ChatAgentVoteDirection | undefined; readonly voteDownReason: ChatAgentVoteDownReason | undefined; readonly followups?: IChatFollowup[] | undefined; @@ -160,6 +178,8 @@ export class ChatRequestModel implements IChatRequestModel { return this._session; } + public shouldBeRemovedOnSend: boolean = false; + public get username(): string { return this.session.requesterUsername; } @@ -199,7 +219,8 @@ export class ChatRequestModel implements IChatRequestModel { private _attempt: number = 0, private _confirmation?: string, private _locationData?: IChatLocationData, - private _attachedContext?: IChatRequestVariableEntry[] + private _attachedContext?: IChatRequestVariableEntry[], + public readonly isCompleteAddedRequest = false, ) { this.id = 'request_' + ChatRequestModel.nextId++; } @@ -256,36 +277,41 @@ export class Response extends Disposable implements IResponse { updateContent(progress: IChatProgressResponseContent | IChatTextEdit | IChatTask, quiet?: boolean): void { if (progress.kind === 'markdownContent') { - const responsePartLength = this._responseParts.length - 1; - const lastResponsePart = this._responseParts[responsePartLength]; + // last response which is NOT a text edit group because we do want to support heterogenous streaming but not have + // the MD be chopped up by text edit groups (and likely other non-renderable parts) + const lastResponsePart = this._responseParts + .filter(p => p.kind !== 'textEditGroup') + .at(-1); if (!lastResponsePart || lastResponsePart.kind !== 'markdownContent' || !canMergeMarkdownStrings(lastResponsePart.content, progress.content)) { // The last part can't be merged with- not markdown, or markdown with different permissions this._responseParts.push(progress); } else { - lastResponsePart.content = appendMarkdownString(lastResponsePart.content, progress.content); + // Don't modify the current object, since it's being diffed by the renderer + const idx = this._responseParts.indexOf(lastResponsePart); + this._responseParts[idx] = { ...lastResponsePart, content: appendMarkdownString(lastResponsePart.content, progress.content) }; } this._updateRepr(quiet); } else if (progress.kind === 'textEdit') { - if (progress.edits.length > 0) { - // merge text edits for the same file no matter when they come in - let found = false; - for (let i = 0; !found && i < this._responseParts.length; i++) { - const candidate = this._responseParts[i]; - if (candidate.kind === 'textEditGroup' && isEqual(candidate.uri, progress.uri)) { - candidate.edits.push(progress.edits); - found = true; - } - } - if (!found) { - this._responseParts.push({ - kind: 'textEditGroup', - uri: progress.uri, - edits: [progress.edits] - }); + // merge text edits for the same file no matter when they come in + let found = false; + for (let i = 0; !found && i < this._responseParts.length; i++) { + const candidate = this._responseParts[i]; + if (candidate.kind === 'textEditGroup' && isEqual(candidate.uri, progress.uri)) { + candidate.edits.push(progress.edits); + candidate.done = progress.done; + found = true; } - this._updateRepr(quiet); } + if (!found) { + this._responseParts.push({ + kind: 'textEditGroup', + uri: progress.uri, + edits: [progress.edits], + done: progress.done + }); + } + this._updateRepr(quiet); } else if (progress.kind === 'progressTask') { // Add a new resolving part const responsePosition = this._responseParts.push(progress) - 1; @@ -317,40 +343,12 @@ export class Response extends Disposable implements IResponse { } private _updateRepr(quiet?: boolean) { - const inlineRefToRepr = (part: IChatContentInlineReference) => - 'uri' in part.inlineReference ? basename(part.inlineReference.uri) : 'name' in part.inlineReference ? part.inlineReference.name : basename(part.inlineReference); - - this._responseRepr = this._responseParts.map(part => { - if (part.kind === 'treeData') { - return ''; - } else if (part.kind === 'inlineReference') { - return inlineRefToRepr(part); - } else if (part.kind === 'command') { - return part.command.title; - } else if (part.kind === 'textEditGroup') { - return localize('editsSummary', "Made changes."); - } else if (part.kind === 'progressMessage' || part.kind === 'codeblockUri') { - return ''; - } else if (part.kind === 'confirmation') { - return `${part.title}\n${part.message}`; - } else if (part.kind === 'planStep') { - return part.description.value; - } else if (part.kind === 'stage') { - return ''; - } else if (part.kind === 'toolTypeError') { - return part.message; - } else { - return part.content.value; - } - }) - .filter(s => s.length > 0) - .join('\n\n'); - + this._responseRepr = this.partsToRepr(this._responseParts); this._responseRepr += this._citations.length ? '\n\n' + getCodeCitationsMessage(this._citations) : ''; this._markdownContent = this._responseParts.map(part => { if (part.kind === 'inlineReference') { - return inlineRefToRepr(part); + return this.inlineRefToRepr(part); } else if (part.kind === 'markdownContent' || part.kind === 'markdownVuln') { return part.content.value; } else { @@ -358,12 +356,84 @@ export class Response extends Disposable implements IResponse { } }) .filter(s => s.length > 0) - .join('\n\n'); + .join(''); if (!quiet) { this._onDidChangeValue.fire(); } } + + private partsToRepr(parts: readonly IChatProgressResponseContent[]): string { + const blocks: string[] = []; + let currentBlockSegments: string[] = []; + + for (const part of parts) { + let segment: { text: string; isBlock?: boolean } | undefined; + switch (part.kind) { + case 'treeData': + case 'progressMessage': + case 'codeblockUri': + case 'stage': + // Ignore + continue; + case 'inlineReference': + segment = { text: this.inlineRefToRepr(part) }; + break; + case 'command': + segment = { text: part.command.title, isBlock: true }; + break; + case 'textEditGroup': + segment = { text: localize('editsSummary', "Made changes."), isBlock: true }; + break; + case 'confirmation': + segment = { text: `${part.title}\n${part.message}`, isBlock: true }; + break; + case 'planStep': + segment = { text: part.description.value }; + break; + case 'toolTypeError': + segment = { text: part.message }; + break; + default: + segment = { text: part.content.value }; + break; + } + + if (segment.isBlock) { + if (currentBlockSegments.length) { + blocks.push(currentBlockSegments.join('')); + currentBlockSegments = []; + } + blocks.push(segment.text); + } else { + currentBlockSegments.push(segment.text); + } + } + + if (currentBlockSegments.length) { + blocks.push(currentBlockSegments.join('')); + } + + return blocks.join('\n\n'); + } + + private inlineRefToRepr(part: IChatContentInlineReference) { + if ('uri' in part.inlineReference) { + return this.uriToRepr(part.inlineReference.uri); + } + + return 'name' in part.inlineReference + ? '`' + part.inlineReference.name + '`' + : this.uriToRepr(part.inlineReference); + } + + private uriToRepr(uri: URI): string { + if (uri.scheme === Schemas.http || uri.scheme === Schemas.https) { + return uri.toString(false); + } + + return basename(uri); + } } export class ChatResponseModel extends Disposable implements IChatResponseModel { @@ -378,10 +448,19 @@ export class ChatResponseModel extends Disposable implements IChatResponseModel return this._session; } + public get shouldBeRemovedOnSend() { + return this._shouldBeRemovedOnSend; + } + public get isComplete(): boolean { return this._isComplete; } + public set shouldBeRemovedOnSend(hidden: boolean) { + this._shouldBeRemovedOnSend = hidden; + this._onDidChange.fire(); + } + public get isCanceled(): boolean { return this._isCanceled; } @@ -440,8 +519,6 @@ export class ChatResponseModel extends Disposable implements IChatResponseModel return this._contentReferences; } - private _editingSession: IAideAgentCodeEditingSession | undefined; - private readonly _codeCitations: IChatCodeCitation[] = []; public get codeCitations(): ReadonlyArray { return this._codeCitations; @@ -457,8 +534,12 @@ export class ChatResponseModel extends Disposable implements IChatResponseModel return this._isStale; } + private _hasSideEffects: boolean = false; + public get hasSideEffects(): boolean { + return this._hasSideEffects; + } + constructor( - @IAideAgentCodeEditingService private readonly _aideAgentCodeEditingService: IAideAgentCodeEditingService, _response: IMarkdownString | ReadonlyArray, private _session: ChatModel, private _agent: IChatAgentData | undefined, @@ -470,6 +551,8 @@ export class ChatResponseModel extends Disposable implements IChatResponseModel private _voteDownReason?: ChatAgentVoteDownReason, private _result?: IChatAgentResult, followups?: ReadonlyArray, + public readonly isCompleteAddedRequest = false, + private _shouldBeRemovedOnSend: boolean = false, ) { super(); @@ -487,6 +570,16 @@ export class ChatResponseModel extends Disposable implements IChatResponseModel */ updateContent(responsePart: IChatProgressResponseContent | IChatTextEdit, quiet?: boolean) { this._response.updateContent(responsePart, quiet); + + // Update side-effects + if (responsePart.kind === 'textEdit') { + this._hasSideEffects = true; + } else if (responsePart.kind === 'markdownContent') { + const newContent = responsePart.content; + if (newContent.value.toLowerCase().startsWith('running command')) { + this._hasSideEffects = true; + } + } } /** @@ -501,26 +594,6 @@ export class ChatResponseModel extends Disposable implements IChatResponseModel } } - async applyCodeEdit(codeEdit: IChatCodeEdit) { - let editingSession = this._aideAgentCodeEditingService.getExistingCodeEditingSession(this.session.sessionId); - if (!editingSession) { - editingSession = this._register(this._aideAgentCodeEditingService.getOrStartCodeEditingSession(this.session.sessionId)); - this._register(editingSession.onDidChange(() => { - this._onDidChange.fire(); - })); - this._register(editingSession.onDidDispose(() => { - this._editingSession = undefined; - })); - } - this._editingSession = editingSession; - - for (const edit of codeEdit.edits.edits) { - if (isWorkspaceTextEdit(edit)) { - this._editingSession.apply(edit); - } - } - } - applyCodeCitation(progress: IChatCodeCitation) { this._codeCitations.push(progress); this._response.addCitation(progress); @@ -544,8 +617,6 @@ export class ChatResponseModel extends Disposable implements IChatResponseModel this._response.clear(); } - this._editingSession?.complete(); - this._isComplete = true; this._onDidChange.fire(); } @@ -597,6 +668,7 @@ export interface IChatModel { readonly inputPlaceholder?: string; readonly plan?: IAideAgentPlanModel; isDevtoolsContext: boolean; + disableRequests(requestIds: ReadonlyArray): void; getExchanges(): IChatExchangeModel[]; toExport(): IExportableChatData; toJSON(): ISerializableChatData; @@ -613,6 +685,9 @@ export interface ISerializableChatRequestData { message: string | IParsedChatRequest; // string => old format /** Is really like "prompt data". This is the message in the format in which the agent gets it + variable values. */ variableData: IChatRequestVariableData; + + /**Old, persisted name for shouldBeRemovedOnSend */ + isHidden: boolean; } export interface ISerializableChatResponseData { @@ -630,6 +705,8 @@ export interface ISerializableChatResponseData { usedContext?: IChatUsedContext; contentReferences?: ReadonlyArray; codeCitations?: ReadonlyArray; + /**Old, persisted name for shouldBeRemovedOnSend */ + isHidden: boolean; } export type ISerializableExchange = ISerializableChatRequestData | ISerializableChatResponseData; @@ -687,12 +764,12 @@ export function isSerializableSessionData(obj: unknown): obj is ISerializableCha export type IChatChangeEvent = | IChatInitEvent - | IChatAddRequestEvent | IChatChangedRequestEvent | IChatRemoveRequestEvent + | IChatAddRequestEvent | IChatChangedRequestEvent | IChatRemoveExchangeEvent | IChatAddResponseEvent | IChatSetAgentEvent | IChatMoveEvent - | IChatCodeEditEvent - | IChatStartPlanEvent; + | IChatStartPlanEvent + | IChatSetHiddenEvent; export interface IChatAddRequestEvent { kind: 'addRequest'; @@ -709,7 +786,7 @@ export interface IChatAddResponseEvent { response: IChatResponseModel; } -export const enum ChatRequestRemovalReason { +export const enum ChatExchangeRemovalReason { /** * "Normal" remove */ @@ -721,11 +798,10 @@ export const enum ChatRequestRemovalReason { Resend, } -export interface IChatRemoveRequestEvent { - kind: 'removeRequest'; - requestId: string; - responseId?: string; - reason: ChatRequestRemovalReason; +export interface IChatRemoveExchangeEvent { + kind: 'removeExchange'; + exchangeId: string; + reason: ChatExchangeRemovalReason; } export interface IChatMoveEvent { @@ -734,9 +810,9 @@ export interface IChatMoveEvent { range: IRange; } -export interface IChatCodeEditEvent { - kind: 'codeEdit'; - edits: WorkspaceEdit; +export interface IChatSetHiddenEvent { + kind: 'setHidden'; + hiddenRequestIds: Set; } export interface IChatSetAgentEvent { @@ -903,7 +979,6 @@ export class ChatModel extends Disposable implements IChatModel { @IContextKeyService contextKeyService: IContextKeyService, @ILogService private readonly logService: ILogService, @IAideAgentAgentService private readonly chatAgentService: IAideAgentAgentService, - @IAideAgentCodeEditingService private readonly aideAgentCodeEditingService: IAideAgentCodeEditingService, @IInstantiationService private readonly instantiationService: IInstantiationService, @IAideAgentTerminalService private readonly aideAgentTerminalService: IAideAgentTerminalService ) { @@ -927,12 +1002,14 @@ export class ChatModel extends Disposable implements IChatModel { this.isPlanVisible = CONTEXT_CHAT_IS_PLAN_VISIBLE.bindTo(contextKeyService); this.lastExchangeComplete = CONTEXT_CHAT_LAST_EXCHANGE_COMPLETE.bindTo(contextKeyService); + /* this._register(this.aideAgentCodeEditingService.onDidComplete(() => { // TODO(@ghostwriternr): Hmm, as per the original design, a plan could span multiple exchanges. // But because we want to reset the plan for new exchanges, we are currently resetting the plan here. // We should clean this up at some point. this.plan = undefined; })); + */ } private _deserialize(obj: IExportableChatData): Array { @@ -952,7 +1029,9 @@ export class ChatModel extends Disposable implements IChatModel { // Old messages don't have variableData, or have it in the wrong (non-array) shape const variableData: IChatRequestVariableData = this.reviveVariableData(raw.variableData); - return new ChatRequestModel(this, parsedRequest, variableData); + const request = new ChatRequestModel(this, parsedRequest, variableData); + request.shouldBeRemovedOnSend = !!raw.isHidden; + return request; } else if (raw.type === 'response') { if (raw.response || raw.result || (raw as any).responseErrorDetails) { const agent = (raw.agent && 'metadata' in raw.agent) ? // Check for the new format, ignore entries in the old format @@ -964,12 +1043,12 @@ export class ChatModel extends Disposable implements IChatModel { { errorDetails: raw.responseErrorDetails } as IChatAgentResult : raw.result; // TODO(@ghostwriternr): We used to assign the response to the request here, but now we don't. const response = new ChatResponseModel( - this.aideAgentCodeEditingService, raw.response ?? [new MarkdownString(raw.response)], this, agent, raw.slashCommand, true, raw.isCanceled, raw.vote, raw.voteDownReason, result, raw.followups ); if (raw.usedContext) { // @ulugbekna: if this's a new vscode sessions, doc versions are incorrect anyway? response.applyReference(revive(raw.usedContext)); } + response.shouldBeRemovedOnSend = !!raw.isHidden; raw.contentReferences?.forEach(r => response.applyReference(revive(r))); raw.codeCitations?.forEach(c => response.applyCodeCitation(revive(c))); @@ -1063,14 +1142,25 @@ export class ChatModel extends Disposable implements IChatModel { return this._exchanges; } - addRequest(message: IParsedChatRequest, variableData: IChatRequestVariableData, attempt: number, chatAgent?: IChatAgentData, slashCommand?: IChatAgentCommand, confirmation?: string, locationData?: IChatLocationData, attachments?: IChatRequestVariableEntry[]): ChatRequestModel { + disableRequests(requestIds: ReadonlyArray) { + const toHide = new Set(requestIds); + + this._exchanges.forEach((exchange) => { + const shouldBeRemovedOnSend = toHide.has(exchange.id); + exchange.shouldBeRemovedOnSend = shouldBeRemovedOnSend; + }); + + this._onDidChange.fire({ + kind: 'setHidden', + hiddenRequestIds: new Set(requestIds), + }); + } + + addRequest(message: IParsedChatRequest, variableData: IChatRequestVariableData, attempt: number, chatAgent?: IChatAgentData, slashCommand?: IChatAgentCommand, confirmation?: string, locationData?: IChatLocationData, attachments?: IChatRequestVariableEntry[], isCompleteAddedRequest?: boolean): ChatRequestModel { this.autoAcceptLastExchange(); - const request = new ChatRequestModel(this, message, variableData, attempt, confirmation, locationData, attachments); - const response = new ChatResponseModel( - this.aideAgentCodeEditingService, - [], this, chatAgent, slashCommand - ); + const request = new ChatRequestModel(this, message, variableData, attempt, confirmation, locationData, attachments, isCompleteAddedRequest); + const response = new ChatResponseModel([], this, chatAgent, slashCommand, undefined, undefined, undefined, undefined, undefined, undefined, isCompleteAddedRequest); this._exchanges.push(request, response); this._lastMessageDate = Date.now(); @@ -1081,17 +1171,11 @@ export class ChatModel extends Disposable implements IChatModel { // TODO(@ghostwriternr): This might break if we do proactive agent? private autoAcceptLastExchange() { - const editingSession = this.aideAgentCodeEditingService.getExistingCodeEditingSession(this.sessionId); - if (editingSession) { - editingSession.accept(); - } + // TODO } addResponse(): ChatResponseModel { - const response = new ChatResponseModel( - this.aideAgentCodeEditingService, - [], this, undefined, undefined - ); + const response = new ChatResponseModel([], this, undefined, undefined); this._exchanges.push(response); // TODO(@ghostwriternr): Just looking at the above, do we need to update the last message date here? What is it used for? this._onDidChange.fire({ kind: 'addResponse', response }); @@ -1119,10 +1203,7 @@ export class ChatModel extends Disposable implements IChatModel { */ // TODO(@ghostwriternr): This will break, because this node is not added to the exchanges. if (!response) { - response = new ChatResponseModel( - this.aideAgentCodeEditingService, - [], this, undefined, undefined - ); + response = new ChatResponseModel([], this, undefined, undefined); } if (progress.kind === 'markdownContent' || @@ -1152,9 +1233,6 @@ export class ChatModel extends Disposable implements IChatModel { response.applyCodeCitation(progress); } else if (progress.kind === 'move') { this._onDidChange.fire({ kind: 'move', target: progress.uri, range: progress.range }); - } else if (progress.kind === 'codeEdit') { - response.applyCodeEdit(progress); - this._onDidChange.fire({ kind: 'codeEdit', edits: progress.edits }); } else if (progress.kind === 'planStep') { this.applyPlanStep(progress); } else { @@ -1173,18 +1251,30 @@ export class ChatModel extends Disposable implements IChatModel { this.plan.updateSteps(progress); } - /* TODO(@ghostwriternr): This method was used to remove/resend requests. We can add it back in if we need it. - removeRequest(id: string, reason: ChatRequestRemovalReason = ChatRequestRemovalReason.Removal): void { - const index = this._exchanges.findIndex(request => request.id === id); - const request = this._exchanges[index]; + disableExchange(id: string, _reason: ChatExchangeRemovalReason = ChatExchangeRemovalReason.Removal): void { + const index = this._exchanges.findIndex(exchange => exchange.id === id); + const exchange = this._exchanges[index]; + exchange.shouldBeRemovedOnSend = true; + } + + enableExchange(id: string): void { + const index = this._exchanges.findIndex(exchange => exchange.id === id); + const exchange = this._exchanges[index]; + exchange.shouldBeRemovedOnSend = false; + } + + removeExchange(id: string, reason: ChatExchangeRemovalReason = ChatExchangeRemovalReason.Removal): void { + const index = this._exchanges.findIndex(exchange => exchange.id === id); + const exchange = this._exchanges[index]; if (index !== -1) { - this._onDidChange.fire({ kind: 'removeRequest', requestId: request.id, responseId: request.response?.id, reason }); + this._onDidChange.fire({ kind: 'removeExchange', exchangeId: exchange.id, reason }); this._exchanges.splice(index, 1); - request.response?.dispose(); + if (exchange instanceof ChatResponseModel) { + exchange.dispose(); + } } } - */ cancelResponse(response: ChatResponseModel): void { if (response) { @@ -1238,7 +1328,8 @@ export class ChatModel extends Disposable implements IChatModel { return { type: 'request', message, - variableData: r.variableData + variableData: r.variableData, + isHidden: r.shouldBeRemovedOnSend, }; } else if (isResponseModel(r)) { const agent = r.agent; @@ -1267,7 +1358,8 @@ export class ChatModel extends Disposable implements IChatModel { slashCommand: r.slashCommand, usedContext: r.usedContext, contentReferences: r.contentReferences, - codeCitations: r.codeCitations + codeCitations: r.codeCitations, + isHidden: r.shouldBeRemovedOnSend, }; } else { // TODO (g-danna) is it a good idea to throw an error here? @@ -1353,9 +1445,3 @@ export function getCodeCitationsMessage(citations: ReadonlyArraycandidate).resource) - && isObject((candidate).textEdit); -} diff --git a/src/vs/workbench/contrib/aideAgent/common/aideAgentParserTypes.ts b/src/vs/workbench/contrib/aideAgent/common/aideAgentParserTypes.ts index 45709640b97..dcc4dcb2746 100644 --- a/src/vs/workbench/contrib/aideAgent/common/aideAgentParserTypes.ts +++ b/src/vs/workbench/contrib/aideAgent/common/aideAgentParserTypes.ts @@ -4,11 +4,13 @@ *--------------------------------------------------------------------------------------------*/ import { revive } from '../../../../base/common/marshalling.js'; +import { ThemeIcon } from '../../../../base/common/themables.js'; import { IOffsetRange, OffsetRange } from '../../../../editor/common/core/offsetRange.js'; import { IRange } from '../../../../editor/common/core/range.js'; import { ChatAgentLocation, IChatAgentCommand, IChatAgentData, IAideAgentAgentService, reviveSerializedAgent } from './aideAgentAgents.js'; import { IChatSlashData } from './aideAgentSlashCommands.js'; import { IChatRequestVariableValue } from './aideAgentVariables.js'; +import { IToolData } from './languageModelToolsService.js'; // These are in a separate file to avoid circular dependencies with the dependencies of the parser @@ -72,7 +74,7 @@ export class ChatRequestVariablePart implements IParsedChatRequestPart { export class ChatRequestToolPart implements IParsedChatRequestPart { static readonly Kind = 'tool'; readonly kind = ChatRequestToolPart.Kind; - constructor(readonly range: OffsetRange, readonly editorRange: IRange, readonly toolName: string, readonly toolId: string) { } + constructor(readonly range: OffsetRange, readonly editorRange: IRange, readonly toolName: string, readonly toolId: string, readonly displayName?: string, readonly icon?: IToolData['icon']) { } get text(): string { return `${chatVariableLeader}${this.toolName}`; @@ -140,7 +142,7 @@ export class ChatRequestSlashCommandPart implements IParsedChatRequestPart { export class ChatRequestDynamicVariablePart implements IParsedChatRequestPart { static readonly Kind = 'dynamic'; readonly kind = ChatRequestDynamicVariablePart.Kind; - constructor(readonly range: OffsetRange, readonly editorRange: IRange, readonly text: string, readonly id: string, readonly modelDescription: string | undefined, readonly data: IChatRequestVariableValue) { } + constructor(readonly range: OffsetRange, readonly editorRange: IRange, readonly text: string, readonly id: string, readonly modelDescription: string | undefined, readonly data: IChatRequestVariableValue, readonly fullName?: string, readonly icon?: ThemeIcon, readonly isFile?: boolean) { } get referenceText(): string { return this.text.replace(chatVariableLeader, ''); @@ -174,7 +176,9 @@ export function reviveParsedChatRequest(serialized: IParsedChatRequest): IParsed new OffsetRange(part.range.start, part.range.endExclusive), part.editorRange, (part as ChatRequestToolPart).toolName, - (part as ChatRequestToolPart).toolId + (part as ChatRequestToolPart).toolId, + (part as ChatRequestToolPart).displayName, + (part as ChatRequestToolPart).icon, ); } else if (part.kind === ChatRequestAgentPart.Kind) { let agent = (part as ChatRequestAgentPart).agent; @@ -204,7 +208,10 @@ export function reviveParsedChatRequest(serialized: IParsedChatRequest): IParsed (part as ChatRequestDynamicVariablePart).text, (part as ChatRequestDynamicVariablePart).id, (part as ChatRequestDynamicVariablePart).modelDescription, - revive((part as ChatRequestDynamicVariablePart).data) + revive((part as ChatRequestDynamicVariablePart).data), + (part as ChatRequestDynamicVariablePart).fullName, + (part as ChatRequestDynamicVariablePart).icon, + (part as ChatRequestDynamicVariablePart).isFile ); } else { throw new Error(`Unknown chat request part: ${part.kind}`); diff --git a/src/vs/workbench/contrib/aideAgent/common/aideAgentPlanViewModel.ts b/src/vs/workbench/contrib/aideAgent/common/aideAgentPlanViewModel.ts index 0d8d8dcd335..67198c8151b 100644 --- a/src/vs/workbench/contrib/aideAgent/common/aideAgentPlanViewModel.ts +++ b/src/vs/workbench/contrib/aideAgent/common/aideAgentPlanViewModel.ts @@ -145,7 +145,7 @@ export class AideAgentPlanViewModel extends Disposable implements IAideAgentPlan if (token.type === 'code') { const lang = token.lang || ''; const text = token.text; - this.codeBlockModelCollection.update(this._model.sessionId, model, codeBlockIndex++, { text, languageId: lang }); + this.codeBlockModelCollection.update(this._model.sessionId, model, codeBlockIndex++, { text, languageId: lang, isComplete: true }); } }); } diff --git a/src/vs/workbench/contrib/aideAgent/common/aideAgentService.ts b/src/vs/workbench/contrib/aideAgent/common/aideAgentService.ts index b3c5a85c6dd..86567eaa4b7 100644 --- a/src/vs/workbench/contrib/aideAgent/common/aideAgentService.ts +++ b/src/vs/workbench/contrib/aideAgent/common/aideAgentService.ts @@ -11,7 +11,7 @@ import { ThemeIcon } from '../../../../base/common/themables.js'; import { URI } from '../../../../base/common/uri.js'; import { IRange, Range } from '../../../../editor/common/core/range.js'; import { ISelection } from '../../../../editor/common/core/selection.js'; -import { Command, Location, TextEdit, WorkspaceEdit } from '../../../../editor/common/languages.js'; +import { Command, Location, TextEdit } from '../../../../editor/common/languages.js'; import { AgentMode } from '../../../../platform/aideAgent/common/model.js'; import { FileType } from '../../../../platform/files/common/files.js'; import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; @@ -183,11 +183,7 @@ export interface IChatTextEdit { uri: URI; edits: TextEdit[]; kind: 'textEdit'; -} - -export interface IChatCodeEdit { - edits: WorkspaceEdit; - kind: 'codeEdit'; + done?: boolean; } export interface IChatConfirmation { @@ -236,7 +232,6 @@ export type IChatProgress = | IChatCommandButton | IChatWarningMessage | IChatTextEdit - | IChatCodeEdit | IChatMoveMessage | IChatResponseCodeblockUriPart | IChatConfirmation @@ -334,7 +329,14 @@ export interface IChatInlineChatCodeAction { action: 'accepted' | 'discarded'; } -export type ChatUserAction = IChatVoteAction | IChatCopyAction | IChatInsertAction | IChatApplyAction | IChatTerminalAction | IChatCommandAction | IChatFollowupAction | IChatBugReportAction | IChatInlineChatCodeAction; +export interface IChatEditingSessionAction { + kind: 'chatEditingSessionAction'; + uri: URI; + hasRemainingEdits: boolean; + outcome: 'accepted' | 'rejected' | 'saved'; +} + +export type ChatUserAction = IChatVoteAction | IChatCopyAction | IChatInsertAction | IChatApplyAction | IChatTerminalAction | IChatCommandAction | IChatFollowupAction | IChatBugReportAction | IChatInlineChatCodeAction | IChatEditingSessionAction; export interface IChatUserActionEvent { action: ChatUserAction; @@ -450,15 +452,14 @@ export interface IAideAgentService { sendRequest(sessionId: string, message: string, options?: IChatSendRequestOptions): Promise; // TODO(@ghostwriternr): This method already seems unused. Remove it? // resendRequest(request: IChatRequestModel, options?: IChatSendRequestOptions): Promise; - // TODO(@ghostwriternr): Remove this if we no longer need to remove requests. - // removeRequest(sessionid: string, requestId: string): Promise; cancelExchange(exchangeId: string): void; cancelAllExchangesForSession(): void; - readonly lastExchangeId: string | undefined; initiateResponse(sessionId: string): Promise<{ responseId: string; callback: (p: IChatProgress) => void; token: CancellationToken }>; - + disableExchange(sessionId: string, exchangeId: string): Promise; + enableExchange(sessionId: string, exchangeId: string): Promise; + cancelCurrentRequestForSession(sessionId: string): void; clearSession(sessionId: string): void; addCompleteRequest(sessionId: string, message: IParsedChatRequest | string, variableData: IChatRequestVariableData | undefined, attempt: number | undefined, response: IChatCompleteResponse): void; getHistory(): IChatDetail[]; diff --git a/src/vs/workbench/contrib/aideAgent/common/aideAgentServiceImpl.ts b/src/vs/workbench/contrib/aideAgent/common/aideAgentServiceImpl.ts index 82753e94ccc..48d9494f59f 100644 --- a/src/vs/workbench/contrib/aideAgent/common/aideAgentServiceImpl.ts +++ b/src/vs/workbench/contrib/aideAgent/common/aideAgentServiceImpl.ts @@ -26,7 +26,7 @@ import { IExtensionService } from '../../../services/extensions/common/extension import { IRageShakeService } from '../../../services/rageShake/common/rageShake.js'; import { ChatAgentLocation, IAideAgentAgentService, IChatAgent, IChatAgentCommand, IChatAgentData, IChatAgentRequest, IChatAgentResult } from './aideAgentAgents.js'; import { CONTEXT_VOTE_UP_ENABLED } from './aideAgentContextKeys.js'; -import { AgentScope, ChatModel, ChatRequestModel, ChatResponseModel, IChatModel, IChatRequestVariableData, IChatResponseModel, IExportableChatData, ISerializableChatData, ISerializableChatDataIn, ISerializableChatsData, updateRanges } from './aideAgentModel.js'; +import { AgentScope, ChatModel, ChatRequestModel, ChatResponseModel, IChatModel, IChatRequestVariableData, IChatResponseModel, IExportableChatData, ISerializableChatData, ISerializableChatDataIn, ISerializableChatsData, isResponseModel, updateRanges } from './aideAgentModel.js'; import { ChatRequestAgentPart, ChatRequestAgentSubcommandPart, ChatRequestSlashCommandPart, IParsedChatRequest, chatAgentLeader, chatSubcommandLeader, getPromptText } from './aideAgentParserTypes.js'; import { ChatRequestParser } from './aideAgentRequestParser.js'; import { IAideAgentService, IChatCompleteResponse, IChatDetail, IChatFollowup, IChatProgress, IChatSendRequestData, IChatSendRequestOptions, IChatSendRequestResponseState, IChatTransferredSessionData, IChatUserActionEvent } from './aideAgentService.js'; @@ -73,11 +73,6 @@ export class ChatService extends Disposable implements IAideAgentService { private readonly _pendingExchanges = this._register(new DisposableMap()); private _persistedSessions: ISerializableChatsData; - private _lastExchangeId: string | undefined; - get lastExchangeId(): string | undefined { - return this._lastExchangeId; - } - /** Just for empty windows, need to enforce that a chat was deleted, even though other windows still have it */ private _deletedChatIds = new Set(); @@ -504,6 +499,14 @@ export class ChatService extends Disposable implements IAideAgentService { } */ + const exchanges = model.getExchanges(); + for (let i = exchanges.length - 1; i >= 0; i -= 1) { + const exchange = exchanges[i]; + if (exchange.shouldBeRemovedOnSend) { + this.removeExchange(sessionId, exchange.id); + } + } + const location = options?.location ?? model.initialLocation; const attempt = options?.attempt ?? 0; const defaultAgent = this.chatAgentService.getDefaultAgent(location)!; @@ -588,7 +591,6 @@ export class ChatService extends Disposable implements IAideAgentService { const prepareChatAgentRequest = async (agent: IChatAgentData, command?: IChatAgentCommand, enableCommandDetection?: boolean, chatRequest?: ChatRequestModel, isParticipantDetected?: boolean): Promise => { const initVariableData: IChatRequestVariableData = { variables: [] }; request = chatRequest ?? model.addRequest(parsedRequest, initVariableData, attempt, agent, command, options?.confirmation, options?.locationData, options?.attachedContext); - this._lastExchangeId = request.id; // Variables may have changed if the agent and slash command changed, so resolve them again even if we already had a chatRequest const variableData = await this.chatVariablesService.resolveVariables( @@ -732,8 +734,7 @@ export class ChatService extends Disposable implements IAideAgentService { }; } - /* TODO(@ghostwriternr): Remove this if we no longer need to remove requests. - async removeRequest(sessionId: string, requestId: string): Promise { + async disableExchange(sessionId: string, exchangeId: string): Promise { const model = this._sessionModels.get(sessionId); if (!model) { throw new Error(`Unknown session: ${sessionId}`); @@ -741,15 +742,41 @@ export class ChatService extends Disposable implements IAideAgentService { await model.waitForInitialization(); - const pendingRequest = this._pendingRequests.get(sessionId); - if (pendingRequest?.requestId === requestId) { + const pendingRequest = this._pendingExchanges.get(sessionId); + if (pendingRequest?.exchangeId === exchangeId) { pendingRequest.cancel(); - this._pendingRequests.deleteAndDispose(sessionId); + this._pendingExchanges.deleteAndDispose(sessionId); } - model.removeRequest(requestId); + model.disableExchange(exchangeId); + } + + async enableExchange(sessionId: string, exchangeId: string): Promise { + const model = this._sessionModels.get(sessionId); + if (!model) { + throw new Error(`Unknown session: ${sessionId}`); + } + + await model.waitForInitialization(); + model.enableExchange(exchangeId); + } + + private async removeExchange(sessionId: string, exchangeId: string): Promise { + const model = this._sessionModels.get(sessionId); + if (!model) { + throw new Error(`Unknown session: ${sessionId}`); + } + + await model.waitForInitialization(); + + const pendingRequest = this._pendingExchanges.get(sessionId); + if (pendingRequest?.exchangeId === exchangeId) { + pendingRequest.cancel(); + this._pendingExchanges.deleteAndDispose(sessionId); + } + + model.removeExchange(exchangeId); } - */ async initiateResponse(sessionId: string): Promise<{ responseId: string; callback: (p: IChatProgress) => void; token: CancellationToken }> { const model = this._sessionModels.get(sessionId); @@ -759,8 +786,19 @@ export class ChatService extends Disposable implements IAideAgentService { await model.waitForInitialization(); + // Check if the previous exchange was a response, and if it had any response parts. + // Sometimes, we end up creating empty exchanges from the sidecar. So if we detect this, + // just get rid of that unused exchange. + const exchanges = model.getExchanges(); + const lastExchange = exchanges[exchanges.length - 1]; + if (lastExchange && + isResponseModel(lastExchange) && + lastExchange.response.value.length === 0) { + // Remove the empty exchange before proceeding + this.removeExchange(sessionId, lastExchange.id); + } + const response = model.addResponse(); - this._lastExchangeId = response.id; const cts = new CancellationTokenSource(); this._pendingExchanges.set(response.id, new CancellableExchange(cts)); this._register(cts.token.onCancellationRequested(() => { @@ -819,6 +857,12 @@ export class ChatService extends Disposable implements IAideAgentService { } } + cancelCurrentRequestForSession(sessionId: string): void { + this.trace('cancelCurrentRequestForSession', `sessionId: ${sessionId}`); + this._pendingExchanges.get(sessionId)?.cancel(); + this._pendingExchanges.deleteAndDispose(sessionId); + } + clearSession(sessionId: string): void { this.trace('clearSession', `sessionId: ${sessionId}`); const model = this._sessionModels.get(sessionId); diff --git a/src/vs/workbench/contrib/aideAgent/common/aideAgentVariables.ts b/src/vs/workbench/contrib/aideAgent/common/aideAgentVariables.ts index c9c42b14f63..2cafe5c8cb3 100644 --- a/src/vs/workbench/contrib/aideAgent/common/aideAgentVariables.ts +++ b/src/vs/workbench/contrib/aideAgent/common/aideAgentVariables.ts @@ -26,7 +26,7 @@ export interface IChatVariableData { canTakeArgument?: boolean; } -export type IChatRequestVariableValue = string | URI | Location | unknown; +export type IChatRequestVariableValue = string | URI | Location | unknown | Uint8Array; export type IChatVariableResolverProgress = | IChatContentReference @@ -61,5 +61,6 @@ export interface IDynamicVariable { icon?: ThemeIcon; prefix?: string; modelDescription?: string; + isFile?: boolean; data: IChatRequestVariableValue; } diff --git a/src/vs/workbench/contrib/aideAgent/common/aideAgentViewModel.ts b/src/vs/workbench/contrib/aideAgent/common/aideAgentViewModel.ts index 00e7bfca72d..22df5d2e035 100644 --- a/src/vs/workbench/contrib/aideAgent/common/aideAgentViewModel.ts +++ b/src/vs/workbench/contrib/aideAgent/common/aideAgentViewModel.ts @@ -28,7 +28,7 @@ export function isResponseVM(item: unknown): item is IChatResponseViewModel { return !!item && typeof (item as IChatResponseViewModel).setVote !== 'undefined'; } -export type IChatViewModelChangeEvent = IChatAddRequestEvent | IChangePlaceholderEvent | IChatSessionInitEvent | null; +export type IChatViewModelChangeEvent = IChatAddRequestEvent | IChangePlaceholderEvent | IChatSessionInitEvent | IChatSetHiddenEvent | null; export interface IChatAddRequestEvent { kind: 'addRequest'; @@ -42,6 +42,10 @@ export interface IChatSessionInitEvent { kind: 'initialize'; } +export interface IChatSetHiddenEvent { + kind: 'setHidden'; +} + export interface IChatViewModel { readonly model: IChatModel; readonly initState: ChatModelInitState; @@ -69,6 +73,9 @@ export interface IChatRequestViewModel { currentRenderedHeight: number | undefined; readonly contentReferences?: ReadonlyArray; readonly confirmation?: string; + readonly shouldBeRemovedOnSend: boolean; + readonly isComplete: boolean; + readonly isCompleteAddedRequest: boolean; } export interface IChatResponseMarkdownRenderData { @@ -164,6 +171,7 @@ export interface IChatResponseViewModel { readonly isComplete: boolean; readonly isCanceled: boolean; readonly isStale: boolean; + isFirst: boolean; isLast: boolean; readonly vote: ChatAgentVoteDirection | undefined; readonly voteDownReason: ChatAgentVoteDownReason | undefined; @@ -171,6 +179,8 @@ export interface IChatResponseViewModel { readonly errorDetails?: IChatResponseErrorDetails; readonly result?: IChatAgentResult; readonly contentUpdateTimings?: IChatLiveUpdateData; + readonly shouldBeRemovedOnSend: boolean; + readonly isCompleteAddedRequest: boolean; renderData?: IChatResponseRenderData; currentRenderedHeight: number | undefined; setVote(vote: ChatAgentVoteDirection): void; @@ -190,6 +200,19 @@ export class ChatViewModel extends Disposable implements IChatViewModel { private _items: (ChatRequestViewModel | ChatResponseViewModel)[] = []; + private updateFirstResponseState(): void { + let lastRequestIndex = -1; + for (let i = 0; i < this._items.length; i++) { + const item = this._items[i]; + if (isRequestVM(item)) { + lastRequestIndex = i; + } else if (isResponseVM(item)) { + // A response is "first" if it immediately follows a request + item.isFirst = i === lastRequestIndex + 1; + } + } + } + private removeItem(index: number) { if (index >= 0) { const items = this._items.splice(index, 1); @@ -197,6 +220,7 @@ export class ChatViewModel extends Disposable implements IChatViewModel { if (item instanceof ChatResponseViewModel) { item.dispose(); } + this.updateFirstResponseState(); } } @@ -210,6 +234,7 @@ export class ChatViewModel extends Disposable implements IChatViewModel { } } this._items.push(item); + this.updateFirstResponseState(); } private _inputPlaceholder: string | undefined = undefined; @@ -274,19 +299,16 @@ export class ChatViewModel extends Disposable implements IChatViewModel { */ } else if (e.kind === 'addResponse') { this.onAddResponse(e.response); - } else if (e.kind === 'removeRequest') { - const requestIdx = this._items.findIndex(item => isRequestVM(item) && item.id === e.requestId); - this.removeItem(requestIdx); - - const responseIdx = e.responseId && this._items.findIndex(item => isResponseVM(item) && item.id === e.responseId); - if (typeof responseIdx === 'number') { - this.removeItem(responseIdx); - } + } else if (e.kind === 'removeExchange') { + const exchangeIdx = this._items.findIndex(item => item.id === e.exchangeId); + this.removeItem(exchangeIdx); } - const modelEventToVmEvent: IChatViewModelChangeEvent = e.kind === 'addRequest' ? { kind: 'addRequest' } : - e.kind === 'initialize' ? { kind: 'initialize' } : - null; + const modelEventToVmEvent: IChatViewModelChangeEvent = + e.kind === 'addRequest' ? { kind: 'addRequest' } + : e.kind === 'initialize' ? { kind: 'initialize' } + : e.kind === 'setHidden' ? { kind: 'setHidden' } + : null; this._onDidChange.fire(modelEventToVmEvent); })); } @@ -304,7 +326,7 @@ export class ChatViewModel extends Disposable implements IChatViewModel { } getItems(): (IChatRequestViewModel | IChatResponseViewModel)[] { - return [...this._items]; + return this._items.filter((item) => !item.shouldBeRemovedOnSend); } override dispose() { @@ -327,7 +349,7 @@ export class ChatViewModel extends Disposable implements IChatViewModel { if (token.type === 'code') { const lang = token.lang || ''; const text = token.text; - this.codeBlockModelCollection.update(this._model.sessionId, model, codeBlockIndex++, { text, languageId: lang }); + this.codeBlockModelCollection.update(this._model.sessionId, model, codeBlockIndex++, { text, languageId: lang, isComplete: true }); } }); } @@ -339,7 +361,7 @@ export class ChatRequestViewModel implements IChatRequestViewModel { } get dataId() { - return this.id + `_${ChatModelInitState[this._model.session.initState]}_${hash(this.variables)}`; + return this.id + `_${ChatModelInitState[this._model.session.initState]}_${hash(this.variables)}_${hash(this.isComplete)}`; } get sessionId() { @@ -380,6 +402,18 @@ export class ChatRequestViewModel implements IChatRequestViewModel { return this._model.confirmation; } + get isComplete() { + return false; + } + + get isCompleteAddedRequest() { + return this._model.isCompleteAddedRequest; + } + + get shouldBeRemovedOnSend() { + return this._model.shouldBeRemovedOnSend; + } + currentRenderedHeight: number | undefined; constructor( @@ -401,10 +435,29 @@ export class ChatResponseViewModel extends Disposable implements IChatResponseVi return this._model.id; } - isLast = true; - get dataId() { - return this._model.id + `_${this._modelChangeCount}` + `_${ChatModelInitState[this._model.session.initState]}`; + return this._model.id + + `_${this._modelChangeCount}` + + `_${ChatModelInitState[this._model.session.initState]}` + + (this.isLast ? '_last' : ''); + } + + private _isFirst: boolean = false; + get isFirst() { + return this._isFirst; + } + + set isFirst(_isFirst: boolean) { + this._isFirst = _isFirst; + } + + private _isLast: boolean = true; + get isLast() { + return this._isLast; + } + + set isLast(_isLast: boolean) { + this._isLast = _isLast; } get sessionId() { @@ -468,6 +521,14 @@ export class ChatResponseViewModel extends Disposable implements IChatResponseVi return this._model.isCanceled; } + get shouldBeRemovedOnSend() { + return this._model.shouldBeRemovedOnSend; + } + + get isCompleteAddedRequest() { + return this._model.isCompleteAddedRequest; + } + get replyFollowups() { return this._model.followups?.filter((f): f is IChatFollowup => f.kind === 'reply'); } diff --git a/src/vs/workbench/contrib/aideAgent/common/aideAgentWidgetHistoryService.ts b/src/vs/workbench/contrib/aideAgent/common/aideAgentWidgetHistoryService.ts index 60c2f4a2503..9df8b80c010 100644 --- a/src/vs/workbench/contrib/aideAgent/common/aideAgentWidgetHistoryService.ts +++ b/src/vs/workbench/contrib/aideAgent/common/aideAgentWidgetHistoryService.ts @@ -8,6 +8,7 @@ import { createDecorator } from '../../../../platform/instantiation/common/insta import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; import { Memento } from '../../../common/memento.js'; import { ChatAgentLocation } from './aideAgentAgents.js'; +import { IChatRequestVariableEntry } from './aideAgentModel.js'; import { CHAT_PROVIDER_ID } from './aideAgentParticipantContribTypes.js'; export interface IChatHistoryEntry { @@ -15,6 +16,12 @@ export interface IChatHistoryEntry { state?: any; } +/** The collected input state of ChatWidget contribs + attachments */ +export interface IChatInputState { + [key: string]: any; + chatContextAttachments?: ReadonlyArray; +} + export const IAideAgentWidgetHistoryService = createDecorator('IAideAgentWidgetHistoryService'); export interface IAideAgentWidgetHistoryService { _serviceBrand: undefined; diff --git a/src/vs/workbench/contrib/aideAgent/common/codeBlockModelCollection.ts b/src/vs/workbench/contrib/aideAgent/common/codeBlockModelCollection.ts index ee8257a6977..23b2f545c5c 100644 --- a/src/vs/workbench/contrib/aideAgent/common/codeBlockModelCollection.ts +++ b/src/vs/workbench/contrib/aideAgent/common/codeBlockModelCollection.ts @@ -3,23 +3,33 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { Iterable } from '../../../../base/common/iterator.js'; import { Disposable, IReference } from '../../../../base/common/lifecycle.js'; -import { ResourceMap } from '../../../../base/common/map.js'; import { Schemas } from '../../../../base/common/network.js'; import { URI } from '../../../../base/common/uri.js'; import { Range } from '../../../../editor/common/core/range.js'; import { ILanguageService } from '../../../../editor/common/languages/language.js'; -import { EndOfLinePreference } from '../../../../editor/common/model.js'; +import { EndOfLinePreference, ITextModel } from '../../../../editor/common/model.js'; import { IResolvedTextEditorModel, ITextModelService } from '../../../../editor/common/services/resolverService.js'; import { IAideAgentPlanStepViewModel } from './aideAgentPlanViewModel.js'; import { IChatRequestViewModel, IChatResponseViewModel, isResponseVM } from './aideAgentViewModel.js'; import { extractCodeblockUrisFromText, extractVulnerabilitiesFromText, IMarkdownVulnerability } from './annotations.js'; +interface CodeBlockContent { + readonly text: string; + readonly languageId?: string; + readonly isComplete: boolean; +} +interface CodeBlockEntry { + readonly model: Promise; + readonly vulns: readonly IMarkdownVulnerability[]; + readonly codemapperUri?: URI; +} export class CodeBlockModelCollection extends Disposable { - private readonly _models = new ResourceMap<{ - readonly model: Promise>; + private readonly _models = new Map>; vulns: readonly IMarkdownVulnerability[]; codemapperUri?: URI; }>(); @@ -33,7 +43,7 @@ export class CodeBlockModelCollection extends Disposable { constructor( @ILanguageService private readonly languageService: ILanguageService, - @ITextModelService private readonly textModelService: ITextModelService + @ITextModelService private readonly textModelService: ITextModelService, ) { super(); } @@ -43,44 +53,52 @@ export class CodeBlockModelCollection extends Disposable { this.clear(); } - get(sessionId: string, chat: IChatRequestViewModel | IChatResponseViewModel | IAideAgentPlanStepViewModel, codeBlockIndex: number): { model: Promise; readonly vulns: readonly IMarkdownVulnerability[]; readonly codemapperUri?: URI } | undefined { - const uri = this.getUri(sessionId, chat, codeBlockIndex); - const entry = this._models.get(uri); + get(sessionId: string, chat: IChatRequestViewModel | IChatResponseViewModel | IAideAgentPlanStepViewModel, codeBlockIndex: number): CodeBlockEntry | undefined { + const entry = this._models.get(this.getKey(sessionId, chat, codeBlockIndex)); if (!entry) { return; } - return { model: entry.model.then(ref => ref.object), vulns: entry.vulns, codemapperUri: entry.codemapperUri }; + return { + model: entry.model.then(ref => ref.object.textEditorModel), + vulns: entry.vulns, + codemapperUri: entry.codemapperUri + }; } - getOrCreate(sessionId: string, chat: IChatRequestViewModel | IChatResponseViewModel | IAideAgentPlanStepViewModel, codeBlockIndex: number): { model: Promise; readonly vulns: readonly IMarkdownVulnerability[]; readonly codemapperUri?: URI } { + getOrCreate(sessionId: string, chat: IChatRequestViewModel | IChatResponseViewModel | IAideAgentPlanStepViewModel, codeBlockIndex: number): CodeBlockEntry { const existing = this.get(sessionId, chat, codeBlockIndex); if (existing) { return existing; } - const uri = this.getUri(sessionId, chat, codeBlockIndex); - const ref = this.textModelService.createModelReference(uri); - this._models.set(uri, { model: ref, vulns: [], codemapperUri: undefined }); + const uri = this.getCodeBlockUri(sessionId, chat, codeBlockIndex); + const model = this.textModelService.createModelReference(uri); + this._models.set(this.getKey(sessionId, chat, codeBlockIndex), { + model: model, + vulns: [], + codemapperUri: undefined, + }); while (this._models.size > this.maxModelCount) { - const first = Array.from(this._models.keys()).at(0); + const first = Iterable.first(this._models.keys()); if (!first) { break; } this.delete(first); } - return { model: ref.then(ref => ref.object), vulns: [], codemapperUri: undefined }; + return { model: model.then(x => x.object.textEditorModel), vulns: [], codemapperUri: undefined }; } - private delete(codeBlockUri: URI) { - const entry = this._models.get(codeBlockUri); + private delete(key: string) { + const entry = this._models.get(key); if (!entry) { return; } - entry.model.then(ref => ref.dispose()); - this._models.delete(codeBlockUri); + entry.model.then(ref => ref.object.dispose()); + + this._models.delete(key); } clear(): void { @@ -88,7 +106,7 @@ export class CodeBlockModelCollection extends Disposable { this._models.clear(); } - updateSync(sessionId: string, chat: IChatRequestViewModel | IChatResponseViewModel | IAideAgentPlanStepViewModel, codeBlockIndex: number, content: { text: string; languageId?: string }) { + updateSync(sessionId: string, chat: IChatRequestViewModel | IChatResponseViewModel | IAideAgentPlanStepViewModel, codeBlockIndex: number, content: CodeBlockContent): CodeBlockEntry { const entry = this.getOrCreate(sessionId, chat, codeBlockIndex); const extractedVulns = extractVulnerabilitiesFromText(content.text); @@ -100,10 +118,22 @@ export class CodeBlockModelCollection extends Disposable { this.setCodemapperUri(sessionId, chat, codeBlockIndex, codeblockUri.uri); } + if (content.isComplete) { + this.markCodeBlockCompleted(sessionId, chat, codeBlockIndex); + } + return this.get(sessionId, chat, codeBlockIndex) ?? entry; } - async update(sessionId: string, chat: IChatRequestViewModel | IChatResponseViewModel | IAideAgentPlanStepViewModel, codeBlockIndex: number, content: { text: string; languageId?: string }) { + markCodeBlockCompleted(sessionId: string, chat: IChatRequestViewModel | IChatResponseViewModel | IAideAgentPlanStepViewModel, codeBlockIndex: number): void { + const entry = this._models.get(this.getKey(sessionId, chat, codeBlockIndex)); + if (!entry) { + return; + } + // TODO: fill this in once we've implemented https://github.com/microsoft/vscode/issues/232538 + } + + async update(sessionId: string, chat: IChatRequestViewModel | IChatResponseViewModel | IAideAgentPlanStepViewModel, codeBlockIndex: number, content: CodeBlockContent): Promise { const entry = this.getOrCreate(sessionId, chat, codeBlockIndex); const extractedVulns = extractVulnerabilitiesFromText(content.text); @@ -116,7 +146,15 @@ export class CodeBlockModelCollection extends Disposable { newText = codeblockUri.textWithoutResult; } - const textModel = (await entry.model).textEditorModel; + if (content.isComplete) { + this.markCodeBlockCompleted(sessionId, chat, codeBlockIndex); + } + + const textModel = await entry.model; + if (textModel.isDisposed()) { + return entry; + } + if (content.languageId) { const vscodeLanguageId = this.languageService.getLanguageIdByLanguageName(content.languageId); if (vscodeLanguageId && vscodeLanguageId !== textModel.getLanguageId()) { @@ -143,22 +181,24 @@ export class CodeBlockModelCollection extends Disposable { } private setCodemapperUri(sessionId: string, chat: IChatRequestViewModel | IChatResponseViewModel | IAideAgentPlanStepViewModel, codeBlockIndex: number, codemapperUri: URI) { - const uri = this.getUri(sessionId, chat, codeBlockIndex); - const entry = this._models.get(uri); + const entry = this._models.get(this.getKey(sessionId, chat, codeBlockIndex)); if (entry) { entry.codemapperUri = codemapperUri; } } private setVulns(sessionId: string, chat: IChatRequestViewModel | IChatResponseViewModel | IAideAgentPlanStepViewModel, codeBlockIndex: number, vulnerabilities: IMarkdownVulnerability[]) { - const uri = this.getUri(sessionId, chat, codeBlockIndex); - const entry = this._models.get(uri); + const entry = this._models.get(this.getKey(sessionId, chat, codeBlockIndex)); if (entry) { entry.vulns = vulnerabilities; } } - private getUri(sessionId: string, chat: IChatRequestViewModel | IChatResponseViewModel | IAideAgentPlanStepViewModel, index: number): URI { + private getKey(sessionId: string, chat: IChatRequestViewModel | IChatResponseViewModel | IAideAgentPlanStepViewModel, index: number): string { + return `${sessionId}/${chat.id}/${index}`; + } + + private getCodeBlockUri(sessionId: string, chat: IChatRequestViewModel | IChatResponseViewModel | IAideAgentPlanStepViewModel, index: number): URI { const metadata = this.getUriMetaData(chat); return URI.from({ scheme: Schemas.vscodeAideAgentCodeBlock, diff --git a/src/vscode-dts/vscode.proposed.aideAgent.d.ts b/src/vscode-dts/vscode.proposed.aideAgent.d.ts index 9c943ad0b1c..115438a33bc 100644 --- a/src/vscode-dts/vscode.proposed.aideAgent.d.ts +++ b/src/vscode-dts/vscode.proposed.aideAgent.d.ts @@ -67,11 +67,6 @@ declare module 'vscode' { readonly isDevtoolsContext: boolean; } - export class ChatResponseCodeEditPart { - edits: WorkspaceEdit; - constructor(edits: WorkspaceEdit); - } - export interface AideAgentPlanStepPart { /** * The index of the step in the plan @@ -91,10 +86,9 @@ declare module 'vscode' { readonly message: string; } - export type AideAgentResponsePart = ExtendedChatResponsePart | ChatResponseCodeEditPart; + export type AideAgentResponsePart = ExtendedChatResponsePart; export interface AideAgentResponseStream extends ChatResponseStream { - codeEdit(edits: WorkspaceEdit): void; push(part: AideAgentResponsePart): void; step(step: AideAgentPlanStepPart): void; stage(stage: AideAgentProgressStagePart): void; @@ -118,7 +112,7 @@ declare module 'vscode' { handleEvent: AideSessionEventHandler; } - interface AideSessionAgent extends Omit { + export interface AideSessionAgent extends Omit { requestHandler: AideSessionEventHandler; readonly initResponse: AideSessionEventSender; }